管线与渲染线程

自动关联目录:管线与渲染线程

UE 的渲染不是"一个函数画一帧",而是三条线程流水作业:游戏线程算逻辑、渲染线程生成命令、RHI 线程提交给 GPU。它们之间允许错开一帧,这是吞吐与延迟之间的核心取舍。

一句话定位:理解帧流水线,才能解释"为什么我改了代码但这一帧没变""为什么 stat unit 里 Game 不高但帧率还是低"。这是整个 Rendering 分支的地基。

1. 定位

本页回答三个问题:

  1. 一帧是怎么从逻辑变成 GPU 命令的
  2. 三条线程之间在哪里同步,同步的代价是什么
  3. 写渲染相关代码时必须遵守哪些线程规则

不回答:具体 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.OneFrameThreadLag1允许渲染线程比游戏线程落后一帧设为 0 降低延迟但牺牲吞吐,三条线程被拉得更同步
r.RenderThread.Enable1是否启用独立渲染线程关闭后退化成单线程,编辑器调试偶用
r.GTSyncType1游戏线程与渲染线程的同步方式影响 stat unit 里 Game/Draw 的归属
r.RHIThread.Enable1独立 RHI 线程关闭后命令提交回到渲染线程,减少一层并行
stat unit—显示 Game / Draw / GPU / RHIT 四项时间排查第一命令
stat unitgraph—图形化显示同上看抖动比数字直观
stat sceneupdate—场景更新(剔除、变换推送)耗时大量动态物体时看它
stat rhi—draw call、三角形、渲染目标数判断 draw call 是否过多
r.SceneRenderTargetResizeMethod0分辨率变化时如何重建 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()
渲染资源手动 deleteRHI 断言崩溃是否走了 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 看游戏线程的阻塞栈。