From 608ad19a4b0304217e2c6866b92a29e5e9556ebd Mon Sep 17 00:00:00 2001 From: William Emfinger Date: Fri, 9 Oct 2026 21:13:52 -0500 Subject: [PATCH] perf(manager): apply the render snapshot outside its lock ApplyLatestRenderState moved every body and geom component while holding the snapshot lock, which the stepping thread needs to publish the next frame. On a 140-body, 485-geom robot that held the stepper behind the game thread for most of each frame: the simulation ran at about a quarter of real time with MuJoCo itself idle 95% of the time. Copy the snapshot out under the lock (microseconds) and apply the copy after releasing it. The buffer is game-thread only and reused between calls. The same model now steps at real time at a 1 ms timestep. Co-Authored-By: Claude Opus 5.5 --- .../URLab/Private/MuJoCo/Core/AMjManager.cpp | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/Source/URLab/Private/MuJoCo/Core/AMjManager.cpp b/Source/URLab/Private/MuJoCo/Core/AMjManager.cpp index 21ac66ce..9d5e1637 100644 --- a/Source/URLab/Private/MuJoCo/Core/AMjManager.cpp +++ b/Source/URLab/Private/MuJoCo/Core/AMjManager.cpp @@ -760,26 +760,37 @@ void AAMjManager::ApplyLatestRenderState() const TArray& Arts = PhysicsEngine->GetAllArticulations(); const TArray Quicks = PhysicsEngine->GetAllQuickComponents(); - PhysicsEngine->WithRenderState([&](const FMjRenderSnapshot& Snap) { - for (AMjArticulation* Art : Arts) + // Copy the frame out and apply it after releasing the snapshot lock. The + // stepping thread takes that lock to publish, and applying a frame moves + // every body and geom component in the scene -- milliseconds on a large + // model, tens of them on a slow frame -- while copying it is microseconds. + // Applying under the lock held the stepper behind the game thread for + // most of every frame: a 140-body, 485-geom robot stepped at about a + // quarter of real time with MuJoCo itself idle 95% of the time. + // + // Game thread only, and used start to finish within this call, so one + // buffer serves every call and keeps its allocations between frames. + static FMjRenderSnapshot Applied; + PhysicsEngine->WithRenderState([](const FMjRenderSnapshot& Snap) { Applied = Snap; }); + + for (AMjArticulation* Art : Arts) + { + if (Art) { - if (Art) - { - Art->ApplyRenderState(Snap); - } + Art->ApplyRenderState(Applied); } - for (UMjQuickConvertComponent* Quick : Quicks) + } + for (UMjQuickConvertComponent* Quick : Quicks) + { + if (Quick) { - if (Quick) - { - Quick->ApplyRenderState(Snap); - } + Quick->ApplyRenderState(Applied); } - // Record which post-step state the actors now reflect so cameras can - // tag their readbacks with it (frame_id association for the bridge). - LastAppliedRenderFrameId.store(Snap.FrameId, std::memory_order_release); - LastAppliedRenderSimTime.store(Snap.SimTime, std::memory_order_release); - }); + } + // Record which post-step state the actors now reflect so cameras can + // tag their readbacks with it (frame_id association for the bridge). + LastAppliedRenderFrameId.store(Applied.FrameId, std::memory_order_release); + LastAppliedRenderSimTime.store(Applied.SimTime, std::memory_order_release); } AAMjManager* AAMjManager::GetManager()