perf(apply): stutter is NOT the game thread -- and the flat floor cannot be relaxed
Two things the owner reported from playing412a7ad. One is answered, one is a null result that Codex flagged itself rather than dressing up. THE FLATTENED TUNNEL FLOORS -- capability-safe, and worth almost nothing He has now flagged these twice: every tunnel carries a smooth flat strip running in and out through otherwise craggy rock. The idea was to make the floor CONDITIONAL -- assert it only where the natural rock fails walkability, leave the rock alone elsewhere. Cheaper and better looking, in theory. The floor is now skipped only when the exact no-floor field passes the same player-fit validator at foot offsets 0, -1, +1. Unknown or failed keeps the slab. natural-floor skips accepted 1,539 of 1,529,241 tests = 0.10% player-fit cells 20,153 -> 20,153 reachable cells 10,483 -> 10,483 arrival -> departure connected, both dihedral p50/p90 2.5429 / 33.8499 deg, IDENTICAL mesh time (their batch) 1.757135 -> 1.757140 s Capability preserved exactly. Visual complaint NOT solved, and no speed gained. ⭐ The useful part is what it PROVED by trying to go further. Every broader policy lost walkable space: analytic-only fit -217, reachable -195 tightened analytic fit -76, reachable -76 exact +/-4 foot fit -16, reachable -16 ⇒ The floor cannot be relaxed without changing the walkability contract or adding a route-aware global proof. That is a real constraint, now measured rather than assumed. Codex's own words, and it was right to lead with them: "the conservative floor change is capability-safe, but it does not visibly solve the flattened-strip complaint." THE STUTTER -- measured, and it is not where we thought Game-thread apply, meshed tiles, under -nullrhi: total apply p50 0.107 ms p95 0.153 ms max 0.273 ms stream upload/RMC p50 0.040 p95 0.088 max 0.124 component creation p50 0.019 p95 0.021 max 0.035 registration p50 0.010 p95 0.011 max 0.020 collision config p50 0.005 p95 0.007 max 0.019 A quarter of a millisecond at worst. The game-thread drain is NOT the hitch. ⇒ Remaining visible stutter is most likely render-thread/RHI work or asynchronous collision cooking behind RealtimeMeshComponent, which is off-limits for edits. Added anyway as a cheap safety valve: MaxMeshApplyMilliseconds, default 2 ms of drain budget per frame. Over 729 apply frames: drain p50/p95/max 0.111/0.182/0.313 ms, budget hits 0, at most 2 visible applies in a frame. It costs nothing today and caps a future burst. Request-to-ready, now timed after game-thread application: all p50 57.1 ms p95 432.9 ms max 919.4 ms -> 2.60 m at 6 m/s meshed p50 134.1 ms p95 457.4 ms max 919.4 ms ⚠️ My own verification: 1.197 / 1.131 / 1.118 s vs 1.088-1.102 s on412a7ad. Mean +4.7% -- below the 17% noise floor, but slower in all three runs, and the validator runs 1.5M times to accept 1,539 skips. Geometry moved by 4 triangles (153,740 -> 153,744), which is allowed. If the next round cannot make that validator earn its place, it should come out. Build 0 errors. Peak RSS 1.67 GB export / 1.90 GB game. Nothing outside Plugins/VoxelForge; RealtimeMeshComponent untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aheo2crrZmHuYtPpdY2Cb
This commit is contained in:
1 parent
412a7ad219
commit
383ec86c37
11 files changed
+508
-43
No files matched your search
@@ -1172,11 +1172,22 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
// This prevents stutters from applying too many meshes in one frame.
|
||||
// Budget limits how many VISIBLE mesh applies we do per frame (GPU upload cost).
|
||||
// Empty meshes and stale results are free to drain — don't count them.
|
||||
const int32 MaxApplies = Settings ? Settings->MaxMeshAppliesPerFrame : 4;
|
||||
const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0;
|
||||
const uint64 DrainStartCycles = FPlatformTime::Cycles64();
|
||||
const int32 MaxApplies = FMath::Max(
|
||||
Settings ? Settings->MaxMeshAppliesPerFrame : 4, 1);
|
||||
const float ApplyBudgetMilliseconds = Settings
|
||||
? FMath::Max(Settings->MaxMeshApplyMilliseconds, 0.0f)
|
||||
: 2.0f;
|
||||
const double ApplyBudgetSeconds =
|
||||
static_cast<double>(ApplyBudgetMilliseconds) * 0.001;
|
||||
int32 MeshesApplied = 0;
|
||||
int32 ResultsDrained = 0;
|
||||
bool bTimeBudgetHit = false;
|
||||
FChunkResult DequeuedChunk;
|
||||
while (ProcessQueue.Dequeue(DequeuedChunk))
|
||||
{
|
||||
++ResultsDrained;
|
||||
PendingTiles.Remove(DequeuedChunk.Tile);
|
||||
|
||||
// A worker can observe shutdown after entering the mesher. Do not turn that partial
|
||||
@@ -1190,13 +1201,37 @@ void AVoxelWorld::ProcessPendingChunks()
|
||||
// Only a real (visible) upload counts against the per-frame budget — stale/empty drain free.
|
||||
if (ApplyTileResult(DequeuedChunk))
|
||||
{
|
||||
if (++MeshesApplied >= MaxApplies)
|
||||
{
|
||||
break;
|
||||
}
|
||||
++MeshesApplied;
|
||||
}
|
||||
|
||||
// Time-box the entire game-thread drain, not only visible uploads. Empty releases and
|
||||
// stale-result cleanup normally cost nothing, but a pooled component teardown or a
|
||||
// backend callback can still be the one result that produces the player's hitch.
|
||||
if (ApplyBudgetSeconds > 0.0
|
||||
&& FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles)
|
||||
>= ApplyBudgetSeconds)
|
||||
{
|
||||
bTimeBudgetHit = true;
|
||||
break;
|
||||
}
|
||||
if (MeshesApplied >= MaxApplies)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bProfileApply && ResultsDrained > 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeApplyFrame] drained=%d meshes=%d elapsed=%.6f "
|
||||
"max_meshes=%d budget_ms=%.3f budget_hit=%d"),
|
||||
ResultsDrained,
|
||||
MeshesApplied,
|
||||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - DrainStartCycles),
|
||||
MaxApplies,
|
||||
ApplyBudgetMilliseconds,
|
||||
bTimeBudgetHit ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Game-thread apply for one gen result. Shared by ProcessPendingChunks (async drain) and
|
||||
@@ -1209,13 +1244,21 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GVoxelForgeProfileTileGeneration != 0 && Result.RequestStartCycles != 0)
|
||||
// "Ready" is deliberately emitted after the game-thread apply, not when the worker result is
|
||||
// dequeued. This makes request-to-ready include component setup, stream/buffer submission,
|
||||
// and collision configuration—the point at which the chunk is actually visible/collidable.
|
||||
const bool bResultEmpty = Result.bEmpty || !Result.Streams;
|
||||
const auto LogTileReady = [&]()
|
||||
{
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeTileReady] level=%d empty=%d request_to_ready=%.6f"),
|
||||
Result.Tile.Level, Result.bEmpty || !Result.Streams ? 1 : 0,
|
||||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - Result.RequestStartCycles));
|
||||
}
|
||||
if (GVoxelForgeProfileTileGeneration != 0 && Result.RequestStartCycles != 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeTileReady] level=%d empty=%d request_to_ready=%.6f"),
|
||||
Result.Tile.Level, bResultEmpty ? 1 : 0,
|
||||
FPlatformTime::ToSeconds64(
|
||||
FPlatformTime::Cycles64() - Result.RequestStartCycles));
|
||||
}
|
||||
};
|
||||
|
||||
// Mark the tile loaded (even if empty — so we don't re-submit it).
|
||||
LoadedTiles.Add(Result.Tile);
|
||||
@@ -1240,17 +1283,35 @@ bool AVoxelWorld::ApplyTileResult(FChunkResult& Result)
|
||||
// bande déplacée hors de la tuile, ou skip cellule-plus-haute-que-la-bande après un
|
||||
// changement de strate (LoadTile). L'ancien composant doit tomber, sinon sa vieille
|
||||
// géométrie (l'autre strate !) reste affichée. Première gen vide : Find rate, no-op.
|
||||
const uint64 EmptyReleaseStartCycles = GVoxelForgeProfileTileGeneration != 0
|
||||
? FPlatformTime::Cycles64() : 0;
|
||||
bool bReleasedComponent = false;
|
||||
if (URealtimeMeshComponent** OldComp = TileComponents.Find(Result.Tile))
|
||||
{
|
||||
if (*OldComp) { ReleaseTileComponent(*OldComp); }
|
||||
if (*OldComp)
|
||||
{
|
||||
ReleaseTileComponent(*OldComp);
|
||||
bReleasedComponent = true;
|
||||
}
|
||||
TileComponents.Remove(Result.Tile);
|
||||
}
|
||||
if (GVoxelForgeProfileTileGeneration != 0)
|
||||
{
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeApplyProfile] level=%d empty=1 component_release=%d total=%.6f"),
|
||||
Result.Tile.Level,
|
||||
bReleasedComponent ? 1 : 0,
|
||||
FPlatformTime::ToSeconds64(
|
||||
FPlatformTime::Cycles64() - EmptyReleaseStartCycles));
|
||||
}
|
||||
LogTileReady();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply mesh (GPU upload). The vertex/index buffers were already built (T1.f, on the worker for
|
||||
// the async path or inline for the sync carve path); the game thread only uploads them here.
|
||||
ApplyMeshToTile(Result);
|
||||
LogTileReady();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2291,8 +2352,15 @@ static FRealtimeMeshSectionGroupKey VoxelTileGroupKey()
|
||||
//=============================================================================
|
||||
// Recycler les composants de tuile au lieu de les détruire/recréer.
|
||||
|
||||
URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent()
|
||||
URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent(
|
||||
double* OutCreationSeconds,
|
||||
double* OutRegistrationSeconds,
|
||||
bool* bOutCreated)
|
||||
{
|
||||
if (OutCreationSeconds != nullptr) { *OutCreationSeconds = 0.0; }
|
||||
if (OutRegistrationSeconds != nullptr) { *OutRegistrationSeconds = 0.0; }
|
||||
if (bOutCreated != nullptr) { *bOutCreated = false; }
|
||||
|
||||
// Reuse a parked component when one is available — skips NewObject + RegisterComponent
|
||||
// (and the full proxy teardown/GC of a destroy) during fast travel & regen bursts.
|
||||
while (TileComponentPool.Num() > 0)
|
||||
@@ -2305,6 +2373,10 @@ URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent()
|
||||
}
|
||||
}
|
||||
|
||||
const bool bMeasureComponentPhases = OutCreationSeconds != nullptr
|
||||
|| OutRegistrationSeconds != nullptr;
|
||||
const uint64 CreationStartCycles = bMeasureComponentPhases
|
||||
? FPlatformTime::Cycles64() : 0;
|
||||
URealtimeMeshComponent* MeshComp = NewObject<URealtimeMeshComponent>(this);
|
||||
// Generated once, never moves → Static so RMC's cached static draw path + VSM shadow
|
||||
// caching apply (see the root SetMobility note in BeginPlay). Must be set before register.
|
||||
@@ -2313,8 +2385,21 @@ URealtimeMeshComponent* AVoxelWorld::AcquireTileComponent()
|
||||
MeshComp->SetMobility(EComponentMobility::Static);
|
||||
MeshComp->SetGenerateOverlapEvents(false); // chunks use raycasts, not overlaps
|
||||
MeshComp->SetCanEverAffectNavigation(false);
|
||||
if (OutCreationSeconds != nullptr)
|
||||
{
|
||||
*OutCreationSeconds = FPlatformTime::ToSeconds64(
|
||||
FPlatformTime::Cycles64() - CreationStartCycles);
|
||||
}
|
||||
const uint64 RegistrationStartCycles = bMeasureComponentPhases
|
||||
? FPlatformTime::Cycles64() : 0;
|
||||
MeshComp->RegisterComponent();
|
||||
MeshComp->AttachToComponent(GetRootComponent(), FAttachmentTransformRules::KeepRelativeTransform);
|
||||
if (OutRegistrationSeconds != nullptr)
|
||||
{
|
||||
*OutRegistrationSeconds = FPlatformTime::ToSeconds64(
|
||||
FPlatformTime::Cycles64() - RegistrationStartCycles);
|
||||
}
|
||||
if (bOutCreated != nullptr) { *bOutCreated = true; }
|
||||
return MeshComp;
|
||||
}
|
||||
|
||||
@@ -2358,6 +2443,9 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
{
|
||||
TRACE_CPUPROFILER_EVENT_SCOPE(VoxelForge_ApplyMeshToChunk);
|
||||
|
||||
const bool bProfileApply = GVoxelForgeProfileTileGeneration != 0;
|
||||
const uint64 ApplyStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
|
||||
// Streams are pre-built on the worker (T1.f) and guaranteed non-empty by the caller
|
||||
// (ProcessPendingChunks skips empty tiles). This path is game-thread-CHEAP: material lookup +
|
||||
// component get/create + the upload + per-section config. No per-vertex work here.
|
||||
@@ -2414,6 +2502,9 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(GroundMaterial)) { GroundMaterial = MID; }
|
||||
if (UMaterialInstanceDynamic* MID = GetOrCreateTerrainMID(CeilingMaterial)) { CeilingMaterial = MID; }
|
||||
}
|
||||
const double MaterialSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles)
|
||||
: 0.0;
|
||||
|
||||
// The geometry stream set was built on the worker (BuildTileStreamSet, T1.f); we just upload it.
|
||||
// Vertices are world-space; the component sits at the actor origin.
|
||||
@@ -2422,11 +2513,22 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
// cheap on the game thread (no batching needed). Collision + content are level-0 only.
|
||||
// T2.c: the component comes from the pool when one is parked (see AcquireTileComponent).
|
||||
URealtimeMeshComponent* MeshComp = TileComponents.FindRef(Tile);
|
||||
const bool bComponentAlreadyPresent = MeshComp != nullptr;
|
||||
const uint64 ComponentStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
double ComponentCreationSeconds = 0.0;
|
||||
double ComponentRegistrationSeconds = 0.0;
|
||||
bool bComponentCreated = false;
|
||||
if (!MeshComp)
|
||||
{
|
||||
MeshComp = AcquireTileComponent();
|
||||
MeshComp = AcquireTileComponent(
|
||||
bProfileApply ? &ComponentCreationSeconds : nullptr,
|
||||
bProfileApply ? &ComponentRegistrationSeconds : nullptr,
|
||||
bProfileApply ? &bComponentCreated : nullptr);
|
||||
TileComponents.Add(Tile, MeshComp);
|
||||
}
|
||||
const double ComponentSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ComponentStartCycles)
|
||||
: 0.0;
|
||||
|
||||
// §9.4 RENDER-SKIP — a tile only a CollisionOnly anchor wants (not the player clipmap) cooks its
|
||||
// collision below but is hidden (no draw / VSM). Set every apply (overrides the pool's default-
|
||||
@@ -2438,8 +2540,22 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
// calling it unconditionally (as before) orphaned one mesh object per re-apply to the GC.
|
||||
// The RemoveSectionGroup below does the actual geometry clearing on reuse.
|
||||
URealtimeMeshSimple* RTMesh = MeshComp->GetRealtimeMeshAs<URealtimeMeshSimple>();
|
||||
const uint64 MeshInitStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
if (!RTMesh) { RTMesh = MeshComp->InitializeRealtimeMesh<URealtimeMeshSimple>(); }
|
||||
if (!RTMesh) { return; }
|
||||
const double MeshInitSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MeshInitStartCycles)
|
||||
: 0.0;
|
||||
if (!RTMesh)
|
||||
{
|
||||
if (bProfileApply)
|
||||
{
|
||||
UE_LOG(LogTemp, Warning,
|
||||
TEXT("[VoxelForgeApplyProfile] level=%d empty=0 failed_mesh_init=1 total=%.6f"),
|
||||
Tile.Level,
|
||||
FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - ApplyStartCycles));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Shadow casting: far (level >= 2) tiles never cast; the sky-cap SECTION never casts either
|
||||
// — otherwise the high rock ceiling shadows the entire terrain below it. F17: shadow is now
|
||||
// PER SECTION, so a mixed tile keeps its ground shadow while its cap stays shadowless.
|
||||
@@ -2447,17 +2563,33 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
MeshComp->SetCastShadow(bCastShadow);
|
||||
|
||||
const FRealtimeMeshSectionGroupKey GroupKey = VoxelTileGroupKey();
|
||||
const uint64 RemoveGroupStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
RTMesh->RemoveSectionGroup(GroupKey); // clear old geometry on re-mesh
|
||||
// (no-op on a fresh/pooled mesh)
|
||||
const double RemoveGroupSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - RemoveGroupStartCycles)
|
||||
: 0.0;
|
||||
const uint64 MaterialSlotStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
RTMesh->SetupMaterialSlot(0, "Main", GroundMaterial);
|
||||
RTMesh->SetupMaterialSlot(1, "SkyCap", CeilingMaterial);
|
||||
const double MaterialSlotSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - MaterialSlotStartCycles)
|
||||
: 0.0;
|
||||
// RMC owns both the CPU stream-to-buffer conversion and the render-resource upload behind
|
||||
// CreateSectionGroup. Keep this as one phase: it is the exact plugin/RMC boundary we can
|
||||
// budget without modifying RealtimeMeshComponent.
|
||||
const uint64 StreamUploadStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
RTMesh->CreateSectionGroup(GroupKey, MoveTemp(Streams));
|
||||
const double StreamUploadSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - StreamUploadStartCycles)
|
||||
: 0.0;
|
||||
|
||||
// RMC casts shadows PER SECTION (FRealtimeMeshSectionConfig::bCastsShadow, default true) — the
|
||||
// component-level UPrimitiveComponent::CastShadow is NOT honored by the RMC proxy, so the real
|
||||
// shadow lever is the section flag. RMC auto-created one section per non-empty polygroup above
|
||||
// (default config already maps material slot = polygroup index); only config sections that
|
||||
// exist — the bHas* flags come from the worker. Collision at level 0 only (T1.c), both groups.
|
||||
const uint64 CollisionConfigStartCycles = bProfileApply ? FPlatformTime::Cycles64() : 0;
|
||||
if (bHasGroundTris)
|
||||
{
|
||||
FRealtimeMeshSectionConfig GroundConfig(0);
|
||||
@@ -2474,6 +2606,35 @@ void AVoxelWorld::ApplyMeshToTile(FChunkResult& Result)
|
||||
FRealtimeMeshSectionKey::CreateForPolyGroup(GroupKey, 1),
|
||||
CapConfig, /*bShouldCreateCollision*/ bLevel0);
|
||||
}
|
||||
const double CollisionConfigSeconds = bProfileApply
|
||||
? FPlatformTime::ToSeconds64(FPlatformTime::Cycles64() - CollisionConfigStartCycles)
|
||||
: 0.0;
|
||||
|
||||
if (bProfileApply)
|
||||
{
|
||||
const double TotalSeconds = FPlatformTime::ToSeconds64(
|
||||
FPlatformTime::Cycles64() - ApplyStartCycles);
|
||||
UE_LOG(LogTemp, Display,
|
||||
TEXT("[VoxelForgeApplyProfile] level=%d empty=0 created=%d pooled=%d "
|
||||
"material=%.6f component=%.6f component_create=%.6f "
|
||||
"component_register=%.6f mesh_init=%.6f remove_group=%.6f "
|
||||
"material_slots=%.6f stream_upload=%.6f create_section_group=%.6f "
|
||||
"collision_config=%.6f total=%.6f"),
|
||||
Tile.Level,
|
||||
bComponentCreated ? 1 : 0,
|
||||
(!bComponentAlreadyPresent && !bComponentCreated) ? 1 : 0,
|
||||
MaterialSeconds,
|
||||
ComponentSeconds,
|
||||
ComponentCreationSeconds,
|
||||
ComponentRegistrationSeconds,
|
||||
MeshInitSeconds,
|
||||
RemoveGroupSeconds,
|
||||
MaterialSlotSeconds,
|
||||
StreamUploadSeconds,
|
||||
StreamUploadSeconds,
|
||||
CollisionConfigSeconds,
|
||||
TotalSeconds);
|
||||
}
|
||||
|
||||
// Water is no longer spawned per tile — it's a single player-following ocean plane (UpdateWater,
|
||||
// driven from Tick), so it renders at every LOD and to the horizon with no per-tile gaps.
|
||||
|
||||
Reference in new issue
Block a user