mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3401 字
10 分钟
UE5 渲染管线 01:线程模型、Scene Proxy 与一帧延迟
2026-07-16
NOTE

系列导航:00 总览01 线程与 Scene Proxy02 InitViews 与 Mesh Drawing Pipeline。版本基线为 UE 5.8;私有 Renderer 实现以目标源码分支为准。

先给结论#

UE5 把 Gameplay 世界和渲染世界分开维护:

  • Game Thread 拥有 Actor、Component 和绝大多数 UObject 状态;
  • Render Thread 面向 FSceneFPrimitiveSceneProxyFPrimitiveSceneInfo 和每个 View 的渲染数据;
  • RHI Thread 或并行 RHI 路径把高层渲染命令翻译、整理并提交给图形 API;
  • GPU 执行的通常已经是更早一时刻准备好的工作。

这种分离让 Game Thread 和 Render Thread 能并行推进,但也带来三条硬约束:

  1. Render Thread 不应直接追着读取会被 Game Thread 修改或回收的 UObject。
  2. 跨线程更新必须显式排队,并把执行时需要的数据复制到安全的所有者中。
  3. “本帧 Gameplay 已经改变”不等于“显示器上的当前图像已经包含该改变”。

四条执行通道,不是四步串行流程#

sequenceDiagram autonumber participant GT as Game Thread participant RT as Render Thread participant RHI as RHI Thread / Submit Path participant GPU as GPU GT->>GT: Tick Frame N GT->>RT: 排队场景更新与 ViewFamily N par 跨帧并行 GT->>GT: 准备 Frame N+1 RT->>RT: 构建 Render Frame N RHI->>RHI: 翻译/提交更早的命令 GPU->>GPU: 执行更早的 GPU Frame end RT->>RHI: RHI Command Lists RHI->>GPU: D3D12 / Vulkan / Metal 提交

图中的 Frame 编号用于表达数据世代,不保证各阶段永远恰好只差一帧。Editor、平台 RHI、VSync、Present 队列、GPU 饱和、r.OneFrameThreadLag 和低延迟模式都会改变排队深度。

所有权速查表#

数据 / 对象主要所有者生命周期其他线程怎样使用
AActorUActorComponentGame ThreadGameplay 生命周期不应由 Render Thread 随意解引用
UPrimitiveComponent 的变换、可见性、配置Game ThreadComponent 生命周期通过标脏和 Scene 更新同步
FPrimitiveSceneProxy创建阶段从 Component 复制数据,之后由 Renderer 管理Render State 生命周期通过有序 Render Command 更新
FPrimitiveSceneInfoRender Thread / FScenePrimitive 在 Scene 中注册期间维护 Scene 索引、可见性和绘制相关连接
FSceneViewFamily / FSceneView每次渲染请求构建通常按帧 / View FamilyRenderer 转成内部 FViewInfo 使用
FSceneViewState 一类持久 View 状态Renderer 侧持久状态跨帧保存遮挡与时域历史等状态
RHI ResourceRHI / Render Resource 生命周期显式初始化、释放通过 RHI Command List 访问
RDG Resource当前 Graph一次 Graph 执行跨 Graph 必须 Externalize / Extract

这里最重要的不是“构造函数在哪条线程运行”,而是对象进入并行阶段后由谁读写、由谁负责销毁。某些对象会在 Game Thread 上构造或暂存,然后通过命令移交给 Render Thread;不能据此推断它仍可被两个线程自由访问。

Primitive 注册:Component 怎样进入 FScene#

以一个普通可渲染 UPrimitiveComponent 为例,稳定的概念链路如下:

flowchart TD A[Component 注册或 Render State 重建] B[UPrimitiveComponent::CreateRenderState_Concurrent] C[CreateSceneProxy] D[构造 FPrimitiveSceneProxy] E[FSceneInterface::AddPrimitive] F[向 Render Thread 排队] G[FScene 接纳 Primitive] H[FPrimitiveSceneInfo 与 Scene 索引] I[Octree / GPU Scene / Draw Command Cache 等] A --> B --> C --> D --> E --> F --> G --> H --> I

在不同版本中,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 DirtyMarkRenderTransformDirty()位置、旋转、缩放、Bounds通常最小,不应重建整个 Proxy
Dynamic Data DirtyMarkRenderDynamicDataDirty()每帧动画参数、自定义动态数据组件负责把更新数据发送给 Proxy
Render State DirtyMarkRenderStateDirty()材质槽、渲染路径、会改变 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 有一帧延迟”不是单一机制,而是多段队列与时域处理的合成:

flowchart LR A[输入采样] --> B[Game Thread 模拟] B --> C[Render Thread 构建] C --> D[RHI / Driver Queue] D --> E[GPU 执行] E --> F[Present / VSync] F --> G[显示扫描]

可能增加延迟的因素包括:

  • 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:

序号符号观察内容
1UPrimitiveComponent::CreateRenderState_Concurrent什么条件下 Component 真正加入 Scene?
2具体组件的 CreateSceneProxyProxy 构造时复制了哪些值?
3FScene::AddPrimitive / 批量变体哪些工作在 GT 暂存,哪个点排队到 RT?
4Render Thread 侧 AddPrimitiveSceneInfo...Scene 索引、空间结构、GPU Scene 更新在哪里发生?
5SendRenderTransform_Concurrent只移动组件时走的更新路径是什么?
6SendRenderDynamicData_Concurrent动态数据怎样复制进 Proxy?
7DestroyRenderState_Concurrent移除与 Proxy 销毁怎样排序?
8UGameViewportClient::DrawView Family 与 View 在哪里建立?
9BeginRenderingViewFamily...哪个点跨入 Render Thread?
10FDeferredShadingSceneRenderer::Render当前 View Family 的主渲染入口是什么?

每次命中记录:当前线程名、Game Frame / Render Frame 编号、Component / Proxy 地址、调用栈。没有这四项,截图很难支持线程时序结论。

四个可证伪实验#

H1:普通 Transform 更新不会重建 Scene Proxy#

步骤:

  1. 给一个 UStaticMeshComponent 固定材质,记录其 Proxy 地址。
  2. 在运行时仅调用 SetWorldTransform,连续移动 120 帧。
  3. CreateSceneProxySendRenderTransform_Concurrent 和 Proxy 析构处计数。

预期:Transform 更新路径持续命中,Proxy 地址保持稳定;若每帧重建,应检查组件逻辑是否额外标记 Render State Dirty。

H2:Render State Dirty 会触发 Proxy 生命周期变化#

步骤:

  1. 保持组件 Transform 不变。
  2. 修改确实影响 Render State 的属性,或在测试代码中调用一次 MarkRenderStateDirty()
  3. 观察 Destroy / Create Render State、Scene Remove / Add 和 Proxy 地址。

预期:旧 Proxy 被移除,新 Proxy 建立;具体发生在同一 Game Frame 还是随后 Render Frame,以 Trace 为证。

H3:Render Thread 不需要回读 Component 的动态字段#

步骤:

  1. 在自定义 Proxy 构造和动态数据发送路径记录被复制的字段。
  2. 在 Proxy 的渲染函数中检查读取来源。
  3. 搜索 Proxy 内保存的 UObject 指针,并逐个证明只读、生命周期和线程安全。

预期:真正用于渲染的动态值已复制到 Proxy / Render Resource;若 Render Thread 直接解引用可变 Component,应视为待修复设计。

H4:允许线程滞后会改变 CPU 排队,但不等于固定显示延迟#

步骤:

  1. 固定场景、帧率策略、VSync 与分辨率,采集一段 Unreal Insights Trace。
  2. 分别测试 r.OneFrameThreadLag 10
  3. 对比 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 时间线看并行与等待,而不是只看平均毫秒数
FrameGameFrameRenderingFrame 事件对齐不同数据世代
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 就是 GPURender Thread 是 CPU 线程;GPU 执行的是后续提交的工作
Scene Proxy 是 Component 的线程安全指针Proxy 是 Renderer 专用表示,不应借它跨线程回读任意 Component 状态
所有改动都调用 MarkRenderStateDirtyTransform、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

官方资料#

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

UE5 渲染管线 01:线程模型、Scene Proxy 与一帧延迟
https://example.pages.dev/posts/ue5-rendering-pipeline/01-threading-scene-proxy/
作者
博主
发布于
2026-07-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录