diff --git a/CODEMAP.md b/CODEMAP.md index 06cb52a..4c4474f 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -308,8 +308,10 @@ casse cette arithmetique *structurellement* — `BeginPlay` loggue une **Error** | `RecordTileHash` / `WriteTileHashDump` | — | Headless `-voxel.TileHashDump=` parity instrument. Records the exact `FVoxelMeshData` used by the streaming result, including empty/classified tiles, then writes sorted absolute tile keys, LOD step/cells, content-band limits, settings digest context, and the shared `VoxelGeometryHash` at `EndPlay`. | | `SyncRemeshTile` | — | **INSTANT DIG**: level-0 same-frame re-mesh on the game thread (`GenerateTileResult` + `ApplyTileResult` inline, Cells=CHUNK_SIZE/Step=1 + strate band, no capture). Used for the tile under the brush centre so a carve is visible THIS frame; one full-res gen on the game thread. | | `UpdateChunksAroundPosition` | 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. 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 the first crossing below spawn height. | -| `BuildDesiredTiles` | 6482 | Builds `DesiredSorted`+`DesiredStamped` (player clipmap shells) then `AddAnchorDesiredTiles()` before the leaver sweep; it orders from the true local pawn position and builds `CriticalDesiredTiles` for the occupied/support and heading tiles. Also rebuilds `DesiredTransitionDescendants` (ancestor → desired descendants) for the reconcile pass. | +| `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. | +| `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. | | `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). | diff --git a/Source/VoxelForge/Private/VoxelWorld.cpp b/Source/VoxelForge/Private/VoxelWorld.cpp index a956d42..8694801 100644 --- a/Source/VoxelForge/Private/VoxelWorld.cpp +++ b/Source/VoxelForge/Private/VoxelWorld.cpp @@ -585,6 +585,7 @@ void AVoxelWorld::RegenerateAllChunks() // §9.4 collision-only tracking references destroyed tiles — clear (rebuilt on the next crossing). CollisionOnlyTiles.Empty(); PrevCollisionOnlyTiles.Empty(); + CollisionGateDesiredTiles.Empty(); // Reset streaming state so the next Tick rebuilds the desired set and reloads. LastUpdateCenter = FIntVector(INT32_MAX, INT32_MAX, INT32_MAX); @@ -1883,6 +1884,8 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() bHeadlessCollisionGateStressTestTerrainMeasurementMissing = false; bHeadlessCollisionGateStressTestLanded = false; bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = false; + bHeadlessCollisionGateStressDeepDrop = false; + bHeadlessCollisionGateStressDeepDropPrepared = false; HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; HeadlessCollisionGateStressTestDurationSeconds = 2.0f; HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f; @@ -2192,15 +2195,23 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() *HeadlessCollisionGateStressTestFailureReason); } float StressSpeed = HeadlessCollisionGateStressTestSpeedCmPerSecond; + float StressSpawnHeight = HeadlessCollisionGateStressTestSpawnHeightVoxels; float StressDuration = HeadlessCollisionGateStressTestDurationSeconds; float StressTimeout = HeadlessCollisionGateStressTestTimeoutSeconds; + int32 StressDeepDrop = 0; FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressSpeedCmPerSecond="), StressSpeed); + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressSpawnHeightVoxels="), + StressSpawnHeight); + FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressDeepDrop="), + StressDeepDrop); FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressDurationSeconds="), StressDuration); FParse::Value(CommandLine, TEXT("voxel.TestCollisionGateStressTimeoutSeconds="), StressTimeout); HeadlessCollisionGateStressTestSpeedCmPerSecond = FMath::Max(StressSpeed, 1.0f); + HeadlessCollisionGateStressTestSpawnHeightVoxels = FMath::Max(StressSpawnHeight, 1.0f); + bHeadlessCollisionGateStressDeepDrop = StressDeepDrop != 0; HeadlessCollisionGateStressTestDurationSeconds = FMath::Max(StressDuration, 0.25f); HeadlessCollisionGateStressTestTimeoutSeconds = FMath::Max(StressTimeout, 1.0f); // The stress path owns movement and keeps the streamer centred at the launch tile. Do @@ -2210,8 +2221,10 @@ void AVoxelWorld::ConfigureHeadlessStreamingTest() HeadlessCollisionGateStressTestBeginSeconds = 0.0; UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGateStressTest] configured=1 speed_cm_s=%.1f " - "duration_after_landing_s=%.3f timeout_s=%.3f"), + "spawn_height_vox=%.2f deep_drop=%d duration_after_landing_s=%.3f timeout_s=%.3f"), HeadlessCollisionGateStressTestSpeedCmPerSecond, + HeadlessCollisionGateStressTestSpawnHeightVoxels, + bHeadlessCollisionGateStressDeepDrop ? 1 : 0, HeadlessCollisionGateStressTestDurationSeconds, HeadlessCollisionGateStressTestTimeoutSeconds); } @@ -2725,7 +2738,7 @@ void AVoxelWorld::ObserveHeadlessStrateCrossingTest( const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition); FVoxelTileKey PendingFloorTile; if (IsPawnCapsuleOverlappingPendingFloor( - PlayerPawn, PlayerPosition, &PendingFloorTile)) + PlayerPawn, PlayerPosition, PlayerPosition, &PendingFloorTile)) { bHeadlessStrateCrossingTestPendingFloorOverlap = true; bHeadlessStrateCrossingTestFailed = true; @@ -3002,6 +3015,21 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( const double Now = FPlatformTime::Seconds(); 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); + 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")); + } + UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGateStressTest] owner_settings clip_radius=%d " "max_clip_level=%d render_distance_chunks=%d"), @@ -3022,7 +3050,8 @@ void AVoxelWorld::AdvanceHeadlessCollisionGateStressTest( // must fall onto a freshly requested level-0 tile before the horizontal diagnostic. constexpr float LaunchLocalVoxelX = 18.2f; constexpr float LaunchLocalVoxelY = 0.0f; - constexpr float LaunchHeightAboveGroundVoxels = 10.0f; + const float LaunchHeightAboveGroundVoxels = + HeadlessCollisionGateStressTestSpawnHeightVoxels; const FVector SurfaceProbeWorld = LocalVoxelToWorld( FVector(LaunchLocalVoxelX, LaunchLocalVoxelY, 0.0f)); float ProbeFeetDensity = 0.0f; @@ -4032,7 +4061,7 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() UE_LOG(LogTemp, Display, TEXT("[VoxelForgeCollisionGateStressTest] result=%s reason=%s elapsed_s=%.6f " - "distance_m=%.6f expected_distance_m=%.6f speed_cm_s=%.1f " + "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 " "full_stops=%d full_stop_total_s=%.6f collision_gate=%d " "support_tile=(%d,%d,%d) support_first_check_to_submit_s=%.6f " @@ -4051,6 +4080,8 @@ void AVoxelWorld::MaybeFinishHeadlessCollisionGateStressTest() HeadlessCollisionGateStressTestDistanceCm / 100.0, ExpectedDistanceCm / 100.0, HeadlessCollisionGateStressTestSpeedCmPerSecond, + HeadlessCollisionGateStressTestSpawnHeightVoxels, + bHeadlessCollisionGateStressDeepDrop ? 1 : 0, HeadlessCollisionGateStressTestGateHoldCount, HeadlessCollisionGateStressTestTotalGateDurationSeconds, HeadlessCollisionGateStressTestMaxGateDurationSeconds, @@ -4978,7 +5009,8 @@ bool AVoxelWorld::IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& Play } bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( - APawn* Pawn, const FVector& PlayerPosition, FVoxelTileKey* OutPendingTile) const + APawn* Pawn, const FVector& PlayerPosition, const FVector& NextPlayerPosition, + FVoxelTileKey* OutPendingTile) const { if (OutPendingTile != nullptr) { @@ -5025,6 +5057,7 @@ bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( } const FVector LocalPosition = WorldToLocalVoxel(PlayerPosition); + const FVector LocalNextPosition = WorldToLocalVoxel(NextPlayerPosition); const float HalfHeightVoxels = HalfHeightCm / VOXEL_SIZE; const float RadiusVoxels = RadiusCm / VOXEL_SIZE; const float TileMinX = static_cast(SupportTile.Coord.X * CHUNK_SIZE); @@ -5033,12 +5066,14 @@ bool AVoxelWorld::IsPawnCapsuleOverlappingPendingFloor( const float TileMaxY = static_cast((SupportTile.Coord.Y + 1) * CHUNK_SIZE); const float TileMinZ = static_cast(SupportTile.Coord.Z * CHUNK_SIZE); const float TileMaxZ = static_cast((SupportTile.Coord.Z + 1) * CHUNK_SIZE); - const float CapsuleMinZ = LocalPosition.Z - HalfHeightVoxels; - const float CapsuleMaxZ = LocalPosition.Z + HalfHeightVoxels; - const bool bHorizontalOverlap = LocalPosition.X + RadiusVoxels > TileMinX - && LocalPosition.X - RadiusVoxels < TileMaxX - && LocalPosition.Y + RadiusVoxels > TileMinY - && LocalPosition.Y - RadiusVoxels < TileMaxY; + const float CapsuleMinZ = FMath::Min(LocalPosition.Z, LocalNextPosition.Z) - HalfHeightVoxels; + const float CapsuleMaxZ = FMath::Max(LocalPosition.Z, LocalNextPosition.Z) + HalfHeightVoxels; + const float CapsuleMinX = FMath::Min(LocalPosition.X, LocalNextPosition.X) - RadiusVoxels; + const float CapsuleMaxX = FMath::Max(LocalPosition.X, LocalNextPosition.X) + RadiusVoxels; + const float CapsuleMinY = FMath::Min(LocalPosition.Y, LocalNextPosition.Y) - RadiusVoxels; + const float CapsuleMaxY = FMath::Max(LocalPosition.Y, LocalNextPosition.Y) + RadiusVoxels; + const bool bHorizontalOverlap = CapsuleMaxX > TileMinX && CapsuleMinX < TileMaxX + && CapsuleMaxY > TileMinY && CapsuleMinY < TileMaxY; const bool bVerticalOverlap = CapsuleMaxZ > TileMinZ && CapsuleMinZ < TileMaxZ; if (!bHorizontalOverlap || !bVerticalOverlap) { @@ -5101,10 +5136,38 @@ 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 (!bDesiredAtRequest) + bool bNeedsDesiredRebuild = !bDesiredAtRequest; + constexpr int32 CollisionGatePrefetchDownTiles = 4; + for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It) { - bForceDesiredRebuild = true; + const FVoxelTileKey& ExistingTile = *It; + const int32 RelativeZ = SupportTile.Coord.Z - ExistingTile.Coord.Z; + if (ExistingTile.Coord.X != SupportTile.Coord.X + || ExistingTile.Coord.Y != SupportTile.Coord.Y + || RelativeZ < 0 || RelativeZ >= CollisionGatePrefetchDownTiles) + { + bNeedsDesiredRebuild |= IsDesired(ExistingTile); + It.RemoveCurrent(); + } } + for (int32 Offset = 0; Offset < CollisionGatePrefetchDownTiles; ++Offset) + { + FVoxelTileKey ColumnTile = SupportTile; + ColumnTile.Coord.Z -= Offset; + const TCHAR* UnavailableReason = nullptr; + if (IsTileCollisionReady(ColumnTile) + || CollisionNotRequiredTiles.Contains(ColumnTile) + || IsCollisionTileUnavailable(ColumnTile, UnavailableReason)) + { + continue; + } + if (!CollisionGateDesiredTiles.Contains(ColumnTile)) + { + CollisionGateDesiredTiles.Add(ColumnTile); + bNeedsDesiredRebuild |= !IsDesired(ColumnTile); + } + } + bForceDesiredRebuild |= bNeedsDesiredRebuild; // Preserve the established critical floor ordering when it already belongs to this desired // epoch. Before the first build there is no array to promote into; LoadTile below still uses @@ -5188,7 +5251,6 @@ void AVoxelWorld::RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTi void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime) { - static_cast(DeltaTime); if (!IsValid(Pawn) || bShuttingDown.load(std::memory_order_relaxed)) { return; @@ -5307,6 +5369,35 @@ void AVoxelWorld::UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosi } } + // 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 PendingFloorTile; + if (!IsPawnCapsuleOverlappingPendingFloor( + Pawn, PlayerPosition, NextPlayerPosition, &PendingFloorTile)) + { + ReleasePawnCollisionGate(); + return; + } + if (!bPawnCollisionGateEngaged) { UE_LOG(LogTemp, Display, @@ -5335,6 +5426,8 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) FVector Velocity = CharacterMovement->Velocity; if (Velocity.Z < 0.0f) { + SavedGateVerticalVelocity = Velocity.Z; + bSavedGateVerticalVelocity = true; Velocity.Z = 0.0f; CharacterMovement->Velocity = Velocity; CharacterMovement->UpdateComponentVelocity(); @@ -5345,6 +5438,8 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) FVector Velocity = PawnMovement->Velocity; if (Velocity.Z < 0.0f) { + SavedGateVerticalVelocity = Velocity.Z; + bSavedGateVerticalVelocity = true; Velocity.Z = 0.0f; PawnMovement->Velocity = Velocity; PawnMovement->UpdateComponentVelocity(); @@ -5366,6 +5461,8 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) FVector Velocity = CharacterMovement->Velocity; if (Velocity.Z < 0.0f) { + SavedGateVerticalVelocity = Velocity.Z; + bSavedGateVerticalVelocity = true; Velocity.Z = 0.0f; CharacterMovement->Velocity = Velocity; } @@ -5383,6 +5480,8 @@ void AVoxelWorld::EngagePawnCollisionGate(APawn* Pawn) FVector Velocity = PawnMovement->Velocity; if (Velocity.Z < 0.0f) { + SavedGateVerticalVelocity = Velocity.Z; + bSavedGateVerticalVelocity = true; Velocity.Z = 0.0f; PawnMovement->Velocity = Velocity; PawnMovement->UpdateComponentVelocity(); @@ -5410,6 +5509,8 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); bSavedCharacterGravityScale = false; + bSavedGateVerticalVelocity = false; + SavedGateVerticalVelocity = 0.0f; bPawnGateSupportRequestReported = false; PawnGateEngagedAtSeconds = 0.0; return; @@ -5420,8 +5521,20 @@ void AVoxelWorld::ReleasePawnCollisionGate() if (bSavedCharacterGravityScale) { CharacterMovement->GravityScale = SavedCharacterGravityScale; - CharacterMovement->UpdateComponentVelocity(); } + if (bSavedGateVerticalVelocity && CharacterMovement->Velocity.Z <= KINDA_SMALL_NUMBER) + { + CharacterMovement->Velocity.Z = SavedGateVerticalVelocity; + } + CharacterMovement->UpdateComponentVelocity(); + } + else if (UPawnMovementComponent* PawnMovement = CollisionGatedPawnMovement.Get()) + { + if (bSavedGateVerticalVelocity && PawnMovement->Velocity.Z <= KINDA_SMALL_NUMBER) + { + PawnMovement->Velocity.Z = SavedGateVerticalVelocity; + } + PawnMovement->UpdateComponentVelocity(); } if (APawn* Pawn = CollisionGatedPawn.Get()) @@ -5437,6 +5550,8 @@ void AVoxelWorld::ReleasePawnCollisionGate() CollisionGatedPawnMovement.Reset(); CollisionGatedCharacterMovement.Reset(); bSavedCharacterGravityScale = false; + bSavedGateVerticalVelocity = false; + SavedGateVerticalVelocity = 0.0f; bPawnCollisionGateEngaged = false; PawnGateEngagedAtSeconds = 0.0; bPawnGateSupportRequestReported = false; @@ -6516,6 +6631,30 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla // once it moves away or is unregistered. No-op (zero cost) when there are no anchors. AddAnchorDesiredTiles(); + // A pending support target and a few tiles below it are collision-only desired tiles. This + // keeps an out-of-ring request alive across this rebuild and lets the worker/apply queues fill + // the landing column while the pawn is still above it. + for (auto It = CollisionGateDesiredTiles.CreateIterator(); It; ++It) + { + const FVoxelTileKey& Key = *It; + const TCHAR* UnavailableReason = nullptr; + if (IsTileCollisionReady(Key) || CollisionNotRequiredTiles.Contains(Key) + || IsCollisionTileUnavailable(Key, UnavailableReason)) + { + It.RemoveCurrent(); + continue; + } + + uint32& Stamp = DesiredStamped.FindOrAdd(Key); + const bool bNew = Stamp != DesiredStamp; + if (bNew) + { + Stamp = DesiredStamp; + DesiredSorted.Add(Key); + CollisionOnlyTiles.Add(Key); + } + } + // 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. @@ -6585,6 +6724,17 @@ void AVoxelWorld::BuildDesiredTiles(const FIntVector& Center, const FVector& Pla CriticalDesiredTiles.Add(Key); } }; + if (CollisionGateDesiredTiles.Contains(PawnGateRequestedSupportTile)) + { + AddCriticalTile(PawnGateRequestedSupportTile); + } + for (const FVoxelTileKey& Key : DesiredSorted) + { + if (CollisionGateDesiredTiles.Contains(Key)) + { + AddCriticalTile(Key); + } + } AddCriticalTile(OccupiedTile); FVector Heading = PlayerHeading; diff --git a/Source/VoxelForge/Public/VoxelWorld.h b/Source/VoxelForge/Public/VoxelWorld.h index 879f1c4..fdfc99e 100644 --- a/Source/VoxelForge/Public/VoxelWorld.h +++ b/Source/VoxelForge/Public/VoxelWorld.h @@ -868,6 +868,7 @@ public: FVoxelTileKey* OutSupportTile = nullptr, const TCHAR** OutUnavailableReason = nullptr) const; bool IsPawnCapsuleOverlappingPendingFloor(APawn* Pawn, const FVector& PlayerPosition, + const FVector& NextPlayerPosition, FVoxelTileKey* OutPendingTile = nullptr) const; void RequestCollisionGateSupportTile(const FVoxelTileKey& SupportTile); @@ -970,6 +971,9 @@ public: // without an O(loaded) scan. Both empty when there are no CollisionOnly anchors → zero cost. TSet CollisionOnlyTiles; TSet PrevCollisionOnlyTiles; + // 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; void ReconcileAnchorTileVisibility(); // --- Streaming work-avoidance (perf) --- @@ -1105,7 +1109,10 @@ public: bool bHeadlessCollisionGateStressTestTerrainMeasurementMissing = false; bool bHeadlessCollisionGateStressTestLanded = false; bool bHeadlessCollisionGateStressTestLaunchSupportTimingCaptured = false; + bool bHeadlessCollisionGateStressDeepDrop = false; + bool bHeadlessCollisionGateStressDeepDropPrepared = false; float HeadlessCollisionGateStressTestSpeedCmPerSecond = 6000.0f; + float HeadlessCollisionGateStressTestSpawnHeightVoxels = 10.0f; float HeadlessCollisionGateStressTestDurationSeconds = 2.0f; float HeadlessCollisionGateStressTestTimeoutSeconds = 15.0f; double HeadlessCollisionGateStressTestBeginSeconds = 0.0; @@ -1228,6 +1235,8 @@ public: bool bCollisionGateEnabled = true; float SavedCharacterGravityScale = 1.0f; bool bSavedCharacterGravityScale = false; + float SavedGateVerticalVelocity = 0.0f; + bool bSavedGateVerticalVelocity = false; bool bPawnCollisionGateEngaged = false; double PawnGateEngagedAtSeconds = 0.0; bool bPawnGateUnavailableReported = false; diff --git a/Tools/VoxelForgeTest.ps1 b/Tools/VoxelForgeTest.ps1 index e53d2d6..78854f3 100644 --- a/Tools/VoxelForgeTest.ps1 +++ b/Tools/VoxelForgeTest.ps1 @@ -989,6 +989,7 @@ function Get-CollisionGateStressMetrics([string]$LogPath) { $result = [regex]::Match($line, 'result=(?PASS|FAIL)') if ($result.Success -and $result.Groups['v'].Value -eq 'PASS') { $metrics.status = 'passed' } 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', 'support_first_check_to_submit_s',