技术博客

渲染线程模型:GameThread/RenderThread/RHIThread 的边界

第 29 / 100 篇 · 2025-05-24 · ue5 · 渲染多线程源码分析

UE 渲染的多线程模型一句话版本:游戏线程产生意图,渲染线程消费场景,RHI 线程对 GPU 说话。这三者不是风格选择,是控制台世代(Xbox 360/PS3 之后)确立的流水线结构,为的是让 CPU 模拟和 GPU 提交重叠进行。源码:RenderCore/Public/RenderingThread.h、RenderCore/Private/RenderingThread.cpp(UE5.3)。

三条线程的分工

GameThread(GT):跑全部 gameplay 逻辑、物理、动画。它对渲染的贡献是改数据——移动 Actor、改材质参数、生成新 primitive。它从不直接调图形 API。

RenderThread(RT):把游戏线程改过的场景整理成一帧的渲染命令序列。它持有渲染侧的场景表示(FScene、FPrimitiveSceneInfo),执行视口遍历、剔除调度、RDG 构建。它调 RHI 层,但调的是"录命令",不是"提交给 GPU"。

RHIThread(RHI-T):真正把 RHI 命令翻译成平台 API(DX12 的 ID3D12GraphicsCommandList、Vulkan 的 VkCommandBuffer)并提交执行。它的存在让 RT 的录命令和 GPU 的提交进一步解耦:RT 可以录第 N+1 帧的命令,RHI 线程在提交第 N 帧。

开启状态看 r.RHIThread.Enable(DX12/Vulkan 默认开,部分平台强制)。关掉它 RHI 命令就在 RT 上直接提交,省一条线程上下文切换,但 RT 会被 GPU 驱动的同步调用拖住——实测在命令量大的场景 stat rendering 的 RT 耗时上涨就是这个原因。

ENQUEUE_RENDER_COMMAND 的真相

这个宏(RenderingThread.h)经常被误解为"把函数指针扔进队列等渲染线程执行"。实际它建一个 FRenderCommand 对象(捕获 lambda),塞进当前线程到渲染线程的命令管道:

// 典型用法
ENQUEUE_RENDER_COMMAND(MyUpdateCmd)(
    [Scene = Scene](FRHICommandListImmediate& RHICmdList)
    {
        Scene->UpdateMyStuff();   // 在渲染线程上执行
    });

两个必须知道的性质。其一,lambda 内访问的一切必须是渲染线程可安全触碰的:通常是 FScene 侧的镜像数据,不是游戏线程的 UObject。把 AActor 指针捕获进去就是跨线程裸奔,崩溃日志里表现为 !IsInRenderingThread() 断言或随机损坏。引擎给游戏侧对象的规矩是:UObject 改场景数据时发命令,由渲染侧代理(SceneProxy)在命令里改自己的副本。

其二,入队不保证执行时机。命令在 RT 处理到对应栅栏时才跑,晚一两帧是常态。所以"我在 ENQUEUE 之后立刻读 GPU 侧结果"是错的——要么继续传命令读,要么用 fence 等。

帧同步:双向栅栏

两条线程不是全异步的,帧边界用一对栅栏卡死:

  • RenderingFence(帧末栅栏):RT 完成一帧的命令处理时更新。GT 在准备下一帧的渲染相关数据前等上一帧的栅栏(FrameNumber 对不上就阻塞),保证"游戏线程改场景数据时,渲染线程已经不用上一版快照"。
  • RHI 提交栅栏:RHI 线程提交完第 N 帧后释放,RT 复用命令缓冲区内存靠它保证。

时序上形成了经典的二级流水:

帧 N:   GT 改场景 → 发命令 ─┐
帧 N:                    RT 处理命令(用帧 N-1 快照不成立,用 GT 本帧已发的)
帧 N:                        RHI 提交帧 N-1 的命令

关键洞察:GT 改场景和 RT 渲场景用的是同一份 UObject 数据,靠栅栏保证错开。GT 在等 RT 栅栏时阻塞,这就是 stat game 里 GameThread 偶尔"莫名"等待的来源——不是游戏逻辑慢,是渲染追不上。反过来 RT 等 GT 则表现为渲染线程空闲(stat rendering 里大片 Waiting)。分析卡顿先看两边谁在等谁。

为什么不是更多线程

RT 只有一条,理由和物理线程类似:场景数据的组织(剔除结构、渲染项排序)在多线程下需要全量加锁,锁竞争会吃掉并行收益。真正的并行在更下层——RT 内的工作被拆给 TaskGraph 的任务(剔除、网格处理并行),RDG pass 间无依赖的也可并行录命令。所以 UE 的 CPU 渲染扩展性是"RT 主流程串行 + 任务并行",加渲染线程数不是旋钮。

排障速查

  • 崩溃栈出现 RenderThread + 访问 UObject:跨线程访问,查最近新加的 ENQUEUE 捕获。
  • stat unit 里 Game 和 Draw 都高但 GT 逻辑不多:大概率 GT 在等 RT 栅栏,渲染侧是瓶颈。
  • RHI 线程 CPU 高:stat rhi 看提交量,draw call 太多的第一征兆。
  • 想关 RHI 线程做对照实验:r.RHIThread.Enable 0 后重启生效,对比 stat rendering 差异。

帧流水线:三条线程靠栅栏错开一帧 GameThread RenderThread RHIThread 改场景 / 发命令 N 等 RT 栅栏 N-1 改场景 / 发命令 N+1 处理命令 N(剔除/RDG) 更新栅栏 N 提交帧 N-1 到 GPU 提交帧 N 到 GPU 命令管道 栅栏等待 RHI 命令缓冲 GT 等栅栏阻塞 = stat game 里 GT 莫名变长的常见原因;先分清谁在等谁再优化 红线:ENQUEUE 的 lambda 只碰渲染侧数据,UObject 指针跨线程是裸奔