fix(streaming): collision-ready gate -- the pawn waits for a COOKED floor, not a submitted one

WORK-NEXT item 1, plus all three of item 6. Item 2 (the fused evaluator) untouched as
instructed.

⭐ RMC DOES EXPOSE WHAT WE NEEDED, so the gate rests on a real signal:
  OnCollisionBodyUpdated                              (delegate -- diagnostic)
  SetCollisionConfig() -> TFuture<ERealtimeMeshCollisionUpdateResult>  (AUTHORITATIVE)
RealtimeMeshComponent was READ ONLY, per the owner's grant. Nothing there was modified.

THE INVARIANT
Until now nothing guaranteed a tile the player occupies has collision. apply_s ended at
mesh SUBMISSION; RMC cooks asynchronously and we recorded no completion, so no
measurement this project has ever taken said when physics could actually collide.

  - per-tile CollisionReadyTiles state, with stale-callback protection
  - HandleTileCollisionCookComplete driven by RMC's cook future
  - pawn gated at BeginPlay and pre-physics on the FEET-SUPPORT tile -- deliberately not
    the actor-centre tile, because the floor that catches a falling pawn is what must be
    cooked -- plus the predicted movement path and solid/air traversal
  - movement-component state saved/restored, so a temporary stream wait cannot
    permanently change a character's movement mode
  - new trace fields: mesh_submit_to_cook_s, collision_request_to_cook_s,
    body_event_to_future_s -- three boundaries where we had one blurred number

ITEM 6, all three:
  - LoadedTiles is now recorded only after SUCCESSFUL mesh submission, and a failed
    mesh init retries. Previously a failed init left a tile marked loaded forever -- a
    permanent hole.
  - origin spawning fixed (the zero-position guard made a pawn at exactly
    FVector::ZeroVector indistinguishable from "no pawn" -- and the owner spawns at 0,0)
  - exact core-lattice validation for uniform whole-tile verdicts, which previously had
    no safety net while block verdicts did

⚠️ I FIXED TWO COMPILE ERRORS MYSELF rather than spend another Codex round on them:
`GetMovementMode()` / `GetCustomMovementMode()` do not exist on
UCharacterMovementComponent -- MovementMode and CustomMovementMode are public fields.
Two lines. Codex did not build this round, so it could not have caught them.

VERIFIED BY ME: build 0 errors after the patch; export 1.022 / 1.055 s (aa62971 was
1.034 / 1.089); geometry SHA-256 b3e5f4c3..9b377 UNCHANGED -- generation is untouched,
as it should be for a streaming-lifecycle change.

⚠️ UNVERIFIED AND IMPORTANT: the submission-to-cook gap is still unmeasured -- Codex had
no live process to measure it in. The instrumentation exists and will report it on the
next play session. And the GATE ITSELF IS UNTESTED IN MOTION: if a support tile never
becomes collision-ready, the pawn stays frozen rather than falling. That trade is
deliberate, but the owner should watch for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-09-12 03:38:58 +02:00
1 parent 58bdfb32de
commit 081cc5e459
5 files changed
+920 -70

No files matched your search

@@ -113,7 +113,7 @@ namespace
if (Sample.bCacheHit) ++CacheHits; if (Sample.bCacheHit) ++CacheHits;
if (Sample.bRegionHit) ++RegionHits; if (Sample.bRegionHit) ++RegionHits;
Triangles += Sample.Triangles; Triangles += Sample.Triangles;
Request.Add(Sample.RequestToReadySeconds); Request.Add(Sample.RequestToApplySeconds);
Queue.Add(Sample.QueueWaitSeconds); Queue.Add(Sample.QueueWaitSeconds);
Generation.Add(Sample.GenerationSeconds); Generation.Add(Sample.GenerationSeconds);
Classify.Add(Sample.ClassifySeconds); Classify.Add(Sample.ClassifySeconds);
@@ -123,7 +123,7 @@ namespace
return FString::Printf( return FString::Printf(
TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"cache_hits\":%d,\"region_hits\":%d,\"triangles\":%lld," TEXT("{\"count\":%d,\"empty\":%d,\"all_air\":%d,\"all_solid\":%d,\"mixed\":%d,\"cache_hits\":%d,\"region_hits\":%d,\"triangles\":%lld,"
"\"request_ready_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"request_apply_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"queue_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"generation_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
"\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s}," "\"classify_s\":{\"p50\":%s,\"p95\":%s,\"max\":%s},"
@@ -265,11 +265,11 @@ void RecordTile(const FTileSample& Sample)
++GState->TileRecordsWritten; ++GState->TileRecordsWritten;
WriteEventLocked(*GState, TEXT("tile"), FString::Printf( WriteEventLocked(*GState, TEXT("tile"), FString::Printf(
TEXT("\"level\":%d,\"verdict\":%d,\"empty\":%d,\"cache_hit\":%d,\"region_hit\":%d,\"triangles\":%d," TEXT("\"level\":%d,\"verdict\":%d,\"empty\":%d,\"cache_hit\":%d,\"region_hit\":%d,\"triangles\":%d,"
"\"request_to_ready_s\":%s,\"queue_wait_s\":%s,\"worker_queue_s\":%s,\"result_queue_s\":%s," "\"request_to_apply_s\":%s,\"queue_wait_s\":%s,\"worker_queue_s\":%s,\"result_queue_s\":%s,"
"\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"), "\"generation_s\":%s,\"classify_s\":%s,\"mesh_s\":%s,\"stream_s\":%s,\"apply_s\":%s"),
Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0, Sample.Level, Sample.Verdict, Sample.bEmpty ? 1 : 0,
Sample.bCacheHit ? 1 : 0, Sample.bRegionHit ? 1 : 0, Sample.Triangles, Sample.bCacheHit ? 1 : 0, Sample.bRegionHit ? 1 : 0, Sample.Triangles,
*Number(Sample.RequestToReadySeconds), *Number(Sample.QueueWaitSeconds), *Number(Sample.RequestToApplySeconds), *Number(Sample.QueueWaitSeconds),
*Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds), *Number(Sample.WorkerQueueSeconds), *Number(Sample.ResultQueueSeconds),
*Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds), *Number(Sample.GenerationSeconds), *Number(Sample.ClassifySeconds),
*Number(Sample.MeshSeconds), *Number(Sample.StreamSeconds), *Number(Sample.ApplySeconds))); *Number(Sample.MeshSeconds), *Number(Sample.StreamSeconds), *Number(Sample.ApplySeconds)));
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -15,7 +15,9 @@ namespace VoxelForgeStartupTrace
bool bCacheHit = false; bool bCacheHit = false;
bool bRegionHit = false; bool bRegionHit = false;
int32 Triangles = 0; int32 Triangles = 0;
double RequestToReadySeconds = 0.0; // This is worker-result -> game-thread mesh submission. Collision readiness is a later
// per-tile RMC completion event recorded separately as collision_ready.
double RequestToApplySeconds = 0.0;
double QueueWaitSeconds = 0.0; double QueueWaitSeconds = 0.0;
double WorkerQueueSeconds = 0.0; double WorkerQueueSeconds = 0.0;
double ResultQueueSeconds = 0.0; double ResultQueueSeconds = 0.0;
+2 -2
View File
@@ -32,8 +32,8 @@ constexpr float VOXEL_SIZE = 25.0f;
// TRIVIAL-TILE CLASSIFICATION (T1.d) // TRIVIAL-TILE CLASSIFICATION (T1.d)
//============================================================================= //=============================================================================
// Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ : // Verdict de ClassifyTile pour une tuile AVANT le pré-échantillonnage 33³+ :
// AllSolid / AllAir garantissent que CHAQUE point du treillis du mesher (marge // AllSolid / AllAir guarantee every core cell vertex of the mesher (g=0..Cells;
// ±1 incluse) est du même côté de l'iso ⇒ maillage vide, GenerateMesh est // the ±1 halo is normal-only) is on the same side of the iso ⇒ empty mesh, GenerateMesh is
// sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux // sautée. Mixed = "je ne peux pas le prouver" ⇒ génération normale. Un faux
// Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les // Mixed coûte juste du CPU ; un faux AllSolid/AllAir ferait un TROU — les
// verdicts ne sont donc émis que sur des bornes exactes (colonnes surface // verdicts ne sont donc émis que sur des bornes exactes (colonnes surface
+95 -9
View File
@@ -16,7 +16,12 @@
// Forward declaration // Forward declaration
class URealtimeMeshComponent; class URealtimeMeshComponent;
class URealtimeMesh;
class URealtimeMeshSimple; class URealtimeMeshSimple;
class UBodySetup;
class APawn;
class UPawnMovementComponent;
class UCharacterMovementComponent;
class UVoxelDiffLayer; class UVoxelDiffLayer;
class UVoxelContentManager; class UVoxelContentManager;
class UVoxelAtmosphereManager; class UVoxelAtmosphereManager;
@@ -81,18 +86,18 @@ struct FChunkResult
// so the game thread only uploads them — the per-vertex builder loop was the dominant // so the game thread only uploads them — the per-vertex builder loop was the dominant
// game-thread cost while moving (the apply drain). TSharedPtr (not a by-value StreamSet) so // game-thread cost while moving (the apply drain). TSharedPtr (not a by-value StreamSet) so
// FChunkResult stays movable through the MPSC queue with the type only FORWARD-DECLARED here. // FChunkResult stays movable through the MPSC queue with the type only FORWARD-DECLARED here.
// Null ⇒ empty/all-air tile (no component). // Null ⇒ no-surface tile (uniform all-air/all-solid or an empty content band; no component).
TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams; TSharedPtr<RealtimeMesh::FRealtimeMeshStreamSet> Streams;
uint32 Epoch = 0; // Generation epoch — discard if stale uint32 Epoch = 0; // Generation epoch — discard if stale
// Monotonic request timestamp used only for streaming telemetry. It is carried through the // Monotonic request timestamp used only for streaming telemetry. It is carried through the
// worker queue so the profile can report request-to-ready, not just worker generation time. // worker queue so the profile can report request-to-apply, not just worker generation time.
uint64 RequestStartCycles = 0; uint64 RequestStartCycles = 0;
// Worker timing boundaries for separating scheduler wait from generation and result/apply wait. // Worker timing boundaries for separating scheduler wait from generation and result/apply wait.
// These are telemetry-only and remain zero when tile profiling is disabled. // These are telemetry-only and remain zero when tile profiling is disabled.
uint64 GenerationStartCycles = 0; uint64 GenerationStartCycles = 0;
uint64 GenerationEndCycles = 0; int32 ClassifyVerdict = -1; bool bClassifierCacheHit = false; bool bClassifierRegionHit = false; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0; uint64 GenerationEndCycles = 0; int32 ClassifyVerdict = -1; bool bClassifierCacheHit = false; bool bClassifierRegionHit = false; double ClassifySeconds = 0.0; double MeshSeconds = 0.0; double StreamSeconds = 0.0; int32 NumTriangles = 0;
bool bAborted = false; // Worker observed shutdown; never mark this tile loaded bool bAborted = false; // Worker observed shutdown; never mark this tile loaded
bool bEmpty = true; // true ⇒ all-air tile (Streams null); still marked loaded so we don't re-submit bool bEmpty = true; // true ⇒ no surface (uniform all-air/all-solid or empty band); still loaded
// F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the // F17 — the mesher classifies every triangle semantically (sky-cap = down-facing near the
// column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs // column's CeilSurf; overhangs/cave roofs stay ground) and packs them as two contiguous runs
// (ground then cap) in the index buffer → polygroups 0/1 → two RMC sections with their own // (ground then cap) in the index buffer → polygroups 0/1 → two RMC sections with their own
@@ -193,9 +198,41 @@ public:
// game-thread problem (this supersedes the earlier region-batching). Collision + content // game-thread problem (this supersedes the earlier region-batching). Collision + content
// are level-0 only. // are level-0 only.
/** Tiles fully loaded — INCLUDING empty/all-air tiles, so we never re-submit them. */ /** Tiles whose result was successfully applied — INCLUDING empty/all-air tiles, so we never
* re-submit them. A non-empty level-0 tile is not collision-ready until its RMC cook callback
* has added it to CollisionReadyTiles. */
TSet<FVoxelTileKey> LoadedTiles; TSet<FVoxelTileKey> LoadedTiles;
/** Level-0 tiles whose latest submitted RMC collision body has finished cooking and was
* installed. This is the authoritative collision-readiness set used by the pawn gate; mesh
* submission and LoadedTiles are intentionally not sufficient. */
TSet<FVoxelTileKey> CollisionReadyTiles;
/** Level-0 tiles whose applied result is known to contain no surface (uniform empty/solid).
* They need no BodySetup themselves, but the pawn gate may walk past them while looking for
* the first resolved collidable support tile. */
TSet<FVoxelTileKey> CollisionNotRequiredTiles;
/** Subset of CollisionNotRequiredTiles proved all-solid. A support probe inside one of these
* tiles searches upward for the boundary tile; all-air/empty tiles search downward. */
TSet<FVoxelTileKey> CollisionSolidTiles;
struct FPendingCollisionCook
{
TWeakObjectPtr<URealtimeMesh> Mesh;
uint64 SubmissionId = 0;
uint64 MeshSubmittedCycles = 0;
uint64 CollisionSubmittedCycles = 0;
bool bBodyUpdatedEventSeen = false;
uint64 BodyUpdatedEventCycles = 0;
};
/** One serialised completion record per tile. The serial prevents a late RMC future from
* making a remeshed tile, or a pooled component reused by another tile, appear ready. */
TMap<FVoxelTileKey, FPendingCollisionCook> PendingCollisionCooks;
uint64 NextCollisionSubmissionId = 0;
TMap<URealtimeMesh*, FDelegateHandle> CollisionBodyUpdatedHandles;
/** Render component per NON-empty loaded tile (GC-safe via actor ownership; not UPROPERTY /** Render component per NON-empty loaded tile (GC-safe via actor ownership; not UPROPERTY
* because FVoxelTileKey isn't a USTRUCT key). */ * because FVoxelTileKey isn't a USTRUCT key). */
TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents; TMap<FVoxelTileKey, URealtimeMeshComponent*> TileComponents;
@@ -640,9 +677,11 @@ public:
/** /**
* Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile): * Game-thread apply for one gen result (shared by ProcessPendingChunks + SyncRemeshTile):
* epoch check, mark loaded, ingest capture, then either release the tile's component (empty) or * epoch check, ingest capture, then either release the tile's component (empty) or submit a mesh
* ApplyMeshToTile. Returns true iff a VISIBLE mesh was uploaded (counts against the apply budget). * through ApplyMeshToTile. Returns true iff a VISIBLE mesh was submitted (counts against the
* Does NOT touch PendingTiles — the caller owns that. * apply budget); collision readiness is a later RMC completion.
* Does NOT touch PendingTiles — the caller owns that. A non-empty result counts as applied only
* after mesh submission succeeds; collision readiness arrives asynchronously afterwards.
*/ */
bool ApplyTileResult(FChunkResult& Result); bool ApplyTileResult(FChunkResult& Result);
@@ -681,7 +720,7 @@ public:
* the strate content band the mesh was cut to; material lookups clamp into that band). * the strate content band the mesh was cut to; material lookups clamp into that band).
* Never called for empty results. * Never called for empty results.
*/ */
void ApplyMeshToTile(FChunkResult& Result); bool ApplyMeshToTile(FChunkResult& Result);
/** Mini-sun lighting (bounded directional). Each frame writes the nearest 4 active orbs' WORLD /** Mini-sun lighting (bounded directional). Each frame writes the nearest 4 active orbs' WORLD
* positions (+ reach radius in .w) into OrbLightMPC's Orb0..3 vector params; the Directional * positions (+ reach radius in .w) into OrbLightMPC's Orb0..3 vector params; the Directional
@@ -708,9 +747,39 @@ public:
// HELPERS // HELPERS
//========================================================================= //=========================================================================
/** Get the current player position (or zero if no player) */ /** Return the current player position and distinguish "no pawn" from a valid origin position. */
bool TryGetPlayerPosition(FVector& OutPosition, APawn** OutPawn = nullptr) const;
/** Compatibility wrapper for callers that only need the location. A real pawn at the origin
* therefore returns FVector::ZeroVector just like any other valid position. */
FVector GetPlayerPosition() const; FVector GetPlayerPosition() const;
/** Level-0 tile under the pawn's feet. This is deliberately separate from the actor-center tile:
* the floor that catches a falling pawn is what must be cooked before movement is released. */
bool GetPlayerSupportTile(APawn* Pawn, const FVector& PlayerPosition,
FVoxelTileKey& OutSupportTile) const;
bool IsTileCollisionReady(const FVoxelTileKey& Tile) const;
bool IsCollisionReadyFromSupportTile(const FVoxelTileKey& CandidateTile,
FVoxelTileKey* OutSupportTile = nullptr) const;
bool IsPlayerSupportCollisionReady(APawn* Pawn, const FVector& PlayerPosition,
FVoxelTileKey* OutSupportTile = nullptr) const;
/** Gate pawn movement until its current and, when moving, next support tile have a completed
* level-0 RMC collision body. 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. */
void UpdatePawnCollisionGate(APawn* Pawn, const FVector& PlayerPosition, float DeltaTime);
void EngagePawnCollisionGate(APawn* Pawn);
void ReleasePawnCollisionGate();
/** Called by the RMC collision future after async cook + BodySetup installation. */
void HandleTileCollisionCookComplete(const FVoxelTileKey& Tile, uint64 SubmissionId,
URealtimeMesh* Mesh, uint8 Result);
void BindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void UnbindRealtimeMeshCollisionEvent(URealtimeMesh* Mesh);
void HandleRealtimeMeshCollisionBodyUpdated(URealtimeMesh* Mesh, UBodySetup* BodySetup);
// (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap // (GetLODForChunk / LODToStep / IsChunkInRange removed — dead since the clipmap
// streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.) // streaming replaced the distance-LOD scheme; the level lives in FVoxelTileKey.)
@@ -800,6 +869,23 @@ public:
bool bStartupTraceDesiredRecorded = false; bool bStartupTraceDesiredRecorded = false;
TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first TArray<FVoxelTileKey> DesiredSorted; // desired tiles, nearest-first
// PLAYER COLLISION GATE — the pawn is allowed to enter/leave a tile only when the level-0
// collision body covering its feet has completed the RMC cook. The movement component state is
// saved so a temporary stream wait does not permanently change a character's movement mode.
TWeakObjectPtr<APawn> PawnTickPrerequisite;
TWeakObjectPtr<UPawnMovementComponent> PawnMovementTickPrerequisite;
TWeakObjectPtr<APawn> CollisionGatedPawn;
TWeakObjectPtr<UPawnMovementComponent> CollisionGatedPawnMovement;
TWeakObjectPtr<UCharacterMovementComponent> CollisionGatedCharacterMovement;
uint8 SavedCharacterMovementMode = 0;
uint8 SavedCharacterCustomMovementMode = 0;
bool bSavedCharacterMovementMode = false;
bool bSavedPawnMovementActive = false;
bool bPawnCollisionGateEngaged = false;
bool bPawnGateWaitingForPredictedSupport = false;
FVoxelTileKey PawnGatePredictedSupportTile;
bool bPawnGateUnsupportedReported = false;
// Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée. // Desired-set membership STAMPÉE : clé → numéro du dernier crossing où la tuile était désirée.
// BuildDesiredTiles upserte le stamp courant puis balaie la map UNE fois : les entrées à stamp // BuildDesiredTiles upserte le stamp courant puis balaie la map UNE fois : les entrées à stamp
// périmé sont les "leavers" (retirées + renvoyées). Le cull ne considère que ces leavers + la // périmé sont les "leavers" (retirées + renvoyées). Le cull ne considère que ces leavers + la