perf(streaming): item 3 bought nothing, item 5 removed dead weight -- both worth knowing

Two WORK-NEXT items, one null result and one confirmed removal. Item 4 untouched.

ITEM 3 -- CANCEL OBSOLETE WORK AND PRIORITISE THE FLOOR: NULL RESULT
Clean -nullrhi, diagnostics off, 800 cm/s, 200 m of travel:

                          before            after
  request->ready p95      0.369117 s        0.367612 s
  queue wait p95          0.066949 s        0.064483 s
  generation p95          0.287926 s        0.283642 s
  p95 travel distance     2.952938 m        2.940894 m
  obsolete aborts                 0                12

Flat. Aborting obsolete tiles, sorting from the true pawn position and prioritising the
occupied/next tile changed request-to-ready by 0.4%.

⭐ AND THE NULL RESULT IS ITSELF THE FINDING. Look at the split: queue wait is 65 ms of a
368 ms p95; generation is 284 ms. **The queue was never the problem, so reordering it
cannot help.** Only 12 tiles in a 200 m run were obsolete enough to abort. The floor is
late because generation is slow, not because work is badly ordered -- item 3 establishes
that by exclusion, which is worth more than the 0.4%.

The machinery is kept: it is correct, it costs nothing, and it will matter once
generation is fast enough for ordering to be the binding constraint.

ITEM 5 -- VERDICT CACHE REMOVED. It was pure overhead.
Clean moving-session audit:

  outer  calls/probes/hits    2,109 / 2,109 / 0
  nested calls/probes/hits   19,904 / 19,904 / 0
  enclosing hits                              0
  stores 15,877, evictions 15,365

⇒ It stored 15,877 proofs, evicted 15,365 of them, and hit ZERO times. A moving player
visits unique regions; the cache was filling and thrashing a 512-entry table under a
global lock for no benefit. Same-binary A/B: request p95 0.367645 vs 0.365978,
generation p95 0.285559 vs 0.285992 -- neutral within noise, so its cost was the lock and
the linear scans rather than time we can see.

Removed: the global cache, its lock, the entry table, the invalidation path, the
cache-only profiler fields and the synthetic probe. The proof classifier stays.

⚠️ I asked for this cache two days ago on the strength of a commandlet measurement that
re-visited the same region repeatedly. That access pattern is nothing like a moving
player, and I should have said so when specifying it.

VERIFIED BY ME: build 0 errors; export 0.563 / 0.565 / 0.566 s (35de59b was 0.545-0.563,
flat as expected); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED; capability 20,830 /
10,909, connected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-12 06:49:20 +02:00
1 parent 35de59bac5
commit cf0319d480
12 files changed
+660 -514

No files matched your search

+24 -12
View File
@@ -557,10 +557,16 @@ ApplyModification` (BlueprintCallable) + `EditorCarveSphere/EditorFillSphere` (C
driven by `EditorBrush*` props. driven by `EditorBrush*` props.
### 8.10 Performance invariants (DON'T regress) ### 8.10 Performance invariants (DON'T regress)
- **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set ONLY when the - **Streaming** (`UpdateChunksAroundPosition`): rebuild/cull the desired set only when the player
player crosses a chunk boundary (`LastUpdateCenter`); use the `DesiredSet` TSet for the cull; crosses a level-0 chunk boundary, an anchor moves, or a rebuild is forced; use the stamped
idle via `bAllChunksLoaded`. Stationary player ≈ free. (Old per-frame O(loaded×desired) scan = 22ms.) desired map for the cull and idle via `bAllChunksLoaded`. On every rebuild, pending requests whose
- **LOD** changes HOT-SWAP (`LoadChunk` only, never unload-first) → no holes. LOD 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), 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 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 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 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- 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 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 — the better mesh is wholly in, then swaps. In-flight requests are cancelled when their key leaves the
they finish, apply, and are culled later if no longer desired. Collision level-0 only; water level-0 desired set: the game thread flips the request token, the worker returns an aborted result, and the
only; shadows off for level≥2. **Decorations are NO LONGER tied to tiles** — they stream on a fixed world 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), 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` `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 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` (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 (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 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 trace, plus `BuildDesiredTiles` 0.66 ms on the same frames). Pending requests that become leavers
no longer desired are caught at apply time (`ProcessPendingChunks` adds them to the hold); are marked obsolete before replacement submission; `ProcessPendingChunks` reads the token, drops
`UnloadTile` drops hold entries; the settled cull (everything loaded) keeps its full scan as the aborted result, and does not put it in the hold. `ApplyTileResult` independently rejects a
the safety net. `DesiredPending` for the overlap test is now built LAZILY (only when an wrong generation epoch or undesired key. `UnloadTile` drops hold entries; the settled cull
in-range transition candidate exists). **The hold is re-evaluated on a ROTATING BUDGET** (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 (`TransitionHoldQueue` + cursor, ~256 tiles/crossing): re-scanning the whole hold each
crossing degenerates back to the O(loaded) scan whenever streaming never settles (measured 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 2.47 ms/crossing in the first packaged capture) — keeping a tile a few crossings longer is
+13 -11
View File
@@ -47,7 +47,7 @@ A **density-field voxel terrain** plugin for Unreal Engine, built around undergr
Tick → UpdateChunksAroundPosition Tick → UpdateChunksAroundPosition
│ (load/unload around player, by distance + LOD) │ (load/unload around player, by distance + LOD)
▼ ▼
LoadChunk → UE::Tasks::Launch ──────────► background thread LoadTile → UE::Tasks::Launch ──────────► background thread
│ │
UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘ UVoxelMarchingCubesMesher::GenerateMesh(chunk, step) ◄────────────────┘
│ samples density per cell corner │ 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` ### 3.5 World orchestrator — `Public/VoxelWorld.h` + `Private/VoxelWorld.cpp`
`AVoxelWorld : AActor` — owns everything, drives streaming. Also `FChunkResult` struct `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`, **Owned objects (UPROPERTY):** `Settings`, `Generator`, `Mesher`, `StrateManager`,
`DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map, `DiffLayer` (VoxelWorld.h:55-78). **Storage:** `Chunks` map, `ChunkMeshes` map,
`ChunkLODs`, `ProcessQueue` (TQueue), `PendingChunkCoord` (TSet) (VoxelWorld.h:85-312). `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** : **⭐ 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` / `(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`). | | `OnObjectModifiedInEditor` | 58 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). |
| `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | | `EndPlay` | 140 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. |
| `BeginPlay` | 177 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. | | `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.) | | `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`. | | `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 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). | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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. | | `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; the component is PARKED in the pool (T2.c), not destroyed. | | `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. | | `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. | | `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. | | `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). | | View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). |
| LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. | | LOD step mapping | `AVoxelWorld::LODToStep` VoxelWorld.cpp:268; `GetLODForChunk` :242. |
| How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:362. | | 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`. | | 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. | | Tweak room/tunnel shapes | `VoxelCaveMorphology.cpp` `BuildChunkCache` :47 / `EvaluateSDFCached` :757. |
| Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:1241. |
@@ -480,9 +480,6 @@ namespace VoxelDensityProfile
case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid"); case ECounter::TileClassifyAllSolid: return TEXT("TileClassifyAllSolid");
case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir"); case ECounter::TileClassifyAllAir: return TEXT("TileClassifyAllAir");
case ECounter::TileClassifyMixed: return TEXT("TileClassifyMixed"); 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::FusedTunnelSamples: return TEXT("FusedTunnelSamples");
case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples"); case ECounter::FusedTunnelDetailSamples: return TEXT("FusedTunnelDetailSamples");
case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped"); case ECounter::FusedTunnelDetailSkipped: return TEXT("FusedTunnelDetailSkipped");
+1 -216
View File
@@ -22,7 +22,6 @@
#include "HAL/PlatformTime.h" #include "HAL/PlatformTime.h"
#include "HAL/IConsoleManager.h" #include "HAL/IConsoleManager.h"
#include "Misc/CommandLine.h" #include "Misc/CommandLine.h"
#include "Misc/ScopeLock.h"
#if WITH_EDITOR #if WITH_EDITOR
#include "VoxelStrateComposer.h" #include "VoxelStrateComposer.h"
@@ -56,6 +55,7 @@ namespace
GVoxelForgeUseFusedEvaluator = CommandLineValue; 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<int64>(OriginVoxels.X)
+ static_cast<int64>(CellsPerAxis) * Step;
const int64 RequestMaxY = static_cast<int64>(OriginVoxels.Y)
+ static_cast<int64>(CellsPerAxis) * Step;
const int64 RequestMaxZ = static_cast<int64>(OriginVoxels.Z)
+ static_cast<int64>(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<int64>(Entry.Origin.X)
+ static_cast<int64>(Entry.CellsPerAxis) * Step;
const int64 EntryMaxY = static_cast<int64>(Entry.Origin.Y)
+ static_cast<int64>(Entry.CellsPerAxis) * Step;
const int64 EntryMaxZ = static_cast<int64>(Entry.Origin.Z)
+ static_cast<int64>(Entry.CellsPerAxis) * Step;
const int64 DeltaX = static_cast<int64>(OriginVoxels.X) - Entry.Origin.X;
const int64 DeltaY = static_cast<int64>(OriginVoxels.Y) - Entry.Origin.Y;
const int64 DeltaZ = static_cast<int64>(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) void UVoxelGenerator::InitializeSettings(const UVoxelSettings* Settings)
{ {
// Les paramètres globaux sont copiés ici une seule fois : le chemin voxel ne doit pas // 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; OriginSpineRadius = Settings ? Settings->GetEffectiveOriginSpineRadius() : 14.0f;
WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f; WorldRadiusVoxels = Settings ? Settings->GetEffectiveWorldRadiusVoxels() : 0.0f;
EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f; EdgeSealThickness = Settings ? Settings->EdgeSealThickness : 64.0f;
InvalidateTileVerdictCache();
} }
void UVoxelGenerator::BeginDensityBlock(FIntVector OriginVoxels, int32 Step, void UVoxelGenerator::BeginDensityBlock(FIntVector OriginVoxels, int32 Step,
@@ -5115,48 +4951,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
VoxelDensityProfile::AddCounter( VoxelDensityProfile::AddCounter(
VoxelDensityProfile::ECounter::TileClassifyCalls); 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( const EVoxelTileClass Verdict = ClassifyTileUncached(
OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, OutStats); OriginVoxels, NormalizedStep, NormalizedCellsPerAxis, OutStats);
if (Verdict == EVoxelTileClass::AllSolid) if (Verdict == EVoxelTileClass::AllSolid)
@@ -5175,15 +4969,6 @@ EVoxelTileClass UVoxelGenerator::ClassifyTile(const FIntVector& OriginVoxels, in
VoxelDensityProfile::ECounter::TileClassifyMixed); 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; return Verdict;
} }
@@ -98,8 +98,6 @@ namespace
int32 AllAir = 0; int32 AllAir = 0;
int32 AllSolid = 0; int32 AllSolid = 0;
int32 Mixed = 0; int32 Mixed = 0;
int32 CacheHits = 0;
int32 RegionHits = 0;
int64 Triangles = 0; int64 Triangles = 0;
for (const FTileSample& Sample : Samples) for (const FTileSample& Sample : Samples)
@@ -110,8 +108,6 @@ namespace
if (Sample.Verdict == 0) ++Mixed; if (Sample.Verdict == 0) ++Mixed;
else if (Sample.Verdict == 1) ++AllSolid; else if (Sample.Verdict == 1) ++AllSolid;
else if (Sample.Verdict == 2) ++AllAir; else if (Sample.Verdict == 2) ++AllAir;
if (Sample.bCacheHit) ++CacheHits;
if (Sample.bRegionHit) ++RegionHits;
Triangles += Sample.Triangles; Triangles += Sample.Triangles;
Request.Add(Sample.RequestToApplySeconds); Request.Add(Sample.RequestToApplySeconds);
Queue.Add(Sample.QueueWaitSeconds); Queue.Add(Sample.QueueWaitSeconds);
@@ -122,14 +118,14 @@ namespace
} }
return FString::Printf( 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}," "\"request_apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"mesh_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"mesh_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}}"), "\"apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}}"),
Count, Empty, AllAir, AllSolid, Mixed, CacheHits, RegionHits, static_cast<long long>(Triangles), Count, Empty, AllAir, AllSolid, Mixed, static_cast<long long>(Triangles),
*Number(Percentile(Request, 0.50)), *Number(Percentile(Request, 0.95)), *Number(Maximum(Request)), *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(Queue, 0.50)), *Number(Percentile(Queue, 0.95)), *Number(Maximum(Queue)),
*Number(Percentile(Generation, 0.50)), *Number(Percentile(Generation, 0.95)), *Number(Maximum(Generation)), *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->Samples.Add(Sample);
++GState->TileRecordsWritten; ++GState->TileRecordsWritten;
WriteEventLocked(*GState, TEXT("tile"), FString::Printf( 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," "\"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"), "\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"),
Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, Sample.Triangles,
Sample.bCacheHit ? 1 : 0, Sample.bRegionHit ? 1 : 0, Sample.Triangles,
*Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds), *Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds),
*Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds), *Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds),
*Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds), *Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds),
+527 -113
View File
@@ -42,6 +42,7 @@
#include "Misc/CommandLine.h" #include "Misc/CommandLine.h"
#include "Misc/Paths.h" #include "Misc/Paths.h"
#include "HAL/IConsoleManager.h" #include "HAL/IConsoleManager.h"
#include "HAL/PlatformMisc.h"
#include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0) #include "ProfilingDebugging/CpuProfilerTrace.h" // Unreal Insights scopes (Perf 0)
#if WITH_EDITOR #if WITH_EDITOR
@@ -79,33 +80,6 @@ namespace
bool GVoxelForgeStreamingBudgetReported = false; bool GVoxelForgeStreamingBudgetReported = false;
bool GVoxelForgeGenerationCapHitReported = false; bool GVoxelForgeGenerationCapHitReported = false;
int32 GVoxelForgeMaxPendingTilesObserved = 0; 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() AVoxelWorld::AVoxelWorld()
@@ -473,8 +447,22 @@ void AVoxelWorld::RegenerateAllChunks()
// Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick. // Density volume: bump epoch (drop in-flight fills) + drop data → full refill next Tick.
if (DensityVolume) { DensityVolume->Reset(); } if (DensityVolume) { DensityVolume->Reset(); }
// Clear pending set — stale tasks will be discarded by the epoch check. // Mark any still-running request obsolete. Keep its PendingTiles slot and token map until the
PendingTiles.Empty(); // 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<FVoxelTileKey, TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>>& 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 // Tiles are already destroyed above — drop any deferred-teardown keys so the drain doesn't
// try to UnloadTile coords that no longer exist. // try to UnloadTile coords that no longer exist.
PendingUnload.Empty(); PendingUnload.Empty();
@@ -489,7 +477,9 @@ void AVoxelWorld::RegenerateAllChunks()
LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
bAllChunksLoaded = false; bAllChunksLoaded = false;
DesiredSorted.Reset(); DesiredSorted.Reset();
CriticalDesiredTiles.Reset();
DesiredStamped.Reset(); DesiredStamped.Reset();
++DesiredEpoch;
TransitionHold.Reset(); TransitionHold.Reset();
TransitionHoldQueue.Reset(); TransitionHoldQueue.Reset();
TransitionHoldCursor = 0; TransitionHoldCursor = 0;
@@ -970,13 +960,17 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason)
} }
FPlatformProcess::Yield(); // Give CPU to other threads 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 // Drain any queued results
FChunkResult Discard; 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(); PendingTiles.Empty();
PendingTileCancellation.Empty();
PendingUnload.Empty(); PendingUnload.Empty();
DirtyRemeshQueue.Empty(); DirtyRemeshQueue.Empty();
BandRemeshQueue.Empty(); BandRemeshQueue.Empty();
@@ -1034,6 +1028,10 @@ void AVoxelWorld::BeginPlay()
VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay")); VoxelForgeStartupTrace::FStageScope StartupTraceStage(TEXT("BeginPlay"));
Super::BeginPlay(); Super::BeginPlay();
bShuttingDown.store(false, std::memory_order_relaxed); 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 // 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 // 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<FString> 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<double>(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<double>(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<float>(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<ACharacter>(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<double>(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) void AVoxelWorld::Tick(float DeltaTime)
{ {
Super::Tick(DeltaTime); Super::Tick(DeltaTime);
@@ -1225,8 +1372,15 @@ void AVoxelWorld::Tick(float DeltaTime)
const bool bHasPlayer = TryGetPlayerPosition(PlayerLastPos, &PlayerPawn); const bool bHasPlayer = TryGetPlayerPosition(PlayerLastPos, &PlayerPawn);
if (bHasPlayer) { 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); UpdatePawnCollisionGate(PlayerPawn, PlayerLastPos, DeltaTime);
UpdateChunksAroundPosition(PlayerLastPos); UpdateChunksAroundPosition(PlayerLastPos, PlayerPawn, PlayerHeading);
if (AtmosphereManager) if (AtmosphereManager)
{ {
AtmosphereManager->UpdateForPlayer(PlayerLastPos); AtmosphereManager->UpdateForPlayer(PlayerLastPos);
@@ -1320,13 +1474,15 @@ void AVoxelWorld::Tick(float DeltaTime)
Max.Z = FMath::Max(Max.Z, Key.Coord.Z); Max.Z = FMath::Max(Max.Z, Key.Coord.Z);
} }
VoxelForgeStartupTrace::RecordEvent(TEXT("steady_state"), FString::Printf( VoxelForgeStartupTrace::RecordEvent(TEXT("steady_state"), FString::Printf(
TEXT("\"desired_tiles\":%d,\"loaded_tiles\":%d,\"collision_ready_tiles\":%d," TEXT("\"desired_tiles\":%d,\"loaded_tiles\":%d,\"collision_ready_tiles\":%d,"
"\"collision_not_required_tiles\":%d,\"pending_collision_cooks\":%d," "\"collision_not_required_tiles\":%d,\"pending_collision_cooks\":%d,"
"\"pawn_collision_gate_engaged\":%d," "\"pawn_collision_gate_engaged\":%d,"
"\"obsolete_tile_aborts\":%d,\"desired_epoch\":%u,"
"\"pending_tiles\":%d,\"pending_unload\":%d,\"generation_epoch\":%u"), "\"pending_tiles\":%d,\"pending_unload\":%d,\"generation_epoch\":%u"),
DesiredSorted.Num(), LoadedTiles.Num(), CollisionReadyTiles.Num(), DesiredSorted.Num(), LoadedTiles.Num(), CollisionReadyTiles.Num(),
CollisionNotRequiredTiles.Num(), PendingCollisionCooks.Num(), CollisionNotRequiredTiles.Num(), PendingCollisionCooks.Num(),
bPawnCollisionGateEngaged ? 1 : 0, bPawnCollisionGateEngaged ? 1 : 0,
ObsoleteTileAbortCount, DesiredEpoch,
PendingTiles.Num(), PendingUnload.Num(), GenerationEpoch)); PendingTiles.Num(), PendingUnload.Num(), GenerationEpoch));
TArray<int32> SteadyLevels; TArray<int32> SteadyLevels;
SteadyLevelCounts.GetKeys(SteadyLevels); SteadyLevelCounts.GetKeys(SteadyLevels);
@@ -1376,6 +1532,7 @@ void AVoxelWorld::Tick(float DeltaTime)
GVoxelForgePendingModificationStrength); GVoxelForgePendingModificationStrength);
} }
ProcessUnloadQueue(); ProcessUnloadQueue();
MaybeFinishHeadlessStreamingTest();
#if ENABLE_DRAW_DEBUG #if ENABLE_DRAW_DEBUG
// Density-volume overlay (step 1a): cyan boxes for solid level-0 cells near the player. // 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<double> RequestToReady;
TArray<double> QueueWait;
TArray<double> Generation;
TArray<double> 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<double> Values, double Fraction) -> double
{
if (Values.Num() == 0) return 0.0;
Values.Sort();
const int32 Index = FMath::Clamp(
FMath::CeilToInt(Fraction * static_cast<double>(Values.Num() - 1)),
0, Values.Num() - 1);
return Values[Index];
};
const auto Maximum = [](const TArray<double>& 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<double>(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( void AVoxelWorld::HandleTileCollisionCookComplete(
const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result) const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result)
{ {
@@ -1893,6 +2139,11 @@ void AVoxelWorld::HandleTileCollisionCookComplete(
const uint64 MeasuredCollisionSubmittedCycles = Pending->CollisionSubmittedCycles; const uint64 MeasuredCollisionSubmittedCycles = Pending->CollisionSubmittedCycles;
const bool bBodyUpdatedEventSeen = Pending->bBodyUpdatedEventSeen; const bool bBodyUpdatedEventSeen = Pending->bBodyUpdatedEventSeen;
const uint64 BodyUpdatedEventCycles = Pending->BodyUpdatedEventCycles; 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); PendingCollisionCooks.Remove(Tile);
const ERealtimeMeshCollisionUpdateResult CollisionResult = const ERealtimeMeshCollisionUpdateResult CollisionResult =
@@ -1913,6 +2164,12 @@ void AVoxelWorld::HandleTileCollisionCookComplete(
} }
const uint64 ReadyCycles = FPlatformTime::Cycles64(); const uint64 ReadyCycles = FPlatformTime::Cycles64();
if (bBodyInstalled && Tile.Level == 0 && bRecordStreamingLatency)
{
RecordLOD0ReadySample(MeasuredRequestStartCycles, MeasuredGenerationStartCycles,
MeasuredGenerationEndCycles, MeasuredApplyStartCycles,
ReadyCycles, MeasuredCollisionSubmittedCycles);
}
const double MeshToCookSeconds = FPlatformTime::ToSeconds64( const double MeshToCookSeconds = FPlatformTime::ToSeconds64(
ReadyCycles - MeasuredMeshSubmittedCycles); ReadyCycles - MeasuredMeshSubmittedCycles);
const double CollisionRequestToCookSeconds = FPlatformTime::ToSeconds64( const double CollisionRequestToCookSeconds = FPlatformTime::ToSeconds64(
@@ -2008,12 +2265,25 @@ void AVoxelWorld::ProcessPendingChunks()
while (ProcessQueue.Dequeue(DequeuedChunk)) while (ProcessQueue.Dequeue(DequeuedChunk))
{ {
++ResultsDrained; ++ResultsDrained;
const TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe> 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); PendingTiles.Remove(DequeuedChunk.Tile);
// A worker can observe shutdown after entering the mesher. Do not turn that partial // 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. // 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; continue;
} }
@@ -2059,8 +2329,11 @@ void AVoxelWorld::ProcessPendingChunks()
// SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget). // SyncRemeshTile (synchronous carve). Returns true iff a visible mesh was uploaded (budget).
bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
{ {
// Discard results from a previous generation epoch (stale). // Discard results from a previous generation epoch (stale), or for a tile that has since left
if (Result.bAborted || Result.Epoch != GenerationEpoch) // 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; return false;
} }
@@ -2070,9 +2343,8 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
// in HandleTileCollisionCookComplete. // in HandleTileCollisionCookComplete.
const bool bResultEmpty = Result.bEmpty || !Result.Streams; const bool bResultEmpty = Result.bEmpty || !Result.Streams;
const bool bTraceTile = VoxelForgeStartupTrace::IsActive(); const bool bTraceTile = VoxelForgeStartupTrace::IsActive();
const bool bTelemetry = GVoxelForgeProfileTileGeneration != 0 || bTraceTile; const uint64 ApplyStartCycles = FPlatformTime::Cycles64();
const uint64 ApplyStartCycles = bTelemetry Result.ApplyStartCycles = ApplyStartCycles;
? FPlatformTime::Cycles64() : 0;
const auto LogTileApplied = [&]() const auto LogTileApplied = [&]()
{ {
if ((GVoxelForgeProfileTileGeneration != 0 || bTraceTile) if ((GVoxelForgeProfileTileGeneration != 0 || bTraceTile)
@@ -2112,8 +2384,6 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
Sample.Level = Result.Tile.Level; Sample.Level = Result.Tile.Level;
Sample.Verdict = Result.ClassifyVerdict; Sample.Verdict = Result.ClassifyVerdict;
Sample.bEmpty = bResultEmpty; Sample.bEmpty = bResultEmpty;
Sample.bCacheHit = Result.bClassifierCacheHit;
Sample.bRegionHit = Result.bClassifierRegionHit;
Sample.Triangles = Result.NumTriangles; Sample.Triangles = Result.NumTriangles;
Sample.RequestToApplySeconds = RequestToApply; Sample.RequestToApplySeconds = RequestToApply;
Sample.WorkerQueueSeconds = WorkerQueueSeconds; Sample.WorkerQueueSeconds = WorkerQueueSeconds;
@@ -2179,11 +2449,13 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
FPlatformTime::Cycles64() - EmptyReleaseStartCycles)); FPlatformTime::Cycles64() - EmptyReleaseStartCycles));
} }
LoadedTiles.Add(Result.Tile); 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(); LogTileApplied();
if (Result.Tile.Level == 0 && Result.bRecordStreamingLatency)
{
RecordLOD0ReadySample(Result.RequestStartCycles, Result.GenerationStartCycles,
Result.GenerationEndCycles, Result.ApplyStartCycles,
FPlatformTime::Cycles64(), 0);
}
return false; return false;
} }
@@ -2204,7 +2476,6 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
return false; return false;
} }
LoadedTiles.Add(Result.Tile); LoadedTiles.Add(Result.Tile);
if (!IsDesired(Result.Tile)) { AddToTransitionHold(Result.Tile); }
LogTileApplied(); LogTileApplied();
return true; return true;
} }
@@ -2238,6 +2509,8 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile)
} }
FChunkResult Result; FChunkResult Result;
Result.DesiredEpoch = DesiredEpoch;
Result.bRecordStreamingLatency = false;
Result.RequestStartCycles = bProfileModification Result.RequestStartCycles = bProfileModification
? FPlatformTime::Cycles64() : 0; ? FPlatformTime::Cycles64() : 0;
const uint64 GenerateStartCycles = 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, GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false,
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
/*bSheetTile*/ false, /*SheetChunkZ*/ 0, /*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 Result.GenerationEndCycles = GenerateStartCycles != 0
? FPlatformTime::Cycles64() : 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<FVoxelTileKey>& OutLeavers) void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& PlayerPosition,
APawn* PlayerPawn, const FVector& PlayerHeading,
TArray<FVoxelTileKey>& OutLeavers)
{ {
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles); TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_BuildDesiredTiles);
DesiredSorted.Reset(); DesiredSorted.Reset();
CriticalDesiredTiles.Reset();
OutLeavers.Reset(); OutLeavers.Reset();
CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below
++DesiredStamp; // les upserts ci-dessous marquent le crossing courant ++DesiredStamp; // les upserts ci-dessous marquent le crossing courant
++DesiredEpoch;
const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3; const int32 R = Settings ? FMath::Max(1, Settings->ClipRadius) : 3;
const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4; const int32 MaxLevel = Settings ? FMath::Clamp(Settings->MaxClipLevel, 0, 8) : 4;
@@ -2487,14 +2764,110 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, TArray<FVoxelTileK
} }
} }
// Nearest-first (by tile-centre distance to the player), so the closest tiles stream first. // Cancel work that no longer belongs to this desired set before scheduling replacements. A
const FVector PlayerVoxel = (FVector(Center) + FVector(0.5f, 0.5f, 0.5f)) * (float)CHUNK_SIZE; // worker owns no mutable world state; it only observes its request token in GenerateTileResult.
CancelObsoleteTileWork();
// Nearest-first (by tile-centre distance to the TRUE pawn position), so a large tile's
// artificial chunk-centre approximation cannot put work behind the player. The explicit key
// tie-break keeps equal-distance ordering deterministic across runs.
const FVector PlayerVoxel = WorldToLocalCm(PlayerPosition) / VOXEL_SIZE;
DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B) DesiredSorted.Sort([&PlayerVoxel](const FVoxelTileKey& A, const FVoxelTileKey& B)
{ {
const FVector CA = A.CenterCm() / VOXEL_SIZE; const FVector CA = A.CenterCm() / VOXEL_SIZE;
const FVector CB = B.CenterCm() / VOXEL_SIZE; const FVector CB = B.CenterCm() / VOXEL_SIZE;
return FVector::DistSquared(CA, PlayerVoxel) < FVector::DistSquared(CB, PlayerVoxel); const float DA = FVector::DistSquared(CA, PlayerVoxel);
const float DB = FVector::DistSquared(CB, PlayerVoxel);
if (DA != DB) { return DA < DB; }
if (A.Level != B.Level) { return A.Level < B.Level; }
if (A.Coord.X != B.Coord.X) { return A.Coord.X < B.Coord.X; }
if (A.Coord.Y != B.Coord.Y) { return A.Coord.Y < B.Coord.Y; }
return A.Coord.Z < B.Coord.Z;
}); });
// Absolute floor priority. The support tile is the level-0 tile under the pawn's feet — the
// one that can catch a falling pawn — followed by the adjacent tile in the dominant movement
// direction. Keep the actor-centre tile as a fallback for capsule/offset edge cases. Only keys
// in the current desired set are promoted, so this never expands the streaming footprint.
const FVoxelTileKey PlayerTile(
WorldToChunkCoord(WorldToLocalCm(PlayerPosition)), 0);
FVoxelTileKey OccupiedTile = PlayerTile;
FVoxelTileKey SupportTile;
if (GetPlayerSupportTile(PlayerPawn, PlayerPosition, SupportTile))
{
OccupiedTile = SupportTile;
}
auto AddCriticalTile = [this](const FVoxelTileKey& Key)
{
if (IsDesired(Key) && !CriticalDesiredTiles.Contains(Key))
{
CriticalDesiredTiles.Add(Key);
}
};
AddCriticalTile(OccupiedTile);
FVector Heading = PlayerHeading;
if (Heading.IsNearlyZero() && IsValid(PlayerPawn))
{
Heading = PlayerPawn->GetActorForwardVector();
}
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<FVoxelTileCancellationState, ESPMode::ThreadSafe>* 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 // 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); 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); TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_UpdateChunks);
VF_ParseTestStreamingCenter();
const int32 MaxTasks = GetMaxConcurrentTasks(); const int32 MaxTasks = GetMaxConcurrentTasks();
if (GVoxelForgeProfileTileGeneration != 0 if (GVoxelForgeProfileTileGeneration != 0
&& !GVoxelForgeStreamingBudgetReported) && !GVoxelForgeStreamingBudgetReported)
@@ -2676,11 +3049,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
GVoxelForgeStreamingBudgetReported = true; GVoxelForgeStreamingBudgetReported = true;
} }
FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile
if (GVoxelForgeUseTestStreamingCenter)
{
CenterChunk = GVoxelForgeTestStreamingCenter;
}
CurrentCenterChunk = CenterChunk; CurrentCenterChunk = CenterChunk;
// Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the // 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; bAllChunksLoaded = false;
bForceDesiredRebuild = false; // consumed bForceDesiredRebuild = false; // consumed
// DELTA CULL: BuildDesiredTiles renvoie les LEAVERS (désirées au crossing précédent, plus // DELTA CULL: BuildDesiredTiles returns the LEAVERS (desired on the previous crossing, but
// maintenant). Seuls candidats au cull : ces leavers + la TransitionHold (retenues des // not now). Only those leavers + TransitionHold (retained across prior crossings) enter
// crossings passés). Fini le re-scan de TOUTES les tuiles chargées à chaque crossing — // the cull. Pending work that leaves desired is marked obsolete before replacement submit;
// c'était le spike CullTiles ~1.6 ms/crossing (trace 2026-07-05). Les tuiles en vol qui // its aborted result is drained and dropped, while the settled cull remains the full-scan
// finissent hors desired sont capturées à l'apply (ProcessPendingChunks → TransitionHold), // safety net.
// et le settled cull (tout chargé) reste le filet de sécurité plein-scan.
TArray<FVoxelTileKey> Leavers; TArray<FVoxelTileKey> Leavers;
const bool bTraceFirstDesired = VoxelForgeStartupTrace::IsActive() const bool bTraceFirstDesired = VoxelForgeStartupTrace::IsActive()
&& !bStartupTraceDesiredRecorded; && !bStartupTraceDesiredRecorded;
@@ -2727,7 +3095,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
{ {
VoxelForgeStartupTrace::StageBegin(TEXT("BuildDesiredTiles.first")); VoxelForgeStartupTrace::StageBegin(TEXT("BuildDesiredTiles.first"));
} }
BuildDesiredTiles(CenterChunk, Leavers); BuildDesiredTiles(CenterChunk, CenterPosition, PlayerPawn, PlayerHeading, Leavers);
if (bTraceFirstDesired) if (bTraceFirstDesired)
{ {
VoxelForgeStartupTrace::StageEnd(TEXT("BuildDesiredTiles.first")); 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. // do the "settled" cull of the deferred LOD-transition tiles, then go idle.
//========================================================================= //=========================================================================
if (!bAllChunksLoaded) if (!bAllChunksLoaded)
@@ -3000,14 +3368,25 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles); TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_SubmitTiles);
int32 Submitted = 0; int32 Submitted = 0;
// DIG RESPONSIVENESS — drain player-carve re-meshes FIRST (ahead of streaming + band) at // FLOOR RESPONSIVENESS — the occupied support tile and the next tile along the pawn heading
// BackgroundHigh, so a dig gets the task budget before any streaming gen. A tile that's still // are an absolute prefix, ahead of dirty remeshes, band work, and ordinary clipmap work.
// in flight is KEPT queued (retried next frame) so its stale pre-carve result is corrected. // 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) for (auto It = DirtyRemeshQueue.CreateIterator(); It; ++It)
{ {
if (PendingTiles.Num() >= MaxTasks) break; if (PendingTiles.Num() >= MaxTasks) break;
const FVoxelTileKey T = *It; const FVoxelTileKey T = *It;
if (!LoadedTiles.Contains(T)) { It.RemoveCurrent(); continue; } // unloaded — drop 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 if (PendingTiles.Contains(T)) { continue; } // in flight — retry after it lands
It.RemoveCurrent(); It.RemoveCurrent();
LoadTile(T, /*bHighPriority*/ true); LoadTile(T, /*bHighPriority*/ true);
@@ -3030,7 +3409,7 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition)
if (PendingTiles.Num() >= MaxTasks) break; if (PendingTiles.Num() >= MaxTasks) break;
const FVoxelTileKey T = *It; const FVoxelTileKey T = *It;
It.RemoveCurrent(); It.RemoveCurrent();
if (PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue; if (!IsDesired(T) || PendingTiles.Contains(T) || !LoadedTiles.Contains(T)) continue;
LoadTile(T); LoadTile(T);
++Submitted; ++Submitted;
} }
@@ -3087,10 +3466,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
return; // Budget full — wait for a task to finish. return; // Budget full — wait for a task to finish.
} }
PendingTiles.Add(Tile); PendingTiles.Add(Tile);
const bool bTelemetry = GVoxelForgeProfileTileGeneration != 0 const uint64 RequestStartCycles = FPlatformTime::Cycles64();
|| VoxelForgeStartupTrace::IsActive();
const uint64 RequestStartCycles = bTelemetry
? FPlatformTime::Cycles64() : 0;
const FIntVector OriginVoxels = Tile.OriginVoxels(); // min corner, voxel coords 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 int32 Step = FMath::Max(1, Extent / Cells);
const uint32 TaskEpoch = GenerationEpoch; 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 // CAPTURE-DURING-MESHING: only level-0 full-res tiles map 1:1 onto a density-clipmap level
// (Step == 1<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to // (Step == 1<<Level, Cells == CHUNK_SIZE). When the density volume is active, ask the mesher to
@@ -3144,6 +3521,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
FChunkResult Empty; FChunkResult Empty;
Empty.Tile = Tile; Empty.Tile = Tile;
Empty.Epoch = TaskEpoch; Empty.Epoch = TaskEpoch;
Empty.DesiredEpoch = TaskDesiredEpoch;
Empty.RequestStartCycles = RequestStartCycles; Empty.RequestStartCycles = RequestStartCycles;
ProcessQueue.Enqueue(MoveTemp(Empty)); ProcessQueue.Enqueue(MoveTemp(Empty));
return; return;
@@ -3174,6 +3552,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
FChunkResult Empty; FChunkResult Empty;
Empty.Tile = Tile; Empty.Tile = Tile;
Empty.Epoch = TaskEpoch; Empty.Epoch = TaskEpoch;
Empty.DesiredEpoch = TaskDesiredEpoch;
Empty.RequestStartCycles = RequestStartCycles; Empty.RequestStartCycles = RequestStartCycles;
Empty.BandChunkLo = BandChunkLo; Empty.BandChunkLo = BandChunkLo;
Empty.BandChunkHi = BandChunkHi; Empty.BandChunkHi = BandChunkHi;
@@ -3182,22 +3561,25 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
} }
} }
const TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe> Cancellation =
MakeShared<FVoxelTileCancellationState, ESPMode::ThreadSafe>();
PendingTileCancellation.Add(Tile, Cancellation);
ActiveTaskCount.fetch_add(1, std::memory_order_relaxed); ActiveTaskCount.fetch_add(1, std::memory_order_relaxed);
// BackgroundNormal priority: gen runs on background workers that YIELD to foreground // BackgroundNormal priority: gen runs on background workers that YIELD to foreground
// (game/render-thread) tasks. Without this, raising MaxConcurrentTasks past the spare // (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). // 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. // 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 // FLOOR/DIG RESPONSIVENESS: critical support requests and player carves launch at BackgroundHigh
// worker (yields to the frame, keeps the invariant) but jumps AHEAD of all pending streaming gen, // (bHighPriority) — still a background worker (yields to the frame, keeps the invariant) but
// so a dig is never queued behind a shell of streaming tasks. // jumps AHEAD of ordinary pending streaming gen.
const UE::Tasks::ETaskPriority TaskPriority = bHighPriority const UE::Tasks::ETaskPriority TaskPriority = bHighPriority
? UE::Tasks::ETaskPriority::BackgroundHigh ? UE::Tasks::ETaskPriority::BackgroundHigh
: UE::Tasks::ETaskPriority::BackgroundNormal; : UE::Tasks::ETaskPriority::BackgroundNormal;
UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY,
RequestStartCycles, bTelemetry]() RequestStartCycles, TaskDesiredEpoch, Cancellation]()
{ {
// RAII: decrement the counter on every exit path. // RAII: decrement the counter on every exit path.
struct FTaskGuard struct FTaskGuard
@@ -3206,19 +3588,51 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, bool bHighPriority)
~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); } ~FTaskGuard() { Counter.fetch_sub(1, std::memory_order_relaxed); }
} Guard{ActiveTaskCount}; } 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 (ShouldAbortWork()) return;
if (Cancellation->bObsolete.load(std::memory_order_relaxed))
{
EnqueueObsoleteResult();
return;
}
FChunkResult Result; FChunkResult Result;
const uint64 GenerationStartCycles = bTelemetry Result.DesiredEpoch = TaskDesiredEpoch;
? FPlatformTime::Cycles64() : 0; const uint64 GenerationStartCycles = FPlatformTime::Cycles64();
Result.GenerationStartCycles = GenerationStartCycles; Result.GenerationStartCycles = GenerationStartCycles;
GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture,
BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi,
bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY, Result); bSheetTile, SheetChunkZ, HoleMinX, HoleMinY, HoleMaxX, HoleMaxY,
Result, &Cancellation->bObsolete);
Result.GenerationEndCycles = GenerationStartCycles != 0 Result.GenerationEndCycles = GenerationStartCycles != 0
? FPlatformTime::Cycles64() : 0; ? FPlatformTime::Cycles64() : 0;
Result.RequestStartCycles = RequestStartCycles; Result.RequestStartCycles = RequestStartCycles;
if (Cancellation->bObsolete.load(std::memory_order_relaxed) || Result.bAborted)
{
if (!ShouldAbortWork())
{
EnqueueObsoleteResult();
}
return;
}
if (!ShouldAbortWork()) if (!ShouldAbortWork())
{ {
ProcessQueue.Enqueue(MoveTemp(Result)); // move: don't copy the geometry payload 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). // 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. // 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, void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector& OriginVoxels,
int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture,
int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, int32 SheetChunkZ, bool bSheetTile, int32 SheetChunkZ,
int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY,
FChunkResult& Result) FChunkResult& Result,
const std::atomic<bool>* ObsoleteFlag)
{ {
const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0; const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0;
const bool bMeasureTile = bLogTileProfile || VoxelForgeStartupTrace::IsActive(); const bool bMeasureTile = bLogTileProfile || VoxelForgeStartupTrace::IsActive();
@@ -3346,10 +3761,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
Result.Tile = Tile; Result.Tile = Tile;
Result.Epoch = Epoch; Result.Epoch = Epoch;
Result.bAborted = false; Result.bAborted = false;
Result.bObsolete = false;
Result.bEmpty = true; Result.bEmpty = true;
Result.ClassifyVerdict = -1; Result.ClassifyVerdict = -1;
Result.bClassifierCacheHit = false;
Result.bClassifierRegionHit = false;
Result.ClassifySeconds = 0.0; Result.ClassifySeconds = 0.0;
Result.MeshSeconds = 0.0; Result.MeshSeconds = 0.0;
Result.StreamSeconds = 0.0; Result.StreamSeconds = 0.0;
@@ -3364,11 +3778,13 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
auto AbortResult = [&]() auto AbortResult = [&]()
{ {
Result.bAborted = true; Result.bAborted = true;
Result.bObsolete = ObsoleteFlag != nullptr
&& ObsoleteFlag->load(std::memory_order_relaxed);
Result.bEmpty = true; Result.bEmpty = true;
Result.Streams.Reset(); Result.Streams.Reset();
Result.CaptureGrid.Reset(); Result.CaptureGrid.Reset();
}; };
if (ShouldAbortWork()) if (ShouldAbortWork(ObsoleteFlag))
{ {
AbortResult(); AbortResult();
EmitTileProfile(); EmitTileProfile();
@@ -3414,7 +3830,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
bool bExactUniform = true; bool bExactUniform = true;
for (int32 Z = 0; Z <= ValidationCells && bExactUniform && !bValidationAborted; ++Z) for (int32 Z = 0; Z <= ValidationCells && bExactUniform && !bValidationAborted; ++Z)
{ {
if (ShouldAbortWork()) if (ShouldAbortWork(ObsoleteFlag))
{ {
bValidationAborted = true; bValidationAborted = true;
break; break;
@@ -3451,22 +3867,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
return; return;
} }
ClassifySeconds = bMeasureTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0; ClassifySeconds = bMeasureTile ? FPlatformTime::Seconds() - ClassifyStartSeconds : 0.0;
if (bMeasureTile)
{
const VoxelDensityProfile::FSnapshot ClassifyProfileEnd =
VoxelDensityProfile::SnapshotCurrentThread();
const int32 CacheHitIndex = static_cast<int32>(
VoxelDensityProfile::ECounter::TileVerdictCacheHits);
const int32 RegionHitIndex = static_cast<int32>(
VoxelDensityProfile::ECounter::TileVerdictRegionHits);
Result.bClassifierCacheHit =
ClassifyProfileEnd.Counters[CacheHitIndex] > TileProfileStart.Counters[CacheHitIndex];
Result.bClassifierRegionHit =
ClassifyProfileEnd.Counters[RegionHitIndex] > TileProfileStart.Counters[RegionHitIndex];
}
ClassifyVerdict = static_cast<int32>(ValidatedVerdict); ClassifyVerdict = static_cast<int32>(ValidatedVerdict);
Result.ClassifyVerdict = ClassifyVerdict; Result.ClassifyVerdict = ClassifyVerdict;
if (ShouldAbortWork()) if (ShouldAbortWork(ObsoleteFlag))
{ {
AbortResult(); AbortResult();
EmitTileProfile(); EmitTileProfile();
@@ -3501,7 +3904,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
BandVoxLo, BandVoxHi); BandVoxLo, BandVoxHi);
MeshSeconds = bMeasureTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0; MeshSeconds = bMeasureTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0;
INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed); INC_DWORD_STAT(STAT_VoxelForgeTilesMeshed);
if (ShouldAbortWork()) if (ShouldAbortWork(ObsoleteFlag))
{ {
AbortResult(); AbortResult();
EmitTileProfile(); EmitTileProfile();
@@ -3518,7 +3921,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector
Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>(); Result.Streams = MakeShared<RealtimeMesh::FRealtimeMeshStreamSet>();
BuildTileStreamSet(*Result.Streams, MeshData); BuildTileStreamSet(*Result.Streams, MeshData);
StreamSeconds = bMeasureTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0; StreamSeconds = bMeasureTile ? FPlatformTime::Seconds() - StreamStartSeconds : 0.0;
if (ShouldAbortWork()) if (ShouldAbortWork(ObsoleteFlag))
{ {
AbortResult(); AbortResult();
EmitTileProfile(); 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 // 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. // 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<FVoxelTileCancellationState, ESPMode::ThreadSafe>* Cancellation =
PendingTileCancellation.Find(Tile))
{
(*Cancellation)->bObsolete.store(true, std::memory_order_release);
}
CollisionReadyTiles.Remove(Tile); CollisionReadyTiles.Remove(Tile);
CollisionNotRequiredTiles.Remove(Tile); CollisionNotRequiredTiles.Remove(Tile);
CollisionSolidTiles.Remove(Tile); CollisionSolidTiles.Remove(Tile);
@@ -3652,7 +4062,6 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile)
TileComponents.Remove(Tile); TileComponents.Remove(Tile);
} }
LoadedTiles.Remove(Tile); LoadedTiles.Remove(Tile);
PendingTiles.Remove(Tile);
TransitionHold.Remove(Tile); // couvre aussi le settled cull (qui ne tient pas la hold à jour) 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.SubmissionId = SubmissionId;
Pending.MeshSubmittedCycles = MeshSubmittedCycles; Pending.MeshSubmittedCycles = MeshSubmittedCycles;
Pending.CollisionSubmittedCycles = CollisionSubmittedCycles; 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); PendingCollisionCooks.Add(Tile, Pending);
// Register the serial before asking RMC to dirty collision. The current implementation // Register the serial before asking RMC to dirty collision. The current implementation
@@ -120,9 +120,6 @@ namespace VoxelDensityProfile
TileClassifyAllSolid, TileClassifyAllSolid,
TileClassifyAllAir, TileClassifyAllAir,
TileClassifyMixed, TileClassifyMixed,
TileVerdictCacheHits,
TileVerdictRegionHits,
TileVerdictCacheStores,
FusedTunnelSamples, FusedTunnelSamples,
FusedTunnelDetailSamples, FusedTunnelDetailSamples,
FusedTunnelDetailSkipped, FusedTunnelDetailSkipped,
+1 -50
View File
@@ -9,10 +9,10 @@
#pragma once #pragma once
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "HAL/CriticalSection.h"
#include "VoxelTypes.h" #include "VoxelTypes.h"
#include "VoxelStrateTypes.h" #include "VoxelStrateTypes.h"
#include "VoxelBiomeTypes.h" #include "VoxelBiomeTypes.h"
#include "VoxelGenerator.generated.h" #include "VoxelGenerator.generated.h"
// Forward decls (évite les includes transitifs) // Forward decls (évite les includes transitifs)
@@ -119,12 +119,10 @@ public:
void SetStrateManager(const UVoxelStrateManager* InManager) void SetStrateManager(const UVoxelStrateManager* InManager)
{ {
StrateManager = InManager; StrateManager = InManager;
InvalidateTileVerdictCache();
} }
void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer) void SetDiffLayer(const UVoxelDiffLayer* InDiffLayer)
{ {
DiffLayer = InDiffLayer; DiffLayer = InDiffLayer;
InvalidateTileVerdictCache();
} }
// Copie le seed depuis les settings. Appelé au démarrage et lors d'un ChangeSeed. // 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. */ * Process-unique owner identity for the `CP_*` thread-local cache key. */
uint64 DensityCacheOwnerId = 0; 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<FTileVerdictCacheEntry> 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( EVoxelTileClass ClassifyTileUncached(
const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis, const FIntVector& OriginVoxels, int32 Step, int32 CellsPerAxis,
struct FVoxelTileClassificationStats* OutStats) const; struct FVoxelTileClassificationStats* OutStats) const;
@@ -12,8 +12,6 @@ namespace VoxelForgeStartupTrace
int32 Level = -1; int32 Level = -1;
int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (sheet/early path) int32 Verdict = -1; // Mixed=0, AllSolid=1, AllAir=2, -1=not classified (sheet/early path)
bool bEmpty = true; bool bEmpty = true;
bool bCacheHit = false;
bool bRegionHit = false;
int32 Triangles = 0; int32 Triangles = 0;
// This is worker-result -> game-thread mesh submission. Collision readiness is a later // This is worker-result -> game-thread mesh submission. Collision readiness is a later
// per-tile RMC completion event recorded separately as collision_ready. // per-tile RMC completion event recorded separately as collision_ready.
+83 -14
View File
@@ -33,6 +33,13 @@ class UMaterialInstanceDynamic;
class FScopedGenerationPause; class FScopedGenerationPause;
namespace RealtimeMesh { struct FRealtimeMeshStreamSet; } // T1.f — worker-built geometry buffers 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<bool> bObsolete{false};
};
/** /**
* How a streaming anchor wants its tiles built (ARCHITECTURE §9.3). The local player is an implicit * 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 * 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). // Null ⇒ no-surface tile (uniform all-air/all-solid or an empty content band; no component).
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams; TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
uint32 Epoch = 0; // Generation epoch — discard if stale uint32 Epoch = 0; // Generation epoch — discard if stale
// Monotonic request timestamp used only for streaming telemetry. It is carried through the // Monotonic request timestamp used for the low-overhead streaming latency summary and the
// worker queue so the profile can report request-to-apply, not just worker generation time. // opt-in profile. It is carried through the worker queue so the report can separate scheduler
// wait from generation time.
uint64 RequestStartCycles = 0; uint64 RequestStartCycles = 0;
// Worker timing boundaries for separating scheduler wait from generation and result/apply wait. // 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 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; uint64 GenerationEndCycles = 0;
bool bAborted = false; // Worker observed shutdown; never mark this tile loaded 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 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 // 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 // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs
@@ -225,6 +239,12 @@ public:
uint64 CollisionSubmittedCycles = 0; uint64 CollisionSubmittedCycles = 0;
bool bBodyUpdatedEventSeen = false; bool bBodyUpdatedEventSeen = false;
uint64 BodyUpdatedEventCycles = 0; 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 /** 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). */ /** Apply the current TVP0..4 + level-0 volume texture to one MID (also used on MID creation). */
void SetVolumeParamsOnMID(UMaterialInstanceDynamic* MID) const; 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 // 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. // 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) // TVP0 = L0 WindowOrigin.xyz (world cm) TVP1 = L0 OriginMod.xyz (cells)
@@ -647,7 +675,8 @@ public:
* *
* @param CenterPosition - Usually the player's position * @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. * Load a single chunk at the given coordinate.
@@ -673,7 +702,8 @@ public:
int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi,
bool bSheetTile, int32 SheetChunkZ, bool bSheetTile, int32 SheetChunkZ,
int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY, int32 HoleMinX, int32 HoleMinY, int32 HoleMaxX, int32 HoleMaxY,
FChunkResult& Result); FChunkResult& Result,
const std::atomic<bool>* ObsoleteFlag = nullptr);
/** /**
* Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile): * 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. /** 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 * 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. */ * candidats au cull (delta), au lieu de re-scanner TOUTES les tuiles chargées par crossing. */
void BuildDesiredTiles(const FIntVector& CenterChunkCoord, TArray<FVoxelTileKey>& OutLeavers); void BuildDesiredTiles(const FIntVector& CenterChunkCoord, const FVector& PlayerPosition,
APawn* PlayerPawn, const FVector& PlayerHeading,
TArray<FVoxelTileKey>& OutLeavers);
void CancelObsoleteTileWork();
/** True if a tile's world footprint is still within the outermost clip shell (so a /** 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, * 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 BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh); void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup); 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 // (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap
// streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.) // 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. // budget is exhausted and streaming stalls permanently. Mpsc guards the producer side.
TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue; TQueue<FChunkResult, EQueueMode::Mpsc> ProcessQueue;
TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight TSet<FVoxelTileKey> PendingTiles; // tiles with a gen task in flight
// Game-thread-owned map; workers retain the shared token and only read its atomic flag.
TMap<FVoxelTileKey, TSharedPtr<FVoxelTileCancellationState, ESPMode::ThreadSafe>>
PendingTileCancellation;
// STRATE CONTENT CUT (see UVoxelSettings::StrateContentCutMinLevel) — the player-strate // 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). // 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 // Number of async tasks currently running — EndPlay waits for this to reach 0
std::atomic<int32> ActiveTaskCount{0}; std::atomic<int32> ActiveTaskCount{0};
FORCEINLINE bool ShouldAbortWork() const FORCEINLINE bool ShouldAbortWork(const std::atomic<bool>* ObsoleteFlag = nullptr) const
{ {
return bShuttingDown.load(std::memory_order_relaxed) 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. // Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes.
@@ -861,13 +903,40 @@ public:
void ReconcileAnchorTileVisibility(); void ReconcileAnchorTileVisibility();
// --- Streaming work-avoidance (perf) --- // --- Streaming work-avoidance (perf) ---
// The desired tile set only changes when the player crosses a level-0 tile boundary. // The desired tile set only changes when the player/anchor crosses a level-0 tile boundary or
// We cache it and only rebuild/cull/sort on a real move, and go idle once every desired // a rebuild is forced. We cache it and only rebuild/cull/sort then, and go idle once every
// tile is streamed in — so a stationary player costs ~nothing per frame. // desired tile is streamed in — so a stationary player costs ~nothing per frame.
FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); FIntVector LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX);
bool bAllChunksLoaded = false; bool bAllChunksLoaded = false;
bool bStartupTraceDesiredRecorded = false; bool bStartupTraceDesiredRecorded = false;
TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first TArray<FVoxelTileKey> DesiredSorted; // critical floor prefix, then true-pawn distance-first
TArray<FVoxelTileKey> 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<FStreamingLatencySample> 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 // 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 // collision body covering its feet has completed the RMC cook. The movement component state is
@@ -268,7 +268,6 @@ struct FExploreArguments
bool bProfileDensityFull = false; bool bProfileDensityFull = false;
bool bProfileLod = false; bool bProfileLod = false;
bool bOpBounds = false; bool bOpBounds = false;
bool bProfileTileVerdictCache = false;
bool bFailureFocusRender = false; bool bFailureFocusRender = false;
FString OutDirectory; FString OutDirectory;
@@ -361,8 +360,6 @@ bool ParseArguments(const FString& Params, FExploreArguments& OutArguments, FStr
OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull; OutArguments.bProfileDensity |= OutArguments.bProfileDensityFull;
OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod")); OutArguments.bProfileLod = FParse::Param(*Params, TEXT("profilelod"));
OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds")); OutArguments.bOpBounds = FParse::Param(*Params, TEXT("opbounds"));
OutArguments.bProfileTileVerdictCache = FParse::Param(
*Params, TEXT("profiletileverdictcache"));
FParse::Value(*Params, TEXT("out="), OutText); FParse::Value(*Params, TEXT("out="), OutText);
FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth); FParse::Value(*Params, TEXT("renderwidth="), OutArguments.RenderWidth);
FParse::Value(*Params, TEXT("renderheight="), OutArguments.RenderHeight); 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"), Arguments.bProfileDensity);
Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull); Writer->WriteValue(TEXT("profile_density_full"), Arguments.bProfileDensityFull);
Writer->WriteValue(TEXT("profile_lod"), Arguments.bProfileLod); 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("op_bounds"), Arguments.bOpBounds);
Writer->WriteValue(TEXT("budget_minutes"), static_cast<double>(Arguments.BudgetMinutes)); Writer->WriteValue(TEXT("budget_minutes"), static_cast<double>(Arguments.BudgetMinutes));
Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory); Writer->WriteValue(TEXT("out_directory"), Arguments.OutDirectory);
@@ -5410,66 +5405,6 @@ bool RunOpBounds(
return true; 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<int32>(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<int32>(
VoxelDensityProfile::ECounter::TileClassifyCalls);
const int32 HitsIndex = static_cast<int32>(
VoxelDensityProfile::ECounter::TileVerdictCacheHits);
const int32 RegionHitsIndex = static_cast<int32>(
VoxelDensityProfile::ECounter::TileVerdictRegionHits);
const int32 StoresIndex = static_cast<int32>(
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<unsigned long long>(Snapshot.Counters[CallsIndex]),
static_cast<unsigned long long>(Snapshot.Counters[HitsIndex]),
static_cast<unsigned long long>(Snapshot.Counters[RegionHitsIndex]),
static_cast<unsigned long long>(Snapshot.Counters[StoresIndex]));
return true;
}
} // namespace } // namespace
UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet() UVoxelForgeExploreCommandlet::UVoxelForgeExploreCommandlet()
@@ -5526,16 +5461,6 @@ int32 RunExploreCase(const FString& Params, FString* OutJson)
} }
const double SetupSeconds = FPlatformTime::Seconds() - SetupStartSeconds; 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; bool bRequestedModeFailed = false;
if (Arguments.bOpBounds) if (Arguments.bOpBounds)
{ {
+7 -6
View File
@@ -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. **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 The clean moving-session audit found zero outer or nested hits: 2,109 outer probes and 19,904 nested
barely reuses across LODs. It helped a deliberately repeated commandlet case, which is not how a probes produced 15,877 stores and 15,365 replacements in the full 512-entry table. Same-binary
moving player streams. 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, The global verdict cache, lock, entry table, invalidation path, cache-only profiler fields, and
take it out and reclaim the memory. deliberate cache probe are removed. The proof classifier remains; item 4 is still deliberately untouched.
## 6. Three small, sharp ones · tiny ## 6. Three small, sharp ones · tiny