渲染线程模型那篇讲过 GameThread 和 RenderThread 的队列边界。这篇往下一层:一个 AActor 挂上 UStaticMeshComponent 之后,它的数据具体是怎么流到 GPU 的。这条链路涉及三个关键对象——FPrimitiveSceneInfo、FPrimitiveSceneProxy、FGPUScene,代码在 Engine/Source/Runtime/Renderer/Private/PrimitiveSceneInfo.cpp、Renderer/Private/SceneProxyRendering.cpp(实际在 Renderer/Private/StaticMeshResources.cpp 附近,核心定义在 Renderer/Public/PrimitiveSceneProxy.h)和 Renderer/Private/GPUScene.cpp(UE5.3)。
为什么需要 SceneProxy:线程安全的隔离层
GameThread 上的 UStaticMeshComponent 不能直接被渲染线程碰——UObject 归 GC 管,随时可能因为在 GameThread 上被置空引用而销毁。渲染线程需要一个自己的、不随 GC 消失的快照,这就是 FPrimitiveSceneProxy。
注册发生在组件被场景接纳时(UActorComponent::RegisterComponentWithWorld → FScene::AddPrimitive,Scene.cpp):
- GameThread 调
UPrimitiveComponent::CreateSceneProxy(),构造一个 proxy 对象(静态网格是FStaticMeshSceneProxy)。这个构造是同步的,proxy 深拷贝了渲染需要的所有数据——LOD 信息、材质接口指针(UObject 指针,渲染线程只读不持有所有权)、包围体; - GameThread 把
{UPrimitiveComponent*, FPrimitiveSceneProxy*, ...}打包成FPrimitiveSceneInfo,通过渲染命令发给 RenderThread; - RenderThread 在自己的
FScene::Primitives数组里登记,分配PrimitiveId——这个数组下标就是全渲染器的对象身份,后面所有 pass 都拿它索引。
Proxy 的生命周期由渲染线程自己管理:GameThread 上组件销毁时只发一个"移除"命令,proxy 的 delete 发生在 RenderThread 排空所有引用它的工作之后。GC 随便什么时候回收 UObject,渲染这边永远握着自己的拷贝。
延迟注册与数据竞争的一个坑
CreateSceneProxy 发生在 GameThread,但 proxy 读的是组件的当前状态(比如材质数组)。如果此时渲染线程正在画上一帧、正读到这个 proxy——不会,因为新 proxy 还没注册进 FScene,渲染线程根本看不见它。真正的坑在更新路径:GameThread 改了组件属性(换材质、改 transform),通过 SendRenderTransform / SendRenderDynamicData 发增量更新。这些命令在队列里排队,RenderThread 可能延迟若干帧才执行——所以 GameThread 上"我改了"和画面上"变了"之间没有同步保证,做多帧精确的时序联动(比如第 N 帧必须换材质)不能靠连续赋值,要靠事件回调确认。
GPU Scene:一张大缓冲统管一切
Renderer 需要的 primitive 数据(LocalToWorld 矩阵、包围盒、LOD 参数、自定义数据)如果每个 pass 各自上传,带宽和同步都是灾难。UE5 的做法是收敛成一张结构体大数组(FGPUScenePrimitiveData,96 字节一个元素,对齐到 GPU 友好),存进一个全局 UAV 缓冲,所有 pass 按 PrimitiveId 索引它:
FScene::Primitives (RenderThread 侧数组)
│ index = PrimitiveId
▼
FGPUScenePrimitiveData[PrimitiveId] ← GPU 大缓冲
│ 增量更新:UpdateBuffer 路径
▼
所有渲染 pass(DepthPass / BasePass / Shadow / Nanite ...)
这就是"GPU Scene"这个名字的来源——场景状态本身成了一张 GPU 资源,pass 之间共享。GPUScene.cpp 的 FGPUScene::Update 每帧做两件事:
- 收集脏 primitive:RenderThread 上被标记 dirty 的项(transform 变了、LOD 切换了)收进更新列表;
- 合并上传:把脏项的结构体数据打包进 staging buffer,一次
RHIUpdateBuffer(或 compute 上传路径)写进大缓冲。
脏标记的粒度是整个 primitive 结构体(96 字节全量重写),不做字段级 diff——一次更新 96 字节,一万个动的物体也就 ~1MB 上传量,字段级 diff 的收益抵不上 bookkeeping 成本。这个"全量小块更新"的折中很典型:用带宽换简单性。
Nanite 与 GPU Scene 的关系
Nanite 实例(Nanite::FScene)的数据也挂在 GPU Scene 的 instance payload 区。每个 Nanite 实例引用一个 FPackedCluster 流送单元,它的 transform、裁剪数据通过同一个 FGPUScenePrimitiveData 通道更新——所以 Nanite 没有自己独立的上传管线,它是 GPU Scene 的一个消费者。这也解释了为什么 Nanite 场景里移动一个静态网格的成本和非 Nanite 一样:都是一次 96 字节的增量更新,差别只在绘制侧。
性能要点与排查
- AddPrimitive/RemovePrimitive 是重操作。它触发渲染命令分配、数组重排(PrimitiveId 的分配走空闲列表)、GPU Scene 槽位分配。运行时频繁 Add/Remove(程序化生成的碎片、弹幕类物体)会在这里花掉可观的 RenderThread 时间。
Stat SceneRendering看AddToSceneTime/RemoveFromSceneTime;优化套路是池化——预先注册一堆隐藏 primitive,用时改 visibility 而不是注册新对象; - Visible 切换便宜。
SetVisibility只改一个标志位(通过渲染命令),不碰 GPU Scene 缓冲,也不重新分配。性能敏感代码里"隐藏 vs 删除"永远选隐藏; - LOD 切换走的数据路径。
Compute LOD在 RenderThread 算出后写进 GPU Scene 的 LOD 字段,传给后续 pass 做 per-instance 的 LOD 掩码(Nanite 走 DAG 切割,非 Nanite 走传统的MinLOD/MaxLOD段选择)。LOD 抖动问题(远处物体在两个 LOD 间来回跳)源头在 ScreenSize 计算的阈值 hysteresis 不够,调r.ForceLOD定位是数据问题还是阈值问题。
源码入口
- 注册主流程:
Scene.cpp的FScene::AddPrimitive,看它如何分配 PrimitiveId、初始化FPrimitiveSceneInfo; - Proxy 基类:
PrimitiveSceneProxy.h,重点看GetViewRelevance(决定这个 primitive 进哪些 pass)和DrawStaticElements(静态路径的提交); - GPU Scene:
GPUScene.cpp的FGPUScene::Update和AddPrimitiveToGPUScene,增量上传的 staging 逻辑在这里。
这条链路的精髓在"身份"的设计:PrimitiveId 一个整数贯穿 RenderThread 数组、GPU 大缓冲、所有 pass 的索引,没有任何一层需要回头查 UObject。游戏引擎渲染器的高性能,多半来自这种"一旦注册就再也不回头"的数据流纪律。