管线与渲染线程
自动关联目录:管线与渲染线程
UE 的渲染不是"一个函数画一帧",而是三条线程流水作业:游戏线程算逻辑、渲染线程生成命令、RHI 线程提交给 GPU。它们之间允许错开一帧,这是吞吐与延迟之间的核心取舍。
一句话定位:理解帧流水线,才能解释"为什么我改了代码但这一帧没变""为什么 stat unit 里 Game 不高但帧率还是低"。这是整个 Rendering 分支的地基。
1. 定位
本页回答三个问题:
- 一帧是怎么从逻辑变成 GPU 命令的
- 三条线程之间在哪里同步,同步的代价是什么
- 写渲染相关代码时必须遵守哪些线程规则
不回答:具体 Pass 怎么实现(那是 RDG 渲染图 及各特性篇的事),以及 GPU 上发生了什么(那是 Base Knowledge / Computer Graphics)。
2. 结构
三条线程
时间轴 ─────────────────────────────────────────────►
游戏线程 [第 N 帧 Tick] [第 N+1 帧 Tick] [第 N+2 帧 Tick]
│ EndFrame
▼
渲染线程 [第 N 帧 可见性+Pass] [第 N+1 帧]
│ 提交命令
▼
RHI 线程 [第 N 帧 提交] [第 N+1 帧]
▼
GPU [第 N 帧 执行]| 线程 | 负责 | 能碰什么 |
|---|---|---|
| 游戏线程 | Tick、动画、物理、蓝图、UI 逻辑 | UObject / Actor / UWorld |
| 渲染线程 | 可见性剔除、Pass 编排、生成渲染命令 | FScene、FPrimitiveSceneProxy、FRenderResource |
| RHI 线程 | 把命令翻译成具体图形 API 调用 | RHI 资源与命令队列 |
| TaskGraph Worker | 并行子任务(剔除、动画、RDG 编译) | 由任务定义,通常只读快照 |
最关键的一条规则:渲染线程不能碰 UObject。UObject 归游戏线程与 GC 管,跨线程访问既是数据竞争也可能读到正在被销毁的对象。
两侧的数据交换靠 Scene Proxy:
游戏线程 渲染线程
UPrimitiveComponent ──► FPrimitiveSceneProxy
(UObject,可改) (纯数据快照,只读渲染用)
│ MarkRenderStateDirty
└──► 重新 CreateSceneProxy,在渲染线程上替换// 组件侧:状态变了要通知渲染线程重建代理
void UMyComp::SetColor(FLinearColor NewColor)
{
Color = NewColor;
MarkRenderStateDirty(); // 触发 CreateSceneProxy
}
FPrimitiveSceneProxy* UMyComp::CreateSceneProxy()
{
return new FMySceneProxy(this); // 在渲染线程上构造,只读拷贝所需数据
}忘记 MarkRenderStateDirty() 的表现:逻辑值变了,画面不变——非常典型。
一帧里的关键步骤
游戏线程
├─ UWorld::Tick(Actor / 组件 / 动画 / 物理)
├─ 更新组件变换 → 推给渲染线程
├─ SendAllEndOfFrameUpdates
└─ UGameViewportClient::Draw → BeginRenderingViewFamily
│
渲染线程
├─ FSceneRenderer 构造(延迟渲染走 FDeferredShadingSceneRenderer)
├─ InitViews:视锥剔除 + 可见性(可并行)
├─ 各 Pass 经 RDG 编排
├─ 生成 RHI 命令列表
└─ 交给 RHI 线程
│
RHI 线程 → 提交 → GPU 执行 → 呈现源码位置:
| 内容 | 路径 |
|---|---|
| 视口与绘制入口 | Engine/Source/Runtime/Engine/Private/UnrealClient.cpp |
| 场景渲染器 | Engine/Source/Runtime/Renderer/Private/SceneRendering.cpp |
| 延迟渲染主流程 | Engine/Source/Runtime/Renderer/Private/DeferredShadingRenderer.cpp |
| 可见性 | Engine/Source/Runtime/Renderer/Private/SceneVisibility.cpp |
| 渲染命令队列 | Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp |
3. 关键 API 与代码
往渲染线程投递命令
ENQUEUE_RENDER_COMMAND(MyCmd)([](FRHICommandListImmediate& RHICmdList)
{
// 这里跑在渲染线程,不能碰 UObject
DoSomethingOnRenderThread(RHICmdList);
});lambda 捕获要小心:捕获裸指针指向的 UObject 是错的(可能已销毁)。要传数据就按值拷贝,要引用对象就用 TWeakObjectPtr 并在内部校验。
栅栏:等待渲染线程追上
FRenderCommandFence Fence;
Fence.BeginFence(); // 投递一个"到了就标记"的命令
Fence.Wait(); // 阻塞游戏线程,直到渲染线程执行到那里| API | 代价 | 何时用 |
|---|---|---|
FRenderCommandFence::Wait | 阻塞游戏线程到该点 | 需要确认之前的渲染命令已完成 |
FlushRenderingCommands() | 阻塞到渲染线程完全追平 | 代价最大,只在必要时(见下) |
BeginReleaseResource / InitResource | 资源生命周期切换 | 创建/销毁渲染资源时 |
// 典型必需场景:从渲染目标回读像素
FlushRenderingCommands(); // 必须先保证 GPU 命令已提交
RHICmdList.ReadSurfaceData(...); // 否则读到的是上一帧或未定义内容FlushRenderingCommands() 是卡顿制造机:它把三条线程的流水硬拉成串行。编辑器工具和截图功能可以用,运行时热路径上出现它基本等于一次掉帧。
渲染资源
class FMyRenderResource : public FRenderResource
{
public:
virtual void InitRHI(FRHICommandListBase& RHICmdList) override { /* 创建 RHI 资源 */ }
virtual void ReleaseRHI() override { /* 释放 */ }
};
// 初始化必须走渲染线程
void Init()
{
Resource = new FMyRenderResource();
BeginInitResource(Resource);
}
void Shutdown()
{
BeginReleaseResource(Resource);
}忘了 BeginReleaseResource 就 delete,会崩在 RHI 层。
4. 参数与控制台变量
| CVar | 默认 | 作用 | 改了会怎样 |
|---|---|---|---|
r.OneFrameThreadLag | 1 | 允许渲染线程比游戏线程落后一帧 | 设为 0 降低延迟但牺牲吞吐,三条线程被拉得更同步 |
r.RenderThread.Enable | 1 | 是否启用独立渲染线程 | 关闭后退化成单线程,编辑器调试偶用 |
r.GTSyncType | 1 | 游戏线程与渲染线程的同步方式 | 影响 stat unit 里 Game/Draw 的归属 |
r.RHIThread.Enable | 1 | 独立 RHI 线程 | 关闭后命令提交回到渲染线程,减少一层并行 |
stat unit | — | 显示 Game / Draw / GPU / RHIT 四项时间 | 排查第一命令 |
stat unitgraph | — | 图形化显示同上 | 看抖动比数字直观 |
stat sceneupdate | — | 场景更新(剔除、变换推送)耗时 | 大量动态物体时看它 |
stat rhi | — | draw call、三角形、渲染目标数 | 判断 draw call 是否过多 |
r.SceneRenderTargetResizeMethod | 0 | 分辨率变化时如何重建 RT | 影响 setres 或窗口缩放时的卡顿 |
r.RenderTargetPoolMin | — | 渲染目标池的最小尺寸 | 调大省重建,占显存 |
版本提示:RHI 线程在 UE 4.22 引入;5.x 上 RHI 线程与 RDG 的并行细节有过调整,具体行为以 stat unit 实测为准。
5. 代价与权衡
| 设计 | 收益 | 代价 | 什么时候不该用 |
|---|---|---|---|
| 三线程流水 | 吞吐高,CPU 与 GPU 重叠 | 逻辑与画面差最多一帧;调试时"改了没生效" | 对延迟极敏感(VR、竞技射击)可考虑关一帧延迟 |
| 一帧延迟 | 三条线程各干各的,利用率最高 | 输入到成像多约一帧 | 见上 |
| Scene Proxy 快照 | 渲染线程不碰 UObject,无锁 | 组件改数据必须显式标脏,否则不生效 | 每帧变化的代理应考虑直接走 GPU 侧更新而非重建 |
FlushRenderingCommands | 保证时序正确 | 强制串行,一次几十毫秒 | 运行时热路径一律不该用 |
| RHI 线程 | 提交不阻塞渲染线程 | 多一层,命令缓冲内存增加 | 极轻量场景收益不明显 |
最容易踩的权衡:为了让"改了立刻生效"而在 Tick 里 FlushRenderingCommands。正确做法是先想清楚——画面本来就该落后逻辑一帧,这是设计,不是 bug。
6. 踩坑与排查
| 坑 | 现象 | 怎么验证 |
|---|---|---|
| 在渲染线程访问 UObject | 随机崩溃、GC 期间必崩 | 检查 ENQUEUE_RENDER_COMMAND 的捕获列表;开启线程检查断言 |
| 改了组件属性不生效 | 逻辑对、画面不变 | 是否调了 MarkRenderStateDirty() |
| 渲染资源手动 delete | RHI 断言崩溃 | 是否走了 BeginReleaseResource + FRenderCommandFence |
Tick 里 FlushRenderingCommands | 每帧掉帧 | stat unit 看 Game 是否异常高,搜代码里所有 Flush 调用 |
| 读回像素读到脏数据 | 截图/描边错误 | 读回前是否 flush 过 |
stat unit 里 GPU 高却去优化 Game | 白费力气 | 先看 stat unit 四项,谁高优化谁 |
| Draw 线程高但 GPU 空闲 | CPU 在生成命令时卡住 | stat sceneupdate 看剔除与变换推送 |
| 大量动态物体导致 Game/Draw 双高 | 每帧重建代理 | 减少 MarkRenderStateDirty 频率,或改走实例化 |
| 编辑器流畅、打包后卡 | 编辑器可能关了部分并行 | 用打包版本测,不要只看 PIE |
| 多线程动画与渲染争抢 | TaskGraph worker 饱和 | stat taskgraph / Insights 看线程占用 |
排查顺序(固定套路)
1. stat unit → 分清 Game / Draw / GPU / RHIT
2. Game 高? → 不是渲染问题,去 [Profile](/4eea9393.html)
3. Draw 高? → stat sceneupdate → 剔除与代理更新
4. GPU 高? → profilegpu / GPU Visualizer → 定位 Pass
5. 都不高但帧率低? → 多半是同步点(Flush)或垂直同步/帧率上限"四项都不高但帧率低"几乎总是同步问题:某处在等栅栏、等加载、或等垂直同步。用 Unreal Insights 看游戏线程的阻塞栈。