From bf22f0ffe0f34c70983617cb91a657bb689c0b83 Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 29 Sep 2026 07:52:18 +0200 Subject: [PATCH] perf(collision): cook stages measured, no standing on dug-out ground; review F/H/E measured 1. Collision cook breakdown (request -> ready), critical support tile: spawn (0,0,-1): generate 107 ms, apply < 1 ms, RMC submit->body 479 ms, total 587 ms; deep floor (0,0,-7): generate 82 ms, RMC 1,441 ms, total 1,548 ms. Almost all of the wait is inside RMC (end-of-frame update + async pool + Chaos cook + body install, not separable from our side). Critical priority does not shorten it. Collision is async, complex-as-simple, fast-cook off; skirts are ~16-18% of collision triangles. No cook change made. Startup outlier: first support tile waited 2.09 s for its game-thread apply. Underfoot dig: a carving brush that intersects the pawn's capsule and support tile now disables the old body and withdraws readiness on the carve frame: 0 frames standing on removed ground (replacement 91.8 ms later). 8 neighbouring digs: 0 gate frames. Gap audit 0. 2. Stack sampler, 49.4 s game path: top resolved leaf was the profiler's own VoxelDensityProfile::BucketFromName (4.86%). Linear label scan -> CRC-keyed lookup with exact compare: 59.26 -> 57.87 ms/tile (-2.35%), 980/980 tile hashes identical. 3. Measured only: F (classifier mode 1, no exact check) +48% worker CPU, 0/980 tiles differ -> not worth it. H (handoff component scans) p95 0.07 ms/frame -> not worth it. E (FullResClipLevels=1): worker CPU 56.7 -> 35.7 s, settle 9.6 -> 7.0 s, crossing samples -30%; changes the look, owner's decision. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- CODEMAP.md | 31 +- .../Private/VoxelDensityProfile.cpp | 21 +- .../Private/VoxelMarchingCubesMesher.cpp | 1 + Source/VoxelForge/Private/VoxelWorld.cpp | 280 +++++++++++++++++- Source/VoxelForge/Public/VoxelTypes.h | 6 + Source/VoxelForge/Public/VoxelWorld.h | 24 +- Tools/VoxelForgeTest.ps1 | 29 +- 7 files changed, 356 insertions(+), 36 deletions(-) diff --git a/CODEMAP.md b/CODEMAP.md index fbf1fe2..0cdad09 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -135,7 +135,7 @@ remains `0`, and lateral regions remain gated off. | `SmoothStep01` | 306 | 3x²-2x³ — used everywhere for blends. | | `VOXEL_NOISE_SCALE` (1.25f) | 313 | Rescales UE PerlinNoise3D to ~[-1,1]. | | `EVoxelTileClass` enum (`Mixed`/`AllSolid`/`AllAir`) | 231 | T1.d verdict. Lives here so `VoxelDensityOp.h` can share it without a UCLASS dependency. A false `AllSolid`/`AllAir` is a HOLE; a false `Mixed` only costs CPU. | -| `FVoxelMeshData` struct | 365-394 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `NumSurfaceVertices` marks the real isosurface prefix before LOD seam skirts are appended; visibility audit witnesses sample only this prefix. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. | +| `FVoxelMeshData` struct | 365-400 | Mesher output (Vertices/Triangles/UVs/Normals/**Colors**). Plain C++, not USTRUCT. `NumSurfaceVertices` marks the real isosurface prefix before LOD seam skirts are appended; visibility audit witnesses sample only this prefix. `NumSkirtTriangles` counts appended render skirts for collision-cook profiling without changing indices. `Colors` = F6 material masks (R=dominant biome palette, G=slope, B=border blend weight, A=neighbour biome palette). §8.15. | ### 3.2b Density operator stack contract — `Public/VoxelDensityOp.h` (plain C++, no UHT) **The production density path** — `GetDensityAt` builds and evaluates a stack for every strate of @@ -182,6 +182,7 @@ Brute-forced end to end by `VoxelForge.OpStack.ClassifyTileSoundness`. | Symbol | Role | Notes | |--------|------|-------| | `FVoxelOpStack` | — | Ordered move-only list of `TUniquePtr` entries. `Add` snapshots channel metadata; `PrepareChunk` / `EvalInternal` / `EvalMC` / `ClassifyBox` (the fold lets a later forcing post overwrite a dead hypothesis). | +| `VoxelDensityProfile::BucketFromName` (`Private/VoxelDensityProfile.cpp`) | 710 | Maps operator stack labels to profiler buckets. The sampled game stack attributed 4.86% of resolved leaves here; the linear name scan is now a CRC-keyed candidate lookup with exact string comparison for collision-safe bucket identity. | | `FVoxelOpStack::ValidateChannelOrder` | — | Assembly/diagnostic-only channel/resource DAG validator. It requires prior producers, rejects write-only clobbers and invalid additive declarations, and requires room/shaft/surface state providers before consumers. It permits the one root SDF identity fold used by Maze. `VoxelForge.OpStack.ChannelDAG` validates all 8 shipping stacks. | | `FVoxelOpStack::AppendStructuralPost` | 4 | Appends spine → vertical seal → passage tube/landing floor → XY edge seal **in that fixed order**. An author cannot omit or reorder them. The diff layer is NOT here yet — it still lives in `GetDensityAt` after the MC negate, with disturbances. | | `VoxelDensityOps::MakeConstantRockSource` | 1 | `Density = BaseDensity`. `ClassifyBox` → **AllSolid**, exact and free. Shared by TunnelNetwork, Maze, VerticalShafts and bedrock gaps. Class is `FConstantFieldSource` (one class, two factories). | @@ -299,13 +300,13 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `PostEditChangeProperty` | 1000 | Editor live-edit hook. | | `OnObjectModifiedInEditor` | 1022 | Regenerates when a strate asset is edited (if `bLiveEditStrates`). | | `EndPlay` | 1118 | Sets `bShuttingDown`, **waits for `ActiveTaskCount`→0**, unbinds delegate. | -| `BeginPlay` | 1429 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. Creates `UVoxelDensityVolume` only when `bEnableDensityVolume` is true; otherwise seeds terrain MIDs with the disabled neutral parameters and null volume textures. | +| `BeginPlay` | 1576 | Constructs Generator/Mesher/StrateManager/DiffLayer, wires services, seeds. Creates `UVoxelDensityVolume` only when `bEnableDensityVolume` is true; otherwise seeds terrain MIDs with the disabled neutral parameters and null volume textures. Explicitly parses command-line profiling switches and the `voxel.TestFullResClipLevels` measurement override because headless module CVar startup ordering can otherwise leave the authored/default value active. | | `RebuildStrates` | 724 | Reinitializes the volume only when enabled; when disabled, shuts down and clears any existing volume and reapplies neutral terrain material params. | -| `Tick` | 5643 | `UpdateChunksAroundPosition(player, heading)` + `ProcessPendingChunks()`; the clean LOD0 latency summary is flushed at `EndPlay`. Density-volume update/material work is inside the non-null volume branch, so it is absent when disabled. | +| `Tick` | 5715 | `UpdateChunksAroundPosition(player, heading)` + `ProcessPendingChunks()`; the clean LOD0 latency summary is flushed at `EndPlay`. Density-volume update/material work is inside the non-null volume branch, so it is absent when disabled. | | `GetPlayerPosition` | 4831 | Pawn position or zero. | | `AdvanceHeadlessDigMotionTest` / `RecordHeadlessDigMotionFrame` | 3060 / 3217 | Explicit `-voxel.TestDigMotion=1` repro: digs eight times beside a walking pawn, records each frame's vertical velocity, gate, support tile/readiness, movement mode, then digs underfoot and requires ordinary falling. The accompanying gap audit is scoped to the dig session. | | `UpdateVisualStalenessAudit` | 5034 | Test-only camera-frustum coverage audit: tracks up to 24 real pre-skirt isosurface points per visible component, then counts frames where no visible tile component covers a previously visible, still-desired point (excluding edited bounds). `voxel.TestVisualGapInjectEarlyHide=1` hides a valid component for three frames and must be caught. `voxel.TestGapAuditLegacyHandoff=1` restores old handoff culling for A/B baselines. | -| `ProcessPendingChunks` | 5856 | 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`. | +| `ProcessPendingChunks` | 7435 | Drains critical, edited, near, then ordinary result queues under the 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`. Collision profiling timestamps the result's arrival here to separate worker completion from game-thread apply wait. | | `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). | | `FVoxelStackSampler` / `FScopedVoxelStackRegistration` (`VoxelStackSampler.h/.cpp`) | — | **Diagnostic raw stack sampler, off by default.** A sampler thread captures registered generation workers' stacks (`CaptureThreadStackBackTrace`) every N µs and tags each with the tile's LOD. It writes bounded raw PCs to CSV plus a symbolization-free summary under `Saved/`; symbol resolution is deferred until after process exit so the measured process never initializes/calls DbgHelp for this diagnostic. Lifecycle teardown is serialized and registration TLS is session-tagged. Registration is per tile TASK in `LoadTile` (and the export's tasks), never per sample; disabled costs one relaxed atomic load. Game `-voxel.SampleStacks=`, commandlet `-samplestacks[=us]`. | | Room-cache performance (2026-09-13): `VF_FindPlayerFitPointForRoomMemoized` / `GPlayerFitMemo` (`VoxelCaveMorphology.cpp`), `voxel.TileCacheWindow` + `VoxelGenLOD::TileOriginVoxels/TileCellsPerAxis` (`VoxelGenerator.h`), `FChunkSDFSpatialIndex` | — | **Player-fit memo:** exact 97-word key, process-wide, `voxel.PlayerFitMemo`. **Tile cache window:** the room cache covers the requesting tile's footprint plus a fixed collect margin. It is set via `TGuardValue` in `GenerateMesh`/classify, and is window-invariant by construction (the margin does not grow with the tile). **LOD-gated:** fused path at LOD ≥ `voxel.TileCacheWindowMinLOD` (3), op-stack at LOD ≥ `voxel.TileCacheWindowOpStackMinLOD` (4); lower LODs keep the 4-chunk worker window, because the tile window slowed LOD0 evaluation ~6%. Strates whose authored terrain ops exceed the proven 256-voxel extent (or are malformed/unloaded) fall back to the legacy window (`VF_ComputeTerrainOpEnvelope`). `voxel.SpatialIndex` is -1 auto (off at fused LOD0, on elsewhere), 0 or 1. **Spatial index:** ordered XY buckets over cache primitives, exact rejection. The `static_assert` in `VoxelStrateTypes.h` ties `VF_STRATE_PARAM_FIELDS` to `FStrateGenerationParams` (83 fields + 8 B padding). | @@ -313,28 +314,29 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `RecordTileHash` / `WriteTileHashDump` | — | Headless `-voxel.TileHashDump=` parity instrument. Records the exact `FVoxelMeshData` used by the streaming result, including empty/classified tiles, then writes sorted absolute tile keys, LOD step/cells, content-band limits, settings digest context, and the shared `VoxelGeometryHash` at `EndPlay`. | | `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread. Uses `PreEditDensityCache` when the tile is resident in the bounded local cache, replays the current DiffLayer over that exact procedural lattice, then meshes/applies inline so a carve is visible THIS frame. A cache miss uses normal generation; it captures the unedited lattice if the tile needs meshing, while uniform-tile proofs remain enabled. | | `PreEditDensityCache` / `VoxelForgePreEditDensityCache::BuildEditedGrid` | — | Per-world cache of the pawn's current L0 tile: exact 35³ pre-edit samples, max **171,500 bytes**. It is pruned when the pawn crosses chunk boundaries and cleared on `RegenerateAllChunks`. `BuildEditedGrid` snapshots touched diff chunks once and reproduces `GetDensityAt`'s final `pre-edit - EvaluateMods` operation before the per-call shared-grid mesher path. | -| `UpdateChunksAroundPosition` | 6870 | 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 (`VF_OuterShell` shared with `IsTileInClipRange` so the cull sees the same horizon), dz pre-clamped to the vertical band. **§9.3 anchors:** also prunes dead `StreamingAnchors` + detects their chunk crossings → rebuilds the desired set when an anchor moves/(un)registers (`bAnchorsMoved`/`bForceDesiredRebuild`), same cadence as player movement. §8.10. | +| `UpdateChunksAroundPosition` | 8603 | 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 (`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. | | `AdvanceHeadlessCollisionGateStressTest` / `ObserveHeadlessCollisionGateStressTest` / `MaybeFinishHeadlessCollisionGateStressTest` / `MeasureHeadlessCollisionGateStressTerrain` | — | Explicit `-voxel.TestCollisionGateStress=1` harness only. The normal spawn-height cvar offsets from the measured density surface; `voxel.TestCollisionGateStressDeepDrop=1` carves a process-local box shaft from above the top to just above the floor tile, extending along the post-landing route so the pawn does not run into the shaft sidewall, temporarily raises only the transient test DiffLayer brush-radius allowance for that fixture, removes only the local pawn's redundant streaming anchor for this isolated repro, proves every sample in two complete outside-ring level-0 tiles is air before seeding their known-empty status, prewarms the known-empty in-ring column, and asserts that the support walk reaches the in-layout floor outside the aligned level-0 render ring before the pawn drops. The log reports whether the fixed gate retains that otherwise out-of-ring target. Other streaming anchors remain active. The fixture rejects a target or floor outside the layout, a non-air empty-tile proof, or a floor inside the aligned level-0 ring. The `GetDensityAt` column at capsule feet defines the local air/solid surface; the deep-drop column crossing is cached after its first exact scan. Each frame records actor position, velocity, direct foot tile and gate support readiness, gate state, signed feet clearance, and a ≤0.5-voxel sweep between positions. PASS requires no measured clearance below −5 cm; deep-drop gate-on also requires horizontal travel during a hold. Result logs include the terminal density column, first crossing below spawn height, hold count/time, and gated horizontal progress. | | `BuildDesiredTiles` | 6482 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells), then adds streaming anchors and the pending player's collision-only support column before the leaver sweep. It orders from the true local pawn position and builds `CriticalDesiredTiles` for the unresolved support target, occupied/support and heading tiles. Also rebuilds `DesiredTransitionDescendants` (ancestor → desired descendants, including face/edge-touching replacements) for the reconcile pass. | | `CollisionGateDesiredTiles` / `RequestCollisionGateSupportTile` | 5062 | Retains an unresolved support target and up to three level-0 tiles below it across desired-set rebuilds. The additional keys are hidden collision-only tiles, queued ahead of ordinary streaming, and pruned once resolved or outside the active column. | | `IsPawnCapsuleOverlappingPendingFloor` / `UpdatePawnCollisionGate` | 4987 / 5195 | Uses the current-to-next-step swept capsule bounds to start a hold only when the pending support tile is about to intersect the pawn. When current support is ready, it also requests and checks the next-step support tile before a horizontal boundary crossing. Character holds use gravity-free `MOVE_Flying` with horizontal input live, then restore movement mode, gravity, and saved downward speed when collision is ready. | | `VoxelClipmapDesiredTiles::Build` / `AlignedRingBounds` (`Private/VoxelClipmapDesiredTiles.h`) | — | The pure clipmap selector. **Every finer ring snaps outward to a child-pair boundary** (`AlignedRingBounds`, alignment 2; the outermost ring needs none), so a coarser tile is wholly inside or wholly outside the finer ring: **no two desired tiles of different levels overlap** (an unaligned 7-wide odd ring leaves one half-covered coarse tile per axis side, drawn over the fine one: 635 pairs at the owner config). Cost: LOD0 ring 343 → 512 tiles. Guarded by `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage`. Anchor tiles appended later are outside this guarantee. | -| `VF_AppendTouchingAncestors` / `ReconcileReadyTransitionVisibility` | 5001 / 7775 | Boundary-safe visibility handoff: a desired tile sharing a closed face/edge with a retired tile counts as a replacement, so the old surface remains visible until the boundary neighbor is loaded. `NeedsHold` uses the same contact-inclusive readiness. Contact does not imply volumetric overlap; overlap audit remains strict. **Indexed** (dyadic ancestors + `DesiredTransitionDescendants`, visible retired tiles collected once per pass): moving p95 32 ms → 0.31 ms. `voxel.MeasureReadyTransitionVisibility=1` logs per-frame samples. | +| `VF_AppendTouchingAncestors` / `ReconcileReadyTransitionVisibility` / `LogReadyTransitionVisibilityMeasure` | 5041 / 7939 / 5599 | Boundary-safe visibility handoff: a desired tile sharing a closed face/edge with a retired tile counts as a replacement, so the old surface remains visible until the boundary neighbor is loaded. `NeedsHold` uses the same contact-inclusive readiness. Contact does not imply volumetric overlap; overlap audit remains strict. **Indexed** (dyadic ancestors + `DesiredTransitionDescendants`, visible retired tiles collected once per pass): moving p95 32 ms → 0.31 ms. `voxel.MeasureReadyTransitionVisibility=1` logs per-frame game-thread time, sampled component counts, and 128-component count buckets. | | `IsTileCollisionDesired` / `ReconcileCollisionDesiredTiles` / `ApplyMeshToTile` | — | Collision is independent of rendering: the player's level-0 `PawnCollisionRadiusChunks` box, each anchor's authored box, and the gate's retained support column union into `CollisionDesiredTiles`. Level-0 meshes outside that set remain visible with `NoCollision` and do not submit an RMC cook. Entering the set queues a remesh for already-loaded render tiles; leaving disables their component immediately; a late cook cannot restore readiness outside the set. | | `VoxelCollisionDesiredTiles::AddBox` (`Private/VoxelCollisionDesiredTiles.h`) | — | Shared pure box selector for the pawn collision radius and anchor extents; tested for pawn radius bounds and an independent anchor box. | | `RequestCollisionGateSupportTile` / `UpdatePawnCollisionGate` | — | The gate submits the pawn's level-0 support tile **immediately** as `CollisionCritical`, even before the first desired set exists, and retains up to three more support-column tiles across desired-set rebuilds. A previously rendered no-collision tile is remeshed before the gate can accept it. The gate holds only at swept contact, keeps horizontal input live, has no timeout, and restores movement mode, gravity and saved fall speed on release. | | `AddAnchorDesiredTiles` | — | **§9.3 multi-anchor:** folds each `FVoxelStreamingAnchor`'s thin level-0 box (`XYRadiusChunks`/`ZBelowChunks`/`ZAboveChunks`) into the SAME desired set and the independent collision set, 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. | -| `LoadTile` | 7291 | 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). §8.10. | +| `LoadTile` | 9033 | 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`). `voxel.TestFullResClipLevels` is a measurement-only runtime override; default -1 uses the authored setting. 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). §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` | 9667 | 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. During a same-tile re-cook it retains readiness only while collision is still desired and the existing RMC body setup/collision remain valid; a first cook or out-of-range tile withdraws readiness. Routes each terrain base through shared MIDs; disabled volume MIDs bind neutral `VolP*` and null `VolTex*`. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. | +| `ApplyMeshToTile` | 9838 | 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. Ordinary same-tile re-cooks retain the installed body while it is valid. A carve intersecting the pawn's current support tile and capsule bounds suppresses that old body until the replacement cook is installed. `voxel.ProfileCollisionCookStages` reports generation/apply waits, combined RMC time, completion hops, triangle/skirt counts and collision settings. Routes each terrain base through shared MIDs; disabled volume MIDs bind neutral `VolP*` and null `VolTex*`. **§9.4:** `SetVisibility(false)` when the tile is in `CollisionOnlyTiles` (anchor-only, hidden — collision still cooks). §8.10 + ARCHITECTURE SurfaceWorld row. | +| `HandleRealtimeMeshCollisionBodyUpdated` / `HandleTileCollisionCookComplete` | 6964 / 7192 | Correlate RMC body-install event and collision-future completion. `voxel.ProfileCollisionCookStages=1` logs worker queue/gen, result wait, apply/upload/config submission, RMC submit→body update, body event→future, future→game-thread hop, submit frames, pending VoxelForge cooks, priority, collision flags, and triangle/skirt totals. RMC's internal end-of-frame, async-pool wait, and cook remain one combined interval because they are inside the external RMC plugin. | | `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` | 8351 | Gameplay query → strate index. | | `GetBiomeAtWorldLocation` | — | **BlueprintCallable** biome probe at a world point (undoes actor xf → voxel → `Generator::QueryBiomeAt`). Returns `FVoxelBiomeQuery` for BP debug ("what biome / how many decos under the cursor?"). | | `GetVoxelSurfaceHeightAt` | 8385 | **BlueprintCallable** ground finder (F7 bridge): world XY → terrain + sky-cap world-Z via `Generator::GetSurfaceHeightAt`, NO trace/collision, deterministic, available before the area meshes → self-arranging prefab/ruin BPs snap their parts to the real ground. False (outs=input Z) on non-SurfaceWorld strates; ignores passage/spine carving. | -| `CarveAtPosition` / `FillAtPosition` | 8413 / 8422 | Build `FVoxelModification` → `ApplyModification`. | -| `ApplyModification` | 8431 | Single funnel for all brushes: DiffLayer → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., ExcludeTile)` for the neighbours → `RemoveDecorationsInSphere`. | +| `CarveAtPosition` / `FillAtPosition` | 10230 / 10239 | Build `FVoxelModification` → `ApplyModification`. | +| `ApplyModification` | 10248 | Single funnel for all brushes: DiffLayer → when a negative brush intersects the pawn's capsule bounds and affects its ready support tile, disable that old body and withdraw readiness before remesh → **sync-remesh the brush-centre level-0 tile** (`SyncRemeshTile`, instant hole; skipped if that tile is mid-gen — would race a stale in-flight result) → `RemeshDirtyChunks(..., ExcludeTile)` for the neighbours → `RemoveDecorationsInSphere`. A successful replacement cook restores collision/readiness; neighboring edits that miss the capsule bounds keep the same-tile recook handoff. | | `ClearAllModifications` | 8696 | Clears diff layer, regenerates. | | `ChangeSeed` | 8710 | **Legacy season reset**: new seed everywhere, clear diffs, bump season, reload. Rejected while a cooked season owns the seed. | | `GetCurrentSeed` / `GetCurrentSeason` / `GetCurrentSeasonContentHash` | — | Effective cooked-or-legacy identity; the hash is the join-time stale-season comparison value. | @@ -694,8 +696,9 @@ text plus independently copied metadata/hash, and retains soft references to any The pre-sampled grid supplies positions AND gradients inline (T1.b). | Method | .cpp line | Role | |--------|-----------|------| -| **`GenerateMesh`** | 96 | The MC loop over cells; `Step` controls LOD sampling. Sets the T2.b octave bias for the tile (`TGuardValue` on `VoxelGenLOD::OctaveBias`, from `LODOctaveDrop` × log2(Step); 0 at LOD0/off). Edge `t` + grid-gradient normals computed inline (`SampleG`/`GradAt`). Optional `OutCaptureGrid` (4th arg) captures the already-sampled `CHUNK_SIZE³` R8 density clipmap. Optional `OutPreEditDensityGrid` captures exact float samples before player edits for full-res L0 tiles; optional `InSharedDensityGrid` is per-call (not the mesher's shared mutable setting) so sync carve reuse cannot race async tile tasks. Capture/reuse leaves §8.10's thread-local scratch, two-pass MC and density-grid semantics intact. **F17 surface class**: each unique vertex is classified sol/sky-cap in `GetOrCreateVertex` (down-facing only → memoized `GetSurfaceHeightAt`, nearer `CeilSurf` = cap); triangles bucket by majority into `GroundTris`/`CapTris` (thread_local), skirts emit per bucket, then `Triangles = ground‖cap` + `FVoxelMeshData::NumCeilingTriangles` (→ polygroups in `BuildTileStreamSet`). Same tris, only index ORDER changes. STRATE CONTENT CUT: optional `BandZMin/MaxVox` params restrict the cz cell loop (+ gz sampling rows) to the player-strate band — coarse straddling tiles mesh ONE strate; meshed cells bit-identical. | +| **`GenerateMesh`** | 96 | The MC loop over cells; `Step` controls LOD sampling. Sets the T2.b octave bias for the tile (`TGuardValue` on `VoxelGenLOD::OctaveBias`, from `LODOctaveDrop` × log2(Step); 0 at LOD0/off). Edge `t` + grid-gradient normals computed inline (`SampleG`/`GradAt`). Optional `OutCaptureGrid` (4th arg) captures the already-sampled `CHUNK_SIZE³` R8 density clipmap. Optional `OutPreEditDensityGrid` captures exact float samples before player edits for full-res L0 tiles; optional `InSharedDensityGrid` is per-call (not the mesher's shared mutable setting) so sync carve reuse cannot race async tile tasks. Capture/reuse leaves §8.10's thread-local scratch, two-pass MC and density-grid semantics intact. **F17 surface class**: each unique vertex is classified sol/sky-cap in `GetOrCreateVertex` (down-facing only → memoized `GetSurfaceHeightAt`, nearer `CeilSurf` = cap); triangles bucket by majority into `GroundTris`/`CapTris` (thread_local), skirts emit per bucket and increment diagnostic `NumSkirtTriangles`, then `Triangles = ground‖cap` + `FVoxelMeshData::NumCeilingTriangles` (→ polygroups in `BuildTileStreamSet`). Same tris, only index ORDER changes. STRATE CONTENT CUT: optional `BandZMin/MaxVox` params restrict the cz cell loop (+ gz sampling rows) to the player-strate band — coarse straddling tiles mesh ONE strate; meshed cells bit-identical. | | `FVoxelMeshData::NumSurfaceVertices` | 375 | Prefix length for density-isosurface vertices before seam skirt vertices are appended. The test-only visual-gap audit samples this prefix so artificial skirts cannot become expected terrain points. | +| `FVoxelMeshData::NumSkirtTriangles` | — | Counts skirt triangles appended by `GenerateMesh`; consumed only by opt-in collision-cook stage reports. | **`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing. @@ -808,9 +811,9 @@ Stage order (negative=solid throughout). Each stage's anchor: |------|----------| | Chunk size / voxel scale | `VoxelTypes.h:211-213` (rebuild everything). | | View distance / task budget / LOD distances | `VoxelSettings.h` (no recompile of logic — data asset). | -| LOD step mapping | `LoadTile` VoxelWorld.cpp:7329-7335: extent `CHUNK_SIZE << Level`, cells from `FullResClipLevels`/`CoarseTileCells`, `Step = Extent / Cells`; tile selection in `VoxelClipmapDesiredTiles::Build` (`Private/VoxelClipmapDesiredTiles.h`). | +| LOD step mapping | `LoadTile` VoxelWorld.cpp:9033, effective cell selection near :9071: extent `CHUNK_SIZE << Level`, cells from `FullResClipLevels`/`CoarseTileCells`, `Step = Extent / Cells`; tile selection in `VoxelClipmapDesiredTiles::Build` (`Private/VoxelClipmapDesiredTiles.h`). | | How chunks stream in/out | `UpdateChunksAroundPosition` VoxelWorld.cpp:6870. | -| Async threading / stale-result handling | `LoadTile` :7291, `ProcessPendingChunks` :5856, generation Epoch + per-request cancellation token. | +| Async threading / stale-result handling | `LoadTile` :9033, `ProcessPendingChunks` :7435, 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` :6782 / `EvaluateSDFCached` :8569. | | Worm tunnel behavior | `GetDensityWithParams` Step 5, VoxelGenerator.cpp:4905. | @@ -818,7 +821,7 @@ Stage order (negative=solid throughout). Each stage's anchor: | Publish/use an offline season | Create `UVoxelSeasonAsset`, set `SourceManifestJson`, press `ImportSeasonManifestJson`, then assign it to `UVoxelSettings::Season`. | | Boundary blend between strates | `GetGenerationParams` :6509 + `FStrateGenerationParams::Lerp` (expands `VF_STRATE_PARAM_FIELDS`, StrateTypes.h — new fields go in that list). | | Passages / landing geometry between strates | `GeneratePassages` :1659 + `EvaluateModifierSDF` :2643 + `ApplyPassageModifier` / `ApplyPassageLandingFloorMC` (StrateManager.cpp) + `ClassifyTile` floor guard (Generator.cpp). | -| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:8413/8422 → `UVoxelDiffLayer::ApplyModification` :185. | +| Player carve/fill | `CarveAtPosition`/`FillAtPosition` VoxelWorld.cpp:10230/10239 → `AVoxelWorld::ApplyModification` :10248 → `UVoxelDiffLayer::ApplyModification` :185. | | Mesh smoothness / normals | Grid-gradient in `GenerateMesh` (`GradAt` lambda), `IsoLevel` (h). | | New slab/flat-world generator | `VoxelDensityOps::BuildSlabStack` + `FSlabVoidSource` / `FGridColumnMod` (VoxelDensityOpStack.cpp) + `FSlabGenerationParams` (StrateTypes.h:1223). | | Biome placement / layout | `BiomeMapParams` on the strate (cell size, warp, climate freqs) + each biome's climate box. Bake `AVoxelWorld::BakeBiomePreview` to tune. §8.14. | diff --git a/Source/VoxelForge/Private/VoxelDensityProfile.cpp b/Source/VoxelForge/Private/VoxelDensityProfile.cpp index c01c3e2..107285d 100644 --- a/Source/VoxelForge/Private/VoxelDensityProfile.cpp +++ b/Source/VoxelForge/Private/VoxelDensityProfile.cpp @@ -1,4 +1,5 @@ #include "VoxelDensityProfile.h" +#include "Misc/Crc.h" namespace VoxelDensityProfile { @@ -778,11 +779,25 @@ namespace VoxelDensityProfile { TEXT("MesherStreamBuilding"), EBucket::MesherStreamBuilding }, { TEXT("MesherOther"), EBucket::MesherOther }, }; - for (const FNameBucket& Entry : Names) + static const TMap> NamesByHash = []() { - if (FCString::Strcmp(Name, Entry.Name) == 0) + TMap> Lookup; + for (const FNameBucket& Entry : Names) { - return Entry.Bucket; + Lookup.FindOrAdd(FCrc::StrCrc32(Entry.Name)).Add(&Entry); + } + return Lookup; + }(); + + const TArray* Candidates = NamesByHash.Find(FCrc::StrCrc32(Name)); + if (Candidates != nullptr) + { + for (const FNameBucket* Entry : *Candidates) + { + if (Entry != nullptr && FCString::Strcmp(Name, Entry->Name) == 0) + { + return Entry->Bucket; + } } } return EBucket::OtherOp; diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 0feb7ef..27b5500 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -1122,6 +1122,7 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, Tris.Add(iA); Tris.Add(iA2); Tris.Add(iB2); } } + MeshData.NumSkirtTriangles += (Tris.Num() - BaseTriNum) / 3; }; EmitSkirts(GroundTris); EmitSkirts(CapTris); diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 454e2ad..100eaa2 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -84,6 +84,18 @@ namespace GVoxelForgeProfileTileGeneration, TEXT("Log worker tile generation time with level/step/cell count.")); + int32 GVoxelForgeProfileCollisionCookStages = 0; + FAutoConsoleVariableRef CVarVoxelForgeProfileCollisionCookStages( + TEXT("voxel.ProfileCollisionCookStages"), + GVoxelForgeProfileCollisionCookStages, + TEXT("Log per-tile collision queue/apply/RMC/future timing and cooked geometry counts.")); + + int32 GVoxelForgeTestFullResClipLevels = -1; + FAutoConsoleVariableRef CVarVoxelForgeTestFullResClipLevels( + TEXT("voxel.TestFullResClipLevels"), + GVoxelForgeTestFullResClipLevels, + TEXT("Measurement-only override for the streaming full-resolution clip level count; -1 uses the asset.")); + int32 GVoxelForgeProfileDensity = 0; FAutoConsoleVariableRef CVarVoxelForgeProfileDensity( TEXT("voxel.ProfileDensity"), @@ -663,6 +675,8 @@ void AVoxelWorld::RegenerateAllChunks() ModificationRevisions.Reset(); AppliedModificationEpoch.Reset(); CollisionReadyTiles.Empty(); + CollisionSuppressedUntilCook.Empty(); + CollisionSuppressedAtCycles.Empty(); CollisionNotRequiredTiles.Empty(); CollisionSolidTiles.Empty(); PendingCollisionCooks.Empty(); @@ -1506,6 +1520,8 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) BandRemeshQueue.Empty(); PendingCollisionCooks.Empty(); CollisionReadyTiles.Empty(); + CollisionSuppressedUntilCook.Empty(); + CollisionSuppressedAtCycles.Empty(); CollisionDesiredTiles.Empty(); CollisionNotRequiredTiles.Empty(); CollisionSolidTiles.Empty(); @@ -1615,6 +1631,8 @@ void AVoxelWorld::BeginPlay() int32 CommandLineProfileFull = GVoxelForgeProfileDensityFull; int32 CommandLinePerfAttribution = GVoxelForgePerfAttribution; int32 CommandLineProfileTileGeneration = GVoxelForgeProfileTileGeneration; + int32 CommandLineProfileCollisionCookStages = GVoxelForgeProfileCollisionCookStages; + int32 CommandLineTestFullResClipLevels = GVoxelForgeTestFullResClipLevels; int32 CommandLineUseBlockEarlyOut = GVoxelForgeUseBlockEarlyOut; int32 CommandLineOuterClassifierMode = GVoxelForgeOuterClassifierMode; int32 CommandLineSealedSolidProof = GVoxelForgeSealedSolidProof; @@ -1639,6 +1657,20 @@ void AVoxelWorld::BeginPlay() { GVoxelForgeProfileTileGeneration = CommandLineProfileTileGeneration; } + if (FParse::Value( + FCommandLine::Get(), + TEXT("voxel.ProfileCollisionCookStages="), + CommandLineProfileCollisionCookStages)) + { + GVoxelForgeProfileCollisionCookStages = CommandLineProfileCollisionCookStages; + } + if (FParse::Value( + FCommandLine::Get(), + TEXT("voxel.TestFullResClipLevels="), + CommandLineTestFullResClipLevels)) + { + GVoxelForgeTestFullResClipLevels = CommandLineTestFullResClipLevels; + } if (FParse::Value(FCommandLine::Get(), TEXT("voxel.UseBlockEarlyOut="), CommandLineUseBlockEarlyOut)) { GVoxelForgeUseBlockEarlyOut = CommandLineUseBlockEarlyOut; @@ -1894,6 +1926,14 @@ void AVoxelWorld::BeginPlay() GVoxelForgeOuterClassifierMode, GVoxelForgeSealedSolidProof, Mesher->bUseBlockEarlyOut ? 1 : 0); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeFullResClipConfig] authored=%d effective=%d override=%d"), + Settings->FullResClipLevels, + GVoxelForgeTestFullResClipLevels >= 0 + ? FMath::Clamp(GVoxelForgeTestFullResClipLevels, 1, 9) + : FMath::Max(1, Settings->FullResClipLevels), + GVoxelForgeTestFullResClipLevels); + VoxelForgeStartupTrace::RecordEvent(TEXT("generator_mesher_constructed"), FString::Printf( TEXT("\"generator\":1,\"mesher\":1,\"generate_skirts\":%d,\"full_res_clip_levels\":%d,\"coarse_tile_cells\":%d"), Settings->bGenerateSkirts ? 1 : 0, @@ -5603,6 +5643,38 @@ void AVoxelWorld::LogReadyTransitionVisibilityMeasure() MinComponents, MaxComponents, MinDesired, MaxDesired, static_cast(TotalComponents) / Sorted.Num(), static_cast(TotalDesired) / Sorted.Num()); + + constexpr int32 ComponentBucketWidth = 128; + TMap> MillisecondsByComponentBucket; + for (const FReadyTransitionVisibilitySample& Sample : Sorted) + { + const int32 BucketMin = (Sample.TileComponentCount / ComponentBucketWidth) + * ComponentBucketWidth; + MillisecondsByComponentBucket.FindOrAdd(BucketMin).Add( + FPlatformTime::ToSeconds64(Sample.Cycles) * 1000.0); + } + TArray BucketMins; + MillisecondsByComponentBucket.GetKeys(BucketMins); + BucketMins.Sort(); + for (const int32 BucketMin : BucketMins) + { + TArray& Times = MillisecondsByComponentBucket.FindChecked(BucketMin); + Times.Sort(); + double TotalMs = 0.0; + for (const double TimeMs : Times) + { + TotalMs += TimeMs; + } + const int32 P50BucketIndex = Times.Num() / 2; + const int32 P95BucketIndex = FMath::Clamp( + FMath::CeilToInt(static_cast(Times.Num()) * 0.95f) - 1, + 0, Times.Num() - 1); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeReadyVisibilityBucket] components=%d..%d samples=%d " + "p50_ms=%.6f p95_ms=%.6f mean_ms=%.6f"), + BucketMin, BucketMin + ComponentBucketWidth - 1, Times.Num(), + Times[P50BucketIndex], Times[P95BucketIndex], TotalMs / Times.Num()); + } } if (!FarFieldSubmitSamples.IsEmpty()) @@ -6905,6 +6977,7 @@ void AVoxelWorld::HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UB { Pending.bBodyUpdatedEventSeen = true; Pending.BodyUpdatedEventCycles = FPlatformTime::Cycles64(); + Pending.BodyUpdatedEventFrame = GFrameCounter; return; } } @@ -7117,18 +7190,20 @@ void AVoxelWorld::LogStreamingLatencySummary() const } void AVoxelWorld::HandleTileCollisionCookComplete( - const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result) + const FVoxelTileKey& Tile, uint64 SubmissionId, URealtimeMesh* Mesh, uint8 Result, + uint64 FutureCallbackCycles) { + const uint64 CompletionGameThreadCycles = FPlatformTime::Cycles64(); if (!IsInGameThread()) { TWeakObjectPtr WeakWorld(this); AsyncTask(ENamedThreads::GameThread, - [WeakWorld, Tile, SubmissionId, Mesh, Result]() + [WeakWorld, Tile, SubmissionId, Mesh, Result, FutureCallbackCycles]() { if (AVoxelWorld* World = WeakWorld.Get()) { World->HandleTileCollisionCookComplete( - Tile, SubmissionId, Mesh, Result); + Tile, SubmissionId, Mesh, Result, FutureCallbackCycles); } }); return; @@ -7149,14 +7224,29 @@ void AVoxelWorld::HandleTileCollisionCookComplete( return; } + Pending->FutureCallbackCycles = FutureCallbackCycles; + Pending->CompletionGameThreadCycles = CompletionGameThreadCycles; + const uint64 MeasuredMeshSubmittedCycles = Pending->MeshSubmittedCycles; const uint64 MeasuredCollisionSubmittedCycles = Pending->CollisionSubmittedCycles; const bool bBodyUpdatedEventSeen = Pending->bBodyUpdatedEventSeen; const uint64 BodyUpdatedEventCycles = Pending->BodyUpdatedEventCycles; + const uint64 BodyUpdatedEventFrame = Pending->BodyUpdatedEventFrame; + const uint64 MeasuredCollisionSubmittedFrame = Pending->CollisionSubmittedFrame; const uint64 MeasuredRequestStartCycles = Pending->RequestStartCycles; const uint64 MeasuredGenerationStartCycles = Pending->GenerationStartCycles; const uint64 MeasuredGenerationEndCycles = Pending->GenerationEndCycles; const uint64 MeasuredApplyStartCycles = Pending->ApplyStartCycles; + const EVoxelTileWorkPriority WorkPriority = Pending->WorkPriority; + const int32 NumVertices = Pending->NumVertices; + const int32 NumTriangles = Pending->NumTriangles; + const int32 NumSkirtTriangles = Pending->NumSkirtTriangles; + const int32 PendingCooksAtSubmit = Pending->PendingCooksAtSubmit; + const bool bUseAsyncCook = Pending->bUseAsyncCook; + const bool bUseComplexAsSimpleCollision = Pending->bUseComplexAsSimpleCollision; + const bool bShouldFastCookMeshes = Pending->bShouldFastCookMeshes; + const bool bMergeAllMeshes = Pending->bMergeAllMeshes; + const uint64 SuppressedAtCycles = Pending->SuppressedAtCycles; const bool bRecordStreamingLatency = Pending->bRecordStreamingLatency; PendingCollisionCooks.Remove(Tile); @@ -7167,6 +7257,12 @@ void AVoxelWorld::HandleTileCollisionCookComplete( const bool bCollisionStillDesired = IsTileCollisionDesired(Tile); if (bBodyInstalled && bCollisionStillDesired) { + CollisionSuppressedUntilCook.Remove(Tile); + CollisionSuppressedAtCycles.Remove(Tile); + if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile)) + { + Comp->SetCollisionEnabled(ECollisionEnabled::QueryAndPhysics); + } CollisionReadyTiles.Add(Tile); if (bStartupCollisionGateTimingCaptured && StartupCollisionGateSupportTile.Coord == Tile.Coord @@ -7196,6 +7292,11 @@ void AVoxelWorld::HandleTileCollisionCookComplete( else { CollisionReadyTiles.Remove(Tile); + if (!bCollisionStillDesired) + { + CollisionSuppressedUntilCook.Remove(Tile); + CollisionSuppressedAtCycles.Remove(Tile); + } if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile)) { Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); @@ -7224,6 +7325,33 @@ void AVoxelWorld::HandleTileCollisionCookComplete( const double BodyEventToFutureSeconds = bBodyUpdatedEventSeen && ReadyCycles >= BodyUpdatedEventCycles ? FPlatformTime::ToSeconds64(ReadyCycles - BodyUpdatedEventCycles) : -1.0; + const auto SecondsBetween = [](uint64 EndCycles, uint64 StartCycles) -> double + { + return StartCycles != 0 && EndCycles >= StartCycles + ? FPlatformTime::ToSeconds64(EndCycles - StartCycles) + : -1.0; + }; + const double RequestToWorkerStartSeconds = SecondsBetween( + MeasuredGenerationStartCycles, MeasuredRequestStartCycles); + const double WorkerGenerationSeconds = SecondsBetween( + MeasuredGenerationEndCycles, MeasuredGenerationStartCycles); + const double WorkerResultToApplySeconds = SecondsBetween( + MeasuredApplyStartCycles, MeasuredGenerationEndCycles); + const double ApplyToMeshSubmitSeconds = SecondsBetween( + MeasuredMeshSubmittedCycles, MeasuredApplyStartCycles); + const double MeshSubmitToCollisionSubmitSeconds = SecondsBetween( + MeasuredCollisionSubmittedCycles, MeasuredMeshSubmittedCycles); + const double RMCSubmitToBodyUpdateSeconds = bBodyUpdatedEventSeen + ? SecondsBetween(BodyUpdatedEventCycles, MeasuredCollisionSubmittedCycles) : -1.0; + const double BodyUpdateToFutureCallbackSeconds = bBodyUpdatedEventSeen + ? SecondsBetween(FutureCallbackCycles, BodyUpdatedEventCycles) : -1.0; + const double FutureCallbackToGameThreadSeconds = SecondsBetween( + CompletionGameThreadCycles, FutureCallbackCycles); + const double SuppressedToReadySeconds = SecondsBetween(ReadyCycles, SuppressedAtCycles); + const int64 FramesToBodyUpdate = bBodyUpdatedEventSeen + && BodyUpdatedEventFrame >= MeasuredCollisionSubmittedFrame + ? static_cast(BodyUpdatedEventFrame - MeasuredCollisionSubmittedFrame) + : -1; const TCHAR* ResultName = TEXT("Unknown"); switch (CollisionResult) { @@ -7232,32 +7360,66 @@ void AVoxelWorld::HandleTileCollisionCookComplete( case ERealtimeMeshCollisionUpdateResult::Error: ResultName = TEXT("Error"); break; default: break; } - if (GVoxelForgeProfileTileGeneration != 0 || VoxelForgeStartupTrace::IsActive() + if (GVoxelForgeProfileTileGeneration != 0 || GVoxelForgeProfileCollisionCookStages != 0 + || VoxelForgeStartupTrace::IsActive() || !bBodyInstalled) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionReady] tile=(%d,%d,%d) level=%d result=%s body_setup=%d " - "ready=%d body_event=%d mesh_submit_to_cook=%.6f " - "collision_request_to_cook=%.6f body_event_to_future=%.6f"), + "ready=%d body_event=%d priority=%d critical=%d vertices=%d triangles=%d " + "skirt_triangles=%d pending_cooks=%d async=%d complex_as_simple=%d fast_cook=%d " + "merge=%d request_to_worker_s=%.6f worker_gen_s=%.6f worker_to_apply_s=%.6f " + "apply_to_mesh_submit_s=%.6f mesh_to_collision_submit_s=%.6f " + "rmc_submit_to_body_update_s=%.6f submit_to_body_frames=%lld " + "body_update_to_future_callback_s=%.6f future_callback_to_gt_s=%.6f " + "suppressed_to_ready_s=%.6f mesh_submit_to_ready=%.6f " + "collision_request_to_ready=%.6f body_event_to_future=%.6f"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, ResultName, CompletedMesh->GetBodySetup() != nullptr ? 1 : 0, bBodyInstalled ? 1 : 0, bBodyUpdatedEventSeen ? 1 : 0, - MeshToCookSeconds, CollisionRequestToCookSeconds, BodyEventToFutureSeconds); + static_cast(WorkPriority), + WorkPriority == EVoxelTileWorkPriority::CollisionCritical ? 1 : 0, + NumVertices, NumTriangles, NumSkirtTriangles, PendingCooksAtSubmit, + bUseAsyncCook ? 1 : 0, bUseComplexAsSimpleCollision ? 1 : 0, + bShouldFastCookMeshes ? 1 : 0, bMergeAllMeshes ? 1 : 0, + RequestToWorkerStartSeconds, WorkerGenerationSeconds, WorkerResultToApplySeconds, + ApplyToMeshSubmitSeconds, MeshSubmitToCollisionSubmitSeconds, + RMCSubmitToBodyUpdateSeconds, FramesToBodyUpdate, + BodyUpdateToFutureCallbackSeconds, FutureCallbackToGameThreadSeconds, + SuppressedToReadySeconds, MeshToCookSeconds, + CollisionRequestToCookSeconds, BodyEventToFutureSeconds); } if (VoxelForgeStartupTrace::IsActive()) { VoxelForgeStartupTrace::RecordEvent(TEXT("collision_ready"), FString::Printf( TEXT("\"tile\":[%d,%d,%d],\"level\":%d,\"result\":\"%s\",\"body_setup\":%d," "\"ready\":%d,\"body_update_event\":%d," - "\"mesh_submit_to_cook_s\":%s,\"collision_request_to_cook_s\":%s," - "\"body_event_to_future_s\":%s"), + "\"priority\":%d,\"critical\":%d,\"vertices\":%d,\"triangles\":%d," + "\"skirt_triangles\":%d,\"pending_cooks_at_submit\":%d," + "\"request_to_worker_s\":%s,\"worker_generation_s\":%s," + "\"worker_result_to_apply_s\":%s,\"apply_to_mesh_submit_s\":%s," + "\"mesh_to_collision_submit_s\":%s,\"rmc_submit_to_body_update_s\":%s," + "\"submit_to_body_frames\":%lld,\"body_update_to_future_callback_s\":%s," + "\"future_callback_to_gt_s\":%s,\"suppressed_to_ready_s\":%s," + "\"mesh_submit_to_ready_s\":%s,\"collision_request_to_ready_s\":%s"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, Tile.Level, ResultName, CompletedMesh->GetBodySetup() != nullptr ? 1 : 0, bBodyInstalled ? 1 : 0, bBodyUpdatedEventSeen ? 1 : 0, + static_cast(WorkPriority), + WorkPriority == EVoxelTileWorkPriority::CollisionCritical ? 1 : 0, + NumVertices, NumTriangles, NumSkirtTriangles, PendingCooksAtSubmit, + *FString::SanitizeFloat(RequestToWorkerStartSeconds), + *FString::SanitizeFloat(WorkerGenerationSeconds), + *FString::SanitizeFloat(WorkerResultToApplySeconds), + *FString::SanitizeFloat(ApplyToMeshSubmitSeconds), + *FString::SanitizeFloat(MeshSubmitToCollisionSubmitSeconds), + *FString::SanitizeFloat(RMCSubmitToBodyUpdateSeconds), FramesToBodyUpdate, + *FString::SanitizeFloat(BodyUpdateToFutureCallbackSeconds), + *FString::SanitizeFloat(FutureCallbackToGameThreadSeconds), + *FString::SanitizeFloat(SuppressedToReadySeconds), *FString::SanitizeFloat(MeshToCookSeconds), - *FString::SanitizeFloat(CollisionRequestToCookSeconds), - *FString::SanitizeFloat(BodyEventToFutureSeconds))); + *FString::SanitizeFloat(CollisionRequestToCookSeconds))); } // A completion can occur between world ticks. Refresh immediately so the pawn is released on @@ -7532,6 +7694,8 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) // An empty re-gen invalidates any previous body and any completion future for the old // geometry. Empty tiles are loaded bookkeeping, never collision-ready. CollisionReadyTiles.Remove(Result.Tile); + CollisionSuppressedUntilCook.Remove(Result.Tile); + CollisionSuppressedAtCycles.Remove(Result.Tile); CollisionSolidTiles.Remove(Result.Tile); if (Result.Tile.Level == 0) { @@ -8904,7 +9068,9 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor // away + skirts hide the seams). Near levels (< FullResClipLevels) stay full CHUNK_SIZE cells. // Extent stays CHUNK_SIZE<FullResClipLevels) : 2; + const int32 FullRes = GVoxelForgeTestFullResClipLevels >= 0 + ? FMath::Clamp(GVoxelForgeTestFullResClipLevels, 1, 9) + : (Settings ? FMath::Max(1, Settings->FullResClipLevels) : 2); const int32 Extent = CHUNK_SIZE << Tile.Level; const int32 Cells = (Tile.Level < FullRes) @@ -9526,6 +9692,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector const int32 NumTris = MeshData.Triangles.Num() / 3; Result.NumTriangles = NumTris; Result.NumCeilingTriangles = MeshData.NumCeilingTriangles; + Result.NumSkirtTriangles = MeshData.NumSkirtTriangles; Result.bHasCeilingTris = MeshData.NumCeilingTriangles > 0; Result.bHasGroundTris = NumTris > MeshData.NumCeilingTriangles; } @@ -9646,6 +9813,8 @@ void AVoxelWorld::UnloadTile(const FVoxelTileKey& Tile) (*Cancellation)->bObsolete.store(true, std::memory_order_release); } CollisionReadyTiles.Remove(Tile); + CollisionSuppressedUntilCook.Remove(Tile); + CollisionSuppressedAtCycles.Remove(Tile); CollisionNotRequiredTiles.Remove(Tile); CollisionSolidTiles.Remove(Tile); PendingCollisionCooks.Remove(Tile); @@ -9682,13 +9851,14 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) const bool bHasCeilingTris = Result.bHasCeilingTris; const bool bLevel0 = (Tile.Level == 0); const bool bWantsCollision = bLevel0 && IsTileCollisionDesired(Tile); + const bool bCollisionSuppressed = CollisionSuppressedUntilCook.Contains(Tile); // RMC keeps its installed BodySetup active while the replacement collision cooks. Preserve // support readiness across that interval when this tile already has a live cooked body; the // cook-complete callback below replaces the body and decides readiness for the new geometry. // A first cook, an unavailable body, or a tile leaving collision range still withdraws ready. URealtimeMeshComponent* ExistingCollisionComponent = TileComponents.FindRef(Tile); - const bool bRetainInstalledCollisionReady = bWantsCollision + const bool bRetainInstalledCollisionReady = bWantsCollision && !bCollisionSuppressed && CollisionReadyTiles.Contains(Tile) && IsValid(ExistingCollisionComponent) && ExistingCollisionComponent->GetCollisionEnabled() != ECollisionEnabled::NoCollision @@ -9819,7 +9989,9 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) MeshComp->SetVisibility( !CollisionOnlyTiles.Contains(Tile) && !bDeferTransitionVisibility); MeshComp->SetCollisionEnabled( - bWantsCollision ? ECollisionEnabled::QueryAndPhysics : ECollisionEnabled::NoCollision); + bWantsCollision && !bCollisionSuppressed + ? ECollisionEnabled::QueryAndPhysics + : ECollisionEnabled::NoCollision); // Reuse the component's existing mesh object when it has one (pooled component or carve // re-mesh) — InitializeRealtimeMesh allocates a brand-new URealtimeMesh EVERY call, so @@ -9920,10 +10092,21 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) Pending.SubmissionId = SubmissionId; Pending.MeshSubmittedCycles = MeshSubmittedCycles; Pending.CollisionSubmittedCycles = CollisionSubmittedCycles; + Pending.CollisionSubmittedFrame = GFrameCounter; Pending.RequestStartCycles = Result.RequestStartCycles; Pending.GenerationStartCycles = Result.GenerationStartCycles; Pending.GenerationEndCycles = Result.GenerationEndCycles; Pending.ApplyStartCycles = Result.ApplyStartCycles; + Pending.WorkPriority = Result.WorkPriority; + Pending.NumVertices = Result.NumVertices; + Pending.NumTriangles = Result.NumTriangles; + Pending.NumSkirtTriangles = Result.NumSkirtTriangles; + Pending.PendingCooksAtSubmit = PendingCollisionCooks.Num(); + Pending.bUseAsyncCook = CollisionConfig.bUseAsyncCook; + Pending.bUseComplexAsSimpleCollision = CollisionConfig.bUseComplexAsSimpleCollision; + Pending.bShouldFastCookMeshes = CollisionConfig.bShouldFastCookMeshes; + Pending.bMergeAllMeshes = CollisionConfig.bMergeAllMeshes; + Pending.SuppressedAtCycles = CollisionSuppressedAtCycles.FindRef(Tile); Pending.bRecordStreamingLatency = Result.bRecordStreamingLatency; PendingCollisionCooks.Add(Tile, Pending); @@ -9941,7 +10124,8 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) if (AVoxelWorld* World = WeakWorld.Get()) { World->HandleTileCollisionCookComplete( - Tile, SubmissionId, WeakMesh.Get(), static_cast(CollisionResult)); + Tile, SubmissionId, WeakMesh.Get(), static_cast(CollisionResult), + FPlatformTime::Cycles64()); } }); } @@ -10072,6 +10256,72 @@ void AVoxelWorld::ApplyModification(const FVoxelModification& Modification) ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DiffStartCycles) : 0.0; + // If a carve removes the surface directly beneath this pawn, an installed old BodySetup would + // otherwise keep it standing on geometry that the diff has already removed. Suppress only the + // current support tile, and only when the brush bounds intersect the pawn bounds; nearby digs + // keep their existing same-tile recook handoff. + if (Modification.Strength < 0.0f && AffectedChunks.Num() > 0) + { + FVector PlayerPosition = FVector::ZeroVector; + APawn* Pawn = nullptr; + FVoxelTileKey SupportTile; + if (TryGetPlayerPosition(PlayerPosition, &Pawn) + && GetPlayerSupportTile(Pawn, PlayerPosition, SupportTile) + && AffectedChunks.Contains(SupportTile.Coord) + && IsTileCollisionReady(SupportTile)) + { + FVector EditMinVoxel, EditMaxVoxel; + Modification.GetWorldBounds(EditMinVoxel, EditMaxVoxel); + FBox EditWorldBounds(ForceInit); + for (int32 Corner = 0; Corner < 8; ++Corner) + { + const FVector LocalVoxel( + (Corner & 1) != 0 ? EditMaxVoxel.X : EditMinVoxel.X, + (Corner & 2) != 0 ? EditMaxVoxel.Y : EditMinVoxel.Y, + (Corner & 4) != 0 ? EditMaxVoxel.Z : EditMinVoxel.Z); + EditWorldBounds += LocalVoxelToWorld(LocalVoxel); + } + + FBox PawnWorldBounds(ForceInit); + if (const ACharacter* Character = Cast(Pawn)) + { + if (const UCapsuleComponent* Capsule = Character->GetCapsuleComponent()) + { + PawnWorldBounds = Capsule->Bounds.GetBox(); + } + } + if (!PawnWorldBounds.IsValid) + { + FVector PawnBoundsOrigin = FVector::ZeroVector; + FVector PawnBoundsExtent = FVector::ZeroVector; + Pawn->GetActorBounds(true, PawnBoundsOrigin, PawnBoundsExtent); + PawnWorldBounds = FBox( + PawnBoundsOrigin - PawnBoundsExtent, + PawnBoundsOrigin + PawnBoundsExtent); + } + + if (EditWorldBounds.Intersect(PawnWorldBounds)) + { + if (URealtimeMeshComponent* SupportComponent = TileComponents.FindRef(SupportTile)) + { + CollisionSuppressedUntilCook.Add(SupportTile); + CollisionSuppressedAtCycles.Add(SupportTile, FPlatformTime::Cycles64()); + CollisionReadyTiles.Remove(SupportTile); + // A prior cook can still complete after this edit. Remove its serial so that + // its completion cannot restore readiness over the replacement geometry. + PendingCollisionCooks.Remove(SupportTile); + SupportComponent->SetCollisionEnabled(ECollisionEnabled::NoCollision); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeUnderfootCollisionSuppressed] tile=(%d,%d,%d) " + "frame=%llu brush_capsule_bounds_intersect=1"), + SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, + static_cast(GFrameCounter)); + UpdatePawnCollisionGate(Pawn, PlayerPosition, 0.0f); + } + } + } + } + if (AffectedChunks.Num() > 0) { ++ModificationEpoch; diff --git a/Source/VoxelForge/Public/VoxelTypes.h b/Source/VoxelForge/Public/VoxelTypes.h index 8ada7d5..418fcca 100644 --- a/Source/VoxelForge/Public/VoxelTypes.h +++ b/Source/VoxelForge/Public/VoxelTypes.h @@ -374,6 +374,11 @@ struct FVoxelMeshData // this prefix and must not be used as witnesses for terrain visibility audits. int32 NumSurfaceVertices = 0; + // Diagnostic-only count: skirt triangles are render geometry and are included in the RMC + // sections that currently request collision. This lets collision profiling quantify their + // share without changing the emitted indices. + int32 NumSkirtTriangles = 0; + // F17 — classe de surface par TRIANGLE, portée par l'ORDRE du buffer d'indices : les // NumCeilingTriangles DERNIERS triangles de Triangles sont la classe "plafond sky-cap" // (polygroup 1 → slot matériau 1, sans ombre) ; tout ce qui précède est "sol/roche" @@ -388,6 +393,7 @@ struct FVoxelMeshData Normals.Empty(); Colors.Empty(); NumSurfaceVertices = 0; + NumSkirtTriangles = 0; NumCeilingTriangles = 0; } diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 632444f..2d5d09e 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -117,7 +117,7 @@ struct FChunkResult uint64 GenerationEndCycles = 0; uint64 ApplyStartCycles = 0; EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon; - int32 ClassifyVerdict = -1; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; int32 NumCeilingTriangles = 0; + int32 ClassifyVerdict = -1; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; int32 NumCeilingTriangles = 0; int32 NumSkirtTriangles = 0; int32 NumVertices = 0; uint64 GeometryBytes = 0; bool bSealedSolidProof = false; // true when the cheap gap/seal proof skipped meshing @@ -270,6 +270,11 @@ public: * submission and LoadedTiles are intentionally not sufficient. */ TSet CollisionReadyTiles; + /** A carve intersected the pawn's current support tile. Its previous body is withdrawn + * immediately and readiness returns only after the replacement cook is installed. */ + TSet CollisionSuppressedUntilCook; + TMap CollisionSuppressedAtCycles; + /** Level-0 tiles whose applied result is known to contain no surface (uniform empty/solid). * They need no BodySetup themselves, but the pawn gate may walk past them while looking for * the first resolved collidable support tile. */ @@ -285,13 +290,27 @@ public: uint64 SubmissionId = 0; uint64 MeshSubmittedCycles = 0; uint64 CollisionSubmittedCycles = 0; + uint64 CollisionSubmittedFrame = 0; bool bBodyUpdatedEventSeen = false; uint64 BodyUpdatedEventCycles = 0; + uint64 BodyUpdatedEventFrame = 0; + uint64 FutureCallbackCycles = 0; + uint64 CompletionGameThreadCycles = 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; + EVoxelTileWorkPriority WorkPriority = EVoxelTileWorkPriority::Horizon; + int32 NumVertices = 0; + int32 NumTriangles = 0; + int32 NumSkirtTriangles = 0; + int32 PendingCooksAtSubmit = 0; + bool bUseAsyncCook = true; + bool bUseComplexAsSimpleCollision = true; + bool bShouldFastCookMeshes = false; + bool bMergeAllMeshes = false; + uint64 SuppressedAtCycles = 0; bool bRecordStreamingLatency = false; }; @@ -896,7 +915,8 @@ public: /** Called by the RMC collision future after async cook + BodySetup installation. */ void HandleTileCollisionCookComplete(const FVoxelTileKey& Tile, uint64 SubmissionId, - URealtimeMesh* Mesh, uint8 Result); + URealtimeMesh* Mesh, uint8 Result, + uint64 FutureCallbackCycles); void BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh); void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh); void HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup); diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 1d19592..bf50d0a 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism')] + [ValidateSet('canonical', 'owner', 'probe', 'perf', 'visual-edit', 'visual-lod', 'visual-gap-walk', 'visual-gap-inject', 'dig-motion', 'tests', 'surface-fall', 'crossing', 'gate-stress', 'parity', 'horizon', 'horizon-render', 'determinism', 'symbolize-stacks')] [string]$Scenario = 'canonical', [switch]$Build, [ValidateSet('owner', 'default')] @@ -9,6 +9,7 @@ param( [string]$TestFilter, [string]$Out, [string]$Label, + [string]$StackSamplesPath, [ValidateRange(0, 8)] [int]$HorizonLevel = 5, [ValidateRange(1, 100000)] @@ -1302,7 +1303,7 @@ try { $buildStdout = Join-Path $RunRoot 'Logs\build.stdout.log' $buildError = Join-Path $RunRoot 'Logs\build.err.log' $buildArgs = @(('"{0}"' -f $UbtDll), 'UnrealEditor', 'Win64', 'Development', ('-Project="{0}"' -f $HostProject), - '-WaitMutex', '-FromMsBuild', '-architecture=x64', '-NoUBA', '-MaxParallelActions=4', + '-WaitMutex', '-FromMsBuild', '-architecture=x64', '-NoUBA', '-ForceHeaderGeneration', '-MaxParallelActions=4', ('-Log="{0}"' -f $buildLog)) $BuildRecord = Invoke-BuildProcess $buildArgs $buildLog $buildError $buildStdout if ($BuildRecord.status -ne 'passed') { Add-Failure "staged build $($BuildRecord.status)" } @@ -1375,6 +1376,30 @@ try { (Join-Path $RunRoot 'Logs\probe.log') (Join-Path $RunRoot 'Logs\probe.stdout.log') (Join-Path $RunRoot 'Logs\probe.stderr.log') $CommandletMetrics = Get-CommandletMetrics $commandletOut 'owner probe' } + 'symbolize-stacks' { + if (([string]::IsNullOrWhiteSpace($StackSamplesPath)) -or -not (Test-Path -LiteralPath $StackSamplesPath -PathType Leaf)) { + throw "-StackSamplesPath must name an existing raw sampler CSV." + } + $symbolizedOutput = Join-Path $RunRoot 'VoxelStackResolved.tsv' + $symbolizeCvars = [ordered]@{ + symbolizestacks = [IO.Path]::GetFullPath($StackSamplesPath) + symbolizedout = $symbolizedOutput + } + $symbolizeLog = Join-Path $RunRoot 'Logs\symbolize-stacks.log' + $args = New-CommonArguments 'commandlet' $symbolizeLog (Merge-Cvars $symbolizeCvars) + $commandletLedger = Invoke-UnrealLaunch 'offline-stack-symbolizer' $args ` + $symbolizeLog (Join-Path $RunRoot 'Logs\symbolize-stacks.stdout.log') ` + (Join-Path $RunRoot 'Logs\symbolize-stacks.stderr.log') + $CommandletMetrics = [ordered]@{ + status = if (($commandletLedger.status -eq 'passed') -and (Test-Path -LiteralPath $symbolizedOutput -PathType Leaf)) { 'ok' } else { 'missing' } + raw_samples = [IO.Path]::GetFullPath($StackSamplesPath) + resolved_stacks = $symbolizedOutput + log = $symbolizeLog + } + if ($CommandletMetrics.status -ne 'ok') { + Add-Failure 'offline stack symbolizer did not produce the resolved stack table' + } + } 'horizon' { $tracePath = Join-Path $RunRoot 'horizon_trace.jsonl' $baseCvars['voxel.TestRenderDistanceChunks'] = $HorizonRenderDistanceChunks