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
+650 -504

No files matched your search

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