diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dac2cc7..34a668b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -557,10 +557,16 @@ ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (C driven by `EditorBrush*` props. ### 8.10 Performance invariants (DON'T regress) -- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the - player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull; - idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.) -- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD +- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set only when the player + crosses a level-0 chunk boundary, an anchor moves, or a rebuild is forced; use the stamped + desired map for the cull and idle via `bAllChunksLoaded`. On every rebuild, pending requests whose + keys left the desired set are marked obsolete through a per-request atomic token. Workers only + read that token (and `bShuttingDown`/generation pause), abort between expensive stages, and send + the result through the normal MPSC queue; the game thread reads the token again before applying. + `FChunkResult::Epoch` still guards the generation epoch. Desired work is sorted from the true pawn + position, with the occupied/support LOD0 tile and the next tile along the heading in an absolute + high-priority prefix. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.) +- **LOD** changes HOT-SWAP (`LoadTile` only, never unload-first) → no holes. LOD reconciliation lives in the PERSISTENT per-frame submit loop (same loop as new-chunk loads), NOT as a one-shot on the boundary-cross frame — a one-shot drops every chunk past the task budget and strands it at a stale LOD. Idle (`bAllChunksLoaded`) only when a full scan finds @@ -688,9 +694,13 @@ driven by `EditorBrush*` props. tile is replaced by several finer tiles, so the old center-owner check (`ReplacementLoaded`) dropped it as soon as the ONE tile over its centre loaded → the not-yet- ready edges flashed a hole; the full-coverage check keeps the old tile at its current resolution until - the better mesh is wholly in, then swaps. In-flight (pending) tiles are NEVER cancelled on a rebuild — - they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0 - only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world + the better mesh is wholly in, then swaps. In-flight requests are cancelled when their key leaves the + desired set: the game thread flips the request token, the worker returns an aborted result, and the + queue consumer drops it even if cancellation raced the final enqueue. A result must also match the + current generation epoch and desired membership before it can apply. `TransitionHold` therefore + protects only already-applied geometry during a LOD transition; obsolete work never becomes loaded + geometry. Collision level-0 only; water level-0 only; shadows off for level≥2. **Decorations are NO + LONGER tied to tiles** — they stream on a fixed world grid by distance (§8.5), so they don't pop on LOD swaps. Settings: `VoxelSettings::ClipRadius` (full-res near radius, tiles/level), `MaxClipLevel` (far reach). **NEAR-FIELD GEN COST levers** (`LoadTile`): levels `< FullResClipLevels` mesh at full `CHUNK_SIZE` cells (≈35³ `GetDensityAt` incl. margin ring), coarser levels at @@ -756,11 +766,13 @@ driven by `EditorBrush*` props. (stale stamp — removed + returned). The cull then considers ONLY leavers + `TransitionHold` (tiles kept by load-before-unload from earlier crossings) instead of re-scanning EVERY loaded tile per crossing — that scan was the measured ~1.6 ms/crossing `CullTiles` spike (2026-07-05 - trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). In-flight tiles that finish while - no longer desired are caught at apply time (`ProcessPendingChunks` adds them to the hold); - `UnloadTile` drops hold entries; the settled cull (everything loaded) keeps its full scan as - the safety net. `DesiredPending` for the overlap test is now built LAZILY (only when an - in-range transition candidate exists). **The hold is re-evaluated on a ROTATING BUDGET** + trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). Pending requests that become leavers + are marked obsolete before replacement submission; `ProcessPendingChunks` reads the token, drops + the aborted result, and does not put it in the hold. `ApplyTileResult` independently rejects a + wrong generation epoch or undesired key. `UnloadTile` drops hold entries; the settled cull + (everything loaded) keeps its full scan as the safety net. `DesiredPending` for the overlap test is + now built LAZILY (only when an in-range transition candidate exists). **The hold is re-evaluated on + a ROTATING BUDGET** (`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured 2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is diff --git a/CODEMAP.md b/CODEMAP.md index f901cdb..9b272d0 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -47,7 +47,7 @@ A **density-field voxel terrain** plugin for Unreal Engine, built around undergr Tick → UpdateChunksAroundPosition │ (load/unload around player, by distance + LOD) ▼ - LoadChunk → UE::Tasks::Launch ──────────► background thread + LoadTile → UE::Tasks::Launch ──────────► background thread │ UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘ │ samples density per cell corner @@ -265,12 +265,14 @@ redesign; tile identity lives in `FVoxelTileKey` (VoxelWorld.h). ### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp` `AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct -(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**). +(VoxelWorld.h:35) = async task payload (coord, chunk, meshdata, LOD, **Epoch**, request timing, +desired-set epoch and obsolete/cancellation state). **Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`, `DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map, `ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312). -**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`. +**Async state:** `bShuttingDown`, `ActiveTaskCount` (atomics), `GenerationEpoch`, and the +game-thread `PendingTileCancellation` map whose per-request atomic flags are read-only on workers. **⭐ Espace ACTEUR / ACTOR SPACE (2026-08-17).** Le champ de densite est defini en espace **acteur** : `(0,0)` est l origine de l acteur `AVoxelWorld`, pas celle du monde Unreal. `WorldToLocalCm` / @@ -295,19 +297,19 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). | | `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | | `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | -| `Tick` | 220 | `UpdateChunksAroundPosition(player)` + `ProcessPendingChunks()`. | +| `Tick` | 220 | `UpdateChunksAroundPosition(player, heading)` + `ProcessPendingChunks()`; the clean LOD0 latency summary is flushed at `EndPlay`. | | `GetPlayerPosition` | 231 | Pawn position or zero. (`GetLODForChunk`/`LODToStep`/`IsChunkInRange` removed — dead since the clipmap.) | -| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; applies each via `ApplyTileResult`. | -| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale epochs, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). | +| `ProcessPendingChunks` | 301 | Drains ProcessQueue under per-frame budget; reads the per-request cancellation token before removing its map entry, drops obsolete/aborted results, then applies each live result via `ApplyTileResult`. | +| `ApplyTileResult` | — | **Shared game-thread apply** for one `FChunkResult` (async drain + sync carve): discards stale generation epochs and keys no longer desired, marks loaded, ingests capture, EMPTY releases the tile's existing component (a re-gen can flip content→empty on band change — old geometry must not linger), else `ApplyMeshToTile`. Returns true iff a visible mesh uploaded (counts the budget). Doesn't touch `PendingTiles` (caller's). | | `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): ClassifyTile (T1.d) → `GenerateMesh`/`GenerateSheetMesh` → `BuildTileStreamSet`. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | | `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | -| `UpdateChunksAroundPosition` | 362 | Builds desired set, sorts by distance, loads/unloads, handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | -| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep. | +| `UpdateChunksAroundPosition` | 362 | Builds desired set, cancels pending keys that left it, sorts from the **true pawn position**, submits the occupied/support LOD0 tile and the next tile along the pawn heading as an absolute `BackgroundHigh` prefix, then loads/unloads and handles LOD changes. **Delta cull**: `BuildDesiredTiles` returns the LEAVERS (stamped `DesiredStamped` map, one sweep) — only those + `TransitionHold` are considered per crossing, not every loaded tile. `BuildDesiredTiles` also applies `RenderDistanceChunks`: the outermost shell widens to cover the distance — as level-MaxClipLevel MC tiles, or (F18 `bFarSheetRing`) as a SHEET ring at level MaxClipLevel+span (covered-check vs the MaxClipLevel box; `VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | +| `BuildDesiredTiles` | ~722 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells + F18 sheet ring) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. | | `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set (deduped by stamp) so AI/remote players keep collision loaded around them; delta cull releases them on move/unregister. **§9.4:** a tile only a CollisionOnly anchor wants (clipmap didn't stamp it) → `CollisionOnlyTiles` → hidden at apply. Zero cost when no anchors. | | `ReconcileAnchorTileVisibility` | — | **§9.4:** after each rebuild, toggle `SetVisibility` on ALREADY-LOADED tiles that flipped render↔collision-only (diff `CollisionOnlyTiles` vs prev — bounded, no O(loaded) scan; unhide only if still desired). No-op without CollisionOnly anchors. | | `RegisterStreamingAnchor` / `UnregisterStreamingAnchor` | — | **BlueprintCallable §9.3:** add/remove an actor as a streaming anchor (`EVoxelAnchorPolicy` CollisionOnly/FullVisual + XY/ZBelow/ZAbove box). Idempotent; forces a rebuild next Tick. §9.4: CollisionOnly tiles cook collision but are hidden (no draw/VSM) unless the player clipmap wants them too. | -| `LoadChunk` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. | -| `UnloadTile` | — | Clears tile state; the component is PARKED in the pool (T2.c), not destroyed. | +| `LoadTile` | 445 | Budget check → `UE::Tasks::Launch` background gen+mesh; RAII task guard. Worker runs `Generator->ClassifyTile` first (T1.d): AllSolid/AllAir ⇒ skip `GenerateMesh`, tile stays empty (capture tiles always generate). It carries `GenerationEpoch`/desired epoch and checks `bShuttingDown`, generation pause, and its read-only cancellation token between expensive stages; obsolete work returns through `ProcessQueue`. STRATE CONTENT CUT: tiles ≥ `StrateContentCutMinLevel` pass the player-strate band (`MeshBandChunkLo/Hi` → voxels) to `GenerateMesh` + stamp it on `FChunkResult::BandChunkLo/Hi`; band change re-queues via `BandRemeshQueue` (see `UpdateChunksAroundPosition`). TOO-COARSE SKIP: if one cell is taller than the band (`Step > band height` — level ≥7 territory) the tile is enqueued EMPTY without launching a task (cell-granular cut could only render garbage). F18: `Tile.Level > MaxClipLevel` = SHEET tile → routed to `GenerateSheetMesh` (band mid-chunk = strate ref; band unarmed ⇒ empty; MC-ring sampling density, cells capped 128/axis; carries the XY hole `SheetHole*Vox` — hole moves ⇒ overlapping sheets re-queue via `BandRemeshQueue`, see `UpdateChunksAroundPosition`). §8.10. | +| `UnloadTile` | — | Clears tile state; marks any matching generation request obsolete without removing its pending slot, so a late result cannot clear a replacement request; the component is PARKED in the pool (T2.c), not destroyed. | | `ApplyMeshToTile` | — | Upload geometry. One component per tile (clipmap keeps count low; supersedes the old region batching); worker-built streams (T1.f) → `CreateSectionGroup(MoveTemp)`. Reuses the component's existing `URealtimeMesh` (no per-apply mesh alloc). **F17: two polygroups** (0 ground / 1 sky-cap, per-triangle class from the mesher) → RMC auto-section per non-empty group; slot 0 = override/default material, slot 1 = `CeilingMaterial` (fallback ground); config gated by `FChunkResult::bHasGroundTris/bHasCeilingTris`. Takes `FChunkResult&`; strate lookups clamp Z into `Result.BandChunkLo/Hi` (strate content cut) — ground at clamped bottom chunk, cap at clamped top (mid = gap fallback). Collision level-0 only (T1.c) both groups; shadow per SECTION: ground casts at level≤1, cap never. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. | | `AcquireTileComponent` / `ReleaseTileComponent` | — | **T2.c component pool** (`TileComponentPool`, bounded): park on unload (geometry+collision stripped, hidden, stays registered), pop on apply — no `NewObject`/`RegisterComponent`/GC churn during travel & regen. §8.10. | | `GetStrateAtPosition` | 965 | Gameplay query → strate index. | @@ -768,7 +770,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). | | LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. | | How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. | -| Async threading / stale-result handling | `LoadChunk` :445, `ProcessPendingChunks` :301, Epoch logic. | +| Async threading / stale-result handling | `LoadTile` :445, `ProcessPendingChunks` :301, generation Epoch + per-request cancellation token. | | Add a new cave feature / terrain op | Add enum in `VoxelTerrainOpDefinition.h:36`, params there, `ApplyTo` (.cpp:6), transport fields in `FStrateGenerationParams`, consume it in a new Step inside `GetDensityWithParams`. | | Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index 0993286..e1e0268 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -480,9 +480,6 @@ namespace VoxelDensityProfile case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid"); case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir"); case ECounter::TileClassifyMixed: return TEXT("TileClassifyMixed"); - case ECounter::TileVerdictCacheHits: return TEXT("TileVerdictCacheHits"); - case ECounter::TileVerdictRegionHits: return TEXT("TileVerdictRegionHits"); - case ECounter::TileVerdictCacheStores: return TEXT("TileVerdictCacheStores"); case ECounter::FusedTunnelSamples: return TEXT("FusedTunnelSamples"); case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples"); case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped"); diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index 646ee50..cd2aa96 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -22,7 +22,6 @@ #include "HAL/PlatformTime.h" #include "HAL/IConsoleManager.h" #include "Misc/CommandLine.h" -#include "Misc/ScopeLock.h" #if WITH_EDITOR #include "VoxelStrateComposer.h" @@ -56,6 +55,7 @@ namespace GVoxelForgeUseFusedEvaluator = CommandLineValue; } } + } //============================================================================= @@ -1532,169 +1532,6 @@ UVoxelGenerator::UVoxelGenerator() { } -void UVoxelGenerator::InvalidateTileVerdictCache() const -{ - FScopeLock Lock(&TileVerdictCacheLock); - TileVerdictCache.Reset(); - TileVerdictCacheClock = 0; -} - -bool UVoxelGenerator::FindTileVerdict( - const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, - int32 ManagerWorldSeed, uint32 LayoutVersion, uint32 DiffVersion, - uint64 ParamsFingerprint, uint8 PassageSupportFloorWrites, - EVoxelTileClass& OutVerdict, bool& bOutRegionHit) const -{ - bOutRegionHit = false; - FScopeLock Lock(&TileVerdictCacheLock); - ++TileVerdictCacheClock; - - const int64 RequestMaxX = static_cast(OriginVoxels.X) - + static_cast(CellsPerAxis) * Step; - const int64 RequestMaxY = static_cast(OriginVoxels.Y) - + static_cast(CellsPerAxis) * Step; - const int64 RequestMaxZ = static_cast(OriginVoxels.Z) - + static_cast(CellsPerAxis) * Step; - - int32 BestIndex = INDEX_NONE; - bool bBestExact = false; - for (int32 Index = 0; Index < TileVerdictCache.Num(); ++Index) - { - FTileVerdictCacheEntry& Entry = TileVerdictCache[Index]; - if (Entry.GeneratorSeed != Seed - || Entry.ManagerWorldSeed != ManagerWorldSeed - || Entry.OriginSpineRadius != OriginSpineRadius - || Entry.WorldRadiusVoxels != WorldRadiusVoxels - || Entry.EdgeSealThickness != EdgeSealThickness - || Entry.ManagerLifetimeId != (StrateManager ? StrateManager->GetCacheLifetimeId() : 0) - || Entry.LayoutVersion != LayoutVersion - || Entry.DiffVersion != DiffVersion - || Entry.ParamsFingerprint != ParamsFingerprint - || Entry.PassageSupportFloorWrites != PassageSupportFloorWrites - || Entry.Step != Step - || Entry.CellsPerAxis < CellsPerAxis) - { - continue; - } - - const int64 EntryMaxX = static_cast(Entry.Origin.X) - + static_cast(Entry.CellsPerAxis) * Step; - const int64 EntryMaxY = static_cast(Entry.Origin.Y) - + static_cast(Entry.CellsPerAxis) * Step; - const int64 EntryMaxZ = static_cast(Entry.Origin.Z) - + static_cast(Entry.CellsPerAxis) * Step; - const int64 DeltaX = static_cast(OriginVoxels.X) - Entry.Origin.X; - const int64 DeltaY = static_cast(OriginVoxels.Y) - Entry.Origin.Y; - const int64 DeltaZ = static_cast(OriginVoxels.Z) - Entry.Origin.Z; - if (DeltaX < 0 || DeltaY < 0 || DeltaZ < 0 - || (DeltaX % Step) != 0 || (DeltaY % Step) != 0 || (DeltaZ % Step) != 0 - || RequestMaxX > EntryMaxX || RequestMaxY > EntryMaxY || RequestMaxZ > EntryMaxZ) - { - continue; - } - - const bool bExact = Entry.Origin == OriginVoxels - && Entry.CellsPerAxis == CellsPerAxis; - if (BestIndex == INDEX_NONE || (bExact && !bBestExact) - || (bExact == bBestExact - && Entry.CellsPerAxis < TileVerdictCache[BestIndex].CellsPerAxis)) - { - BestIndex = Index; - bBestExact = bExact; - } - } - - if (BestIndex == INDEX_NONE) - { - return false; - } - - FTileVerdictCacheEntry& Entry = TileVerdictCache[BestIndex]; - Entry.LastUse = TileVerdictCacheClock; - OutVerdict = Entry.Verdict; - bOutRegionHit = !(Entry.Origin == OriginVoxels - && Entry.CellsPerAxis == CellsPerAxis); - return OutVerdict != EVoxelTileClass::Mixed; -} - -void UVoxelGenerator::StoreTileVerdict( - const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, - int32 ManagerWorldSeed, uint32 LayoutVersion, uint32 DiffVersion, - uint64 ParamsFingerprint, uint8 PassageSupportFloorWrites, - EVoxelTileClass Verdict) const -{ - if (Verdict == EVoxelTileClass::Mixed) - { - return; - } - - FScopeLock Lock(&TileVerdictCacheLock); - ++TileVerdictCacheClock; - - int32 SlotIndex = INDEX_NONE; - int32 VictimIndex = INDEX_NONE; - uint32 OldestUse = MAX_uint32; - for (int32 Index = 0; Index < TileVerdictCache.Num(); ++Index) - { - FTileVerdictCacheEntry& Entry = TileVerdictCache[Index]; - if (Entry.GeneratorSeed == Seed - && Entry.ManagerWorldSeed == ManagerWorldSeed - && Entry.OriginSpineRadius == OriginSpineRadius - && Entry.WorldRadiusVoxels == WorldRadiusVoxels - && Entry.EdgeSealThickness == EdgeSealThickness - && Entry.ManagerLifetimeId == (StrateManager ? StrateManager->GetCacheLifetimeId() : 0) - && Entry.LayoutVersion == LayoutVersion - && Entry.DiffVersion == DiffVersion - && Entry.ParamsFingerprint == ParamsFingerprint - && Entry.PassageSupportFloorWrites == PassageSupportFloorWrites - && Entry.Origin == OriginVoxels - && Entry.Step == Step - && Entry.CellsPerAxis == CellsPerAxis) - { - SlotIndex = Index; - break; - } - if (Entry.LastUse < OldestUse) - { - OldestUse = Entry.LastUse; - VictimIndex = Index; - } - } - - if (SlotIndex == INDEX_NONE) - { - if (TileVerdictCache.Num() < TileVerdictCacheCapacity) - { - SlotIndex = TileVerdictCache.AddDefaulted(); - } - else if (VictimIndex != INDEX_NONE) - { - SlotIndex = VictimIndex; - } - else - { - return; - } - } - - FTileVerdictCacheEntry& Entry = TileVerdictCache[SlotIndex]; - Entry.Origin = OriginVoxels; - Entry.Step = Step; - Entry.CellsPerAxis = CellsPerAxis; - Entry.GeneratorSeed = Seed; - Entry.ManagerWorldSeed = ManagerWorldSeed; - Entry.OriginSpineRadius = OriginSpineRadius; - Entry.WorldRadiusVoxels = WorldRadiusVoxels; - Entry.EdgeSealThickness = EdgeSealThickness; - Entry.ManagerLifetimeId = StrateManager ? StrateManager->GetCacheLifetimeId() : 0; - Entry.LayoutVersion = LayoutVersion; - Entry.DiffVersion = DiffVersion; - Entry.ParamsFingerprint = ParamsFingerprint; - Entry.PassageSupportFloorWrites = PassageSupportFloorWrites; - Entry.Verdict = Verdict; - Entry.LastUse = TileVerdictCacheClock; -} - void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) { // Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas @@ -1703,7 +1540,6 @@ void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings) OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f; WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f; EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f; - InvalidateTileVerdictCache(); } void UVoxelGenerator::BeginDensityBlock(FIntVector OriginVoxels, int32 Step, @@ -5115,48 +4951,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in VoxelDensityProfile::AddCounter( VoxelDensityProfile::ECounter::TileClassifyCalls); - const uint64 ParamsFingerprint = StrateManager - ? StrateManager->GetGenerationParamsFingerprint() : 0; - const bool bCacheable = ParamsFingerprint != 0; - const uint32 LayoutVersion = StrateManager ? StrateManager->GetLayoutVersion() : 0; - const uint32 DiffVersion = DiffLayer ? DiffLayer->GetModsVersion() : 0; - const int32 ManagerWorldSeed = StrateManager ? StrateManager->GetWorldSeed() : 0; - const uint8 PassageSupportFloorWrites = StrateManager - && StrateManager->ArePassageSupportFloorWritesEnabledForDiagnostics() ? 1u : 0u; - - EVoxelTileClass CachedVerdict = EVoxelTileClass::Mixed; - bool bRegionHit = false; - if (bCacheable - && FindTileVerdict( - OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, - ManagerWorldSeed, LayoutVersion, DiffVersion, ParamsFingerprint, - PassageSupportFloorWrites, CachedVerdict, bRegionHit)) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileVerdictCacheHits); - if (bRegionHit) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileVerdictRegionHits); - } - if (CachedVerdict == EVoxelTileClass::AllSolid) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileClassifyAllSolid); - } - else if (CachedVerdict == EVoxelTileClass::AllAir) - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileClassifyAllAir); - } - else - { - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileClassifyMixed); - } - return CachedVerdict; - } - const EVoxelTileClass Verdict = ClassifyTileUncached( OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, OutStats); if (Verdict == EVoxelTileClass::AllSolid) @@ -5175,15 +4969,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in VoxelDensityProfile::ECounter::TileClassifyMixed); } - if (bCacheable && Verdict != EVoxelTileClass::Mixed) - { - StoreTileVerdict( - OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, - ManagerWorldSeed, LayoutVersion, DiffVersion, ParamsFingerprint, - PassageSupportFloorWrites, Verdict); - VoxelDensityProfile::AddCounter( - VoxelDensityProfile::ECounter::TileVerdictCacheStores); - } return Verdict; } diff --git a/Source/VoxelForge/Private/VoxelStartupTrace.cpp b/Source/VoxelForge/Private/VoxelStartupTrace.cpp index 75f7231..299c591 100644 --- a/Source/VoxelForge/Private/VoxelStartupTrace.cpp +++ b/Source/VoxelForge/Private/VoxelStartupTrace.cpp @@ -98,8 +98,6 @@ namespace int32 AllAir = 0; int32 AllSolid = 0; int32 Mixed = 0; - int32 CacheHits = 0; - int32 RegionHits = 0; int64 Triangles = 0; for (const FTileSample& Sample : Samples) @@ -110,8 +108,6 @@ namespace if (Sample.Verdict == 0) ++Mixed; else if (Sample.Verdict == 1) ++AllSolid; else if (Sample.Verdict == 2) ++AllAir; - if (Sample.bCacheHit) ++CacheHits; - if (Sample.bRegionHit) ++RegionHits; Triangles += Sample.Triangles; Request.Add(Sample.RequestToApplySeconds); Queue.Add(Sample.QueueWaitSeconds); @@ -122,14 +118,14 @@ namespace } return FString::Printf( - TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"cache_hits\":%d,\"region_hits\":%d,\"triangles\":%lld," + TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"triangles\":%lld," "\"request_apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"mesh_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}}"), - Count, Empty, AllAir, AllSolid, Mixed, CacheHits, RegionHits, static_cast(Triangles), + Count, Empty, AllAir, AllSolid, Mixed, static_cast(Triangles), *Number(Percentile(Request, 0.50)), *Number(Percentile(Request, 0.95)), *Number(Maximum(Request)), *Number(Percentile(Queue, 0.50)), *Number(Percentile(Queue, 0.95)), *Number(Maximum(Queue)), *Number(Percentile(Generation, 0.50)), *Number(Percentile(Generation, 0.95)), *Number(Maximum(Generation)), @@ -264,11 +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,\"cache_hit\":%d,\"region_hit\":%d,\"triangles\":%d," + TEXT("\"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.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, - Sample.bCacheHit ? 1 : 0, Sample.bRegionHit ? 1 : 0, Sample.Triangles, + Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, Sample.Triangles, *Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds), *Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds), *Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds), diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 3aa3fa2..7436809 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -42,6 +42,7 @@ #include "Misc/CommandLine.h" #include "Misc/Paths.h" #include "HAL/IConsoleManager.h" +#include "HAL/PlatformMisc.h" #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #if WITH_EDITOR @@ -79,33 +80,6 @@ namespace bool GVoxelForgeStreamingBudgetReported = false; bool GVoxelForgeGenerationCapHitReported = false; int32 GVoxelForgeMaxPendingTilesObserved = 0; - bool GVoxelForgeTestStreamingCenterParsed = false; - bool GVoxelForgeUseTestStreamingCenter = false; - FIntVector GVoxelForgeTestStreamingCenter = FIntVector::ZeroValue; - - void VF_ParseTestStreamingCenter() - { - if (GVoxelForgeTestStreamingCenterParsed) return; - GVoxelForgeTestStreamingCenterParsed = true; - - int32 X = 0, Y = 0, Z = 0; - const TCHAR* CommandLine = FCommandLine::Get(); - const bool bHaveX = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterX="), X); - const bool bHaveY = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterY="), Y); - const bool bHaveZ = FParse::Value(CommandLine, TEXT("voxel.StreamingTestCenterZ="), Z); - if (!bHaveX || !bHaveY || !bHaveZ) - { - return; - } - - GVoxelForgeTestStreamingCenter = FIntVector(X, Y, Z); - GVoxelForgeUseTestStreamingCenter = true; - UE_LOG(LogTemp, Display, - TEXT("[VoxelForge] diagnostic fixed streaming center: (%d,%d,%d)"), - GVoxelForgeTestStreamingCenter.X, - GVoxelForgeTestStreamingCenter.Y, - GVoxelForgeTestStreamingCenter.Z); - } } AVoxelWorld::AVoxelWorld() @@ -473,8 +447,22 @@ void AVoxelWorld::RegenerateAllChunks() // Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick. if (DensityVolume) { DensityVolume->Reset(); } - // Clear pending set — stale tasks will be discarded by the epoch check. - PendingTiles.Empty(); + // Mark any still-running request obsolete. Keep its PendingTiles slot and token map until the + // worker/result drains; otherwise a late old result could remove the slot belonging to a new + // request for the same key. Live-edit callers normally arrive with generation paused (and no + // active tasks), in which case the stale queue can be discarded immediately. + for (TPair>& Pair : + PendingTileCancellation) + { + if (Pair.Value) { Pair.Value->bObsolete.store(true, std::memory_order_release); } + } + if (ActiveTaskCount.load(std::memory_order_relaxed) == 0) + { + FChunkResult StaleResult; + while (ProcessQueue.Dequeue(StaleResult)) {} + PendingTiles.Empty(); + PendingTileCancellation.Empty(); + } // Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't // try to UnloadTile coords that no longer exist. PendingUnload.Empty(); @@ -489,7 +477,9 @@ void AVoxelWorld::RegenerateAllChunks() LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); bAllChunksLoaded = false; DesiredSorted.Reset(); + CriticalDesiredTiles.Reset(); DesiredStamped.Reset(); + ++DesiredEpoch; TransitionHold.Reset(); TransitionHoldQueue.Reset(); TransitionHoldCursor = 0; @@ -970,13 +960,17 @@ 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; - while (ProcessQueue.Dequeue(Discard)) {} + while (ProcessQueue.Dequeue(Discard)) + { + if (Discard.bObsolete) { ++ObsoleteTileAbortCount; } + } + UE_LOG(LogTemp, Display, TEXT("[VoxelWorld] EndPlay: workers drained in %.6fs; obsolete_tile_aborts=%d"), + FPlatformTime::Seconds() - EndPlayStartSeconds, ObsoleteTileAbortCount); + LogStreamingLatencySummary(); PendingTiles.Empty(); + PendingTileCancellation.Empty(); PendingUnload.Empty(); DirtyRemeshQueue.Empty(); BandRemeshQueue.Empty(); @@ -1034,6 +1028,10 @@ void AVoxelWorld::BeginPlay() VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay")); Super::BeginPlay(); bShuttingDown.store(false, std::memory_order_relaxed); + LOD0ReadySamples.Reset(); + ObsoleteTileAbortCount = 0; + PeakObservedPawnSpeedCmPerSecond = 0.0f; + ConfigureHeadlessStreamingTest(); // Some headless harnesses load module CVars after the engine's startup-CVar pass. Read the // profiling switches explicitly as well, so a reported game profile cannot silently run with @@ -1216,6 +1214,155 @@ void AVoxelWorld::BeginPlay() } } +void AVoxelWorld::ConfigureHeadlessStreamingTest() +{ + bHeadlessStreamingTestMovement = false; + bHeadlessStreamingTestExitRequested = false; + HeadlessStreamingTestMoveAttempts = 0; + HeadlessStreamingTestDistanceCm = 0.0; + HeadlessStreamingTestSpeedCmPerSecond = 0.0f; + HeadlessStreamingTestStartSeconds = 0.0f; + HeadlessStreamingTestExitSeconds = 0.0f; + HeadlessStreamingTestDirection = FVector::XAxisVector; + HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; + HeadlessStreamingTestBeginSeconds = 0.0; + HeadlessStreamingTestLastElapsedSeconds = 0.0; + + const TCHAR* CommandLine = FCommandLine::Get(); + int32 MoveValue = 0; + const bool bMoveRequested = + (FParse::Value(CommandLine, TEXT("voxel.TestMove="), MoveValue) && MoveValue != 0) + || FParse::Param(CommandLine, TEXT("voxel.TestMove")); + float Speed = 800.0f; + float StartSeconds = 20.0f; + float ExitSeconds = 0.0f; + FParse::Value(CommandLine, TEXT("voxel.TestMoveSpeedCmPerSecond="), Speed); + FParse::Value(CommandLine, TEXT("voxel.TestMoveStartSeconds="), StartSeconds); + FParse::Value(CommandLine, TEXT("voxel.TestExitSeconds="), ExitSeconds); + + if (bMoveRequested) + { + FString DirectionText; + if (FParse::Value(CommandLine, TEXT("voxel.TestMoveDirection="), DirectionText)) + { + TArray Components; + DirectionText.ParseIntoArray(Components, TEXT(","), true); + if (Components.Num() == 3) + { + HeadlessStreamingTestDirection = FVector( + FCString::Atof(*Components[0]), + FCString::Atof(*Components[1]), + FCString::Atof(*Components[2])); + } + } + HeadlessStreamingTestDirection = HeadlessStreamingTestDirection.GetSafeNormal(); + if (HeadlessStreamingTestDirection.IsNearlyZero()) + { + HeadlessStreamingTestDirection = FVector::XAxisVector; + } + HeadlessStreamingTestSpeedCmPerSecond = FMath::Max(Speed, 0.0f); + HeadlessStreamingTestStartSeconds = FMath::Max(StartSeconds, 0.0f); + bHeadlessStreamingTestMovement = HeadlessStreamingTestSpeedCmPerSecond > 0.0f; + } + + HeadlessStreamingTestExitSeconds = FMath::Max(ExitSeconds, 0.0f); + if (bHeadlessStreamingTestMovement || HeadlessStreamingTestExitSeconds > 0.0f) + { + HeadlessStreamingTestBeginSeconds = FPlatformTime::Seconds(); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingTest] clean=1 movement=%d speed_cm_s=%.3f start_s=%.3f " + "exit_s=%.3f direction=(%.3f,%.3f,%.3f)"), + bHeadlessStreamingTestMovement ? 1 : 0, + HeadlessStreamingTestSpeedCmPerSecond, + HeadlessStreamingTestStartSeconds, + HeadlessStreamingTestExitSeconds, + HeadlessStreamingTestDirection.X, + HeadlessStreamingTestDirection.Y, + HeadlessStreamingTestDirection.Z); + } +} + +void AVoxelWorld::AdvanceHeadlessStreamingTest( + FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn) +{ + if (!bHeadlessStreamingTestMovement || !PlayerPawn || HeadlessStreamingTestBeginSeconds <= 0.0) + { + return; + } + + const double ElapsedSeconds = FPlatformTime::Seconds() - HeadlessStreamingTestBeginSeconds; + if (ElapsedSeconds < static_cast(HeadlessStreamingTestStartSeconds)) + { + return; + } + + // The headless game runs uncapped, so simulation DeltaTime is not a stable movement clock. Use + // wall time for this explicit harness path; both A/B runs then cover exactly the same distance + // regardless of how much work the scheduler is doing per frame. + const double PreviousElapsedSeconds = FMath::Max( + HeadlessStreamingTestLastElapsedSeconds, + static_cast(HeadlessStreamingTestStartSeconds)); + const double MoveSeconds = FMath::Max(0.0, ElapsedSeconds - PreviousElapsedSeconds); + HeadlessStreamingTestLastElapsedSeconds = ElapsedSeconds; + if (MoveSeconds <= 0.0) + { + return; + } + + const FVector NewPosition = InOutPlayerPosition + + HeadlessStreamingTestDirection + * HeadlessStreamingTestSpeedCmPerSecond + * static_cast(MoveSeconds); + const bool bMoved = PlayerPawn->SetActorLocation( + NewPosition, false, nullptr, ETeleportType::TeleportPhysics); + ++HeadlessStreamingTestMoveAttempts; + if (bMoved) + { + const FVector ActualPosition = PlayerPawn->GetActorLocation(); + HeadlessStreamingTestDistanceCm += FVector::Dist(InOutPlayerPosition, ActualPosition); + HeadlessStreamingTestLastActualPosition = ActualPosition; + InOutPlayerPosition = ActualPosition; + InOutPlayerHeading = HeadlessStreamingTestDirection * HeadlessStreamingTestSpeedCmPerSecond; + + // The explicit -nullrhi harness moves the pawn from this world's pre-physics tick. Keep + // the template character movement component from applying its own zero-input/gravity step + // afterward and undoing the synthetic teleport before the next scheduler observation. + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) + { + CharacterMovement->StopMovementImmediately(); + CharacterMovement->SetMovementMode(MOVE_None); + } + } + } +} + +void AVoxelWorld::MaybeFinishHeadlessStreamingTest() +{ + if (bHeadlessStreamingTestExitRequested || HeadlessStreamingTestExitSeconds <= 0.0f + || HeadlessStreamingTestBeginSeconds <= 0.0) + { + return; + } + + if (FPlatformTime::Seconds() - HeadlessStreamingTestBeginSeconds + < static_cast(HeadlessStreamingTestExitSeconds)) + { + return; + } + + bHeadlessStreamingTestExitRequested = true; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingTest] complete; moves=%d distance_m=%.6f last_position=(%.1f,%.1f,%.1f); requesting graceful exit after clean session"), + HeadlessStreamingTestMoveAttempts, + HeadlessStreamingTestDistanceCm / 100.0, + HeadlessStreamingTestLastActualPosition.X, + HeadlessStreamingTestLastActualPosition.Y, + HeadlessStreamingTestLastActualPosition.Z); + FPlatformMisc::RequestExit(false, TEXT("VoxelForge headless streaming test complete")); +} + void AVoxelWorld::Tick(float DeltaTime) { Super::Tick(DeltaTime); @@ -1225,8 +1372,15 @@ void AVoxelWorld::Tick(float DeltaTime) const bool bHasPlayer = TryGetPlayerPosition(PlayerLastPos, &PlayerPawn); if (bHasPlayer) { + // Capture velocity before the collision gate can deliberately zero it while the support + // tile is cooking. The scheduler uses this as the movement heading for its next-tile + // critical prefix; a stationary pawn falls back to actor forward in BuildDesiredTiles. + FVector PlayerHeading = PlayerPawn ? PlayerPawn->GetVelocity() : FVector::ZeroVector; + AdvanceHeadlessStreamingTest(PlayerLastPos, PlayerHeading, PlayerPawn); + PeakObservedPawnSpeedCmPerSecond = FMath::Max( + PeakObservedPawnSpeedCmPerSecond, PlayerHeading.Size2D()); UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime); - UpdateChunksAroundPosition(PlayerLastPos); + UpdateChunksAroundPosition(PlayerLastPos, PlayerPawn, PlayerHeading); if (AtmosphereManager) { AtmosphereManager->UpdateForPlayer(PlayerLastPos); @@ -1320,13 +1474,15 @@ void AVoxelWorld::Tick(float DeltaTime) Max.Z = FMath::Max(Max.Z, Key.Coord.Z); } VoxelForgeStartupTrace::RecordEvent(TEXT("steady_state"), FString::Printf( - TEXT("\"desired_tiles\":%d,\"loaded_tiles\":%d,\"collision_ready_tiles\":%d," - "\"collision_not_required_tiles\":%d,\"pending_collision_cooks\":%d," - "\"pawn_collision_gate_engaged\":%d," + TEXT("\"desired_tiles\":%d,\"loaded_tiles\":%d,\"collision_ready_tiles\":%d," + "\"collision_not_required_tiles\":%d,\"pending_collision_cooks\":%d," + "\"pawn_collision_gate_engaged\":%d," + "\"obsolete_tile_aborts\":%d,\"desired_epoch\":%u," "\"pending_tiles\":%d,\"pending_unload\":%d,\"generation_epoch\":%u"), DesiredSorted.Num(), LoadedTiles.Num(), CollisionReadyTiles.Num(), CollisionNotRequiredTiles.Num(), PendingCollisionCooks.Num(), bPawnCollisionGateEngaged ? 1 : 0, + ObsoleteTileAbortCount, DesiredEpoch, PendingTiles.Num(), PendingUnload.Num(), GenerationEpoch)); TArray SteadyLevels; SteadyLevelCounts.GetKeys(SteadyLevels); @@ -1376,6 +1532,7 @@ void AVoxelWorld::Tick(float DeltaTime) GVoxelForgePendingModificationStrength); } ProcessUnloadQueue(); + MaybeFinishHeadlessStreamingTest(); #if ENABLE_DRAW_DEBUG // Density-volume overlay (step 1a): cyan boxes for solid level-0 cells near the player. @@ -1856,6 +2013,95 @@ void AVoxelWorld::HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UB } } +void AVoxelWorld::RecordLOD0ReadySample( + uint64 RequestStartCycles, uint64 GenerationStartCycles, uint64 GenerationEndCycles, + uint64 ApplyStartCycles, uint64 ReadyCycles, uint64 CollisionSubmittedCycles) +{ + if (RequestStartCycles == 0 || ReadyCycles < RequestStartCycles) + { + return; + } + + const auto SecondsBetween = [](uint64 Later, uint64 Earlier) -> double + { + return Later >= Earlier ? FPlatformTime::ToSeconds64(Later - Earlier) : 0.0; + }; + + FStreamingLatencySample Sample; + Sample.RequestToReadySeconds = SecondsBetween(ReadyCycles, RequestStartCycles); + Sample.GenerationSeconds = (GenerationStartCycles != 0 && GenerationEndCycles >= GenerationStartCycles) + ? SecondsBetween(GenerationEndCycles, GenerationStartCycles) : 0.0; + + // Queue wait is deliberately only the time before generation plus the result wait before the + // game-thread apply. Mesh upload and the RMC collision cook stay in request-to-ready but are not + // mislabeled as scheduler time. + const double WorkerQueueSeconds = GenerationStartCycles != 0 + ? SecondsBetween(GenerationStartCycles, RequestStartCycles) : 0.0; + const double ResultQueueSeconds = GenerationEndCycles != 0 && ApplyStartCycles != 0 + ? SecondsBetween(ApplyStartCycles, GenerationEndCycles) + : (GenerationStartCycles == 0 ? SecondsBetween(ApplyStartCycles, RequestStartCycles) : 0.0); + Sample.QueueWaitSeconds = WorkerQueueSeconds + ResultQueueSeconds; + Sample.CollisionCookSeconds = CollisionSubmittedCycles != 0 + ? SecondsBetween(ReadyCycles, CollisionSubmittedCycles) : 0.0; + LOD0ReadySamples.Add(Sample); +} + +void AVoxelWorld::LogStreamingLatencySummary() const +{ + TArray RequestToReady; + TArray QueueWait; + TArray Generation; + TArray CollisionCook; + RequestToReady.Reserve(LOD0ReadySamples.Num()); + QueueWait.Reserve(LOD0ReadySamples.Num()); + Generation.Reserve(LOD0ReadySamples.Num()); + CollisionCook.Reserve(LOD0ReadySamples.Num()); + for (const FStreamingLatencySample& Sample : LOD0ReadySamples) + { + RequestToReady.Add(Sample.RequestToReadySeconds); + QueueWait.Add(Sample.QueueWaitSeconds); + Generation.Add(Sample.GenerationSeconds); + CollisionCook.Add(Sample.CollisionCookSeconds); + } + + const auto Percentile = [](TArray Values, double Fraction) -> double + { + if (Values.Num() == 0) return 0.0; + Values.Sort(); + const int32 Index = FMath::Clamp( + FMath::CeilToInt(Fraction * static_cast(Values.Num() - 1)), + 0, Values.Num() - 1); + return Values[Index]; + }; + const auto Maximum = [](const TArray& Values) -> double + { + double Result = 0.0; + for (const double Value : Values) { Result = FMath::Max(Result, Value); } + return Result; + }; + + const double RequestP95 = Percentile(RequestToReady, 0.95); + const double TravelP95Metres = RequestP95 + * static_cast(PeakObservedPawnSpeedCmPerSecond) / 100.0; + const bool bDiagnosticsEnabled = GVoxelForgeProfileTileGeneration != 0 + || VoxelForgeStartupTrace::IsActive(); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeStreamingLatency] mode=%s diagnostics=%s lod0_ready_samples=%d " + "request_to_ready_s[p50=%.6f p95=%.6f max=%.6f] " + "queue_wait_s[p50=%.6f p95=%.6f max=%.6f] " + "generation_s[p50=%.6f p95=%.6f max=%.6f] " + "collision_cook_s[p50=%.6f p95=%.6f max=%.6f] " + "peak_speed_cm_s=%.3f p95_travel_m=%.6f obsolete_tile_aborts=%d"), + bDiagnosticsEnabled ? TEXT("profiled") : TEXT("clean"), + bDiagnosticsEnabled ? TEXT("on") : TEXT("off"), + LOD0ReadySamples.Num(), + Percentile(RequestToReady, 0.50), RequestP95, Maximum(RequestToReady), + Percentile(QueueWait, 0.50), Percentile(QueueWait, 0.95), Maximum(QueueWait), + Percentile(Generation, 0.50), Percentile(Generation, 0.95), Maximum(Generation), + Percentile(CollisionCook, 0.50), Percentile(CollisionCook, 0.95), Maximum(CollisionCook), + PeakObservedPawnSpeedCmPerSecond, TravelP95Metres, ObsoleteTileAbortCount); +} + void AVoxelWorld::HandleTileCollisionCookComplete( const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result) { @@ -1893,6 +2139,11 @@ void AVoxelWorld::HandleTileCollisionCookComplete( const uint64 MeasuredCollisionSubmittedCycles = Pending->CollisionSubmittedCycles; const bool bBodyUpdatedEventSeen = Pending->bBodyUpdatedEventSeen; const uint64 BodyUpdatedEventCycles = Pending->BodyUpdatedEventCycles; + const uint64 MeasuredRequestStartCycles = Pending->RequestStartCycles; + const uint64 MeasuredGenerationStartCycles = Pending->GenerationStartCycles; + const uint64 MeasuredGenerationEndCycles = Pending->GenerationEndCycles; + const uint64 MeasuredApplyStartCycles = Pending->ApplyStartCycles; + const bool bRecordStreamingLatency = Pending->bRecordStreamingLatency; PendingCollisionCooks.Remove(Tile); const ERealtimeMeshCollisionUpdateResult CollisionResult = @@ -1913,6 +2164,12 @@ void AVoxelWorld::HandleTileCollisionCookComplete( } const uint64 ReadyCycles = FPlatformTime::Cycles64(); + if (bBodyInstalled && Tile.Level == 0 && bRecordStreamingLatency) + { + RecordLOD0ReadySample(MeasuredRequestStartCycles, MeasuredGenerationStartCycles, + MeasuredGenerationEndCycles, MeasuredApplyStartCycles, + ReadyCycles, MeasuredCollisionSubmittedCycles); + } const double MeshToCookSeconds = FPlatformTime::ToSeconds64( ReadyCycles - MeasuredMeshSubmittedCycles); const double CollisionRequestToCookSeconds = FPlatformTime::ToSeconds64( @@ -2008,12 +2265,25 @@ void AVoxelWorld::ProcessPendingChunks() while (ProcessQueue.Dequeue(DequeuedChunk)) { ++ResultsDrained; + const TSharedPtr Cancellation = + PendingTileCancellation.FindRef(DequeuedChunk.Tile); + const bool bRequestObsolete = DequeuedChunk.bObsolete + || (Cancellation.IsValid() + && Cancellation->bObsolete.load(std::memory_order_acquire)); + PendingTileCancellation.Remove(DequeuedChunk.Tile); 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) + // Read the token here as well as on the worker: cancellation can race the final worker + // check after it has already enqueued a complete result. This also drops a stale result + // if the tile leaves and re-enters the desired set before the queue is drained. + if (DequeuedChunk.bAborted || bRequestObsolete) { + if (bRequestObsolete) + { + ++ObsoleteTileAbortCount; + } continue; } @@ -2059,8 +2329,11 @@ void AVoxelWorld::ProcessPendingChunks() // SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget). bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) { - // Discard results from a previous generation epoch (stale). - if (Result.bAborted || Result.Epoch != GenerationEpoch) + // Discard results from a previous generation epoch (stale), or for a tile that has since left + // the desired set. ProcessPendingChunks also reads the per-request token before removing it, + // closing the race where the worker enqueues just after CancelObsoleteTileWork flips the flag. + if (Result.bAborted || Result.Epoch != GenerationEpoch + || !IsDesired(Result.Tile)) { return false; } @@ -2070,9 +2343,8 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) // in HandleTileCollisionCookComplete. const bool bResultEmpty = Result.bEmpty || !Result.Streams; const bool bTraceTile = VoxelForgeStartupTrace::IsActive(); - const bool bTelemetry = GVoxelForgeProfileTileGeneration != 0 || bTraceTile; - const uint64 ApplyStartCycles = bTelemetry - ? FPlatformTime::Cycles64() : 0; + const uint64 ApplyStartCycles = FPlatformTime::Cycles64(); + Result.ApplyStartCycles = ApplyStartCycles; const auto LogTileApplied = [&]() { if ((GVoxelForgeProfileTileGeneration != 0 || bTraceTile) @@ -2112,8 +2384,6 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) Sample.Level = Result.Tile.Level; Sample.Verdict = Result.ClassifyVerdict; Sample.bEmpty = bResultEmpty; - Sample.bCacheHit = Result.bClassifierCacheHit; - Sample.bRegionHit = Result.bClassifierRegionHit; Sample.Triangles = Result.NumTriangles; Sample.RequestToApplySeconds = RequestToApply; Sample.WorkerQueueSeconds = WorkerQueueSeconds; @@ -2179,11 +2449,13 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) FPlatformTime::Cycles64() - EmptyReleaseStartCycles)); } LoadedTiles.Add(Result.Tile); - // Une tuile en vol n'est JAMAIS annulée : si le desired set a bougé pendant sa gen, elle - // arrive ici hors desired — le delta cull ne re-scanne plus tout, donc on l'inscrit en - // TransitionHold pour qu'elle soit re-considérée au prochain crossing (ou au settled cull). - if (!IsDesired(Result.Tile)) { AddToTransitionHold(Result.Tile); } LogTileApplied(); + if (Result.Tile.Level == 0 && Result.bRecordStreamingLatency) + { + RecordLOD0ReadySample(Result.RequestStartCycles, Result.GenerationStartCycles, + Result.GenerationEndCycles, Result.ApplyStartCycles, + FPlatformTime::Cycles64(), 0); + } return false; } @@ -2204,7 +2476,6 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) return false; } LoadedTiles.Add(Result.Tile); - if (!IsDesired(Result.Tile)) { AddToTransitionHold(Result.Tile); } LogTileApplied(); return true; } @@ -2238,6 +2509,8 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) } FChunkResult Result; + Result.DesiredEpoch = DesiredEpoch; + Result.bRecordStreamingLatency = false; Result.RequestStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; const uint64 GenerateStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; @@ -2245,7 +2518,7 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, /*bSheetTile*/ false, /*SheetChunkZ*/ 0, - /*Hole*/ 0, 0, 0, 0, Result); // hole unused (not a sheet tile) + /*Hole*/ 0, 0, 0, 0, Result, nullptr); // hole unused (not a sheet tile) Result.GenerationEndCycles = GenerateStartCycles != 0 ? FPlatformTime::Cycles64() : 0; @@ -2335,13 +2608,17 @@ static FORCEINLINE void VF_OuterShell(const UVoxelSettings* Settings, int32 R, i } } -void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, TArray& OutLeavers) +void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition, + APawn* PlayerPawn, const FVector& PlayerHeading, + TArray& OutLeavers) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles); DesiredSorted.Reset(); + CriticalDesiredTiles.Reset(); OutLeavers.Reset(); CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below ++DesiredStamp; // les upserts ci-dessous marquent le crossing courant + ++DesiredEpoch; const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; @@ -2487,14 +2764,110 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, TArrayGetActorForwardVector(); + } + Heading = GetActorTransform().InverseTransformVectorNoScale(Heading); + const float AbsX = FMath::Abs(Heading.X); + const float AbsY = FMath::Abs(Heading.Y); + const float AbsZ = FMath::Abs(Heading.Z); + FIntVector HeadingStep = FIntVector::ZeroValue; + if (AbsX > KINDA_SMALL_NUMBER || AbsY > KINDA_SMALL_NUMBER || AbsZ > KINDA_SMALL_NUMBER) + { + if (AbsX >= AbsY && AbsX >= AbsZ) + { + HeadingStep.X = Heading.X >= 0.0f ? 1 : -1; + } + else if (AbsY >= AbsZ) + { + HeadingStep.Y = Heading.Y >= 0.0f ? 1 : -1; + } + else + { + HeadingStep.Z = Heading.Z >= 0.0f ? 1 : -1; + } + } + if (HeadingStep != FIntVector::ZeroValue) + { + FVoxelTileKey NextTile = OccupiedTile; + NextTile.Coord += HeadingStep; + AddCriticalTile(NextTile); + } + AddCriticalTile(PlayerTile); + + // Insert in reverse so CriticalDesiredTiles[0] remains the occupied floor, [1] the heading + // tile, and all ordinary distance-sorted work follows both. + for (int32 Index = CriticalDesiredTiles.Num() - 1; Index >= 0; --Index) + { + const int32 ExistingIndex = DesiredSorted.Find(CriticalDesiredTiles[Index]); + if (ExistingIndex > 0) + { + const FVoxelTileKey Key = DesiredSorted[ExistingIndex]; + DesiredSorted.RemoveAt(ExistingIndex, 1, EAllowShrinking::No); + DesiredSorted.Insert(Key, 0); + } + } +} + +void AVoxelWorld::CancelObsoleteTileWork() +{ + for (const FVoxelTileKey& Tile : PendingTiles) + { + if (IsDesired(Tile)) + { + continue; + } + if (TSharedPtr* Cancellation = + PendingTileCancellation.Find(Tile)) + { + (*Cancellation)->bObsolete.store(true, std::memory_order_release); + } + } } // Fold every registered anchor's small level-0 box into the current desired set (§9.3). Runs inside @@ -2648,10 +3021,10 @@ int32 AVoxelWorld::GetMaxConcurrentTasks() const return FMath::Clamp(Asset, 1, CoreCap); } -void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) +void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APawn* PlayerPawn, + const FVector& PlayerHeading) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks); - VF_ParseTestStreamingCenter(); const int32 MaxTasks = GetMaxConcurrentTasks(); if (GVoxelForgeProfileTileGeneration != 0 && !GVoxelForgeStreamingBudgetReported) @@ -2676,11 +3049,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) GVoxelForgeStreamingBudgetReported = true; } - FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile - if (GVoxelForgeUseTestStreamingCenter) - { - CenterChunk = GVoxelForgeTestStreamingCenter; - } + const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile CurrentCenterChunk = CenterChunk; // Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the @@ -2714,12 +3083,11 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) bAllChunksLoaded = false; bForceDesiredRebuild = false; // consumed - // DELTA CULL: BuildDesiredTiles renvoie les LEAVERS (désirées au crossing précédent, plus - // maintenant). Seuls candidats au cull : ces leavers + la TransitionHold (retenues des - // crossings passés). Fini le re-scan de TOUTES les tuiles chargées à chaque crossing — - // c'était le spike CullTiles ~1.6 ms/crossing (trace 2026-07-05). Les tuiles en vol qui - // finissent hors desired sont capturées à l'apply (ProcessPendingChunks → TransitionHold), - // et le settled cull (tout chargé) reste le filet de sécurité plein-scan. + // DELTA CULL: BuildDesiredTiles returns the LEAVERS (desired on the previous crossing, but + // not now). Only those leavers + TransitionHold (retained across prior crossings) enter + // the cull. Pending work that leaves desired is marked obsolete before replacement submit; + // its aborted result is drained and dropped, while the settled cull remains the full-scan + // safety net. TArray Leavers; const bool bTraceFirstDesired = VoxelForgeStartupTrace::IsActive() && !bStartupTraceDesiredRecorded; @@ -2727,7 +3095,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) { VoxelForgeStartupTrace::StageBegin(TEXT("BuildDesiredTiles.first")); } - BuildDesiredTiles(CenterChunk, Leavers); + BuildDesiredTiles(CenterChunk, CenterPosition, PlayerPawn, PlayerHeading, Leavers); if (bTraceFirstDesired) { VoxelForgeStartupTrace::StageEnd(TEXT("BuildDesiredTiles.first")); @@ -2992,7 +3360,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) } //========================================================================= - // Submit pending work (budgeted, nearest-first). Once everything desired is loaded, + // 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. //========================================================================= if (!bAllChunksLoaded) @@ -3000,14 +3368,25 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles); int32 Submitted = 0; - // DIG RESPONSIVENESS — drain player-carve re-meshes FIRST (ahead of streaming + band) at - // BackgroundHigh, so a dig gets the task budget before any streaming gen. A tile that's still - // in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected. + // FLOOR RESPONSIVENESS — the occupied support tile and the next tile along the pawn heading + // are an absolute prefix, ahead of dirty remeshes, band work, and ordinary clipmap work. + // They run at BackgroundHigh so the pawn's floor gets the first available worker. + for (const FVoxelTileKey& T : CriticalDesiredTiles) + { + if (PendingTiles.Num() >= MaxTasks) break; + if (PendingTiles.Contains(T) || LoadedTiles.Contains(T)) continue; + LoadTile(T, /*bHighPriority*/ true); + ++Submitted; + } + + // DIG RESPONSIVENESS — drain player-carve re-meshes next at BackgroundHigh. A tile that's + // still in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected. for (auto It = DirtyRemeshQueue.CreateIterator(); It; ++It) { if (PendingTiles.Num() >= MaxTasks) break; const FVoxelTileKey T = *It; if (!LoadedTiles.Contains(T)) { It.RemoveCurrent(); continue; } // unloaded — drop + 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); @@ -3030,7 +3409,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition) if (PendingTiles.Num() >= MaxTasks) break; const FVoxelTileKey T = *It; It.RemoveCurrent(); - if (PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue; + if (!IsDesired(T) || PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue; LoadTile(T); ++Submitted; } @@ -3087,10 +3466,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) return; // Budget full — wait for a task to finish. } PendingTiles.Add(Tile); - const bool bTelemetry = GVoxelForgeProfileTileGeneration != 0 - || VoxelForgeStartupTrace::IsActive(); - const uint64 RequestStartCycles = bTelemetry - ? FPlatformTime::Cycles64() : 0; + const uint64 RequestStartCycles = FPlatformTime::Cycles64(); const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords @@ -3118,6 +3494,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) } const int32 Step = FMath::Max(1, Extent / Cells); const uint32 TaskEpoch = GenerationEpoch; + const uint32 TaskDesiredEpoch = DesiredEpoch; // CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level // (Step == 1< Cancellation = + MakeShared(); + PendingTileCancellation.Add(Tile, Cancellation); ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); // BackgroundNormal priority: 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. - // DIG RESPONSIVENESS: player carves launch at BackgroundHigh (bHighPriority) — still a background - // worker (yields to the frame, keeps the invariant) but jumps AHEAD of all pending streaming gen, - // so a dig is never queued behind a shell of streaming tasks. + // 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 ? 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, bTelemetry]() + BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, + bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, + RequestStartCycles, TaskDesiredEpoch, Cancellation]() { // RAII: decrement the counter on every exit path. struct FTaskGuard @@ -3206,19 +3588,51 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); } } Guard{ActiveTaskCount}; + auto EnqueueObsoleteResult = [&]() + { + if (bShuttingDown.load(std::memory_order_relaxed) + || bGenerationPaused.load(std::memory_order_relaxed)) + { + return; + } + FChunkResult Canceled; + Canceled.Tile = Tile; + Canceled.Epoch = TaskEpoch; + Canceled.DesiredEpoch = TaskDesiredEpoch; + Canceled.RequestStartCycles = RequestStartCycles; + Canceled.bAborted = true; + Canceled.bObsolete = true; + ProcessQueue.Enqueue(MoveTemp(Canceled)); + }; + if (ShouldAbortWork()) return; + if (Cancellation->bObsolete.load(std::memory_order_relaxed)) + { + EnqueueObsoleteResult(); + return; + } FChunkResult Result; - const uint64 GenerationStartCycles = bTelemetry - ? FPlatformTime::Cycles64() : 0; + Result.DesiredEpoch = TaskDesiredEpoch; + const uint64 GenerationStartCycles = FPlatformTime::Cycles64(); Result.GenerationStartCycles = GenerationStartCycles; GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, - bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result); + bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, + Result, &Cancellation->bObsolete); Result.GenerationEndCycles = GenerationStartCycles != 0 ? FPlatformTime::Cycles64() : 0; Result.RequestStartCycles = RequestStartCycles; + if (Cancellation->bObsolete.load(std::memory_order_relaxed) || Result.bAborted) + { + if (!ShouldAbortWork()) + { + EnqueueObsoleteResult(); + } + return; + } + if (!ShouldAbortWork()) { ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload @@ -3229,11 +3643,12 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority) // Worker-side gen for one tile (shared by the async ChunkGen task and the synchronous carve path). // READS Generator/Mesher only — safe on a worker or the game thread. Fills Result; no enqueue. void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels, - int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, - int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, - bool bSheetTile, int32 SheetChunkZ, - int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, - FChunkResult& Result) + int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, + int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, + bool bSheetTile, int32 SheetChunkZ, + int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, + FChunkResult& Result, + const std::atomic* ObsoleteFlag) { const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0; const bool bMeasureTile = bLogTileProfile || VoxelForgeStartupTrace::IsActive(); @@ -3346,10 +3761,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector Result.Tile = Tile; Result.Epoch = Epoch; Result.bAborted = false; + Result.bObsolete = false; Result.bEmpty = true; Result.ClassifyVerdict = -1; - Result.bClassifierCacheHit = false; - Result.bClassifierRegionHit = false; Result.ClassifySeconds = 0.0; Result.MeshSeconds = 0.0; Result.StreamSeconds = 0.0; @@ -3364,11 +3778,13 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector auto AbortResult = [&]() { Result.bAborted = true; + Result.bObsolete = ObsoleteFlag != nullptr + && ObsoleteFlag->load(std::memory_order_relaxed); Result.bEmpty = true; Result.Streams.Reset(); Result.CaptureGrid.Reset(); }; - if (ShouldAbortWork()) + if (ShouldAbortWork(ObsoleteFlag)) { AbortResult(); EmitTileProfile(); @@ -3414,7 +3830,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector bool bExactUniform = true; for (int32 Z = 0; Z <= ValidationCells && bExactUniform && !bValidationAborted; ++Z) { - if (ShouldAbortWork()) + if (ShouldAbortWork(ObsoleteFlag)) { bValidationAborted = true; break; @@ -3451,22 +3867,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector return; } ClassifySeconds = bMeasureTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0; - if (bMeasureTile) - { - const VoxelDensityProfile::FSnapshot ClassifyProfileEnd = - VoxelDensityProfile::SnapshotCurrentThread(); - const int32 CacheHitIndex = static_cast( - VoxelDensityProfile::ECounter::TileVerdictCacheHits); - const int32 RegionHitIndex = static_cast( - VoxelDensityProfile::ECounter::TileVerdictRegionHits); - Result.bClassifierCacheHit = - ClassifyProfileEnd.Counters[CacheHitIndex] > TileProfileStart.Counters[CacheHitIndex]; - Result.bClassifierRegionHit = - ClassifyProfileEnd.Counters[RegionHitIndex] > TileProfileStart.Counters[RegionHitIndex]; - } ClassifyVerdict = static_cast(ValidatedVerdict); Result.ClassifyVerdict = ClassifyVerdict; - if (ShouldAbortWork()) + if (ShouldAbortWork(ObsoleteFlag)) { AbortResult(); EmitTileProfile(); @@ -3501,7 +3904,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector BandVoxLo, BandVoxHi); MeshSeconds = bMeasureTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0; INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed); - if (ShouldAbortWork()) + if (ShouldAbortWork(ObsoleteFlag)) { AbortResult(); EmitTileProfile(); @@ -3518,7 +3921,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector Result.Streams = MakeShared(); BuildTileStreamSet(*Result.Streams, MeshData); StreamSeconds = bMeasureTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0; - if (ShouldAbortWork()) + if (ShouldAbortWork(ObsoleteFlag)) { AbortResult(); EmitTileProfile(); @@ -3640,6 +4043,13 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile) { // RMC futures are not cancellable from the world side. Remove this tile's state first so a // late completion from the old component cannot resurrect readiness after pool reuse. + // Generation work is different: mark it obsolete and let its normal aborted result drain so + // PendingTiles cannot be removed on behalf of an older request after a replacement is queued. + if (TSharedPtr* Cancellation = + PendingTileCancellation.Find(Tile)) + { + (*Cancellation)->bObsolete.store(true, std::memory_order_release); + } CollisionReadyTiles.Remove(Tile); CollisionNotRequiredTiles.Remove(Tile); CollisionSolidTiles.Remove(Tile); @@ -3652,7 +4062,6 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile) TileComponents.Remove(Tile); } LoadedTiles.Remove(Tile); - PendingTiles.Remove(Tile); TransitionHold.Remove(Tile); // couvre aussi le settled cull (qui ne tient pas la hold à jour) } @@ -3866,6 +4275,11 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) Pending.SubmissionId = SubmissionId; Pending.MeshSubmittedCycles = MeshSubmittedCycles; Pending.CollisionSubmittedCycles = CollisionSubmittedCycles; + Pending.RequestStartCycles = Result.RequestStartCycles; + Pending.GenerationStartCycles = Result.GenerationStartCycles; + Pending.GenerationEndCycles = Result.GenerationEndCycles; + Pending.ApplyStartCycles = Result.ApplyStartCycles; + Pending.bRecordStreamingLatency = Result.bRecordStreamingLatency; PendingCollisionCooks.Add(Tile, Pending); // Register the serial before asking RMC to dirty collision. The current implementation diff --git a/Source/VoxelForge/Public/VoxelDensityProfile.h b/Source/VoxelForge/Public/VoxelDensityProfile.h index d9705c4..6719989 100644 --- a/Source/VoxelForge/Public/VoxelDensityProfile.h +++ b/Source/VoxelForge/Public/VoxelDensityProfile.h @@ -120,9 +120,6 @@ namespace VoxelDensityProfile TileClassifyAllSolid, TileClassifyAllAir, TileClassifyMixed, - TileVerdictCacheHits, - TileVerdictRegionHits, - TileVerdictCacheStores, FusedTunnelSamples, FusedTunnelDetailSamples, FusedTunnelDetailSkipped, diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index bf253e1..ddb3634 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -9,10 +9,10 @@ #pragma once #include "CoreMinimal.h" -#include "HAL/CriticalSection.h" #include "VoxelTypes.h" #include "VoxelStrateTypes.h" #include "VoxelBiomeTypes.h" + #include "VoxelGenerator.generated.h" // Forward decls (évite les includes transitifs) @@ -119,12 +119,10 @@ public: void SetStrateManager(const UVoxelStrateManager* InManager) { StrateManager = InManager; - InvalidateTileVerdictCache(); } void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer) { DiffLayer = InDiffLayer; - InvalidateTileVerdictCache(); } // Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed. @@ -381,53 +379,6 @@ private: * Process-unique owner identity for the `CP_*` thread-local cache key. */ uint64 DensityCacheOwnerId = 0; - /** - * A bounded, session-resident proof cache for uniform MC tiles. - * - * This is deliberately not a disk cache: a verdict is only useful while the exact generator - * and its immutable layout are alive, and a stale "all air" verdict would be a hole. The key - * carries every runtime invalidator that can change the sampled field. Entries are only - * stored for AllSolid/AllAir; Mixed is never remembered because it is not a proof. - * - * A larger proven tile is also a proof for an aligned sub-tile. FindTileVerdict therefore - * accepts an enclosing entry and records that separately from an exact revisit. Capacity is - * fixed so repeated exploration cannot grow memory without bound. - */ - struct FTileVerdictCacheEntry - { - FIntVector Origin = FIntVector::ZeroValue; - int32 Step = 1; - int32 CellsPerAxis = 0; - int32 GeneratorSeed = 0; - int32 ManagerWorldSeed = 0; - float OriginSpineRadius = 0.0f; - float WorldRadiusVoxels = 0.0f; - float EdgeSealThickness = 0.0f; - uint64 ManagerLifetimeId = 0; - uint32 LayoutVersion = 0; - uint32 DiffVersion = 0; - uint64 ParamsFingerprint = 0; - uint8 PassageSupportFloorWrites = 1; - EVoxelTileClass Verdict = EVoxelTileClass::Mixed; - uint32 LastUse = 0; - }; - - static constexpr int32 TileVerdictCacheCapacity = 512; - mutable FCriticalSection TileVerdictCacheLock; - mutable TArray TileVerdictCache; - mutable uint32 TileVerdictCacheClock = 0; - - void InvalidateTileVerdictCache() const; - bool FindTileVerdict( - const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, - int32 ManagerWorldSeed, uint32 LayoutVersion, uint32 DiffVersion, - uint64 ParamsFingerprint, uint8 PassageSupportFloorWrites, - EVoxelTileClass& OutVerdict, bool& bOutRegionHit) const; - void StoreTileVerdict( - const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, - int32 ManagerWorldSeed, uint32 LayoutVersion, uint32 DiffVersion, - uint64 ParamsFingerprint, uint8 PassageSupportFloorWrites, - EVoxelTileClass Verdict) const; EVoxelTileClass ClassifyTileUncached( const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, struct FVoxelTileClassificationStats* OutStats) const; diff --git a/Source/VoxelForge/Public/VoxelStartupTrace.h b/Source/VoxelForge/Public/VoxelStartupTrace.h index e5eaa5d..b0ec06e 100644 --- a/Source/VoxelForge/Public/VoxelStartupTrace.h +++ b/Source/VoxelForge/Public/VoxelStartupTrace.h @@ -12,8 +12,6 @@ namespace VoxelForgeStartupTrace int32 Level = -1; int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (sheet/early path) bool bEmpty = true; - bool bCacheHit = false; - bool bRegionHit = false; int32 Triangles = 0; // This is worker-result -> game-thread mesh submission. Collision readiness is a later // per-tile RMC completion event recorded separately as collision_ready. diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index e2a7f02..db74fc8 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -33,6 +33,13 @@ class UMaterialInstanceDynamic; class FScopedGenerationPause; namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers +/** Per-request cancellation state. The game thread flips the flag when a pending tile leaves the + * desired set; workers only read it and return an aborted result through the normal MPSC queue. */ +struct FVoxelTileCancellationState +{ + std::atomic bObsolete{false}; +}; + /** * How a streaming anchor wants its tiles built (ARCHITECTURE §9.3). The local player is an implicit * FullVisual anchor (the clipmap). Extra anchors — AI, and later remote players — are registered so @@ -89,14 +96,21 @@ struct FChunkResult // Null ⇒ no-surface tile (uniform all-air/all-solid or an empty content band; no component). TSharedPtr Streams; uint32 Epoch = 0; // Generation epoch — discard if stale - // Monotonic request timestamp used only for streaming telemetry. It is carried through the - // worker queue so the profile can report request-to-apply, not just worker generation time. + // Monotonic request timestamp used for the low-overhead streaming latency summary and the + // opt-in profile. It is carried through the worker queue so the report can separate scheduler + // wait from generation time. uint64 RequestStartCycles = 0; // Worker timing boundaries for separating scheduler wait from generation and result/apply wait. - // These are telemetry-only and remain zero when tile profiling is disabled. + // These are captured with low overhead for the clean streaming-latency summary; expensive + // per-tile profiling remains opt-in. uint64 GenerationStartCycles = 0; - uint64 GenerationEndCycles = 0; int32 ClassifyVerdict = -1; bool bClassifierCacheHit = false; bool bClassifierRegionHit = false; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; - bool bAborted = false; // Worker observed shutdown; never mark this tile loaded + uint64 GenerationEndCycles = 0; + uint64 ApplyStartCycles = 0; + int32 ClassifyVerdict = -1; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; + bool bAborted = false; // Worker observed shutdown or cancellation; never mark this tile loaded + bool bObsolete = false; // Worker observed per-tile desired-set cancellation + uint32 DesiredEpoch = 0; // Desired-set epoch captured at request time + bool bRecordStreamingLatency = true; bool bEmpty = true; // true ⇒ no surface (uniform all-air/all-solid or empty band); still loaded // F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs @@ -225,6 +239,12 @@ public: uint64 CollisionSubmittedCycles = 0; bool bBodyUpdatedEventSeen = false; uint64 BodyUpdatedEventCycles = 0; + // Game-thread-only timing copied from the generation result for the clean LOD0 summary. + uint64 RequestStartCycles = 0; + uint64 GenerationStartCycles = 0; + uint64 GenerationEndCycles = 0; + uint64 ApplyStartCycles = 0; + bool bRecordStreamingLatency = false; }; /** One serialised completion record per tile. The serial prevents a late RMC future from @@ -474,6 +494,14 @@ private: /** Apply the current TVP0..4 + level-0 volume texture to one MID (also used on MID creation). */ void SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const; + /** Explicitly opt-in headless streaming validation. This is a command-line-only harness path; + * normal gameplay never moves the pawn here. It supplies a repeatable moving session and lets + * -nullrhi request a graceful EndPlay so the clean latency summary is flushed. */ + void ConfigureHeadlessStreamingTest(); + void AdvanceHeadlessStreamingTest(FVector& InOutPlayerPosition, + FVector& InOutPlayerHeading, APawn* PlayerPawn); + void MaybeFinishHeadlessStreamingTest(); + // Packed shader params, recomputed each Tick. ALL meaningful data is in .xyz — a material Vector // Parameter only delivers float3 (RGB) into a Custom node (the alpha is dropped), so we never use .w. // TVP0 = L0 WindowOrigin.xyz (world cm) TVP1 = L0 OriginMod.xyz (cells) @@ -647,7 +675,8 @@ public: * * @param CenterPosition - Usually the player's position */ - void UpdateChunksAroundPosition(const FVector& CenterPosition); + void UpdateChunksAroundPosition(const FVector& CenterPosition, APawn* PlayerPawn, + const FVector& PlayerHeading); /** * Load a single chunk at the given coordinate. @@ -673,7 +702,8 @@ public: int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, bool bSheetTile, int32 SheetChunkZ, int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, - FChunkResult& Result); + FChunkResult& Result, + const std::atomic* ObsoleteFlag = nullptr); /** * Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile): @@ -736,7 +766,10 @@ public: /** Build the clipmap desired-tile set (concentric shells) around the player tile. * OutLeavers = les tuiles désirées au crossing PRÉCÉDENT qui ne le sont plus — les seuls * candidats au cull (delta), au lieu de re-scanner TOUTES les tuiles chargées par crossing. */ - void BuildDesiredTiles(const FIntVector& CenterChunkCoord, TArray& OutLeavers); + void BuildDesiredTiles(const FIntVector& CenterChunkCoord, const FVector& PlayerPosition, + APawn* PlayerPawn, const FVector& PlayerHeading, + TArray& OutLeavers); + void CancelObsoleteTileWork(); /** True if a tile's world footprint is still within the outermost clip shell (so a * not-desired loaded tile there is mid-LOD-transition and must wait for its replacement, @@ -779,6 +812,10 @@ public: void BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh); void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh); void HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup); + void RecordLOD0ReadySample(uint64 RequestStartCycles, uint64 GenerationStartCycles, + uint64 GenerationEndCycles, uint64 ApplyStartCycles, + uint64 ReadyCycles, uint64 CollisionSubmittedCycles); + void LogStreamingLatencySummary() const; // (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap // streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.) @@ -793,6 +830,9 @@ public: // budget is exhausted and streaming stalls permanently. Mpsc guards the producer side. 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. + TMap> + PendingTileCancellation; // STRATE CONTENT CUT (see UVoxelSettings::StrateContentCutMinLevel) — the player-strate // chunk-Z band coarse tiles are meshed to (MIN/MAX sentinels = no cut, e.g. in the gap). @@ -830,10 +870,12 @@ public: // Number of async tasks currently running — EndPlay waits for this to reach 0 std::atomic ActiveTaskCount{0}; - FORCEINLINE bool ShouldAbortWork() const + FORCEINLINE bool ShouldAbortWork(const std::atomic* ObsoleteFlag = nullptr) const { return bShuttingDown.load(std::memory_order_relaxed) - || bGenerationPaused.load(std::memory_order_relaxed); + || bGenerationPaused.load(std::memory_order_relaxed) + || (ObsoleteFlag != nullptr + && ObsoleteFlag->load(std::memory_order_relaxed)); } // Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes. @@ -861,13 +903,40 @@ public: void ReconcileAnchorTileVisibility(); // --- Streaming work-avoidance (perf) --- - // The desired tile set only changes when the player crosses a level-0 tile boundary. - // We cache it and only rebuild/cull/sort on a real move, and go idle once every desired - // tile is streamed in — so a stationary player costs ~nothing per frame. + // The desired tile set only changes when the player/anchor crosses a level-0 tile boundary or + // a rebuild is forced. We cache it and only rebuild/cull/sort then, and go idle once every + // desired tile is streamed in — so a stationary player costs ~nothing per frame. FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); bool bAllChunksLoaded = false; bool bStartupTraceDesiredRecorded = false; - TArray DesiredSorted; // desired tiles, nearest-first + TArray DesiredSorted; // critical floor prefix, then true-pawn distance-first + TArray CriticalDesiredTiles; // occupied/support tile, then heading tile + uint32 DesiredEpoch = 0; // increments whenever DesiredStamped is rebuilt + int32 ObsoleteTileAbortCount = 0; // cumulative per-world moving-session cancellation count + float PeakObservedPawnSpeedCmPerSecond = 0.0f; + + struct FStreamingLatencySample + { + double RequestToReadySeconds = 0.0; + double QueueWaitSeconds = 0.0; + double GenerationSeconds = 0.0; + double CollisionCookSeconds = 0.0; + }; + TArray LOD0ReadySamples; + + // 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. + bool bHeadlessStreamingTestMovement = false; + bool bHeadlessStreamingTestExitRequested = false; + int32 HeadlessStreamingTestMoveAttempts = 0; + double HeadlessStreamingTestDistanceCm = 0.0; + float HeadlessStreamingTestSpeedCmPerSecond = 0.0f; + float HeadlessStreamingTestStartSeconds = 0.0f; + float HeadlessStreamingTestExitSeconds = 0.0f; + FVector HeadlessStreamingTestDirection = FVector::XAxisVector; + FVector HeadlessStreamingTestLastActualPosition = FVector::ZeroVector; + double HeadlessStreamingTestBeginSeconds = 0.0; + double HeadlessStreamingTestLastElapsedSeconds = 0.0; // PLAYER COLLISION GATE — the pawn is allowed to enter/leave a tile only when the level-0 // collision body covering its feet has completed the RMC cook. The movement component state is diff --git a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp index 5bb90e5..8d38816 100644 --- a/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp +++ b/Source/VoxelForgeEditor/Private/VoxelForgeExploreCommandlet.cpp @@ -268,7 +268,6 @@ struct FExploreArguments bool bProfileDensityFull = false; bool bProfileLod = false; bool bOpBounds = false; - bool bProfileTileVerdictCache = false; bool bFailureFocusRender = false; FString OutDirectory; @@ -361,8 +360,6 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull; OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds")); - OutArguments.bProfileTileVerdictCache = FParse::Param( - *Params, TEXT("profiletileverdictcache")); FParse::Value(*Params, TEXT("out="), OutText); FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); @@ -4352,8 +4349,6 @@ FString BuildExploreJson( Writer->WriteValue(TEXT("profile_density"), Arguments.bProfileDensity); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); - Writer->WriteValue(TEXT("profile_tile_verdict_cache"), - Arguments.bProfileTileVerdictCache); Writer->WriteValue(TEXT("op_bounds"), Arguments.bOpBounds); Writer->WriteValue(TEXT("budget_minutes"), static_cast(Arguments.BudgetMinutes)); Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); @@ -5410,66 +5405,6 @@ bool RunOpBounds( return true; } -bool RunTileVerdictCacheProbe( - const FExploreArguments& Arguments, - FExploreWorld& World, - FString& OutError) -{ - if (!World.Generator) - { - OutError = TEXT("tile-verdict cache probe has no generator."); - return false; - } - - // Deliberately use a region well above the synthetic layout. The first 32-cell proof is a - // genuine enclosing-region certificate; the 8-cell requests below are aligned subregions of - // that same lattice. No generated cave data is involved in this probe. - const FIntVector RegionOrigin(-32, -32, World.TargetTopWorldZ + 1024); - const EVoxelTileClass RegionVerdict = World.Generator->ClassifyTile( - RegionOrigin, 1, 32); - if (RegionVerdict != EVoxelTileClass::AllAir) - { - OutError = FString::Printf( - TEXT("tile-verdict cache probe expected an all-air enclosing region, got %d."), - static_cast(RegionVerdict)); - return false; - } - - auto ClassifyChildren = [&World, &RegionOrigin]() - { - for (int32 Z = 0; Z < 32; Z += 8) - for (int32 Y = 0; Y < 32; Y += 8) - for (int32 X = 0; X < 32; X += 8) - { - World.Generator->ClassifyTile( - RegionOrigin + FIntVector(X, Y, Z), 1, 8); - } - }; - - ClassifyChildren(); - World.Generator->ClassifyTile(RegionOrigin, 1, 32); - ClassifyChildren(); - - const VoxelDensityProfile::FSnapshot Snapshot = VoxelDensityProfile::Snapshot(); - const int32 CallsIndex = static_cast( - VoxelDensityProfile::ECounter::TileClassifyCalls); - const int32 HitsIndex = static_cast( - VoxelDensityProfile::ECounter::TileVerdictCacheHits); - const int32 RegionHitsIndex = static_cast( - VoxelDensityProfile::ECounter::TileVerdictRegionHits); - const int32 StoresIndex = static_cast( - VoxelDensityProfile::ECounter::TileVerdictCacheStores); - UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeTileVerdictCache] probe region=(%d,%d,%d) cells=32 verdict=AllAir " - "calls=%llu hits=%llu region_hits=%llu stores=%llu"), - RegionOrigin.X, RegionOrigin.Y, RegionOrigin.Z, - static_cast(Snapshot.Counters[CallsIndex]), - static_cast(Snapshot.Counters[HitsIndex]), - static_cast(Snapshot.Counters[RegionHitsIndex]), - static_cast(Snapshot.Counters[StoresIndex])); - return true; -} - } // namespace UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() @@ -5526,16 +5461,6 @@ int32 RunExploreCase(const FString& Params, FString* OutJson) } const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; - if (Arguments.bProfileTileVerdictCache) - { - if (!RunTileVerdictCacheProbe(Arguments, World, Error)) - { - UE_LOG(LogTemp, Error, - TEXT("[VoxelForgeExplore] tile-verdict cache probe failed: %s"), *Error); - return 1; - } - } - bool bRequestedModeFailed = false; if (Arguments.bOpBounds) { diff --git a/WORK-NEXT.md b/WORK-NEXT.md index 0b23493..06dd005 100644 --- a/WORK-NEXT.md +++ b/WORK-NEXT.md @@ -72,14 +72,15 @@ operation sits in the pipeline. It also makes interval proofs harder to trust. **Do**: make the stack the single semantic source of truth. Likely overlaps heavily with item 2. -## 5. Prove the verdict cache earns its place, or remove it · small +## 5. Verdict cache removed after moving-session audit · complete -Zero outer hits in **both** traces. Global lock, linear scan, 512 entries, keyed on sample step so it -barely reuses across LODs. It helped a deliberately repeated commandlet case, which is not how a -moving player streams. +The clean moving-session audit found zero outer or nested hits: 2,109 outer probes and 19,904 nested +probes produced 15,877 stores and 15,365 replacements in the full 512-entry table. Same-binary +cache-on/cache-off request-to-ready was 0.367645/0.365978 s p95 and generation was 0.285559/0.285992 +s p95, so the cache was neutral within run noise while paying the global lock and linear scans. -**Do**: measure nested/outer calls, hits and evictions on a real session. If it is neutral or negative, -take it out and reclaim the memory. +The global verdict cache, lock, entry table, invalidation path, cache-only profiler fields, and +deliberate cache probe are removed. The proof classifier remains; item 4 is still deliberately untouched. ## 6. Three small, sharp ones · tiny