NOTE系列导航:00 总览 → 01 线程与 Scene Proxy → 02 InitViews 与 Mesh Drawing Pipeline。版本基线为 UE 5.8;私有 Renderer 实现以目标源码分支为准。
先给结论
UE5 把 Gameplay 世界和渲染世界分开维护:
- Game Thread 拥有 Actor、Component 和绝大多数 UObject 状态;
- Render Thread 面向
FScene、FPrimitiveSceneProxy、FPrimitiveSceneInfo和每个 View 的渲染数据; - RHI Thread 或并行 RHI 路径把高层渲染命令翻译、整理并提交给图形 API;
- GPU 执行的通常已经是更早一时刻准备好的工作。
这种分离让 Game Thread 和 Render Thread 能并行推进,但也带来三条硬约束:
- Render Thread 不应直接追着读取会被 Game Thread 修改或回收的 UObject。
- 跨线程更新必须显式排队,并把执行时需要的数据复制到安全的所有者中。
- “本帧 Gameplay 已经改变”不等于“显示器上的当前图像已经包含该改变”。
四条执行通道,不是四步串行流程
图中的 Frame 编号用于表达数据世代,不保证各阶段永远恰好只差一帧。Editor、平台 RHI、VSync、Present 队列、GPU 饱和、r.OneFrameThreadLag 和低延迟模式都会改变排队深度。
所有权速查表
| 数据 / 对象 | 主要所有者 | 生命周期 | 其他线程怎样使用 |
|---|---|---|---|
AActor、UActorComponent | Game Thread | Gameplay 生命周期 | 不应由 Render Thread 随意解引用 |
UPrimitiveComponent 的变换、可见性、配置 | Game Thread | Component 生命周期 | 通过标脏和 Scene 更新同步 |
FPrimitiveSceneProxy | 创建阶段从 Component 复制数据,之后由 Renderer 管理 | Render State 生命周期 | 通过有序 Render Command 更新 |
FPrimitiveSceneInfo | Render Thread / FScene | Primitive 在 Scene 中注册期间 | 维护 Scene 索引、可见性和绘制相关连接 |
FSceneViewFamily / FSceneView | 每次渲染请求构建 | 通常按帧 / View Family | Renderer 转成内部 FViewInfo 使用 |
FSceneViewState 一类持久 View 状态 | Renderer 侧持久状态 | 跨帧 | 保存遮挡与时域历史等状态 |
| RHI Resource | RHI / Render Resource 生命周期 | 显式初始化、释放 | 通过 RHI Command List 访问 |
| RDG Resource | 当前 Graph | 一次 Graph 执行 | 跨 Graph 必须 Externalize / Extract |
这里最重要的不是“构造函数在哪条线程运行”,而是对象进入并行阶段后由谁读写、由谁负责销毁。某些对象会在 Game Thread 上构造或暂存,然后通过命令移交给 Render Thread;不能据此推断它仍可被两个线程自由访问。
Primitive 注册:Component 怎样进入 FScene
以一个普通可渲染 UPrimitiveComponent 为例,稳定的概念链路如下:
在不同版本中,Scene Info 的具体创建时机、批量 Add/Remove、GPU Scene 更新和缓存更新函数会调整。因此源码阅读时按符号追踪:
UPrimitiveComponent::CreateRenderState_Concurrent → UPrimitiveComponent::CreateSceneProxy(虚函数) → FSceneInterface::AddPrimitive → FScene::AddPrimitive / AddPrimitives → Render Thread 侧 AddPrimitiveSceneInfo...三个对象分别负责什么
UPrimitiveComponent
它是 Gameplay / Engine 对象,知道组件注册、Attachment、碰撞、编辑器属性和渲染配置。它的职责是决定“我的渲染状态需要创建或更新”,而不是亲自参加每个 GPU Pass。
FPrimitiveSceneProxy
它是 Component 面向 Renderer 的代理:保存 Renderer 需要的快照或专用数据,并提供包围盒、材质相关性、静态/动态 Mesh Element、View Relevance 等信息。自定义 Primitive 最常见的错误,就是让 Proxy 保存指向可变 UObject 数据的裸引用,然后在 Render Thread 读取。
FPrimitiveSceneInfo
它更接近 FScene 内部的管理节点:把 Proxy 接入 Scene 的索引、空间结构、GPU Scene、可见性与 Draw Command 缓存体系。粗略地说:
Proxy 描述“这个 Primitive 怎样参与渲染”SceneInfo 描述“这个 Primitive 在当前 FScene 里怎样被管理”两者不是同义词。
三种标脏,不要一律重建 Render State
Component 改变后,UE 提供不同粒度的同步意图。具体实现会按组件类型优化,但语义可以这样区分:
| 意图 | 常见入口 | 适合变化 | 相对成本 |
|---|---|---|---|
| Transform Dirty | MarkRenderTransformDirty() | 位置、旋转、缩放、Bounds | 通常最小,不应重建整个 Proxy |
| Dynamic Data Dirty | MarkRenderDynamicDataDirty() | 每帧动画参数、自定义动态数据 | 组件负责把更新数据发送给 Proxy |
| Render State Dirty | MarkRenderStateDirty() | 材质槽、渲染路径、会改变 Proxy 结构的配置 | 通常销毁并重建 Render State / Proxy,最重 |
典型更新链可抽象为:
Game Thread 修改 Component ├─ Transform Dirty │ └─ SendRenderTransform_Concurrent → Scene UpdatePrimitiveTransform ├─ Dynamic Data Dirty │ └─ SendRenderDynamicData_Concurrent → 自定义 Proxy 更新命令 └─ Render State Dirty └─ DestroyRenderState + CreateRenderState → Proxy 移除与重建WARNING函数名带
_Concurrent不表示可以从任意线程随意调用,也不表示内部没有排队。它通常表示组件注册/更新框架允许该阶段并行处理,并且实现必须遵守对应线程契约。调用点仍应以 API 注释和源码检查为准。
为什么 MarkRenderStateDirty() 不能每 Tick 滥用
完整 Render State 重建可能触发:
- 旧 Primitive 从 Scene 移除;
- Cached Mesh Draw Command 和可见性相关状态失效;
- 新 Proxy / Scene Info 建立;
- GPU Scene、Ray Tracing Scene、Nanite / Lumen / Shadow 相关表示更新;
- Render Thread 与内存分配压力上升。
如果变化只是一组数值,让 Proxy 接收紧凑 Dynamic Data,通常比每帧重建代理合理。是否值得定制更新路径,应由 Insights 中的实际成本决定。
跨线程命令:安全来自复制和有序生命周期
典型模式是 Game Thread 读取 UObject 状态,把 Render Thread 真正需要的值复制出来,然后排队一个渲染命令:
// 仅示意所有权,不是可直接粘贴的完整组件实现。const FVector3f SafeValue = FVector3f(ComponentOwnedValue);FMySceneProxy* Proxy = static_cast<FMySceneProxy*>(SceneProxy);
ENQUEUE_RENDER_COMMAND(UpdateMyProxy)( [Proxy, SafeValue](FRHICommandListImmediate& RHICmdList) { Proxy->SetValue_RenderThread(SafeValue); });这个模式成立需要额外前提:
SafeValue是值拷贝,不再依赖 Component 内存;- Proxy 的删除命令与更新命令进入可证明的有序命令流;
- 命令不是从多个生产线程无约束竞争;
SetValue_RenderThread只修改 Render Thread 拥有的数据;- 如果生命周期无法用命令顺序证明,就使用明确的引用所有权、Release 流程或 Fence。
下面这种写法风险很高:
ENQUEUE_RENDER_COMMAND(UnsafeUpdate)( [this](FRHICommandListImmediate& RHICmdList) { // 执行时 UObject 可能已改变、Pending Kill,甚至被回收。 SceneProxy->SetValue_RenderThread(ComponentOwnedValue); });TWeakObjectPtr 也不会自动让 UObject 变成跨线程可安全读取的数据;对象有效性检查与对象字段的并发读写安全是两件事。
删除、Fence 与 Flush
Component 注销或销毁时,Game Thread 发起从 Scene 移除;Render Thread 按命令顺序清理 Scene Info、缓存和 Proxy。因为两个线程不同步,Game Thread 对象的析构不应假设 Render Thread 已经立即完成删除。
常见同步工具:
| 工具 | 语义 | 使用原则 |
|---|---|---|
FRenderCommandFence::BeginFence() | 在当前已排队渲染命令之后放置 Fence | 用于证明此前工作已经越过某个点 |
FRenderCommandFence::Wait() | Game Thread 等待 Fence 完成 | 会阻塞;只在生命周期确实需要时用 |
FlushRenderingCommands() | 等待已排队渲染命令完成 | 重型同步,不能作为日常线程安全补丁 |
BeginInitResource / BeginReleaseResource | 按 Render Resource 生命周期排队初始化或释放 | 对应 FRenderResource,遵循成对生命周期 |
“加一个 Flush 就不崩了”只能说明存在竞态或生命周期错误,不代表 Flush 是正确最终方案。它会抹掉 Game / Render 并行性,并可能把偶发卡顿带进运行时。
一帧延迟到底来自哪里
“UE 有一帧延迟”不是单一机制,而是多段队列与时域处理的合成:
可能增加延迟的因素包括:
- Render Thread 被允许落后于 Game Thread;
- CPU 提交速度高于 GPU 消费速度,Driver / GPU Queue 变深;
- VSync 或显示交换链排队;
- TSR、TAA、Lumen 等使用历史帧,但“使用历史”本身不等于固定增加完整一帧输入延迟;
- Frame Generation 会改变显示帧节奏,需要单独分析输入到显示的延迟。
r.OneFrameThreadLag 控制是否允许 Render Thread 相对 Game Thread 保持一帧线程滞后。把它设为 0 可用于实验低延迟与 CPU 等待的取舍,但不能由此假定整条输入到显示链路只剩零帧延迟。
View Family:场景同步之后,谁描述“这次看什么”
Primitive 注册描述“世界里有什么”;View Family 描述“这次怎样看这个世界”。主视口的一次 Draw 大致经过:
UGameViewportClient::Draw → 建立 FSceneViewFamilyContext → LocalPlayer::CalcSceneView 等建立 FSceneView → View Extension 设置 / 更新 → RendererModule.BeginRenderingViewFamily / ViewFamilies → Render Thread 创建具体 FSceneRenderer → FDeferredShadingSceneRenderer::Render同一个 FScene 可以被主视口、分屏、Stereo View、Scene Capture、Reflection Capture 等不同 View Family 渲染。因而“世界 Tick 一次”与“Renderer 只渲染一个 View”没有必然关系。
FSceneViewState 一类对象保存跨帧 View 状态,例如遮挡查询和时域历史所需的信息。排查 TSR、Lumen 或 Occlusion 的历史问题时,要同时问:
- View State 是否持久?
- 相机 Cut 是否使历史失效?
- 分辨率或 View Rect 是否变化?
- 这是不是 Scene Capture 或没有持久 View State 的 View?
源码断点路线
有 UE 源码时,第一次跟读只设下面这些断点,不要先追进所有模板和 Task:
| 序号 | 符号 | 观察内容 |
|---|---|---|
| 1 | UPrimitiveComponent::CreateRenderState_Concurrent | 什么条件下 Component 真正加入 Scene? |
| 2 | 具体组件的 CreateSceneProxy | Proxy 构造时复制了哪些值? |
| 3 | FScene::AddPrimitive / 批量变体 | 哪些工作在 GT 暂存,哪个点排队到 RT? |
| 4 | Render Thread 侧 AddPrimitiveSceneInfo... | Scene 索引、空间结构、GPU Scene 更新在哪里发生? |
| 5 | SendRenderTransform_Concurrent | 只移动组件时走的更新路径是什么? |
| 6 | SendRenderDynamicData_Concurrent | 动态数据怎样复制进 Proxy? |
| 7 | DestroyRenderState_Concurrent | 移除与 Proxy 销毁怎样排序? |
| 8 | UGameViewportClient::Draw | View Family 与 View 在哪里建立? |
| 9 | BeginRenderingViewFamily... | 哪个点跨入 Render Thread? |
| 10 | FDeferredShadingSceneRenderer::Render | 当前 View Family 的主渲染入口是什么? |
每次命中记录:当前线程名、Game Frame / Render Frame 编号、Component / Proxy 地址、调用栈。没有这四项,截图很难支持线程时序结论。
四个可证伪实验
H1:普通 Transform 更新不会重建 Scene Proxy
步骤:
- 给一个
UStaticMeshComponent固定材质,记录其 Proxy 地址。 - 在运行时仅调用
SetWorldTransform,连续移动 120 帧。 - 在
CreateSceneProxy、SendRenderTransform_Concurrent和 Proxy 析构处计数。
预期:Transform 更新路径持续命中,Proxy 地址保持稳定;若每帧重建,应检查组件逻辑是否额外标记 Render State Dirty。
H2:Render State Dirty 会触发 Proxy 生命周期变化
步骤:
- 保持组件 Transform 不变。
- 修改确实影响 Render State 的属性,或在测试代码中调用一次
MarkRenderStateDirty()。 - 观察 Destroy / Create Render State、Scene Remove / Add 和 Proxy 地址。
预期:旧 Proxy 被移除,新 Proxy 建立;具体发生在同一 Game Frame 还是随后 Render Frame,以 Trace 为证。
H3:Render Thread 不需要回读 Component 的动态字段
步骤:
- 在自定义 Proxy 构造和动态数据发送路径记录被复制的字段。
- 在 Proxy 的渲染函数中检查读取来源。
- 搜索 Proxy 内保存的 UObject 指针,并逐个证明只读、生命周期和线程安全。
预期:真正用于渲染的动态值已复制到 Proxy / Render Resource;若 Render Thread 直接解引用可变 Component,应视为待修复设计。
H4:允许线程滞后会改变 CPU 排队,但不等于固定显示延迟
步骤:
- 固定场景、帧率策略、VSync 与分辨率,采集一段 Unreal Insights Trace。
- 分别测试
r.OneFrameThreadLag 1与0。 - 对比 Game / Render Thread 的 Frame 事件、等待时间、总 CPU 帧时间和输入到 Present 标记。
预期:关闭线程滞后可能增加 Game Thread 等待、降低 CPU 并行余量;端到端延迟的变化还取决于 GPU 和 Present 队列,不能只凭 Game / Render Frame 差得出结论。
Unreal Insights 采集表
建议从 Editor 的 Trace 菜单启动 CPU、Frame、GPU、Bookmark 等相关 Channel;命令行 Channel 名会随版本调整,应从当前版本 Trace UI 或官方文档确认。
| 记录项 | 目的 |
|---|---|
| Game Thread / Render Thread / RHI Thread 时间线 | 看并行与等待,而不是只看平均毫秒数 |
Frame、GameFrame、RenderingFrame 事件 | 对齐不同数据世代 |
| TaskGraph 任务 | 判断 Scene 更新是否被批处理或并行化 |
| Component 更新 Bookmark | 把 Gameplay 事件与 Render 更新关联 |
CreateSceneProxy / Dynamic Data 自定义 Scope | 验证重建次数和成本 |
| GPU Frame / Present | 估算 CPU 队列之外的延迟来源 |
结果记录模板:
UE 分支 / Commit:平台 / RHI:构建配置:VSync / 帧率上限:r.OneFrameThreadLag:
Game Frame:Render Frame:Proxy Create 次数:Transform Update 次数:Render State Recreate 次数:Game 等待点:Render 等待点:GPU / Present 排队观察:结论与反例:常见误区
| 误区 | 更准确的说法 |
|---|---|
| Render Thread 就是 GPU | Render Thread 是 CPU 线程;GPU 执行的是后续提交的工作 |
| Scene Proxy 是 Component 的线程安全指针 | Proxy 是 Renderer 专用表示,不应借它跨线程回读任意 Component 状态 |
所有改动都调用 MarkRenderStateDirty | Transform、Dynamic Data、完整 Render State 有不同更新粒度 |
FlushRenderingCommands 能修复线程安全 | 它只能强制同步;根因仍是所有权、生命周期或命令排序 |
| Render Thread 永远恰好落后一帧 | 排队深度由配置和负载决定,“一帧”只是常见允许策略 |
| 一个 Game Tick 对应一个 View | 一个 Scene 可产生多个 View / View Family 和额外 Capture |
本篇建立的心智模型
Gameplay 状态 └─ Component 判断更新粒度 ├─ Transform Update ├─ Dynamic Data Update └─ Render State Recreate ↓ 有序跨线程同步Renderer Scene └─ Proxy + SceneInfo + GPU Scene / Draw Caches ↓ 每个 View Family 读取SceneRenderer 构建本次帧图 ↓RHI Submit → GPU → Present下一篇将沿着 FScene 和 View 继续进入 InitViews:Primitive、Instance 和 Nanite Cluster 分别在哪里做可见性判断,FMeshBatch 又怎样变成 FMeshDrawCommand。
官方资料
如果这篇文章对你有帮助,欢迎分享给更多人!
部分信息可能已经过时






