How does FStaticMeshSceneProxy safely access UStaticMesh (a raw pointer) on the render thread?

We’re looking at how FStaticMeshSceneProxy guarantees the lifetime of the UStaticMesh (and FStaticMeshRenderData) it references from the render thread.

FStaticMeshSceneProxy stores plain raw pointers copied at construction:

// StaticMeshSceneProxy.h
FStaticMeshRenderData* RenderData;   // :220
const UStaticMesh* StaticMesh;       // :280 (private)
 
// StaticMeshRender.cpp
: RenderData(InComponent->GetStaticMesh()->GetRenderData())   // :202
: StaticMesh(InComponent->GetStaticMesh())                    // :218

UStaticMesh::RenderData is a TUniquePtr<FStaticMeshRenderData> (StaticMesh.h:593), i.e. it is owned by the mesh UObject and deleted when the mesh is destroyed (~UStaticMesh at StaticMesh.cpp:3379). The proxy then dereferences StaticMesh / RenderData on the render thread in several places (e.g. StaticMesh->GetLightMapCoordinateIndex() :480, StaticMesh->SpeedTreeWind :499/512, StaticMesh->GetNavCollision() :1825, RenderData->LODResources throughout GetDynamicMeshElements).

Our understanding of the guarantees (please confirm/correct)​

UStaticMeshComponent::StaticMesh is a TObjectPtr<UStaticMesh> UPROPERTY (StaticMeshComponent.h:354), i.e. a strong GC reference, so the mesh cannot be collected while the component is alive.

When the component is destroyed, DestroyRenderState_Concurrent → FScene::RemovePrimitive fences the render-thread deletion of the proxy before the component finishes destruction, so the proxy is gone before the mesh loses its last reference and can be GC’d.

When the mesh is GC’d, UStaticMesh::BeginDestroy → ReleaseResources (StaticMesh.cpp:4591/3882) starts releasing RHI resources and begins a ReleaseResourcesFence; IsReadyForFinishDestroy (:4601) blocks FinishDestroy until ReleaseResourcesFence.IsFenceComplete(). So the actual delete of RenderData is delayed until the render thread has finished releasing RHI resources.

Questions

In the runtime SetStaticMesh() path (StaticMeshComponent.cpp:2237), swapping the mesh only calls SetStaticMeshInternal(NewMesh) + MarkRenderStateDirty() (:2277) — there is no FlushRenderingCommands and no FStaticMeshComponentRecreateRenderStateContext (that class appears to be editor-only). The old mesh can therefore become unreachable immediately, while the old proxy is not deleted until the end-of-frame proxy recreation. Is there an explicit ordering/fence that guarantees the old proxy is deleted before the old mesh finishes FinishDestroy? Or is the safety here relying on something else (e.g. GC periodicity, the fact that runtime mesh swaps are rare and Mobility=Static rejects them at :2249)?

The ReleaseResourcesFence only gates the release of the RHI resources of RenderData; it does not protect the proxy’s render-thread access to the RenderData object itself (e.g. RenderData->LODResources, which is plain object data, not RHI). Is that reading correct? If so, what actually prevents the proxy from dereferencing a freed RenderData* in the SetStaticMesh swap case?

More generally: is the intended invariant simply “a scene proxy may keep raw pointers into a mesh only for as long as the owning component keeps a strong UPROPERTY reference to that mesh, and the proxy is guaranteed to be destroyed before that UPROPERTY reference is dropped”? If that’s the invariant, SetStaticMesh/SetAsset-style runtime swaps break it by dropping the UPROPERTY reference while the proxy still holds raw pointers — should such components instead hold an independent strong reference (e.g. a TSharedPtr/TRefCountPtr to the render data) across the swap?

[Attachment Removed]

Hello,

Your ideas are essentially correct, and the missing piece in the SetStaticMesh case is an explicit pre-GC flush. All line numbers below are from 5.6.

1) The ordering guarantee in the runtime SetStaticMesh path

You are right that SetStaticMesh (StaticMeshComponent.cpp:2265) only calls SetStaticMeshInternal (:2285) plus MarkRenderStateDirty (:2306), which defers to an end-of-frame recreate via MarkForNeededEndOfFrameRecreate (ActorComponent.cpp:2625,:2636). There is no flush there.

The guarantee comes from garbage collection itself. FCoreUObjectDelegates::GetPreGarbageCollectDelegate is broadcast at the start of CollectGarbage, before reachability analysis runs (GarbageCollection.cpp:5531). UEngine::PreGarbageCollect (UnrealEngine.cpp:1768) is bound to it and calls SendWorldEndOfFrameUpdates, which calls World->SendAllEndOfFrameUpdates for every world (UnrealEngine.cpp:1764). The comment there describes exactly your scenario: “Make sure deferred component updates have been sent to the rendering thread before deleting any UObjects which the rendering thread may be referencing. This fixes rendering thread crashes in the following order of operations 1) UMeshComponent::SetMaterial 2) GC 3) Rendering command that dereferences the UMaterial” (UnrealEngine.cpp:1762-1763).

So before GC can ever call BeginDestroy on the now-unreachable old mesh, the deferred recreate has already run on the game thread. That recreate goes through DestroyRenderState_Concurrent into FScene::RemovePrimitive, which enqueues FRemovePrimitiveCommand (RendererScene.cpp:2026), with the comment “must run RT cmds in order here too”. The render command queue is consumed strictly in order, so the proxy teardown executes on the render thread before any command that UStaticMesh::ReleaseResources enqueues later. The proxy is actually deleted on the render thread in UpdateAllPrimitiveSceneInfos (RendererScene.cpp:6443).

So it is not relying on GC periodicity or on swaps being rare. It is an intentional ordering guarantee: deferred render state updates are always flushed before GC destroys anything.

2) What the fence actually protects

Your reading is correct. UStaticMesh::BeginDestroy calls GetRenderData()->ReleaseResources() and then ReleaseResourcesFence.BeginFence() (StaticMesh.cpp:4957-4960), and IsReadyForFinishDestroy blocks on ReleaseResourcesFence.IsFenceComplete() (StaticMesh.cpp:5826). That fence directly gates only the RHI resource release. No FinishDestroy override frees RenderData, and ~UStaticMesh is empty; the CPU-side FStaticMeshRenderData (LODResources and friends) is freed by the TUniquePtr member destructor when the UStaticMesh memory is finally deleted, after FinishDestroy.

But the fence still provides the protection you are asking about, indirectly. FRenderCommandFence completion means the render thread has consumed every command enqueued before BeginFence. Because the proxy teardown command was enqueued before BeginDestroy ran (as guaranteed by the pre-GC flush above), fence completion implies that the proxy is already gone. So by the time FinishDestroy can run, and the RenderData can be freed, no proxy holds a pointer into it. The protection is the FIFO command ordering plus the fence gating FinishDestroy, not the RHI release itself.

3) The invariant

Yes, the intended contract is the one you stated: a proxy may hold raw pointers into an asset’s render data because the proxy’s render-thread teardown is always enqueued before the asset can begin destruction. The asset’s FinishDestroy is fenced behind the render thread catching up. SetStaticMesh does not break it, because of the pre-GC flush. Nothing else roots the old mesh across the swap: FScene and FPrimitiveSceneInfo do not AddReferencedObjects to the mesh, consistent with the rule that “The rendering thread must not dereference UObjects” for game-thread state (PrimitiveSceneProxy.h:439). No extra TSharedPtr/TRefCountPtr is needed for the standard component paths.

The one real hazard is code that steps outside this pattern: if you hand a proxy a new pointer via a custom render command, or free render data yourself without going through the MarkRenderStateDirty / recreate path, none of the above applies, and you need your own fencing. As long as pointer swaps only ever happen by recreating the proxy, the engine’s ordering covers you.

[Attachment Removed]