From 0bcd67a0168b54a38fe93d785a1ab131ff81c3cd Mon Sep 17 00:00:00 2001 From: Fr0zka Date: Tue, 29 Sep 2026 02:26:44 +0200 Subject: [PATCH] perf(review): gate never deadlocks, collision radius, 20x faster digs From the outside performance review (items 1-4; item 5 prototyped and reverted). 1. Collision gate: a support request outside the stamped desired set was cancelled by the rebuild it forced, and the gate held the pawn with gravity 0. Baseline deep-drop (floor at (0,0,-7), outside the aligned L0 ring): 1 hold of 30.0 s, never released. Fix: the target and 3 tiles below are retained through rebuilds at CollisionCritical; hold only when the swept capsule is about to enter pending support; release restores movement mode, gravity and downward speed. After: 3 holds, 2.21 s total, 112.8 m horizontal progress; gate off still penetrates (-7.4 cm). 2. Suspected cleanup regression (out-of-layout stacks): not real. 44 paired boundary tiles, 30.9 ms vs 32.6 ms at b4ac996, hashes 44/44 identical. No change. 3. PawnCollisionRadiusChunks (new, default 1): L0 tiles outside the collision set stay visible without Chaos cooks; anchors and the gate's support column are unioned in. Cook requests ~217/min -> ~30/min (-86%). Critical support ready time did NOT improve (9.5 s -> 10.6 s in the deep-drop fixture; the wait is the cook itself, ~7.4 s cook-to-ready there). 4. Dig: exact pre-edit L0 density cached (one 35^3 grid, 171.5 KB), carve replays current mods on it. Sync remesh 99.4 ms -> 4.7 ms. 16/16 random carves hash-identical to full regeneration. 5. Parallel grid fill for the critical tile: 16/16 hash parity, but the waited-on tile was already meshed; the wait was collision cooking. Reverted. Review G answered: M_Test1 still ray-marches VolTex0 (custom HLSL mini-sun shadow), so the density volume is live. Known and left alone: outside the strate layout the fallback density can be solid while the gate treats those tiles as air. Tests: VoxelForgeCollisionDesiredTilesTest, VoxelForgePreEditDensityCacheTest; no-overlap, edit ghost, descent corridor pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MmSoF64BbRFmk9C3p1VsBJ --- CODEMAP.md | 23 +- .../VoxelForgeCollisionDesiredTilesTest.cpp | 39 + .../VoxelForgePreEditDensityCacheTest.cpp | 110 +++ .../Private/VoxelCollisionDesiredTiles.h | 25 + Source/VoxelForge/Private/VoxelGenerator.cpp | 10 +- .../Private/VoxelMarchingCubesMesher.cpp | 38 +- .../Private/VoxelPreEditDensityCache.h | 99 +++ Source/VoxelForge/Private/VoxelWorld.cpp | 787 ++++++++++++++++-- Source/VoxelForge/Public/VoxelGenerator.h | 3 +- .../Public/VoxelMarchingCubesMesher.h | 4 +- Source/VoxelForge/Public/VoxelSettings.h | 6 + Source/VoxelForge/Public/VoxelWorld.h | 34 +- Tools/VoxelForgeTest.ps1 | 40 +- 13 files changed, 1114 insertions(+), 104 deletions(-) create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgeCollisionDesiredTilesTest.cpp create mode 100644 Source/VoxelForge/Private/Tests/VoxelForgePreEditDensityCacheTest.cpp create mode 100644 Source/VoxelForge/Private/VoxelCollisionDesiredTiles.h create mode 100644 Source/VoxelForge/Private/VoxelPreEditDensityCache.h diff --git a/CODEMAP.md b/CODEMAP.md index 4c4474f..26bc906 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -254,7 +254,7 @@ Tile identity lives in `FVoxelTileKey` (VoxelTypes.h:326). `UVoxelSettings : UPrimaryDataAsset` — the single tuning asset assigned on `AVoxelWorld`. | Group | Fields (line) | |-------|---------------| -| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4` (defaults — actual values live on the data asset) | +| Streaming | `ViewDistanceXY=16`, `ViewDistanceUp/Down=5`, `MaxConcurrentTasks=16`, `MaxMeshAppliesPerFrame=4`, `PawnCollisionRadiusChunks=1` (defaults — actual values live on the data asset; the collision radius is independent from the render clipmap) | | Clipmap | `ClipRadius`, `MaxClipLevel=5`, `FullResClipLevels`, `CoarseTileCells`, `RenderDistanceChunks=768` (measured ship defaults; custom horizontal reach: the outermost shell keeps generating until it covers this many chunks; 0 = off), skirts, `LODOctaveDrop` (T2.b octave drop on coarse tiles — 0 = off/byte-identical). Existing serialized data assets retain their authored values until deliberately migrated. | | World bounds | `WorldRadiusVoxels=8192` and `EdgeSealThickness=64` (actor-space XY radial shell; radius 0 = true legacy no-op) | | Lighting | `bEnableDensityVolume` + DensityVolume* tunables (§3.11 density clipmap / mini-sun shadows) | @@ -304,18 +304,21 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `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). | -| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): [ClassifyTile (T1.d) + exact validation, only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13**] → `GenerateMesh` → `BuildTileStreamSet`; records vertex count and resident stream allocation bytes for the horizon trace. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | +| `GenerateTileResult` | — | **Shared worker-side gen** for one tile (async `LoadTile` task + sync `SyncRemeshTile`): sealed-solid proof, out-of-layout air proof, optional ClassifyTile (T1.d) + exact validation only when `voxel.OuterClassifierMode=1`; **default 0 since 2026-09-13** → `GenerateMesh` → `BuildTileStreamSet`; records vertex count and resident stream allocation bytes for the horizon trace. Near the player it can capture the exact pre-edit L0 lattice into the result; a cache-hit sync remesh passes an edited per-call shared lattice. Reads Generator/Mesher only → safe on a worker or the game thread; fills `FChunkResult`, no enqueue. | | `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 (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | +| `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread. 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. | -| `AdvanceHeadlessCollisionGateStressTest` / `ObserveHeadlessCollisionGateStressTest` / `MaybeFinishHeadlessCollisionGateStressTest` | — | Explicit `-voxel.TestCollisionGateStress=1` harness only. `voxel.TestCollisionGateStressSpawnHeightVoxels` sets the drop above the measured density surface (default 10 voxels); optional `voxel.TestCollisionGateStressDeepDrop=1` carves a process-local shaft for a fall deeper than the level-0 ring. The `GetDensityAt` column at capsule feet defines the local air/solid surface. 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; support submission/readiness, travel, and holds are diagnostics. Result logs include the terminal density column and first crossing below spawn height. | +| `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) 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. A hold preserves horizontal input and restores downward speed after collision becomes ready. | +| `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. | | `ReconcileReadyTransitionVisibility` | — | Visibility-only pass while streaming is unsettled: hides a retired tile once all desired tiles over its footprint are loaded, reveals new tiles once no visible retired tile overlaps them. **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. | -| `RequestCollisionGateSupportTile` / `UpdatePawnCollisionGate` | — | The gate submits the pawn's level-0 support tile **immediately** as `CollisionCritical`, even before the first desired set exists (the desired rebuild then adopts the same pending key). The gate holds downward Z only, with **no timeout** (a 2 s release would fire at every startup, while the game thread applies nothing for ~2.2 s). | -| `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. | +| `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. | @@ -347,7 +350,7 @@ This is **where terrain shape lives.** | `CellularNoise3D` (static) | 976 | Worley/cellular — grotto/scallop. | | `ApplyBoundarySeal` (static) | 996 | Solidifies strate top/bottom shells. | | `InitializeSettings` | 2691 | Copies the effective seed, spine radius, and global XY world-bound settings from `UVoxelSettings` (the season owns all three when assigned); radius 0 preserves the unbounded field. | -| **`GetDensityAt`** | 2712 | **Entry point.** Picks strate + generator type and, on the per-chunk refetch, builds the chunk's operator stack (editor composer → cooked-season recipe → native archetype stack); evaluates it (TunnelNetwork/Underwater through the fused `GetDensityWithParams`), then adds disturbances, the structural tail and the diff layer. A degenerate strate has no stack and is air. Its `CP_*` state is keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`; workers copy immutable recipe/vector data and never read editor composer state. | +| **`GetDensityAt`** | 2712 | **Entry point.** Picks strate + generator type and, on the per-chunk refetch, builds the chunk's operator stack (editor composer → cooked-season recipe → native archetype stack); evaluates it (TunnelNetwork/Underwater through the fused `GetDensityWithParams`), then adds disturbances and structural posts. Optional `OutPreEditDensity` receives the exact final procedural field immediately before the player DiffLayer tail, allowing the near-player dig cache to replay edits without regenerating the operator stack. Its ordinary one-argument behavior is unchanged. A degenerate strate has no stack and is air; `CP_*` state remains keyed by `(DensityCacheOwnerId, ChunkCoord, LayoutVersion)`. | | **`GetDensityWithParams`** | 3749 | TunnelNetwork pipeline (~1000 lines) — the fused evaluator of the TunnelNetwork/Underwater stack in `GetDensityAt`. See §4. ⚠️ Takes **required** `ParamsFingerprint` + `LayoutVersion` since the AUDIT §C2 fix (2026-07-28) — they go into the SDF cache key so a chunk can no longer be evaluated against a neighbour's rooms. Callers compute the CRC **once per chunk** (`CP_TunnelFP`), never per voxel. The optional `voxel.WormBlockSkip` path (default 0) uses `VF_TryGetWormBlockSkip` + a worker-local 4³ lattice-block cache to prove `|N1| >= WormThreshold` and remove both worm noise calls only; it never changes `NetworkMask` or the fallback field path. | | `SampleSurfaceStructuralZ` | — | **F20:** the RAW SurfaceWorld heightfield (continents+mountains+detail), BEFORE any terrain op; returns terrain Z + relief M. Cliff re-samples it at an XY offset for a cheap analytic slope. | | `ComputeSurfaceTerrainZ` / `ComputeSurfaceColumn` | — | SurfaceWorld heightfield → terrain Z (+ sky-cap ceiling and overhang gate per column); biome **output-blend** lerps dominant/neighbour heights (`ParamsD`/`ParamsN`/weight). **F20 surface ops** (`FSurfaceGenerationParams`, biome-selected + slope/relief-conditioned, all default off): Cliff (slope-gated STEEPENING — push height from local mean where steep ⇒ sheer walls; 4 structural resamples only when on), Terrace (relief-gated + `TerraceHardness`), LayerLines (sedimentary shelves) — pure per-column height REMAPS applied here so the single height oracle stays consistent (MC/ClassifyTile/deco/BP bridge). **Phase 2 OVERHANG** (volumetric — real jutting shelves): in the stack's `FOverhangShelfMod`, for AIR voxels in a window `(TerrainZ, TerrainZ+OverhangHeight]` above a steep slope, the heightfield is re-sampled UPHILL (toward the cliff) by a reach that GROWS with height (tiny low ⇒ air over the void, full high ⇒ borrows the far cliff rock) and unioned in ⇒ a shelf attached to the cliff, tapering out over the void with air beneath (the sketch). Per-column `OverhangAmp`(=strength·slope-gate) + unit uphill `(DirX,DirY)` resolved once in `ComputeSurfaceColumn` (gradient sampled at the REACH scale so a spot over the void can see the cliff), cached on `FSurfaceColumn`. Genuine 3D (per-voxel structural re-eval, gated to steep overhang columns). Off ⇒ byte-identical. The field-preserving LOD0 pass removes only needless temporaries/default construction; every output remains bit-identical. §8.14. | @@ -686,7 +689,7 @@ 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) = CAPTURE-DURING-MESHING: when non-null + full-res (`CellsPerAxis==CHUNK_SIZE`), copies the already-sampled `CHUNK_SIZE³` density grid (quantized via `VF_QuantizeDensity`, VoxelTypes.h) so the density clipmap reuses it instead of re-sampling `GetDensityAt`. Pure read of the grid — §8.10 untouched. **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 (kills far-LOD inter-strate aliasing holes); 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, 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. | **`Public/MarchingCubesTables.h`** — `EdgeTable` + `TriTable` reference data (Paul Bourke). Cube corner/edge layout documented at top (lines 7-37). Rarely needs editing. @@ -738,6 +741,8 @@ second density path. | `VoxelForgeStrateConnectivityTest.cpp` | `VoxelForge.Generation.StrateConnectivity` / `VoxelForge.Generation.StrateConnectivityRefinement` / `VoxelForge.Generation.VerticalShaftSeamFreedom` | Bounded strate metrics with density-polarity and solid-gap controls, deterministic route rechecks, and refinement sweeps. The refinement test uses the bounded 4-chunk density fixture, fits each measurement AABB to the arrival/departure mouth pair plus three shaft spacings (the prescribed local tree fallback), and caps the fitted control at 120,000,000 cells. It reports exact before/after seed-6 cell counts, reruns every negative with the fitted margin doubled, measures tree orphan candidates/path reachability and roughness-bubble proxies, asserts mouths remain inside the deterministic shaft feature core (the exact axis is an unsupported open cylinder), checks source/mirror physical paths, and requires 16/16 effective arrival→departure results across the 16 VerticalShafts seeds. Margin-binding negatives are reported as measurement limits, never as gap findings. The shaft seam test re-evaluates cell-boundary positions after warming distinct neighbouring chunk contexts. | | `VoxelForgeMazeSeamTest.cpp` | `VoxelForge.Generation.MazeSeamFreedom` | Re-evaluates **42** cell-boundary probes through `GetDensityAt` after warming six different neighbouring chunk contexts; must report **0 mismatches**. The Maze source's 2×2×2 lower-node window is deliberately local; no wide collect is involved. | | `VoxelForgeClipmapNoOverlapTest.cpp` | `VoxelForge.Streaming.ClipmapDesiredTilesNoOverlapCoverage` | Sweeps 5,145 cases (343 centres with negative coords and every parity × 5 radius/level/render-distance settings × 3 vertical clamps): **zero overlapping desired pairs AND coverage ⊇ the frozen `7486c29` selector**. Failed on `7486c29` (1,022,679 overlapping pairs) as intended. | +| `VoxelForgeCollisionDesiredTilesTest.cpp` | `VoxelForge.Streaming.CollisionDesiredTiles.PawnAndAnchors` | Checks the pawn collision box stays within its configured radius, includes support tiles below, and unions an independent anchor box at its authored XY/Z extents. | +| `VoxelForgePreEditDensityCacheTest.cpp` | `VoxelForge.Determinism.PreEditDensityCacheCarveParity` | Captures one exact 35³ L0 pre-edit lattice, applies 16 deterministic random carves cumulatively, and requires each cached remesh's complete geometry hash to match full `GetDensityAt` regeneration; also requires non-empty carve geometry. | | `VoxelForgeDensityBlockEquivalenceTest.cpp` | `VoxelForge.Density.OperatorBlockMatchesScalarGrid` | Block evaluation (`EvalBlock` after `BuildActiveOpList` pruning) vs scalar, **bit-identical**: 5.1M mesher-grid samples (6 seeds × 8 archetypes × levels 0–5, with edits) + 300k direct `EvalBlock` samples. Scopes `voxel.UseFusedEvaluator=0` so the real block path runs. An injected optimistic `FPassageCarveOp::EffectOverBox` produced 5,612 mismatches. | | `VoxelForgeWorldEdgeSealTest.cpp` | `VoxelForge.Generation.WorldEdgeSeal` | Positive samples outside the radius across every fixture strate, bit-identical interior negative control against radius 0, monotonic smooth-ramp check, radius-0 no-op, radial `ClassifyBox`/T1.d proofs brute-forced for every reported voxel, and synthetic plus generated near-rim passage coverage. Emits one aggregate summary with proved-box and voxel counts. | | `VoxelForgeCorrectnessRoundTest.cpp` | `VoxelForge.Determinism.DiffApiBoundary` / `DiffLayerIdentity` / `EmptyPassageVersion` / `SurfaceBiomeCacheIdentity` / `TileCacheContextSwitch` / `ZeroTransitionBlend` | Regression coverage for the cold-audit correctness fixes (diff-layer API edges, cache identities, empty-passage layout version, zero transition blend). | diff --git a/Source/VoxelForge/Private/Tests/VoxelForgeCollisionDesiredTilesTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgeCollisionDesiredTilesTest.cpp new file mode 100644 index 0000000..543beb0 --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgeCollisionDesiredTilesTest.cpp @@ -0,0 +1,39 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" + +#include "VoxelCollisionDesiredTiles.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgeCollisionDesiredTilesPawnAndAnchorsTest, + "VoxelForge.Streaming.CollisionDesiredTiles.PawnAndAnchors", + EAutomationTestFlags::EditorContext | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgeCollisionDesiredTilesPawnAndAnchorsTest::RunTest(const FString& Parameters) +{ + (void)Parameters; + TSet CollisionTiles; + const FIntVector PawnChunk(2, -3, 4); + VoxelCollisionDesiredTiles::AddBox(CollisionTiles, PawnChunk, 1, 1, 1); + + TestTrue(TEXT("pawn center remains collidable"), + CollisionTiles.Contains(FVoxelTileKey(PawnChunk, 0))); + TestTrue(TEXT("pawn radius includes horizontal edge"), + CollisionTiles.Contains(FVoxelTileKey(PawnChunk + FIntVector(1, -1, 0), 0))); + TestTrue(TEXT("pawn radius includes support below"), + CollisionTiles.Contains(FVoxelTileKey(PawnChunk + FIntVector(0, 0, -1), 0))); + TestFalse(TEXT("pawn radius does not follow the full render ring"), + CollisionTiles.Contains(FVoxelTileKey(PawnChunk + FIntVector(2, 0, 0), 0))); + + const FIntVector AnchorChunk(-12, 8, -5); + VoxelCollisionDesiredTiles::AddBox(CollisionTiles, AnchorChunk, 2, 1, 3); + TestTrue(TEXT("anchor box includes its far horizontal edge"), + CollisionTiles.Contains(FVoxelTileKey(AnchorChunk + FIntVector(-2, 2, 0), 0))); + TestTrue(TEXT("anchor box includes its upper extent"), + CollisionTiles.Contains(FVoxelTileKey(AnchorChunk + FIntVector(0, 0, 3), 0))); + TestFalse(TEXT("anchor box keeps authored bounds"), + CollisionTiles.Contains(FVoxelTileKey(AnchorChunk + FIntVector(3, 0, 0), 0))); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/Tests/VoxelForgePreEditDensityCacheTest.cpp b/Source/VoxelForge/Private/Tests/VoxelForgePreEditDensityCacheTest.cpp new file mode 100644 index 0000000..2a59a6f --- /dev/null +++ b/Source/VoxelForge/Private/Tests/VoxelForgePreEditDensityCacheTest.cpp @@ -0,0 +1,110 @@ +#if WITH_DEV_AUTOMATION_TESTS + +#include "Misc/AutomationTest.h" +#include "VoxelForgeTestFixture.h" +#include "VoxelMarchingCubesMesher.h" +#include "VoxelGeometryHash.h" +#include "VoxelStackSampler.h" +#include "../VoxelPreEditDensityCache.h" + +IMPLEMENT_SIMPLE_AUTOMATION_TEST( + FVoxelForgePreEditDensityCacheParityTest, + "VoxelForge.Determinism.PreEditDensityCacheCarveParity", + EAutomationTestFlags_ApplicationContextMask | EAutomationTestFlags::EngineFilter) + +bool FVoxelForgePreEditDensityCacheParityTest::RunTest(const FString& Parameters) +{ + using namespace VoxelForgeTest; + + FTestWorld World; + World.Build(/*Seed*/1337, /*GapChunks*/2); + if (!World.IsValid()) + { + AddError(World.WhyInvalid()); + return false; + } + + int32 SlotTop = 0; + int32 SlotBottom = 0; + if (!World.GetSlotVoxelZRange(FTestWorld::SlotTunnelNetwork, SlotTop, SlotBottom)) + { + AddError(TEXT("TunnelNetwork slot range was unavailable.")); + return false; + } + const int32 MidVoxelZ = (SlotTop + SlotBottom) / 2; + const int32 TileZ = FMath::FloorToInt( + static_cast(MidVoxelZ) / CHUNK_SIZE) * CHUNK_SIZE; + const FIntVector TileOrigin(0, 0, TileZ); + TestEqual(TEXT("the runtime pre-edit cache holds only the pawn's current tile"), + VoxelForgePreEditDensityCache::MaxCachedTiles, 1); + TestEqual(TEXT("the cache target radius matches its one-tile memory bound"), + VoxelForgePreEditDensityCache::PawnRadiusChunks, 0); + + TStrongObjectPtr Mesher( + NewObject(GetTransientPackage(), NAME_None, RF_Transient)); + Mesher->SetGenerator(World.Generator.Get()); + + FScopedVoxelStackRegistration StackRegistration(/*InLODLevel*/0); + FVoxelSharedDensityGrid PreEditGrid; + int64 BaseDensitySamples = 0; + Mesher->GenerateMesh(TileOrigin, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX, + &BaseDensitySamples, &PreEditGrid); + TestEqual(TEXT("the cache captured the complete 35³ lattice"), + PreEditGrid.Samples.Num(), (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3)); + TestEqual(TEXT("the cache uses the one-voxel mesh halo"), PreEditGrid.Dim, CHUNK_SIZE + 3); + TestTrue(TEXT("the initial tile sampled its density lattice"), BaseDensitySamples > 0); + if (PreEditGrid.Samples.Num() == 0) + { + return false; + } + + FRandomStream Random(0x4D17); + int32 ComparedCarves = 0; + int32 NonEmptyPairs = 0; + for (int32 CarveIndex = 0; CarveIndex < 16; ++CarveIndex) + { + FVoxelModification Modification; + Modification.Center = FVector( + TileOrigin.X + Random.RandRange(5, 27), + TileOrigin.Y + Random.RandRange(5, 27), + TileOrigin.Z + Random.RandRange(5, 27)); + Modification.Radius = static_cast(Random.RandRange(3, 7)); + Modification.Strength = -50.0f; + const TArray Affected = World.DiffLayer->ApplyModification(Modification); + TestTrue(FString::Printf(TEXT("random carve %d affected at least one chunk"), CarveIndex), + Affected.Num() > 0); + + FVoxelSharedDensityGrid EditedGrid; + TestTrue(FString::Printf(TEXT("random carve %d replayed over pre-edit samples"), CarveIndex), + VoxelForgePreEditDensityCache::BuildEditedGrid( + PreEditGrid, World.DiffLayer.Get(), EditedGrid)); + + const FVoxelMeshData FullRegeneration = Mesher->GenerateMesh( + TileOrigin, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX); + const FVoxelMeshData CachedRemesh = Mesher->GenerateMesh( + TileOrigin, 1, CHUNK_SIZE, nullptr, INT32_MIN, INT32_MAX, + nullptr, nullptr, &EditedGrid); + const FString FullHash = VoxelForgeGeometryHash::Compute(FullRegeneration); + const FString CachedHash = VoxelForgeGeometryHash::Compute(CachedRemesh); + TestEqual(FString::Printf(TEXT("carve %d cached/full tile hash"), CarveIndex), + CachedHash, FullHash); + if (CachedHash == FullHash) + { + ++ComparedCarves; + } + if (!FullRegeneration.IsEmpty() && !CachedRemesh.IsEmpty()) + { + ++NonEmptyPairs; + } + } + + TestEqual(TEXT("all random carves matched a full tile regeneration"), ComparedCarves, 16); + TestTrue(TEXT("the parity set exercised actual carve geometry"), NonEmptyPairs > 0); + AddInfo(FString::Printf( + TEXT("pre_edit_density_cache: carves=%d hash_matches=%d nonempty_pairs=%d samples=%d bytes=%d"), + 16, ComparedCarves, NonEmptyPairs, PreEditGrid.Samples.Num(), + PreEditGrid.Samples.Num() * static_cast(sizeof(float)))); + return true; +} + +#endif // WITH_DEV_AUTOMATION_TESTS diff --git a/Source/VoxelForge/Private/VoxelCollisionDesiredTiles.h b/Source/VoxelForge/Private/VoxelCollisionDesiredTiles.h new file mode 100644 index 0000000..a9075e5 --- /dev/null +++ b/Source/VoxelForge/Private/VoxelCollisionDesiredTiles.h @@ -0,0 +1,25 @@ +#pragma once + +#include "CoreMinimal.h" +#include "VoxelTypes.h" + +namespace VoxelCollisionDesiredTiles +{ + inline void AddBox(TSet& OutTiles, const FIntVector& Center, + int32 XYRadius, int32 ZBelow, int32 ZAbove) + { + const int32 SafeXYRadius = FMath::Max(0, XYRadius); + const int32 SafeZBelow = FMath::Max(0, ZBelow); + const int32 SafeZAbove = FMath::Max(0, ZAbove); + for (int32 Z = -SafeZBelow; Z <= SafeZAbove; ++Z) + { + for (int32 Y = -SafeXYRadius; Y <= SafeXYRadius; ++Y) + { + for (int32 X = -SafeXYRadius; X <= SafeXYRadius; ++X) + { + OutTiles.Add(FVoxelTileKey(Center + FIntVector(X, Y, Z), 0)); + } + } + } + } +} diff --git a/Source/VoxelForge/Private/VoxelGenerator.cpp b/Source/VoxelForge/Private/VoxelGenerator.cpp index e9d7c54..b71a754 100644 --- a/Source/VoxelForge/Private/VoxelGenerator.cpp +++ b/Source/VoxelForge/Private/VoxelGenerator.cpp @@ -2709,7 +2709,8 @@ void UVoxelGenerator::EndDensityBlock() const GVoxelDensityBlockSession.End(); } -float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) const +float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity) const { // Soundness mode: for a sample in a block whose core or passage-carving reach bit is clear, // evaluate the complete canonical field once with the bypass bit set. The normal call then @@ -3696,6 +3697,13 @@ float UVoxelGenerator::GetDensityAt(float WorldX, float WorldY, float WorldZ) co // Fill (diff > 0) → Result -= positive → Result decreases → more solid ✓ VoxelDensityProfile::FScopedTimer DensityDiffLayerTimer( VoxelDensityProfile::EBucket::DensityDiffLayer); + // The sync-remesh cache keeps the exact procedural field after every structural post, but + // before player edits. The cached lattice is replayed with the current DiffLayer snapshot so + // a carve sees the same subtraction order and float result as this canonical path. + if (OutPreEditDensity != nullptr) + { + *OutPreEditDensity = Result; + } if (DiffLayer && DiffLayer->HasAnyMods()) { // ── PER-CHUNK MOD SNAPSHOT ── diff --git a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp index 304302e..8c6760a 100644 --- a/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp +++ b/Source/VoxelForge/Private/VoxelMarchingCubesMesher.cpp @@ -96,11 +96,14 @@ void UVoxelMarchingCubesMesher::InitializeConsoleSwitches() FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, int32 Step, int32 InCellsPerAxis, TArray* OutCaptureGrid, int32 BandZMinVox, int32 BandZMaxVox, - int64* OutDensitySampleCount) + int64* OutDensitySampleCount, + FVoxelSharedDensityGrid* OutPreEditDensityGrid, + const FVoxelSharedDensityGrid* InSharedDensityGrid) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_MesherGenerateMesh); FVoxelMeshData MeshData; if (OutCaptureGrid) { OutCaptureGrid->Reset(); } + if (OutPreEditDensityGrid) { OutPreEditDensityGrid->Reset(); } if (OutDensitySampleCount) { *OutDensitySampleCount = 0; } if (!Generator) return MeshData; VoxelDensityProfile::FScopedTimer MesherProfileTimer( @@ -383,7 +386,11 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // COUPE DE CONTENU PAR STRATE — restreint le maillage (et l'échantillonnage) aux cellules // dont l'intervalle Z chevauche la bande [BandZMinVox, BandZMaxVox] (voxels inclusifs). // Les tuiles à capture ne sont jamais bandées (niveau 0 — garde-fou ci-dessous). - if (OutCaptureGrid) { BandZMinVox = INT32_MIN; BandZMaxVox = INT32_MAX; } + if (OutCaptureGrid || OutPreEditDensityGrid) + { + BandZMinVox = INT32_MIN; + BandZMaxVox = INT32_MAX; + } int32 CzLo = 0, CzHi = CellsPerAxis - 1; if (BandZMinVox > INT32_MIN || BandZMaxVox < INT32_MAX) { @@ -660,7 +667,8 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, // capacité, donc plus de malloc/free de ~170 Ko (35³ floats) par tuile. static thread_local TArray DensityGrid; DensityGrid.SetNumUninitialized(MDim * MDim * MDim); - const FVoxelSharedDensityGrid* ReuseGrid = SharedDensityGrid; + const FVoxelSharedDensityGrid* ReuseGrid = InSharedDensityGrid != nullptr + ? InSharedDensityGrid : SharedDensityGrid; const int32 SharedDeltaX = ReuseGrid ? (OriginVoxels.X - ReuseGrid->OriginVoxels.X) / Step : 0; const int32 SharedDeltaY = ReuseGrid @@ -680,6 +688,17 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, && SharedDeltaX + GridDim < ReuseGrid->Dim && SharedDeltaY + GridDim < ReuseGrid->Dim && SharedDeltaZ + GridDim < ReuseGrid->Dim; + const bool bCapturePreEditGrid = OutPreEditDensityGrid != nullptr + && !bUseSharedDensityGrid + && Step == 1 && CellsPerAxis == CHUNK_SIZE + && CzLo == 0 && CzHi == CellsPerAxis - 1; + if (bCapturePreEditGrid) + { + OutPreEditDensityGrid->OriginVoxels = OriginVoxels + FIntVector(-Step, -Step, -Step); + OutPreEditDensityGrid->Step = Step; + OutPreEditDensityGrid->Dim = MDim; + OutPreEditDensityGrid->Samples.SetNumUninitialized(MDim * MDim * MDim); + } // Bande de strate : seules les rangées Z réellement lues (cellules CzLo..CzHi + marges de // gradient) sont échantillonnées — le reste du tampon reste non initialisé et non lu. VoxelDensityProfile::FScopedTimer MesherDensityGridTimer( @@ -731,10 +750,16 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, VoxelGenLOD::TileReachBlockIndex = ((ReachBlockZ * ReachBlocksPerAxis) + ReachBlockY) * ReachBlocksPerAxis + ReachBlockX; + float PreEditDensity = 0.0f; DensityGrid[LocalIndex] = Generator->GetDensityAt( OriginVoxels.X + gx * Step, OriginVoxels.Y + gy * Step, - OriginVoxels.Z + gz * Step); + OriginVoxels.Z + gz * Step, + bCapturePreEditGrid ? &PreEditDensity : nullptr); + if (bCapturePreEditGrid) + { + OutPreEditDensityGrid->Samples[LocalIndex] = PreEditDensity; + } if (OutDensitySampleCount) { ++(*OutDensitySampleCount); @@ -848,6 +873,11 @@ FVoxelMeshData UVoxelMarchingCubesMesher::GenerateMesh(FIntVector OriginVoxels, } } + if (OutPreEditDensityGrid && !bCapturePreEditGrid) + { + OutPreEditDensityGrid->Reset(); + } + // Lecture grille (avec offset de marge) + gradient central depuis la grille. auto SampleG = [&](int32 gx, int32 gy, int32 gz) -> float { diff --git a/Source/VoxelForge/Private/VoxelPreEditDensityCache.h b/Source/VoxelForge/Private/VoxelPreEditDensityCache.h new file mode 100644 index 0000000..791d9cb --- /dev/null +++ b/Source/VoxelForge/Private/VoxelPreEditDensityCache.h @@ -0,0 +1,99 @@ +// Bounded helpers for replaying player edits over the pre-edit L0 density lattice. +#pragma once + +#include "CoreMinimal.h" +#include "VoxelDiffLayer.h" +#include "VoxelMarchingCubesMesher.h" + +namespace VoxelForgePreEditDensityCache +{ + // Keep only the tile containing the pawn: 35³ floats is a fixed 171,500-byte ceiling. The + // current tile is the synchronous brush-centre remesh target and avoids prewarming empty neighbors. + constexpr int32 PawnRadiusChunks = 0; + constexpr int32 MaxCachedTiles = 1; + + inline bool IsPawnRadiusTile(const FVoxelTileKey& Tile, const FIntVector& PawnChunk) + { + return Tile.Level == 0 + && FMath::Abs(Tile.Coord.X - PawnChunk.X) <= PawnRadiusChunks + && FMath::Abs(Tile.Coord.Y - PawnChunk.Y) <= PawnRadiusChunks + && FMath::Abs(Tile.Coord.Z - PawnChunk.Z) <= PawnRadiusChunks; + } + + inline bool BuildEditedGrid(const FVoxelSharedDensityGrid& PreEditGrid, + const UVoxelDiffLayer* DiffLayer, + FVoxelSharedDensityGrid& OutEditedGrid) + { + OutEditedGrid.Reset(); + if (PreEditGrid.Step != 1 || PreEditGrid.Dim != CHUNK_SIZE + 3 + || PreEditGrid.Samples.Num() != PreEditGrid.Dim * PreEditGrid.Dim * PreEditGrid.Dim) + { + return false; + } + + OutEditedGrid = PreEditGrid; + if (DiffLayer == nullptr || !DiffLayer->HasAnyMods()) + { + return true; + } + + const FIntVector LastVoxel = PreEditGrid.OriginVoxels + + FIntVector(PreEditGrid.Dim - 1, PreEditGrid.Dim - 1, PreEditGrid.Dim - 1) + * PreEditGrid.Step; + const FIntVector MinChunk( + FMath::FloorToInt(static_cast(PreEditGrid.OriginVoxels.X) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(PreEditGrid.OriginVoxels.Y) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(PreEditGrid.OriginVoxels.Z) / CHUNK_SIZE)); + const FIntVector MaxChunk( + FMath::FloorToInt(static_cast(LastVoxel.X) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(LastVoxel.Y) / CHUNK_SIZE), + FMath::FloorToInt(static_cast(LastVoxel.Z) / CHUNK_SIZE)); + + TMap> ChunkMods; + for (int32 CZ = MinChunk.Z;; ++CZ) + { + for (int32 CY = MinChunk.Y;; ++CY) + { + for (int32 CX = MinChunk.X;; ++CX) + { + TArray Mods; + DiffLayer->GetChunkModsSnapshot(FIntVector(CX, CY, CZ), Mods); + ChunkMods.Add(FIntVector(CX, CY, CZ), MoveTemp(Mods)); + if (CX == MaxChunk.X) { break; } + } + if (CY == MaxChunk.Y) { break; } + } + if (CZ == MaxChunk.Z) { break; } + } + + const int32 Dim = PreEditGrid.Dim; + for (int32 Z = 0; Z < Dim; ++Z) + { + const int32 WorldZ = PreEditGrid.OriginVoxels.Z + Z * PreEditGrid.Step; + const int32 ChunkZ = FMath::FloorToInt(static_cast(WorldZ) / CHUNK_SIZE); + for (int32 Y = 0; Y < Dim; ++Y) + { + const int32 WorldY = PreEditGrid.OriginVoxels.Y + Y * PreEditGrid.Step; + const int32 ChunkY = FMath::FloorToInt(static_cast(WorldY) / CHUNK_SIZE); + for (int32 X = 0; X < Dim; ++X) + { + const int32 WorldX = PreEditGrid.OriginVoxels.X + X * PreEditGrid.Step; + const int32 ChunkX = FMath::FloorToInt(static_cast(WorldX) / CHUNK_SIZE); + const TArray* Mods = ChunkMods.Find( + FIntVector(ChunkX, ChunkY, ChunkZ)); + if (Mods == nullptr || Mods->Num() == 0) + { + continue; + } + + const int32 Index = (Z * Dim + Y) * Dim + X; + // Match GetDensityAt's canonical post-edit operation exactly. + OutEditedGrid.Samples[Index] -= UVoxelDiffLayer::EvaluateMods( + *Mods, static_cast(WorldX), static_cast(WorldY), + static_cast(WorldZ)); + } + } + } + return true; + } +} diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index 8694801..fae6d6b 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -22,6 +22,8 @@ #include "VoxelPassageGeometry.h" #include "VoxelTilePostReach.h" #include "VoxelClipmapDesiredTiles.h" +#include "VoxelCollisionDesiredTiles.h" +#include "VoxelPreEditDensityCache.h" // IWYU (FPSemantics = Precise ⇒ plus de PCH partagé) : GetPlayerPosition déréférence le pawn, donc // APawn doit être COMPLET — `Casts.h` n'en donne qu'une déclaration avant. APlayerController n'est // complet que par transitivité : on l'inclut explicitement. @@ -532,6 +534,7 @@ void AVoxelWorld::RegenerateAllChunks() // Bump the generation epoch so in-flight async tasks become stale. // ProcessPendingChunks will discard any result with an old epoch. GenerationEpoch++; + PreEditDensityCache.Reset(); // Tear down every tile component, then clear all tile state. Components are GC-safe via // actor ownership. T2.c: PARK them instead of destroying — the reload right after this @@ -585,6 +588,7 @@ void AVoxelWorld::RegenerateAllChunks() // §9.4 collision-only tracking references destroyed tiles — clear (rebuilt on the next crossing). CollisionOnlyTiles.Empty(); PrevCollisionOnlyTiles.Empty(); + CollisionDesiredTiles.Empty(); CollisionGateDesiredTiles.Empty(); // Reset streaming state so the next Tick rebuilds the desired set and reloads. @@ -1380,6 +1384,7 @@ void AVoxelWorld::EndPlay(const EEndPlayReason::Type EndPlayReason) BandRemeshQueue.Empty(); PendingCollisionCooks.Empty(); CollisionReadyTiles.Empty(); + CollisionDesiredTiles.Empty(); CollisionNotRequiredTiles.Empty(); CollisionSolidTiles.Empty(); for (const TPair& Pair : CollisionBodyUpdatedHandles) @@ -1878,14 +1883,20 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() bHeadlessCollisionGateStressTestFailed = false; bHeadlessCollisionGateStressTestExitRequested = false; bHeadlessCollisionGateStressTestGateWasEngaged = false; + bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = false; bHeadlessCollisionGateStressTestFullStop = false; bHeadlessCollisionGateStressTestStartHeightCrossed = false; bHeadlessCollisionGateStressTestTerrainViolation = false; bHeadlessCollisionGateStressTestTerrainMeasurementMissing = false; bHeadlessCollisionGateStressTestLanded = false; bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = false; + bHeadlessCollisionGateStressTestSettingsReported = false; bHeadlessCollisionGateStressDeepDrop = false; bHeadlessCollisionGateStressDeepDropPrepared = false; + bHeadlessCollisionGateStressDeepDropPrewarmReported = false; + bHeadlessCollisionGateStressDeepDropSurfaceCached = false; + HeadlessCollisionGateStressDeepDropSurfaceXYVox = FVector2D::ZeroVector; + HeadlessCollisionGateStressDeepDropSurfaceZVox = 0.0f; HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; HeadlessCollisionGateStressTestDurationSeconds = 2.0f; HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f; @@ -2940,7 +2951,7 @@ void AVoxelWorld::AdvanceHeadlessStreamingTest( bool AVoxelWorld::MeasureHeadlessCollisionGateStressTerrain( const FVector& WorldPosition, float CapsuleHalfHeightCm, float& OutFeetDensity, float& OutCenterDensity, - float& OutSurfaceLocalVoxelZ, float& OutClearanceCm) const + float& OutSurfaceLocalVoxelZ, float& OutClearanceCm) { OutFeetDensity = 0.0f; OutCenterDensity = 0.0f; @@ -2963,12 +2974,25 @@ bool AVoxelWorld::MeasureHeadlessCollisionGateStressTerrain( return false; } + const bool bUseCachedDeepDropSurface = bHeadlessCollisionGateStressDeepDrop + && bHeadlessCollisionGateStressDeepDropSurfaceCached + && FMath::Abs(LocalPosition.X - HeadlessCollisionGateStressDeepDropSurfaceXYVox.X) < 0.01f + && FMath::Abs(LocalPosition.Y - HeadlessCollisionGateStressDeepDropSurfaceXYVox.Y) < 0.01f; + if (bUseCachedDeepDropSurface) + { + OutSurfaceLocalVoxelZ = HeadlessCollisionGateStressDeepDropSurfaceZVox; + OutClearanceCm = (FeetZ - OutSurfaceLocalVoxelZ) * VOXEL_SIZE; + return true; + } + // The density field is the authoritative terrain oracle (negative = solid). Find the // nearest air/solid zero crossing adjacent to the capsule's lowest point, then linearly // interpolate it. Searching up when the feet sample is solid reports negative clearance // instead of silently choosing a lower floor after a fall-through. constexpr float SearchStepVoxels = 0.5f; - constexpr int32 MaxSearchSteps = 256; // 128 voxels, enough to cover the local landing band. + const int32 MaxSearchSteps = bHeadlessCollisionGateStressDeepDrop + && !bHeadlessCollisionGateStressDeepDropSurfaceCached + ? 512 : 256; float LastZ = FeetZ; float LastDensity = OutFeetDensity; const bool bSearchDown = OutFeetDensity >= 0.0f; @@ -2994,6 +3018,13 @@ bool AVoxelWorld::MeasureHeadlessCollisionGateStressTerrain( : FMath::Clamp(LastDensity / Denominator, 0.0f, 1.0f); OutSurfaceLocalVoxelZ = FMath::Lerp(LastZ, SampleZ, Alpha); OutClearanceCm = (FeetZ - OutSurfaceLocalVoxelZ) * VOXEL_SIZE; + if (bHeadlessCollisionGateStressDeepDrop) + { + HeadlessCollisionGateStressDeepDropSurfaceXYVox = FVector2D( + LocalPosition.X, LocalPosition.Y); + HeadlessCollisionGateStressDeepDropSurfaceZVox = OutSurfaceLocalVoxelZ; + bHeadlessCollisionGateStressDeepDropSurfaceCached = true; + } return true; } @@ -3013,29 +3044,197 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( } const double Now = FPlatformTime::Seconds(); + int32 DeepDropTopChunkZ = 0; + int32 DeepDropBottomChunkZ = 0; + if (bHeadlessCollisionGateStressDeepDrop) + { + if (HeadlessCollisionGateStressTestBeginSeconds <= 0.0) + { + HeadlessCollisionGateStressTestBeginSeconds = Now; + } + if (StrateManager == nullptr + || !StrateManager->GetStrateChunkZBounds( + /*ChunkZ*/ 0, DeepDropTopChunkZ, DeepDropBottomChunkZ)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_fixture_strate_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_fixture_strate_unavailable probe_chunk_z=0")); + return; + } + } if (!bHeadlessCollisionGateStressTestStartPlaced) { if (bHeadlessCollisionGateStressDeepDrop && !bHeadlessCollisionGateStressDeepDropPrepared) { - // Headless regression fixture: carve a narrow shaft through several level-0 tiles so - // the pawn falls through known-empty tiles before reaching an unresolved floor tile. - // This is process-local test data; no asset or save file is touched. - const FVector ShaftTopWorld = LocalVoxelToWorld(FVector(18.2f, 0.0f, 40.0f)); - const FVector ShaftBottomWorld = LocalVoxelToWorld(FVector(18.2f, 0.0f, -90.0f)); - CarveCapsule(ShaftTopWorld, ShaftBottomWorld, 7.0f, 24.0f); + // Carve a tile-wide air column through the top strate. The test later checks the exact + // 35^3 density samples for each pre-proven empty tile before allowing the support walk + // to skip it. The box ends at the first sample below the floor tile's upper boundary, + // avoiding the round end-cap that a capsule would extend into the floor. It extends + // along the test's post-landing route so that route does not hit the shaft's side wall. + // Its long extent needs a larger brush-radius allowance in this transient commandlet + // world only. + constexpr float DeepDropCenterX = CHUNK_SIZE * 0.5f; + constexpr float DeepDropCenterY = CHUNK_SIZE * 0.5f; + constexpr float DeepDropShaftStartX = -32.0f; + const float DeepDropShaftEndX = CHUNK_SIZE * 0.5f + + (HeadlessCollisionGateStressTestSpeedCmPerSecond + * HeadlessCollisionGateStressTestDurationSeconds / VOXEL_SIZE) + + 64.0f; + const float StrateTopExclusiveVoxels = + static_cast(DeepDropTopChunkZ + 1) * CHUNK_SIZE; + const float ShaftTopVoxels = StrateTopExclusiveVoxels + 32.0f; + const float ShaftBottomVoxels = + static_cast(DeepDropBottomChunkZ + 1) * CHUNK_SIZE - 1.0f; + if (ShaftBottomVoxels >= ShaftTopVoxels || DiffLayer == nullptr) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_fixture_strate_or_diff_unavailable"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_fixture_strate_or_diff_unavailable top_chunk_z=%d " + "bottom_chunk_z=%d diff_available=%d"), + DeepDropTopChunkZ, DeepDropBottomChunkZ, DiffLayer != nullptr ? 1 : 0); + return; + } + const float ShaftHalfExtentZ = (ShaftTopVoxels - ShaftBottomVoxels) * 0.5f; + const float ShaftCenterX = + (DeepDropShaftStartX + DeepDropShaftEndX) * 0.5f; + const float ShaftHalfExtentX = + (DeepDropShaftEndX - DeepDropShaftStartX) * 0.5f; + const FVector ShaftCenterWorld = LocalVoxelToWorld(FVector( + ShaftCenterX, DeepDropCenterY, + (ShaftTopVoxels + ShaftBottomVoxels) * 0.5f)); + const FVector ShaftHalfExtent(ShaftHalfExtentX, 18.0f, ShaftHalfExtentZ); + const float FixtureBrushRadius = FMath::Max( + FMath::Max3(ShaftHalfExtent.X, ShaftHalfExtent.Y, ShaftHalfExtent.Z), + Settings != nullptr ? Settings->MaxBrushRadius : 0.0f); + DiffLayer->SetBudget( + Settings != nullptr ? Settings->MaxModifications : 0, + FixtureBrushRadius, + Settings != nullptr ? Settings->MaxTotalVolume : 0.0f); + CarveBox(ShaftCenterWorld, ShaftHalfExtent, 512.0f); + + int32 SuppressedPlayerAnchorCount = 0; + for (const FVoxelStreamingAnchor& Anchor : StreamingAnchors) + { + if (Anchor.Actor.Get() == PlayerPawn) + { + ++SuppressedPlayerAnchorCount; + } + } + if (SuppressedPlayerAnchorCount > 0) + { + // The owner map registers the local pawn as an additional anchor. Suppress only + // that redundant test-world anchor so this fixture exercises the gate's fallback + // path; all other AI/remote-player anchors remain active. The commandlet exits + // with the transient world immediately after this test. + UnregisterStreamingAnchor(PlayerPawn); + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_player_anchor_suppressed=%d " + "remaining_anchors=%d"), + SuppressedPlayerAnchorCount, StreamingAnchors.Num()); + + FIntVector DeepDropRingMin = FIntVector::ZeroValue; + FIntVector DeepDropRingMax = FIntVector::ZeroValue; + VoxelClipmapDesiredTiles::AlignedRingBounds( + FIntVector(0, 0, DeepDropTopChunkZ), + Settings != nullptr ? FMath::Max(1, Settings->ClipRadius) : 3, + 2, DeepDropRingMin, DeepDropRingMax); + for (int32 TileZ = DeepDropRingMin.Z - 1; + TileZ > DeepDropBottomChunkZ; --TileZ) + { + const FVoxelTileKey EmptyTile(FIntVector(0, 0, TileZ), 0); + if (LoadedTiles.Contains(EmptyTile) || PendingTiles.Contains(EmptyTile)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_empty_tile_already_streamed"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_empty_tile_already_streamed tile=(0,0,%d)"), + TileZ); + return; + } + + const int32 TileMinX = EmptyTile.Coord.X * CHUNK_SIZE - 1; + const int32 TileMaxX = (EmptyTile.Coord.X + 1) * CHUNK_SIZE + 1; + const int32 TileMinY = EmptyTile.Coord.Y * CHUNK_SIZE - 1; + const int32 TileMaxY = (EmptyTile.Coord.Y + 1) * CHUNK_SIZE + 1; + const int32 TileMinZ = EmptyTile.Coord.Z * CHUNK_SIZE - 1; + const int32 TileMaxZ = (EmptyTile.Coord.Z + 1) * CHUNK_SIZE + 1; + int32 CheckedSamples = 0; + float MinimumDensity = TNumericLimits::Max(); + float FirstNonAirDensity = 0.0f; + FIntVector FirstNonAirSample = FIntVector::ZeroValue; + bool bEverySampleAir = true; + for (int32 Z = TileMinZ; Z <= TileMaxZ && bEverySampleAir; ++Z) + for (int32 Y = TileMinY; Y <= TileMaxY && bEverySampleAir; ++Y) + for (int32 X = TileMinX; X <= TileMaxX; ++X) + { + const float Density = Generator != nullptr + ? Generator->GetDensityAt( + static_cast(X), static_cast(Y), static_cast(Z)) + : -1.0f; + ++CheckedSamples; + if (!VoxelMath::IsFinite(Density) || Density <= 0.0f) + { + bEverySampleAir = false; + FirstNonAirDensity = Density; + FirstNonAirSample = FIntVector(X, Y, Z); + break; + } + MinimumDensity = FMath::Min(MinimumDensity, Density); + } + if (!bEverySampleAir) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_empty_tile_density_proof_failed"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_empty_tile_density_proof_failed " + "tile=(0,0,%d) samples=%d first_non_air=(%d,%d,%d) " + "density=%.6f"), + TileZ, CheckedSamples, + FirstNonAirSample.X, FirstNonAirSample.Y, FirstNonAirSample.Z, + FirstNonAirDensity); + return; + } + CollisionNotRequiredTiles.Add(EmptyTile); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_empty_tile " + "tile=(0,0,%d) samples=%d min_density=%.6f seeded_known_empty=1"), + TileZ, CheckedSamples, MinimumDensity); + } + bHeadlessCollisionGateStressDeepDropPrepared = true; UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_fixture=carved " - "local_vox_top=(18.20,0.00,40.00) local_vox_bottom=(18.20,0.00,-90.00) " - "radius_vox=7.00")); + "top_chunk_z=%d bottom_chunk_z=%d " + "local_vox_x=(%.2f,%.2f) y=%.2f z=(%.2f,%.2f) " + "half_extent_vox=(%.2f,%.2f,%.2f) fixture_brush_radius=%.2f"), + DeepDropTopChunkZ, DeepDropBottomChunkZ, + DeepDropShaftStartX, DeepDropShaftEndX, DeepDropCenterY, + ShaftTopVoxels, ShaftBottomVoxels, + ShaftHalfExtent.X, ShaftHalfExtent.Y, ShaftHalfExtent.Z, + FixtureBrushRadius); } - UE_LOG(LogTemp, Display, - TEXT("[VoxelForgeCollisionGateStressTest] owner_settings clip_radius=%d " - "max_clip_level=%d render_distance_chunks=%d"), - Settings != nullptr ? Settings->ClipRadius : -1, - Settings != nullptr ? Settings->MaxClipLevel : -1, - Settings != nullptr ? Settings->RenderDistanceChunks : -1); + if (!bHeadlessCollisionGateStressTestSettingsReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] owner_settings clip_radius=%d " + "max_clip_level=%d render_distance_chunks=%d"), + Settings != nullptr ? Settings->ClipRadius : -1, + Settings != nullptr ? Settings->MaxClipLevel : -1, + Settings != nullptr ? Settings->RenderDistanceChunks : -1); + bHeadlessCollisionGateStressTestSettingsReported = true; + } float CapsuleHalfHeightCm = 0.0f; if (const ACharacter* Character = Cast(PlayerPawn)) { @@ -3048,9 +3247,11 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( // The earlier horizontal route ran through an open origin drop and ended more than a // metre above its floor. Start above the measured density surface at this XY so the pawn // must fall onto a freshly requested level-0 tile before the horizontal diagnostic. - constexpr float LaunchLocalVoxelX = 18.2f; - constexpr float LaunchLocalVoxelY = 0.0f; - const float LaunchHeightAboveGroundVoxels = + const float LaunchLocalVoxelX = bHeadlessCollisionGateStressDeepDrop + ? CHUNK_SIZE * 0.5f : 18.2f; + const float LaunchLocalVoxelY = bHeadlessCollisionGateStressDeepDrop + ? CHUNK_SIZE * 0.5f : 0.0f; + float LaunchHeightAboveGroundVoxels = HeadlessCollisionGateStressTestSpawnHeightVoxels; const FVector SurfaceProbeWorld = LocalVoxelToWorld( FVector(LaunchLocalVoxelX, LaunchLocalVoxelY, 0.0f)); @@ -3075,12 +3276,200 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( return; } + float LaunchLocalVoxelZ = SurfaceLocalVoxelZ + + CapsuleHalfHeightCm / VOXEL_SIZE + LaunchHeightAboveGroundVoxels; + int32 FloorChunkZ = FMath::FloorToInt(SurfaceLocalVoxelZ / CHUNK_SIZE); + int32 SurfaceStrateTopChunkZ = 0; + int32 SurfaceStrateBottomChunkZ = 0; + FIntVector LevelZeroRingMin = FIntVector::ZeroValue; + FIntVector LevelZeroRingMax = FIntVector::ZeroValue; + if (bHeadlessCollisionGateStressDeepDrop) + { + if (!StrateManager->GetStrateChunkZBounds( + FloorChunkZ, SurfaceStrateTopChunkZ, SurfaceStrateBottomChunkZ) + || SurfaceStrateTopChunkZ != DeepDropTopChunkZ + || FloorChunkZ != DeepDropBottomChunkZ) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_floor_outside_fixture_strate"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_floor_outside_fixture_strate surface_vox_z=%.3f " + "floor_chunk_z=%d fixture_strate=%d..%d surface_strate=%d..%d"), + SurfaceLocalVoxelZ, FloorChunkZ, DeepDropTopChunkZ, + DeepDropBottomChunkZ, SurfaceStrateTopChunkZ, + SurfaceStrateBottomChunkZ); + return; + } + + const float CapsuleHalfHeightVoxels = CapsuleHalfHeightCm / VOXEL_SIZE; + const float StrateTopExclusiveVoxels = + static_cast(SurfaceStrateTopChunkZ + 1) * CHUNK_SIZE; + LaunchLocalVoxelZ = StrateTopExclusiveVoxels + - CapsuleHalfHeightVoxels - 0.5f; + LaunchHeightAboveGroundVoxels = LaunchLocalVoxelZ + - SurfaceLocalVoxelZ - CapsuleHalfHeightVoxels; + const FIntVector LaunchCenterTile( + FMath::FloorToInt(LaunchLocalVoxelX / CHUNK_SIZE), + FMath::FloorToInt(LaunchLocalVoxelY / CHUNK_SIZE), + FMath::FloorToInt(LaunchLocalVoxelZ / CHUNK_SIZE)); + const int32 ClipRadius = Settings != nullptr + ? FMath::Max(1, Settings->ClipRadius) : 3; + VoxelClipmapDesiredTiles::AlignedRingBounds( + LaunchCenterTile, ClipRadius, 2, LevelZeroRingMin, LevelZeroRingMax); + if (LaunchHeightAboveGroundVoxels <= 0.0f + || FloorChunkZ >= LevelZeroRingMin.Z) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_floor_not_below_level0_ring"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_floor_not_below_level0_ring " + "start_center_z=%.3f surface_z=%.3f floor_tile_z=%d " + "ring_min_z=%d spawn_height_vox=%.3f"), + LaunchLocalVoxelZ, SurfaceLocalVoxelZ, FloorChunkZ, + LevelZeroRingMin.Z, LaunchHeightAboveGroundVoxels); + return; + } + } + + HeadlessCollisionGateStressTestSpawnHeightVoxels = + LaunchHeightAboveGroundVoxels; const FVector LaunchVoxel( - LaunchLocalVoxelX, - LaunchLocalVoxelY, - SurfaceLocalVoxelZ + CapsuleHalfHeightCm / VOXEL_SIZE - + LaunchHeightAboveGroundVoxels); + LaunchLocalVoxelX, LaunchLocalVoxelY, LaunchLocalVoxelZ); const FVector RequestedLaunchWorld = LocalVoxelToWorld(LaunchVoxel); + FVoxelTileKey ExpectedLaunchSupportTile; + if (!GetPlayerSupportTile( + PlayerPawn, RequestedLaunchWorld, ExpectedLaunchSupportTile)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("stress_launch_support_missing"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=stress_launch_support_missing")); + return; + } + if (bHeadlessCollisionGateStressDeepDrop) + { + int32 UnusedTopChunkZ = 0; + int32 UnusedBottomChunkZ = 0; + if (!StrateManager->GetStrateChunkZBounds( + ExpectedLaunchSupportTile.Coord.Z, + UnusedTopChunkZ, UnusedBottomChunkZ)) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_start_outside_layout"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_start_outside_layout start_tile_z=%d"), + ExpectedLaunchSupportTile.Coord.Z); + return; + } + + bHeadlessStreamingTestCenterOverride = true; + HeadlessStreamingTestCenterVoxel = LaunchVoxel; + int32 ResolvedColumnTiles = 0; + const int32 RequiredColumnTiles = + ExpectedLaunchSupportTile.Coord.Z - LevelZeroRingMin.Z + 1; + bool bPrewarmReady = true; + for (int32 TileZ = ExpectedLaunchSupportTile.Coord.Z; + TileZ >= LevelZeroRingMin.Z; --TileZ) + { + const FVoxelTileKey ColumnTile( + FIntVector(ExpectedLaunchSupportTile.Coord.X, + ExpectedLaunchSupportTile.Coord.Y, TileZ), 0); + const bool bAirProven = CollisionNotRequiredTiles.Contains(ColumnTile); + const bool bResolved = IsDesired(ColumnTile) && bAirProven; + if (IsDesired(ColumnTile) && IsTileCollisionReady(ColumnTile) && !bAirProven) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_ring_column_not_empty"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_ring_column_not_empty tile=(%d,%d,%d)"), + ColumnTile.Coord.X, ColumnTile.Coord.Y, ColumnTile.Coord.Z); + return; + } + if (bResolved) + { + ++ResolvedColumnTiles; + } + else + { + bPrewarmReady = false; + } + } + if (!bPrewarmReady) + { + if (!bHeadlessCollisionGateStressDeepDropPrewarmReported) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_prewarm " + "resolved=%d required=%d start_tile=(%d,%d,%d) " + "floor_tile_z=%d ring_min_z=%d"), + ResolvedColumnTiles, RequiredColumnTiles, + ExpectedLaunchSupportTile.Coord.X, + ExpectedLaunchSupportTile.Coord.Y, + ExpectedLaunchSupportTile.Coord.Z, + FloorChunkZ, LevelZeroRingMin.Z); + bHeadlessCollisionGateStressDeepDropPrewarmReported = true; + } + return; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_prewarm_ready " + "empty_tiles=%d required=%d start_tile_z=%d floor_tile_z=%d " + "ring_min_z=%d drop_depth_vox=%.3f"), + ResolvedColumnTiles, RequiredColumnTiles, + ExpectedLaunchSupportTile.Coord.Z, FloorChunkZ, + LevelZeroRingMin.Z, LaunchLocalVoxelZ - SurfaceLocalVoxelZ); + + FVoxelTileKey WalkTarget; + const TCHAR* WalkUnavailableReason = nullptr; + const bool bWalkResolved = IsCollisionReadyFromSupportTile( + ExpectedLaunchSupportTile, &WalkTarget, &WalkUnavailableReason); + const bool bWalkTargetInsideRenderRing = + WalkTarget.Coord.X >= LevelZeroRingMin.X + && WalkTarget.Coord.X <= LevelZeroRingMax.X + && WalkTarget.Coord.Y >= LevelZeroRingMin.Y + && WalkTarget.Coord.Y <= LevelZeroRingMax.Y + && WalkTarget.Coord.Z >= LevelZeroRingMin.Z + && WalkTarget.Coord.Z <= LevelZeroRingMax.Z; + if (bWalkResolved || WalkUnavailableReason != nullptr + || WalkTarget.Coord.Z != FloorChunkZ || bWalkTargetInsideRenderRing) + { + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("deep_drop_support_walk_target_invalid"); + UE_LOG(LogTemp, Error, + TEXT("[VoxelForgeCollisionGateStressTest] launch_setup_failed " + "reason=deep_drop_support_walk_target_invalid resolved=%d " + "unavailable=%s walk_target=(%d,%d,%d) floor_tile_z=%d " + "render_ring_desired=%d gate_retained=%d ring_min_z=%d"), + bWalkResolved ? 1 : 0, + WalkUnavailableReason != nullptr ? WalkUnavailableReason : TEXT("none"), + WalkTarget.Coord.X, WalkTarget.Coord.Y, WalkTarget.Coord.Z, + FloorChunkZ, bWalkTargetInsideRenderRing ? 1 : 0, + CollisionGateDesiredTiles.Contains(WalkTarget) ? 1 : 0, + LevelZeroRingMin.Z); + return; + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] deep_drop_support_walk " + "start=(%d,%d,%d) target=(%d,%d,%d) render_ring_desired=0 " + "gate_retained=%d desired_now=%d ring_min_z=%d floor_tile_z=%d"), + ExpectedLaunchSupportTile.Coord.X, + ExpectedLaunchSupportTile.Coord.Y, + ExpectedLaunchSupportTile.Coord.Z, + WalkTarget.Coord.X, WalkTarget.Coord.Y, WalkTarget.Coord.Z, + CollisionGateDesiredTiles.Contains(WalkTarget) ? 1 : 0, + IsDesired(WalkTarget) ? 1 : 0, LevelZeroRingMin.Z, FloorChunkZ); + } const bool bPlaced = PlayerPawn->SetActorLocation( RequestedLaunchWorld, false, nullptr, ETeleportType::TeleportPhysics); if (!bPlaced) @@ -3217,6 +3606,29 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( // that support gating leaves horizontal movement available. if (!bHeadlessCollisionGateStressTestLanded) { + if (bHeadlessCollisionGateStressDeepDrop + && bPawnCollisionGateEngaged + && HeadlessCollisionGateStressTestGateDistanceCm < 50.0) + { + if (ACharacter* Character = Cast(PlayerPawn)) + { + if (UCharacterMovementComponent* CharacterMovement = + Character->GetCharacterMovement()) + { + FVector Velocity = CharacterMovement->Velocity; + Velocity.X = 100.0f; + CharacterMovement->Velocity = Velocity; + CharacterMovement->UpdateComponentVelocity(); + if (!bHeadlessCollisionGateStressTestHorizontalGateProbeApplied) + { + bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = true; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] horizontal_gate_probe " + "applied=1 velocity_x_cm_s=%.1f gate=1"), Velocity.X); + } + } + } + } InOutPlayerHeading = FVector::ZeroVector; return; } @@ -3268,6 +3680,15 @@ void AVoxelWorld::ObserveHeadlessCollisionGateStressTest( HeadlessCollisionGateStressTestLastActualPosition = PlayerPosition; const bool bGateEngaged = bPawnCollisionGateEngaged; + if (bGateEngaged && bHeadlessCollisionGateStressTestLanded + && HorizontalDelta > 0.1f + && !bHeadlessCollisionGateStressTestHorizontalGateProbeApplied) + { + bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = true; + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] horizontal_gate_probe " + "observed=1 delta_cm=%.3f gate=1"), HorizontalDelta); + } if (bGateEngaged || bHeadlessCollisionGateStressTestGateWasEngaged) { HeadlessCollisionGateStressTestGateDistanceCm += HorizontalDelta; @@ -3962,6 +4383,18 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() HeadlessCollisionGateStressTestFailureReason = TEXT("stress_timeout"); } + if (bHeadlessCollisionGateStressTestPassed + && !bHeadlessCollisionGateStressTestFailed + && bHeadlessCollisionGateStressDeepDrop && bCollisionGateEnabled + && (!bHeadlessCollisionGateStressTestHorizontalGateProbeApplied + || HeadlessCollisionGateStressTestGateDistanceCm < 1.0)) + { + bHeadlessCollisionGateStressTestPassed = false; + bHeadlessCollisionGateStressTestFailed = true; + HeadlessCollisionGateStressTestFailureReason = + TEXT("gate_horizontal_movement_blocked"); + } + if (!bHeadlessCollisionGateStressTestPassed && !bHeadlessCollisionGateStressTestFailed) { return; @@ -4063,6 +4496,7 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() TEXT("[VoxelForgeCollisionGateStressTest] result=%s reason=%s elapsed_s=%.6f " "distance_m=%.6f expected_distance_m=%.6f speed_cm_s=%.1f spawn_height_vox=%.2f deep_drop=%d " "gate_holds=%d gate_total_s=%.6f max_gate_s=%.6f gate_progress_m=%.6f " + "horizontal_gate_probe=%d " "full_stops=%d full_stop_total_s=%.6f collision_gate=%d " "support_tile=(%d,%d,%d) support_first_check_to_submit_s=%.6f " "support_first_check_to_collision_ready_s=%.6f " @@ -4086,6 +4520,7 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() HeadlessCollisionGateStressTestTotalGateDurationSeconds, HeadlessCollisionGateStressTestMaxGateDurationSeconds, HeadlessCollisionGateStressTestGateDistanceCm / 100.0, + bHeadlessCollisionGateStressTestHorizontalGateProbeApplied ? 1 : 0, HeadlessCollisionGateStressTestFullStopCount, HeadlessCollisionGateStressTestFullStopDurationSeconds, bCollisionGateEnabled ? 1 : 0, @@ -4893,7 +5328,8 @@ bool AVoxelWorld::GetPlayerSupportTile(APawn* Pawn, const FVector& PlayerPositio bool AVoxelWorld::IsTileCollisionReady(const FVoxelTileKey& Tile) const { - if (Tile.Level != 0 || !CollisionReadyTiles.Contains(Tile) || !LoadedTiles.Contains(Tile)) + if (Tile.Level != 0 || !IsTileCollisionDesired(Tile) + || !CollisionReadyTiles.Contains(Tile) || !LoadedTiles.Contains(Tile)) { return false; } @@ -4908,6 +5344,12 @@ bool AVoxelWorld::IsTileCollisionReady(const FVoxelTileKey& Tile) const return Mesh != nullptr && Mesh->GetBodySetup() != nullptr; } +bool AVoxelWorld::IsTileCollisionDesired(const FVoxelTileKey& Tile) const +{ + return Tile.Level == 0 + && (CollisionDesiredTiles.Contains(Tile) || CollisionGateDesiredTiles.Contains(Tile)); +} + bool AVoxelWorld::IsCollisionTileUnavailable(const FVoxelTileKey& Tile, const TCHAR*& OutReason) const { @@ -5023,14 +5465,27 @@ bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( FVoxelTileKey SupportTile; const TCHAR* SupportUnavailableReason = nullptr; - if (IsPlayerSupportCollisionReady( - Pawn, PlayerPosition, &SupportTile, &SupportUnavailableReason) - || SupportUnavailableReason != nullptr - || SupportTile.Level != 0 - || CollisionNotRequiredTiles.Contains(SupportTile)) + const bool bCurrentSupportReady = IsPlayerSupportCollisionReady( + Pawn, PlayerPosition, &SupportTile, &SupportUnavailableReason); + if (!bCurrentSupportReady + && (SupportUnavailableReason != nullptr + || SupportTile.Level != 0 + || CollisionNotRequiredTiles.Contains(SupportTile))) { return false; } + if (bCurrentSupportReady) + { + const TCHAR* NextSupportUnavailableReason = nullptr; + if (IsPlayerSupportCollisionReady( + Pawn, NextPlayerPosition, &SupportTile, &NextSupportUnavailableReason) + || NextSupportUnavailableReason != nullptr + || SupportTile.Level != 0 + || CollisionNotRequiredTiles.Contains(SupportTile)) + { + return false; + } + } float HalfHeightCm = 0.0f; float RadiusCm = 0.0f; @@ -5136,6 +5591,29 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi // below adopts the same key through PendingTiles and IsDesired, so it is neither duplicated nor // cancelled when the full clipmap arrives. const bool bDesiredAtRequest = DesiredStamp != 0 && IsDesired(SupportTile); + if (bHeadlessCollisionGateStressDeepDrop + && SupportTile.Coord.Z < 0 && !bPawnGateSupportRequestReported) + { + FIntVector RingMin = FIntVector::ZeroValue; + FIntVector RingMax = FIntVector::ZeroValue; + VoxelClipmapDesiredTiles::AlignedRingBounds( + CurrentCenterChunk, Settings ? FMath::Max(1, Settings->ClipRadius) : 3, + 2, RingMin, RingMax); + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionGateStressTest] gate_context target=(%d,%d,%d) " + "desired=%d center=(%d,%d,%d) center_override=%d override_vox=(%.1f,%.1f,%.1f) " + "ring_min=(%d,%d,%d) ring_max=(%d,%d,%d) anchors=%d"), + SupportTile.Coord.X, SupportTile.Coord.Y, SupportTile.Coord.Z, + bDesiredAtRequest ? 1 : 0, + CurrentCenterChunk.X, CurrentCenterChunk.Y, CurrentCenterChunk.Z, + bHeadlessStreamingTestCenterOverride ? 1 : 0, + HeadlessStreamingTestCenterVoxel.X, + HeadlessStreamingTestCenterVoxel.Y, + HeadlessStreamingTestCenterVoxel.Z, + RingMin.X, RingMin.Y, RingMin.Z, + RingMax.X, RingMax.Y, RingMax.Z, + StreamingAnchors.Num()); + } bool bNeedsDesiredRebuild = !bDesiredAtRequest; constexpr int32 CollisionGatePrefetchDownTiles = 4; for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It) @@ -5166,6 +5644,13 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi CollisionGateDesiredTiles.Add(ColumnTile); bNeedsDesiredRebuild |= !IsDesired(ColumnTile); } + if (LoadedTiles.Contains(ColumnTile) && !PendingCollisionCooks.Contains(ColumnTile)) + { + // A render tile may already be loaded without a BodySetup. Upgrade it through the + // normal generation/apply path so the gate never mistakes LoadedTiles for collision. + DirtyRemeshQueue.Add(ColumnTile); + bAllChunksLoaded = false; + } } bForceDesiredRebuild |= bNeedsDesiredRebuild; @@ -5345,51 +5830,54 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi StartupCollisionGateFirstCheckSeconds = FPlatformTime::Seconds(); } - if (bCurrentSupportReady) - { - ReleasePawnCollisionGate(); - return; - } - - // Request collision even when the pawn is moving up and the gate will not hold it. A spawn or - // teleport should not have to wait for downward velocity before the current floor is queued. - RequestCollisionGateSupportTile(CurrentSupportTile); - - // An unresolved tile above the pawn is not a landing hazard. Let upward movement continue; - // only a downward/standing pawn needs its fall cancelled until the current support resolves. + // Predict one movement step before releasing a ready current tile. This requests a neighboring + // support tile before horizontal movement can carry the capsule across its boundary. + FVector NextPlayerPosition = PlayerPosition; + FVector Velocity = Pawn->GetVelocity(); + float GravityZ = 0.0f; if (const ACharacter* Character = Cast(Pawn)) { if (const UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) { - if (CharacterMovement->Velocity.Z > KINDA_SMALL_NUMBER) - { - ReleasePawnCollisionGate(); - return; - } + Velocity = CharacterMovement->Velocity; + GravityZ = CharacterMovement->GetGravityZ(); } } - - // Ask for support now, but do not stop the pawn while an unresolved tile is still below its - // next physics step. The swept capsule bounds catch the step before movement can enter it. - FVector NextPlayerPosition = PlayerPosition; if (DeltaTime > 0.0f) { - FVector Velocity = Pawn->GetVelocity(); - float GravityZ = 0.0f; - if (const ACharacter* Character = Cast(Pawn)) - { - if (const UCharacterMovementComponent* CharacterMovement = Character->GetCharacterMovement()) - { - Velocity = CharacterMovement->Velocity; - GravityZ = CharacterMovement->GetGravityZ(); - } - } NextPlayerPosition += Velocity * DeltaTime; if (Velocity.Z <= KINDA_SMALL_NUMBER && GravityZ < 0.0f) { NextPlayerPosition.Z += 0.5f * GravityZ * DeltaTime * DeltaTime; } } + + FVoxelTileKey PredictedSupportTile; + const TCHAR* PredictedSupportUnavailableReason = nullptr; + const bool bPredictedSupportReady = IsPlayerSupportCollisionReady( + Pawn, NextPlayerPosition, &PredictedSupportTile, &PredictedSupportUnavailableReason); + if (!bCurrentSupportReady) + { + // Request collision even when the pawn is moving up and the gate will not hold it. A spawn + // or teleport should not have to wait for downward velocity before its floor is queued. + RequestCollisionGateSupportTile(CurrentSupportTile); + + } + else if (!bPredictedSupportReady && PredictedSupportUnavailableReason == nullptr + && PredictedSupportTile.Level == 0 + && !CollisionNotRequiredTiles.Contains(PredictedSupportTile)) + { + RequestCollisionGateSupportTile(PredictedSupportTile); + } + + // An unresolved tile above a rising pawn is not a landing hazard. Keep its request warm, but + // let upward movement continue without entering the temporary collision-hold movement mode. + if (Velocity.Z > KINDA_SMALL_NUMBER) + { + ReleasePawnCollisionGate(); + return; + } + FVoxelTileKey PendingFloorTile; if (!IsPawnCapsuleOverlappingPendingFloor( Pawn, PlayerPosition, NextPlayerPosition, &PendingFloorTile)) @@ -5423,6 +5911,11 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) if (UCharacterMovementComponent* CharacterMovement = CollisionGatedCharacterMovement.Get()) { + if (CharacterMovement->MovementMode != MOVE_Flying) + { + CharacterMovement->SetMovementMode(MOVE_Flying); + } + CharacterMovement->GravityScale = 0.0f; FVector Velocity = CharacterMovement->Velocity; if (Velocity.Z < 0.0f) { @@ -5457,6 +5950,9 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) CollisionGatedCharacterMovement = CharacterMovement; SavedCharacterGravityScale = CharacterMovement->GravityScale; bSavedCharacterGravityScale = true; + SavedCharacterMovementMode = static_cast(CharacterMovement->MovementMode); + SavedCharacterCustomMovementMode = CharacterMovement->CustomMovementMode; + bSavedCharacterMovementMode = true; FVector Velocity = CharacterMovement->Velocity; if (Velocity.Z < 0.0f) @@ -5466,6 +5962,7 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) Velocity.Z = 0.0f; CharacterMovement->Velocity = Velocity; } + CharacterMovement->SetMovementMode(MOVE_Flying); CharacterMovement->GravityScale = 0.0f; CharacterMovement->UpdateComponentVelocity(); bPawnCollisionGateEngaged = true; @@ -5509,6 +6006,7 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); bSavedCharacterGravityScale = false; + bSavedCharacterMovementMode = false; bSavedGateVerticalVelocity = false; SavedGateVerticalVelocity = 0.0f; bPawnGateSupportRequestReported = false; @@ -5518,6 +6016,12 @@ void AVoxelWorld::ReleasePawnCollisionGate() if (UCharacterMovementComponent* CharacterMovement = CollisionGatedCharacterMovement.Get()) { + if (bSavedCharacterMovementMode) + { + CharacterMovement->SetMovementMode( + static_cast(SavedCharacterMovementMode), + SavedCharacterCustomMovementMode); + } if (bSavedCharacterGravityScale) { CharacterMovement->GravityScale = SavedCharacterGravityScale; @@ -5550,6 +6054,7 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); bSavedCharacterGravityScale = false; + bSavedCharacterMovementMode = false; bSavedGateVerticalVelocity = false; SavedGateVerticalVelocity = 0.0f; bPawnCollisionGateEngaged = false; @@ -5857,7 +6362,8 @@ void AVoxelWorld::HandleTileCollisionCookComplete( static_cast(Result); const bool bBodyInstalled = CollisionResult == ERealtimeMeshCollisionUpdateResult::Updated && CompletedMesh->GetBodySetup() != nullptr; - if (bBodyInstalled) + const bool bCollisionStillDesired = IsTileCollisionDesired(Tile); + if (bBodyInstalled && bCollisionStillDesired) { CollisionReadyTiles.Add(Tile); if (bStartupCollisionGateTimingCaptured @@ -5888,11 +6394,18 @@ void AVoxelWorld::HandleTileCollisionCookComplete( else { CollisionReadyTiles.Remove(Tile); - // A failed/ignored current cook is not a loaded guarantee. Leaving the tile unloaded makes - // the normal submit loop retry it while the pawn remains gated. - LoadedTiles.Remove(Tile); - LoadedTileGeometry.Remove(Tile); - bAllChunksLoaded = false; + if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Tile)) + { + Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); + } + if (bCollisionStillDesired) + { + // A failed/ignored current cook is not a loaded guarantee. Leaving the tile unloaded + // makes the normal submit loop retry it while the pawn remains gated. + LoadedTiles.Remove(Tile); + LoadedTileGeometry.Remove(Tile); + bAllChunksLoaded = false; + } } const uint64 ReadyCycles = FPlatformTime::Cycles64(); @@ -6079,6 +6592,18 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result) return false; } + // Store only lattices still within the local pawn's fixed 3×3×3 neighborhood. The cache is + // independent of DiffLayer revisions because it contains only the procedural pre-edit field. + if (Result.PreEditDensityGrid.Samples.Num() > 0 + && VoxelForgePreEditDensityCache::IsPawnRadiusTile(Result.Tile, CurrentCenterChunk)) + { + if (PreEditDensityCache.Num() < VoxelForgePreEditDensityCache::MaxCachedTiles + || PreEditDensityCache.Contains(Result.Tile)) + { + PreEditDensityCache.Add(Result.Tile, MoveTemp(Result.PreEditDensityGrid)); + } + } + // A worker may have captured the density field before one or more edits. Do not let that // result overwrite the current visual/collision state when any later revision touches this // tile's footprint. Keep the old state in place and admit one post-edit request through the @@ -6314,9 +6839,21 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) ? FPlatformTime::Cycles64() : 0; const uint64 GenerateStartCycles = bProfileModification ? FPlatformTime::Cycles64() : 0; Result.GenerationStartCycles = GenerateStartCycles; + FVoxelSharedDensityGrid EditedPreEditGrid; + const FVoxelSharedDensityGrid* SharedDensityOverride = nullptr; + const FVoxelSharedDensityGrid* CachedPreEditGrid = PreEditDensityCache.Find(Tile); + const bool bCacheHit = CachedPreEditGrid != nullptr + && VoxelForgePreEditDensityCache::BuildEditedGrid( + *CachedPreEditGrid, DiffLayer, EditedPreEditGrid); + if (bCacheHit) + { + SharedDensityOverride = &EditedPreEditGrid; + } + const bool bCapturePreEditDensityGrid = !bCacheHit + && VoxelForgePreEditDensityCache::IsPawnRadiusTile(Tile, CurrentCenterChunk); GenerateTileResult(Tile, OriginVoxels, Step, Cells, GenerationEpoch, /*bWantCapture*/ false, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, - Result, nullptr); + Result, nullptr, bCapturePreEditDensityGrid, SharedDensityOverride); Result.GenerationEndCycles = GenerateStartCycles != 0 ? FPlatformTime::Cycles64() : 0; @@ -6330,12 +6867,16 @@ void AVoxelWorld::SyncRemeshTile(const FVoxelTileKey& Tile) { UE_LOG(LogTemp, Display, TEXT("[VoxelForgeSyncRemeshProfile] tile=(%d,%d,%d) empty=%d generation=%.6f " - "apply=%.6f total=%.6f"), + "apply=%.6f total=%.6f cache_hit=%d cache_tiles=%d cache_bytes=%llu"), Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z, bResultEmpty ? 1 : 0, GenerateSeconds, FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles), - FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles)); + FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - SyncStartCycles), + bCacheHit ? 1 : 0, + PreEditDensityCache.Num(), + static_cast(PreEditDensityCache.Num() + * (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3) * (CHUNK_SIZE + 3) * sizeof(float))); } } @@ -6588,6 +7129,7 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla CriticalDesiredTiles.Reset(); OutLeavers.Reset(); CollisionOnlyTiles.Reset(); // §9.4 — rebuilt by AddAnchorDesiredTiles below + CollisionDesiredTiles.Reset(); ++DesiredStamp; // les upserts ci-dessous marquent le crossing courant ++DesiredEpoch; DesiredEpochStartCycles = FPlatformTime::Cycles64(); @@ -6626,6 +7168,12 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla DesiredStamped.FindOrAdd(Key) = DesiredStamp; } + const int32 PawnCollisionRadius = FMath::Clamp( + Settings ? Settings->PawnCollisionRadiusChunks : 1, 1, 4); + VoxelCollisionDesiredTiles::AddBox( + CollisionDesiredTiles, Center, PawnCollisionRadius, + PawnCollisionRadius, PawnCollisionRadius); + // Streaming anchors (AI / remote players, §9.3): fold each one's small level-0 box into the SAME // desired set BEFORE the leaver sweep, so the delta cull releases an anchor's tiles automatically // once it moves away or is unregistered. No-op (zero cost) when there are no anchors. @@ -6653,8 +7201,25 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla DesiredSorted.Add(Key); CollisionOnlyTiles.Add(Key); } + CollisionDesiredTiles.Add(Key); } + if (GVoxelForgeProfileTileGeneration != 0) + { + int32 RenderLOD0Count = 0; + for (const FVoxelTileKey& Key : DesiredSorted) + { + if (Key.Level == 0) { ++RenderLOD0Count; } + } + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionRadius] pawn_radius_chunks=%d collision_l0=%d " + "render_l0=%d anchors=%d gate_column=%d"), + PawnCollisionRadius, CollisionDesiredTiles.Num(), RenderLOD0Count, + StreamingAnchors.Num(), CollisionGateDesiredTiles.Num()); + } + + ReconcileCollisionDesiredTiles(); + // Balayage UNIQUE de la map : les entrées à stamp périmé viennent de quitter le desired set — // ce sont les seuls candidats au cull de ce crossing (avec la TransitionHold). On les retire // ici même (RemoveCurrent est sûr en itérant), la map reste donc == desired set courant. @@ -6858,6 +7423,7 @@ void AVoxelWorld::AddAnchorDesiredTiles() const int32 RZLo = FMath::Clamp(Anchor.ZBelowChunks, 0, 4); const int32 RZHi = FMath::Clamp(Anchor.ZAboveChunks, 0, 4); const bool bColl = (Anchor.Policy == EVoxelAnchorPolicy::CollisionOnly); + VoxelCollisionDesiredTiles::AddBox(CollisionDesiredTiles, AC, RXY, RZLo, RZHi); for (int32 dz = -RZLo; dz <= RZHi; ++dz) for (int32 dy = -RXY; dy <= RXY; ++dy) for (int32 dx = -RXY; dx <= RXY; ++dx) @@ -6884,6 +7450,39 @@ void AVoxelWorld::AddAnchorDesiredTiles() } } +void AVoxelWorld::ReconcileCollisionDesiredTiles() +{ + // A radius/anchor change takes effect on the same desired-set rebuild. Disable old far bodies + // immediately; a late cook callback performs the same check before it can restore readiness. + for (auto It = CollisionReadyTiles.CreateIterator(); It; ++It) + { + const FVoxelTileKey& Key = *It; + if (IsTileCollisionDesired(Key)) + { + continue; + } + if (URealtimeMeshComponent* Comp = TileComponents.FindRef(Key)) + { + Comp->SetCollisionEnabled(ECollisionEnabled::NoCollision); + } + It.RemoveCurrent(); + } + + // Tiles loaded for rendering before entering the collision radius need a collision remesh. + // Proven uniform tiles have no surface/body and stay on the cheap resolved path. + for (const FVoxelTileKey& Key : CollisionDesiredTiles) + { + if (!LoadedTiles.Contains(Key) || CollisionReadyTiles.Contains(Key) + || CollisionNotRequiredTiles.Contains(Key) || PendingTiles.Contains(Key) + || PendingCollisionCooks.Contains(Key)) + { + continue; + } + DirtyRemeshQueue.Add(Key); + bAllChunksLoaded = false; + } +} + // §9.4 — apply visibility flips to ALREADY-LOADED tiles when a tile changed render↔collision-only this // crossing (player walked toward/away from a CollisionOnly cluster). Bounded by the collision-only set // (small); a no-op when there are no CollisionOnly anchors. Newly-loaded tiles get their state at apply @@ -7036,6 +7635,13 @@ void AVoxelWorld::UpdateChunksAroundPosition(const FVector& CenterPosition, APaw const FIntVector CenterChunk = WorldToChunkCoord(WorldToLocalCm(CenterPosition)); // player's level-0 tile CurrentCenterChunk = CenterChunk; + for (auto It = PreEditDensityCache.CreateIterator(); It; ++It) + { + if (!VoxelForgePreEditDensityCache::IsPawnRadiusTile(It.Key(), CenterChunk)) + { + It.RemoveCurrent(); + } + } // Streaming anchors (AI / remote players, §9.3): prune dead ones + detect chunk crossings so the // desired set rebuilds when an anchor moves (its box of collision tiles follows it). Cheap: a few @@ -7479,11 +8085,14 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor const bool bWantCapture = (Tile.Level == 0) && (Cells == CHUNK_SIZE) && DensityVolume != nullptr && Settings && Settings->bEnableDensityVolume && DensityVolume->IsTileCaptureUseful(Tile.Coord); + const bool bWantPreEditDensityGrid = !PreEditDensityCache.Contains(Tile) + && VoxelForgePreEditDensityCache::IsPawnRadiusTile(Tile, CurrentCenterChunk); // STRATE CONTENT CUT — LODs at/above the effective minimum mesh only the player-strate band // (see the band update in UpdateChunksAroundPosition + UVoxelSettings::StrateContentCutMinLevel). - // Chunk band → voxels (inclusive). LOD0 is always full because it owns collision; finer LODs - // below the configured minimum and all LODs in the gap (no band) mesh full. + // Chunk band → voxels (inclusive). LOD0 is always full gameplay resolution; the independent + // collision radius decides which level-0 results also create a Chaos body. Finer LODs below + // the configured minimum and all LODs in the gap (no band) mesh full. int32 BandVoxLo = INT32_MIN, BandVoxHi = INT32_MAX; int32 BandChunkLo = MIN_int32, BandChunkHi = MAX_int32; const int32 CutMin = Settings ? Settings->GetEffectiveStrateContentCutMinLevel() : 9; @@ -7539,7 +8148,8 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor const UE::Tasks::ETaskPriority TaskPriority = bNearPriority ? UE::Tasks::ETaskPriority::BackgroundHigh : UE::Tasks::ETaskPriority::BackgroundNormal; - UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, + UE::Tasks::Launch(TEXT("ChunkGen"), [this, Tile, OriginVoxels, Step, Cells, TaskEpoch, + bWantCapture, bWantPreEditDensityGrid, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, RequestStartCycles, TaskDesiredEpoch, TaskModificationEpoch, Cancellation, WorkPriority]() @@ -7593,7 +8203,7 @@ void AVoxelWorld::LoadTile(const FVoxelTileKey& Tile, EVoxelTileWorkPriority Wor Result.GenerationStartCycles = GenerationStartCycles; GenerateTileResult(Tile, OriginVoxels, Step, Cells, TaskEpoch, bWantCapture, BandVoxLo, BandVoxHi, BandChunkLo, BandChunkHi, - Result, &Cancellation->bObsolete); + Result, &Cancellation->bObsolete, bWantPreEditDensityGrid); Result.GenerationEndCycles = GenerationStartCycles != 0 ? FPlatformTime::Cycles64() : 0; Result.RequestStartCycles = RequestStartCycles; @@ -7639,7 +8249,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, FChunkResult& Result, - const std::atomic* ObsoleteFlag) + const std::atomic* ObsoleteFlag, + bool bWantPreEditDensityGrid, + const FVoxelSharedDensityGrid* SharedDensityOverride) { const bool bLogTileProfile = GVoxelForgeProfileTileGeneration != 0; const bool bMeasureTile = bLogTileProfile @@ -7820,6 +8432,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector Result.MesherDensityCalls = 0; Result.Streams.Reset(); Result.CaptureGrid.Reset(); + Result.PreEditDensityGrid.Reset(); Result.BandChunkLo = BandChunkLo; // strate content cut (MIN/MAX = uncut) Result.BandChunkHi = BandChunkHi; @@ -7833,6 +8446,7 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector Result.bEmpty = true; Result.Streams.Reset(); Result.CaptureGrid.Reset(); + Result.PreEditDensityGrid.Reset(); }; if (ShouldAbortWork(ObsoleteFlag)) { @@ -7850,7 +8464,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector // (Gate IsoLevel == 0 : les verdicts du classifieur supposent l'iso MC à zéro exactement.) bool bTrivialEmpty = false; if (GVoxelForgeSealedSolidProof != 0 - && !bWantCapture && Generator && Mesher + && !bWantCapture && SharedDensityOverride == nullptr + && Generator && Mesher && Mesher->IsoLevel == 0.0f) { TotalSealedSolidProofCandidates.fetch_add(1, std::memory_order_relaxed); @@ -7892,7 +8507,8 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector } const int32 OuterClassifierMode = FMath::Clamp(GVoxelForgeOuterClassifierMode, 0, 1); if (!bTrivialEmpty && OuterClassifierMode != 0 - && !bWantCapture && Generator && Mesher && Mesher->IsoLevel == 0.0f) + && !bWantCapture && SharedDensityOverride == nullptr + && Generator && Mesher && Mesher->IsoLevel == 0.0f) { TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ClassifyTile); INC_DWORD_STAT(STAT_VoxelForgeTilesClassified); @@ -8008,7 +8624,9 @@ void AVoxelWorld::GenerateTileResult(const FVoxelTileKey& Tile, const FIntVector MeshData = Mesher->GenerateMesh(OriginVoxels, Step, Cells, bWantCapture ? &Result.CaptureGrid : nullptr, BandVoxLo, BandVoxHi, - &Result.MesherDensityCalls); + &Result.MesherDensityCalls, + bWantPreEditDensityGrid ? &Result.PreEditDensityGrid : nullptr, + SharedDensityOverride); TotalMesherDensityCalls.fetch_add( static_cast(Result.MesherDensityCalls), std::memory_order_relaxed); MeshSeconds = bMeasureTile ? FPlatformTime::Seconds() - MeshStartSeconds : 0.0; @@ -8205,6 +8823,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) const bool bHasGroundTris = Result.bHasGroundTris; const bool bHasCeilingTris = Result.bHasCeilingTris; const bool bLevel0 = (Tile.Level == 0); + const bool bWantsCollision = bLevel0 && IsTileCollisionDesired(Tile); // A new mesh submission replaces the previous collision body. Invalidate readiness before any // RMC mutation; the old completion future remains alive but its serial/map entry is gone. @@ -8326,7 +8945,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) MeshComp->SetVisibility( !CollisionOnlyTiles.Contains(Tile) && !bDeferTransitionVisibility); MeshComp->SetCollisionEnabled( - bLevel0 ? ECollisionEnabled::QueryAndPhysics : ECollisionEnabled::NoCollision); + bWantsCollision ? 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 @@ -8393,7 +9012,7 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) GroundConfig.bCastsShadow = bCastShadow; RTMesh->UpdateSectionConfig( FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 0), - GroundConfig, /*bShouldCreateCollision*/ bLevel0); + GroundConfig, /*bShouldCreateCollision*/ bWantsCollision); } if (bHasCeilingTris) { @@ -8401,14 +9020,20 @@ bool AVoxelWorld::ApplyMeshToTile(FChunkResult& Result) CapConfig.bCastsShadow = false; // the cap never casts (see above) RTMesh->UpdateSectionConfig( FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1), - CapConfig, /*bShouldCreateCollision*/ bLevel0); + CapConfig, /*bShouldCreateCollision*/ bWantsCollision); } const double CollisionConfigSeconds = bProfileApply ? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - CollisionConfigStartCycles) : 0.0; - if (bLevel0) + if (bWantsCollision) { + if (GVoxelForgeProfileTileGeneration != 0) + { + UE_LOG(LogTemp, Display, + TEXT("[VoxelForgeCollisionCookSubmitted] tile=(%d,%d,%d) level=0"), + Tile.Coord.X, Tile.Coord.Y, Tile.Coord.Z); + } // RMC's collision future is fulfilled only after its async cook has been applied to the // mesh BodySetup. This is the per-apply completion boundary; no submission-time guess is // promoted to CollisionReadyTiles. diff --git a/Source/VoxelForge/Public/VoxelGenerator.h b/Source/VoxelForge/Public/VoxelGenerator.h index 11405ed..5da3619 100644 --- a/Source/VoxelForge/Public/VoxelGenerator.h +++ b/Source/VoxelForge/Public/VoxelGenerator.h @@ -164,7 +164,8 @@ public: * * @param WorldX, WorldY, WorldZ - coordonnées monde en voxels (pas cm) */ - float GetDensityAt(float WorldX, float WorldY, float WorldZ) const; + float GetDensityAt(float WorldX, float WorldY, float WorldZ, + float* OutPreEditDensity = nullptr) const; /** * Open a worker-local lattice hand-off for the following GetDensityAt calls. The mesher uses diff --git a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h index 32e2f44..ce1d623 100644 --- a/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h +++ b/Source/VoxelForge/Public/VoxelMarchingCubesMesher.h @@ -76,7 +76,9 @@ public: FVoxelMeshData GenerateMesh(FIntVector OriginVoxels, int32 Step = 1, int32 CellsPerAxis = CHUNK_SIZE, TArray* OutCaptureGrid = nullptr, int32 BandZMinVox = INT32_MIN, int32 BandZMaxVox = INT32_MAX, - int64* OutDensitySampleCount = nullptr); + int64* OutDensitySampleCount = nullptr, + FVoxelSharedDensityGrid* OutPreEditDensityGrid = nullptr, + const FVoxelSharedDensityGrid* InSharedDensityGrid = nullptr); //========================================================================= // SERVICES (injectés par AVoxelWorld) diff --git a/Source/VoxelForge/Public/VoxelSettings.h b/Source/VoxelForge/Public/VoxelSettings.h index 31297db..1c23a77 100644 --- a/Source/VoxelForge/Public/VoxelSettings.h +++ b/Source/VoxelForge/Public/VoxelSettings.h @@ -81,6 +81,12 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming") int32 MaxUnloadsPerFrame = 6; + // Independent level-0 collision radius around the locally controlled pawn. Render tiles outside + // this cube remain visible but do not queue Chaos cooks; registered streaming anchors keep their + // own authored collision boxes, and the collision gate can extend a landing-support column. + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Voxel|Streaming", meta = (ClampMin = "1", ClampMax = "4")) + int32 PawnCollisionRadiusChunks = 1; + // Strate-aware VERTICAL streaming: don't load chunks into a strate you can't see. // Seals + the inter-strate bedrock gap are light-tight (§8.7), so the strate above/below // the one you're in is fully occluded — loading it just wastes scene primitives (the diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index fdfc99e..aefc266 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -149,6 +149,9 @@ struct FChunkResult // it to UVoxelDensityVolume::IngestTileCapture so the density clipmap reuses the mesher's samples // instead of re-sampling. Moved (not copied) through the MPSC queue. See UVoxelDensityVolume. TArray CaptureGrid; + // Exact pre-edit level-0 lattice for the bounded local-player dig cache. Sampled on the worker + // with the tile's normal generator context and moved through the result queue. + FVoxelSharedDensityGrid PreEditDensityGrid; }; /** One worker-produced mesh identity emitted only by the explicit parity harness. */ @@ -550,7 +553,7 @@ private: bool MeasureHeadlessCollisionGateStressTerrain( const FVector& WorldPosition, float CapsuleHalfHeightCm, float& OutFeetDensity, float& OutCenterDensity, - float& OutSurfaceLocalVoxelZ, float& OutClearanceCm) const; + float& OutSurfaceLocalVoxelZ, float& OutClearanceCm); void AdvanceHeadlessSurfaceFallTest(FVector& InOutPlayerPosition, FVector& InOutPlayerHeading, APawn* PlayerPawn); void InitializeHeadlessStrateCrossingTest(); @@ -769,7 +772,9 @@ public: int32 Step, int32 Cells, uint32 Epoch, bool bWantCapture, int32 BandVoxLo, int32 BandVoxHi, int32 BandChunkLo, int32 BandChunkHi, FChunkResult& Result, - const std::atomic* ObsoleteFlag = nullptr); + const std::atomic* ObsoleteFlag = nullptr, + bool bWantPreEditDensityGrid = false, + const FVoxelSharedDensityGrid* SharedDensityOverride = nullptr); /** * Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile): @@ -859,6 +864,7 @@ public: FVoxelTileKey& OutSupportTile) const; bool IsTileCollisionReady(const FVoxelTileKey& Tile) const; + bool IsTileCollisionDesired(const FVoxelTileKey& Tile) const; bool IsCollisionTileUnavailable(const FVoxelTileKey& Tile, const TCHAR*& OutReason) const; bool IsCollisionReadyFromSupportTile(const FVoxelTileKey& CandidateTile, @@ -872,10 +878,10 @@ public: FVoxelTileKey* OutPendingTile = nullptr) const; void RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile); - /** Cancel only downward pawn motion while the tile beneath its feet is unresolved. Horizontal - * input and movement remain live. The world tick is installed as the pawn prerequisite in the - * implementation (and directly on its movement component) so this check runs before - * CharacterMovementComponent physics. */ + /** Hold vertical character motion in gravity-free MOVE_Flying while a swept pending support tile + * intersects the capsule, preserving horizontal movement until collision is ready. Restore the + * previous mode, gravity, and downward speed on release. The world tick is a prerequisite of the + * pawn and movement component so the gate check runs before movement physics. */ void UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime); void EngagePawnCollisionGate(APawn* Pawn); void ReleasePawnCollisionGate(); @@ -951,6 +957,9 @@ public: // Player's level-0 tile coord (= chunk coord). The desired set is rebuilt when this changes. FIntVector CurrentCenterChunk = FIntVector::ZeroValue; + // Exact unedited L0 lattices retained around the player for same-frame carve remeshes. + // The radius helper bounds this to 27 × 35³ floats (4.42 MiB). + TMap PreEditDensityCache; // Non-player streaming anchors (AI now, remote players later — ARCHITECTURE §9.3). BuildDesiredTiles // folds each anchor's small level-0 box into the desired set (same DesiredStamped machinery → the @@ -960,6 +969,7 @@ public: // Adds each anchor's level-0 tiles to DesiredSorted/DesiredStamped (dedup vs the player clipmap) // and records the ones ONLY a CollisionOnly anchor wants in CollisionOnlyTiles. void AddAnchorDesiredTiles(); + void ReconcileCollisionDesiredTiles(); // Forces a desired-set rebuild next Tick even if neither the player nor an anchor crossed a tile // boundary (set by UnregisterStreamingAnchor so a removed anchor's tiles get culled). bool bForceDesiredRebuild = false; @@ -971,6 +981,9 @@ public: // without an O(loaded) scan. Both empty when there are no CollisionOnly anchors → zero cost. TSet CollisionOnlyTiles; TSet PrevCollisionOnlyTiles; + // Independent from DesiredSorted: rendered LOD0 tiles outside the pawn radius remain visible + // without a Chaos body. Pawn radius, anchor boxes, and collision-gate support are unioned here. + TSet CollisionDesiredTiles; // A short collision-only column below an unresolved player support tile. Keeping these keys // stamped prevents a critical request outside the render clipmap from being canceled on rebuild. TSet CollisionGateDesiredTiles; @@ -1103,14 +1116,18 @@ public: bool bHeadlessCollisionGateStressTestFailed = false; bool bHeadlessCollisionGateStressTestExitRequested = false; bool bHeadlessCollisionGateStressTestGateWasEngaged = false; + bool bHeadlessCollisionGateStressTestHorizontalGateProbeApplied = false; bool bHeadlessCollisionGateStressTestFullStop = false; bool bHeadlessCollisionGateStressTestStartHeightCrossed = false; bool bHeadlessCollisionGateStressTestTerrainViolation = false; bool bHeadlessCollisionGateStressTestTerrainMeasurementMissing = false; bool bHeadlessCollisionGateStressTestLanded = false; bool bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = false; + bool bHeadlessCollisionGateStressTestSettingsReported = false; bool bHeadlessCollisionGateStressDeepDrop = false; bool bHeadlessCollisionGateStressDeepDropPrepared = false; + bool bHeadlessCollisionGateStressDeepDropPrewarmReported = false; + bool bHeadlessCollisionGateStressDeepDropSurfaceCached = false; float HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; float HeadlessCollisionGateStressTestSpawnHeightVoxels = 10.0f; float HeadlessCollisionGateStressTestDurationSeconds = 2.0f; @@ -1139,6 +1156,8 @@ public: FVector HeadlessCollisionGateStressTestLastActualPosition = FVector::ZeroVector; FVector HeadlessCollisionGateStressTestStartPosition = FVector::ZeroVector; FVector HeadlessCollisionGateStressTestMinClearancePosition = FVector::ZeroVector; + FVector2D HeadlessCollisionGateStressDeepDropSurfaceXYVox = FVector2D::ZeroVector; + float HeadlessCollisionGateStressDeepDropSurfaceZVox = 0.0f; FVoxelTileKey HeadlessCollisionGateStressTestLaunchSupportTile; FString HeadlessCollisionGateStressTestFailureReason; @@ -1235,6 +1254,9 @@ public: bool bCollisionGateEnabled = true; float SavedCharacterGravityScale = 1.0f; bool bSavedCharacterGravityScale = false; + uint8 SavedCharacterMovementMode = 0; + uint8 SavedCharacterCustomMovementMode = 0; + bool bSavedCharacterMovementMode = false; float SavedGateVerticalVelocity = 0.0f; bool bSavedGateVerticalVelocity = false; bool bPawnCollisionGateEngaged = false; diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index 78854f3..1087bad 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -216,6 +216,40 @@ function Assert-NoForeignUnreal([string]$When) { } } +if (-not ('VoxelForgeTestMemoryStatus' -as [type])) { + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class VoxelForgeTestMemoryStatus { + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + public class MEMORYSTATUSEX { + public uint dwLength = (uint)Marshal.SizeOf(typeof(MEMORYSTATUSEX)); + public uint dwMemoryLoad; + public ulong ullTotalPhys; + public ulong ullAvailPhys; + public ulong ullTotalPageFile; + public ulong ullAvailPageFile; + public ulong ullTotalVirtual; + public ulong ullAvailVirtual; + public ulong ullAvailExtendedVirtual; + } + [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] + public static extern bool GlobalMemoryStatusEx([In, Out] MEMORYSTATUSEX lpBuffer); +} +'@ +} + +function Assert-EnoughFreeMemory([string]$When) { + $status = New-Object VoxelForgeTestMemoryStatus+MEMORYSTATUSEX + if (-not [VoxelForgeTestMemoryStatus]::GlobalMemoryStatusEx($status)) { + throw "Could not query free physical memory before $When; refusing to launch." + } + $freeGiB = [double]$status.ullAvailPhys / 1GB + if ($freeGiB -lt 6.0) { + throw ("Only {0:N2} GiB free before {1}; refusing to launch (minimum 6 GiB)." -f $freeGiB, $When) + } +} + function Get-CrashSnapshot { $snapshot = [ordered]@{} $roots = @( @@ -330,6 +364,7 @@ function Wait-OwnedProcess($Process, [int]$TimeoutSeconds, [switch]$EnforceMemor function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPath, [string]$StdoutPath, [string]$StderrPath) { Assert-NoForeignUnreal "before $Kind launch" + Assert-EnoughFreeMemory "$Kind launch" Ensure-Directory (Split-Path -Parent $LogPath) Ensure-Directory (Split-Path -Parent $StdoutPath) Ensure-Directory (Split-Path -Parent $StderrPath) @@ -399,6 +434,8 @@ function Invoke-UnrealLaunch([string]$Kind, [string[]]$Arguments, [string]$LogPa function Invoke-BuildProcess([string[]]$Arguments, [string]$LogPath, [string]$ErrorPath, [string]$StdoutPath = $LogPath) { + Assert-NoForeignUnreal 'before staged build launch' + Assert-EnoughFreeMemory 'staged build launch' Ensure-Directory (Split-Path -Parent $LogPath) $dotnetHome = Join-Path $RunRoot 'DotnetHome' Ensure-Directory $dotnetHome @@ -991,7 +1028,8 @@ function Get-CollisionGateStressMetrics([string]$LogPath) { foreach ($key in @('reason', 'elapsed_s', 'distance_m', 'expected_distance_m', 'speed_cm_s', 'spawn_height_vox', 'deep_drop', 'gate_holds', 'gate_total_s', 'max_gate_s', 'full_stops', - 'full_stop_total_s', 'collision_gate', 'gate_progress_m', 'support_tile', + 'full_stop_total_s', 'collision_gate', 'gate_progress_m', + 'horizontal_gate_probe', 'support_tile', 'support_first_check_to_submit_s', 'support_first_check_to_collision_ready_s', 'test_support_tile', 'test_support_first_check_to_submit_s',