来源:UE5 渲染线程源码阅读笔记(RenderCommandPipe 部分)
引擎版本:UE 5.8
面向读者:UE 图形程序员
ENQUEUE_RENDER_COMMAND 在 UE5 里早就不是”一条命令一个 TaskGraph 任务”了。命令先被录进一条链表,由一个任务成批回放;骨骼网格、毛发、缆绳、Niagara 动态数据这几类命令还能离开渲染线程,在 worker 上按各自的顺序回放,再在帧里的几个同步点汇回渲染线程时间线。这套机制叫 RenderCommandPipe。它不碰 GPU 队列,也不改任何 Shader,改的是 CPU 侧渲染命令的调度粒度和执行位置。代码路径均相对引擎根目录。
目录
- 先说结论
- 整体结构
- 入口与分流
- 批处理内核
- 录制窗口 Start 与 Stop
- 交给 RHI 之后
- RHI 资源的延迟删除
- 同步点家族
- FRenderCommandList 按线程录制
- 实例 CPU Skin 的一次更新
- 版本演进
- 调试与开关
- 接入异步 Pipe 的检查清单
- 设计回顾与源码坐标
- 参考链接
一、先说结论
旧模型里,一条 render command 就是一个 TGraphTask:分配任务对象、准备依赖、入队、唤醒渲染线程,全部按命令条数付费。可很多命令的正文只是换一个指针,命令一多,这些固定开销就全压在渲染线程的节拍上。另一个问题是顺序被绑死在线程上:所有命令都得在渲染线程执行,两个毫不相干的骨骼网格资源初始化也只能排队。
RenderCommandPipe 2023 年进入主干(5.4 周期),首个提交说明把目标概括成一句 “commands are no longer 1-to-1 with tasks”。整套设计可以归成三条主线:
| 主线 | 手段 | 代价 |
|---|---|---|
| 摊销调度开销 | 命令录进线性分配的链表,链表由空变非空时才发任务,一个任务回放一整批 | 批次大小由生产与消费的节奏决定 |
| 把顺序从线程里解耦 | 同一 Pipe 的批次靠任务前置链串成 FIFO,可以跑在任意 worker 上 | 不同 Pipe 之间没有相对顺序 |
| 显式同步 | 录制窗口 Start/Stop、FSyncScope、Fence 向 Pipe 投递触发命令、RHI 资源寿命计数 | 复杂度几乎都在这里 |
前两条让它变快,第三条让它变对。几个最容易想当然的地方先列出来:
- Pipe 不是 GPU 队列。 SkeletalMesh Pipe 录出来的是 Graphics 管线上的
FRHICommandList,和 Async Compute、Copy Queue 没有关系。 r.RenderCommandPipeMode=2不等于所有命令并行。 没指定 Pipe 的命令仍在渲染线程上按原顺序执行,只是被成批调度。- 同一条 Pipe 内严格串行。 一百个角色的 CPU Skin 更新全在 SkeletalMesh 这一条链上,收益来自和其他线程的重叠,不是角色之间的并行。
- “回放完成""RHI 提交""GPU 完成”是三件事。 汇合点只保证第一件。
二、整体结构
图 1:RenderCommandPipe 的六层结构。上面两层决定命令去哪儿,中间两层负责装命令和回放,下面两层负责与同步点、资源释放和 RHI 提交对齐。
声明。DEFINE_RENDER_COMMAND_PIPE(Name, Flags) 定义一个全局对象 UE::RenderCommandPipe::Name,顺带注册开关 r.RenderCommandPipe.<Name>(Flags 不含 Disabled 时默认 1)。引擎(含插件)一共定义了四条:
| Pipe | 定义位置 | 引用规模 | 走这条 Pipe 的命令 |
|---|---|---|---|
SkeletalMesh | Engine/Source/Runtime/Engine/Private/Rendering/RenderCommandPipes.cpp | 七十多处 | 骨骼网格 LOD 资源(顶点、索引、蒙皮权重缓冲)的 Init/Release,CPU Skin 更新,PoseWatch 数据 |
Groom | Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Private/GroomResources.cpp | 6 处,另有 11 处被注释掉 | 毛发资源与绑定数据的释放,GroomCache 更新 |
Cable | Engine/Plugins/Runtime/CableComponent/Source/CableComponent/Private/CableComponent.cpp | 3 处 | 缆绳顶点/索引缓冲初始化,动态数据下发 |
NiagaraDynamicData | Engine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraComponent.cpp | 3 处 | 组件动态数据设置、渲染开关、fence 信号 |
真正成规模搬上异步 Pipe 的只有骨骼网格,其余三条都是在各自系统里逐条挑出来的命令。Pipe 更像”给某几条命令开的并行通道”,而不是给某个系统整体开的。四条 Pipe 的客户也是同一类工作:组件和资产的 RHI 资源创建、释放,以及动态数据交接。它们都不需要渲染线程此刻的上下文状态,只要彼此顺序对,谁在什么线程上执行结果都一样。
分发。ENQUEUE_RENDER_COMMAND 展开后只是声明一个命令 tag,再调用唯一的分发入口:
// Engine/Source/Runtime/RenderCore/Public/RenderingThread.h
#define ENQUEUE_RENDER_COMMAND(Type, ...) \
DECLARE_RENDER_COMMAND_TAG(UE_JOIN(FRenderCommandTag_, Type, __LINE__), Type, __VA_ARGS__) \
FRenderCommandDispatcher::Enqueue<UE_JOIN(FRenderCommandTag_, Type, __LINE__)>tag 里带命令名、stat id、Insights 事件的 spec id,以及 5.8 新增的 ERenderCommandCategory(宏的可选第二参数,见 3.3)。
容器。UE::RenderCommandPipe::FCommandList 是一段录制期内的命令链:节点从 FMemStackBase 线性分配,侵入式单链表,节点也可以是”执行另一条链”,消费时原地展开。FRenderCommandList 是按线程录制的集装箱,每条 Pipe 一格,外加渲染线程一格(第九节)。
管道。FRenderThreadCommandPipe(单例,代表渲染线程)和 FRenderCommandPipe(异步,可任意定义)共享基类 FRenderCommandPipeBase,也就是”一个 FContext 加一把 UE::FMutex”。两者的区别只在回放发生在哪、回放到哪(4.3)。
调度。文件内静态的 FRenderCommandPipeRegistry 管理全部 Pipe 和录制状态,对外是 StartRecording / StopRecording / FSyncScope;FRenderCommandFence、FenceBundler 和 RHI 资源寿命计数负责与同步点、资源释放对齐。
交付。异步 Pipe 录出的 RHI 命令表在渲染线程的 RenderCommandPipe_Stop 命令里交给 QueueAsyncCommandListSubmit,按顺序进入 RHI 的 dispatch / translate 链。
三、入口与分流
3.1 签名决定能去哪
命令体存进 FRenderCommandFunctionVariant,一个三选一的 TVariant:void()、void(FRHICommandList&)、void(FRHICommandListImmediate&)。分发器的重载只有三组:
// Engine/Source/Runtime/RenderCore/Public/RenderingThread.h(FRenderCommandDispatcher,仅列签名)
template <typename RenderCommandTag>
static void Enqueue(TUniqueFunction<void(FRHICommandListImmediate&)>&& Function); // 不带 Pipe
template <typename RenderCommandTag>
static void Enqueue(FRenderCommandPipe* Pipe, FRenderCommandPipe::FCommandListFunction&& Function); // void(FRHICommandList&)
template <typename RenderCommandTag>
static void Enqueue(FRenderCommandPipe* Pipe, FRenderCommandPipe::FEmptyFunction&& Function); // void()
// 另有接受 FRenderCommandPipe& 的两个版本,转调指针版本组合起来是一张表:
| lambda 形参 | 不带 Pipe | 带 Pipe |
|---|---|---|
FRHICommandListImmediate& | 渲染线程 | 编译不过 |
FRHICommandList& 或 FRHICommandListBase& | 渲染线程(被当成 Immediate 版本调用) | 异步 Pipe,进不去就退回渲染线程 |
| 无参 | 编译不过 | 异步 Pipe,进不去就退回渲染线程 |
签名本身就是语义声明。FRHICommandListImmediate& 说的是”我要渲染线程此刻的全局状态”:immediate 命令表、RDG(FRDGBuilder 的构造函数只接受 FRHICommandListImmediate&)、场景代理上的渲染线程数据。这类命令从类型上就进不了异步 Pipe。无参命令只改 CPU 数据,回放时也不会为它创建 RHI 命令表。PoseWatch 这条就是最小的例子:
// Engine/Source/Runtime/Engine/Private/Components/SkeletalMeshComponent.cpp
ENQUEUE_RENDER_COMMAND(PoseWatchDynamicDataCommand)(UE::RenderCommandPipe::SkeletalMesh,
[TargetProxy, NewDynamicData]
{
if (TargetProxy->PoseWatchDynamicData)
{
delete TargetProxy->PoseWatchDynamicData;
}
TargetProxy->PoseWatchDynamicData = NewDynamicData;
});Groom 插件里能直接看到筛选标准,进了 Pipe 的命令和把 Pipe 参数注释掉的命令并排存在:
// Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Private/GroomAsset.cpp —— 进了 Pipe
ENQUEUE_RENDER_COMMAND(ReleaseHairResourceCommand)(UE::RenderCommandPipe::Groom,
[InResource](FRHICommandList& RHICmdList)
{
InResource->ReleaseResource();
delete InResource;
});
// Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Private/GroomComponent.cpp —— Pipe 参数被注释掉(循环体有删节)
ENQUEUE_RENDER_COMMAND(FHairComponentSendLODIndex)(/*UE::RenderCommandPipe::Groom,*/
[GroomSceneProxy, CurrLODIndex, LocalLODSelectionType, bHasLODSwitch, bPredictionLoad](FRHICommandListImmediate& RHICmdList)
{
for (const TRefCountPtr<FHairGroupInstance>& Instance : GroomSceneProxy->HairGroupInstances)
{
Instance->Debug.LODForcedIndex = CurrLODIndex;
if (bHasLODSwitch)
{
AddHairStreamingRequest(Instance, CurrLODIndex);
}
}
});被注释掉的 11 处全是 FRHICommandListImmediate& 签名,内容是遍历场景代理上的实例、做资源状态转换、直接构建 RDG;留在 Pipe 里的是”释放一个资源再删掉它”这种只动自己捕获物的命令。⚠️ 这些注释没有附带说明,但两组命令的差异正好就是异步 Pipe 的准入条件:只依赖自己捕获的数据和 RHI 资源,不依赖渲染线程上下文。
3.2 运行时分流
图 2:一条命令按判定顺序可能落到的四个去处。
- 当前线程绑定了
FRenderCommandList(FRecordScope生效中):追加到对应格子,不加锁、不发任务。三组重载都先检查这一点(不带 Pipe 的版本之前还有一个 AutoRTFM 判断,见 3.3)。 - 带 Pipe,
GRenderCommandPipeMode == All,且Pipe->Enqueue接受:进这条 Pipe。Enqueue先看当前线程是否正在回放这条 Pipe,是就当场执行(4.4);否则加 Pipe 锁,检查RecordTask是否有效,只有录制窗口内才有效。 - 其余情况进
FRenderThreadCommandPipe::Enqueue。带 Pipe 的命令在这之前被包一层[Function](FRHICommandListImmediate&) { ... }。 FRenderThreadCommandPipe::Enqueue内部再分:
// Engine/Source/Runtime/RenderCore/Public/RenderingThread.h
#define ShouldExecuteOnRenderThread() (LIKELY(GIsThreadedRendering || !IsInGameThread())) // 非 UE_SERVER 的定义
template <typename RenderCommandTag, typename LambdaType>
static void Enqueue(LambdaType&& Lambda) // FRenderThreadCommandPipe,省略了 stat scope 分支
{
const FRenderCommandTag& Tag = RenderCommandTag::Get();
if (!IsInRenderingThread() && ShouldExecuteOnRenderThread())
{
CheckNotBlockedOnRenderThread();
if (GRenderCommandPipeMode != ERenderCommandPipeMode::None)
{
Instance.EnqueueAndLaunch(MoveTemp(Lambda), Tag); // 批处理
}
else
{
TGraphTask<TRenderCommandTask<LambdaType>>::CreateTask().ConstructAndDispatchWhenReady(MoveTemp(Lambda), Tag); // 逐条任务
}
}
else
{
Lambda(GetImmediateCommandList_ForRenderCommand()); // 就地执行
}
}❗ ShouldExecuteOnRenderThread() 是”有独立渲染线程或者当前不是游戏线程”。没有独立渲染线程时,worker 发出的命令照样排队,排进映射到游戏线程的命名队列,所以 FlushRenderingCommands 在这种情况下要先把游戏线程的队列跑空。
r.RenderCommandPipeMode=0 那条逐条 TGraphTask 的分支是逃生舱:并行引入难查的竞态时,拨到 0 就回到最老的串行语义。Epic 自己就用过:2023 年 9 月,线程本地 compute PSO 的优化暴露了 GPUSkinCache 与 render command 之间的竞态,默认值被临时从 2 降到 1,直到 PSO 缓存清理修好;那几个月里异步 Pipe 还因为蒙皮、Niagara、ISM 的竞态被关掉又打开过好几次。异步 Pipe 改变的是并发假设,不只是性能曲线。
3.3 两个默认不生效的钩子
- AutoRTFM:不带 Pipe 的重载在 AutoRTFM 事务里(
AutoRTFM::IsClosed())不会立刻入队,而是挂到AutoRTFM::OnCommit,事务提交时再发。 - State Stream:
FRenderThreadCommandPipe::EnqueueAndLaunch开头有#if WITH_STATE_STREAM的拦截钩子。WITH_STATE_STREAM在Misc/Build.h里默认是 0;即使打开,钩子目前也只按ERenderCommandCategory(EnterTick/LoopTick/LeaveTick/Unknown)做调试计数,恒返回 false。category 通过ENQUEUE_RENDER_COMMAND(Name, LoopTick)这样的第二个宏参数指定,引擎里只有 4 处标了LoopTick(视口的BeginFrameCommand/EndFrameCommand、EndDrawingCommand、TickPoolElements),其余都是默认的Unknown。
四、批处理内核
4.1 容器:线性分配器上的链表
两种 Pipe 的状态都装在基类的 FContext 里:
// Engine/Source/Runtime/RenderCore/Public/RenderingThread.h(FRenderCommandPipeBase)
struct FContext
{
FContext()
: Allocator(FMemStackBase::EPageSize::Large)
, CommandList(Allocator)
{}
FContext(FContext&& Other)
: CommandList(Allocator, Other.CommandList) // 连同分配器的页一起接管
, bDeleteAfterExecute(Other.bDeleteAfterExecute)
{}
FMemStackBase Allocator;
UE::RenderCommandPipe::FCommandList CommandList;
bool bDeleteAfterExecute = false;
};
FContext* Context = new FContext;
UE::FMutex Mutex;命令节点用 placement new 从 FMemStackBase 按页分配、按页归还,入队就是改尾指针。lambda 本体存在节点里的 TUniqueFunction 中:捕获不超过 24 字节(NUM_TFUNCTION_INLINE_BYTES)时内联存放,超过的仍会单独堆分配。
用链表而不用数组,收益不在”省分配”,而在三件事:追加是 O(1),没有扩容搬移;整批移交只需搬头尾指针和分配器的页;节点可以是 FExecuteCommandListCommand,即”执行另一条链”,消费时原地展开,FRenderCommandList 的子列表就是这样零拷贝挂进父列表的。
FCommandList::Enqueue 的返回值是整个批处理的开关:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(UE::RenderCommandPipe::FCommandList::Enqueue)
bool bWasEmpty = IsEmpty();
if (bWasEmpty)
{
Commands.Head = Commands.Tail = Command;
}
else
{
Commands.Tail->Next = Command;
Commands.Tail = Command;
}
Commands.Num++;
return bWasEmpty;4.2 空转非空才发任务,整批搬走再执行
以异步 Pipe 为例,调用方已经持有 Pipe->Mutex:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(FRenderCommandPipe::EnqueueAndLaunch,删去了 trace scope 与计数检查)
bool bWasEmpty = Context->CommandList.Enqueue(MoveTemp(FunctionVariant), Tag);
NumInFlightCommands.fetch_add(1, std::memory_order_relaxed);
if (bWasEmpty)
{
RecordTask = UE::Tasks::Launch(Name, [this, ContextToConsume = Context]
{
FTaskTagScope Scope(ETaskTag::EParallelRenderingThread);
Mutex.Lock();
const bool bDeleteAfterExecute = ContextToConsume->bDeleteAfterExecute;
FContext ContextToExecute(MoveTemp(*ContextToConsume));
Mutex.Unlock();
ContextToExecute.CommandList.Close();
ExecuteCommands(ContextToExecute.CommandList);
const int32 NumCommands = ContextToExecute.CommandList.NumCommands();
NumInFlightCommands.fetch_sub(NumCommands, std::memory_order_release);
if (bDeleteAfterExecute)
{
delete ContextToConsume;
}
}, RecordTask); // 以上一批为前置
}生产者只关心”链表原来是不是空的”。原来非空,说明已经有一个任务在路上、还没来得及把链表取走,这条命令会被它顺带带走;原来为空才发新任务。
消费者一开始就在锁内把整个 FContext 移到栈上,随即解锁,然后在锁外执行整批命令。锁只保护头尾指针和分配器页的交接,从不覆盖命令正文的执行,生产者在消费者执行期间可以照常入队;链表重新变非空时,下一个任务就发出去了。共享的 Context 被搬空后仍是合法的空上下文,后续入队直接在它上面分配新页。
回放逐条进行,ConsumeCommands 每执行完一个节点就原地调用它的析构函数,lambda 捕获的对象立刻释放,不会拖到整批结束,和逐条任务的释放时机一致。2023 年 8 月重新启用 Pipe 时专门处理过这一点。
图 3:空转非空触发、整批移交,以及同一 Pipe 的批次前置链。队列被搬空不等于上一批已经执行完,所以下一批必须以上一批为前置。
4.3 两种 Pipe 的回放差异
渲染线程 Pipe 的批次任务是一个 TGraphTask,直接派到 ENamedThreads::GetRenderThread(),没有任何前置依赖:渲染线程的命名队列本身就是 FIFO,批次天然按发出顺序执行。异步 Pipe 的批次跑在任意 worker 上,顺序只能靠 RecordTask 前置链保证。
| 渲染线程 Pipe | 异步 Pipe | |
|---|---|---|
| 批次任务 | TGraphTask,进渲染线程命名队列 | UE::Tasks::Launch,任意 worker |
| 批次之间的顺序 | 命名队列 FIFO | 每批以上一批为前置 |
| 线程身份 | 渲染线程 | ETaskTag::EParallelRenderingThread |
| 回放目标 | 全局 immediate 命令表 | Pipe 自己的 FRHICommandList |
| RHI 命令何时交出 | 随 immediate 命令表正常提交 | 等到 RenderCommandPipe_Stop |
| 与渲染线程的关系 | 串行,语义等同逐条任务 | 并行 |
异步 Pipe 的 RHI 命令表是惰性创建的:第一条 void(FRHICommandList&) 命令到来时才 new,整个录制窗口内所有批次都往这一张表里录。批次之间已经串行,不会并发写。
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp
void FRenderCommandPipe::ExecuteCommand(FRenderCommandFunctionVariant&& FunctionVariant, const FRenderCommandTag& Tag)
{
if (!RHICmdList && FunctionVariant.IsType<TUniqueFunction<void(FRHICommandList&)>>())
{
RHICmdList = new FRHICommandList();
RHICmdList->SwitchPipeline(ERHIPipeline::Graphics);
}
::ExecuteCommand(RHICmdList, FunctionVariant, Tag);
}ETaskTag::EParallelRenderingThread 让 IsInParallelRenderingThread() 在回放任务里返回 true,引擎里现成的 check(IsInParallelRenderingThread()) 都能直接通过;但 IsInRenderingThread() 在这里是 false。
❗ 所以 Pipe 命令里再发一条不带 Pipe 的 ENQUEUE_RENDER_COMMAND,不会就地执行,而是排到渲染线程 Pipe 的队尾,和 Pipe 里的后续命令没有先后关系。
4.4 同 Pipe 重入与跨 Pipe
- 同 Pipe 重入:回放时 TLS 变量
ReplayingPipe指向当前 Pipe。命令里再向同一条 Pipe 发命令,Enqueue发现IsReplaying(*this)就当场执行,和渲染线程上ENQUEUE_RENDER_COMMAND就地执行的行为对齐。 - 跨 Pipe:Pipe A 回放中向正在录制的 Pipe B 发命令,会触发
ensureMsgf,消息是Attempting to launch render command to render command pipe %s from another pipe %s;B 不在录制时则静默退回渲染线程。Pipe 之间没有相对顺序,跨 Pipe 发命令等于声明一个系统保证不了的顺序。 - 回放中向 Pipe 提交整条
FRenderCommandList直接checkf。
五、录制窗口 Start 与 Stop
5.1 窗口在帧里的位置
异步 Pipe 不能随时接受写入,否则一条命令该排在帧的哪个位置就无从谈起。录制窗口由 FEngineLoop::Tick 在固定位置开关:
// Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp(FEngineLoop::Tick,节选)
ENQUEUE_RENDER_COMMAND(BeginFrame)([CurrentFrameCounter](FRHICommandListImmediate& RHICmdList)
{
BeginFrameRenderThread(RHICmdList, CurrentFrameCounter);
});
for (FSceneInterface* Scene : GetRendererModule().GetAllocatedScenes())
{
ENQUEUE_RENDER_COMMAND(FScene_StartFrame)([Scene](FRHICommandListImmediate& RHICmdList)
{
Scene->StartFrame();
});
}
UE::RenderCommandPipe::StartRecording(); // 窗口开始
// …… 整个游戏帧:Tick、SendAllEndOfFrameUpdates、视口绘制 ……
UE::RenderCommandPipe::StopRecording(); // 窗口结束
for (FSceneInterface* Scene : GetRendererModule().GetAllocatedScenes())
{
ENQUEUE_RENDER_COMMAND(FScene_EndFrame)([Scene](FRHICommandListImmediate& RHICmdList)
{
Scene->EndFrame(RHICmdList);
});
}窗口会被帧中间的同步点切成好几段(第八节),录制窗口和帧不是一回事。
图 4:一帧之内的录制窗口、两种 Pipe 的批次,以及中途的一个 FSyncScope。
5.2 Start:游戏线程登记,渲染线程开门
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(FRenderCommandPipeRegistry::StartRecording,节选)
UE::Tasks::FTaskEvent TaskEvent{ UE_SOURCE_LOCATION };
for (FRenderCommandPipeSetBitIterator BitIt(PipeBits); BitIt; ++BitIt)
{
FRenderCommandPipe* Pipe = AllPipes[BitIt.GetIndex()];
if (Pipe->bEnabled && !Pipe->bRecording)
{
Pipe->bRecording = true;
NumPipesToStartRecording++;
PipesToStartRecordingBits[BitIt.GetIndex()] = true;
UE::TScopeLock PipeLock(Pipe->Mutex);
Pipe->RecordTask = TaskEvent; // 第一批任务会以它为前置
}
}
ENQUEUE_RENDER_COMMAND(RenderCommandPipe_Start)([this, TaskEvent = MoveTemp(TaskEvent), PipesToStartRecordingBits, NumPipesToStartRecording](FRHICommandListImmediate&) mutable
{
RHIResourceLifetimeAddRef(NumPipesToStartRecording); // 挂起可延寿 RHI 资源的删除(第七节)
NumPipesReplaying += NumPipesToStartRecording;
TaskEvent.Trigger(); // 开门
for (FRenderCommandPipeSetBitIterator BitIt(PipesToStartRecordingBits); BitIt; ++BitIt)
{
AllPipes[BitIt.GetIndex()]->bReplaying = true;
}
});游戏线程这一半只是登记,不等任何东西。窗口一开,生产者就能往 Pipe 里写并发出批次任务,但这些任务以 TaskEvent 为前置,要等渲染线程执行到 RenderCommandPipe_Start,也就是它之前入队的渲染线程命令(BeginFrame、上一段串行区间里的资源初始化等)都已执行完,才会开始回放。异步工作因此不会越过它之前的渲染线程工作。
GRenderCommandPipeMode 不是 All、没有独立渲染线程、或者没有需要启动的 Pipe 时,StartRecording 直接返回。
5.3 Stop:先关入口,再在渲染线程上汇合
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(FRenderCommandPipeRegistry::StopRecording,删去了计数与 trace)
UE::Tasks::FTaskEvent TaskEvent{ UE_SOURCE_LOCATION };
for (FRenderCommandPipeSetBitIterator BitIt(PipeBits); BitIt; ++BitIt)
{
FRenderCommandPipe* Pipe = AllPipes[BitIt.GetIndex()];
Pipe->bRecording = false;
Pipe->Mutex.Lock(); // 一直持有到 Stop 命令入队之后
Pipe->ResetContext();
TaskEvent.AddPrerequisites(Pipe->RecordTask); // 汇合各 Pipe 的尾任务
Pipe->RecordTask = {}; // 入口关闭,之后 Pipe->Enqueue 返回 false
}
TaskEvent.Trigger();
ENQUEUE_RENDER_COMMAND(RenderCommandPipe_Stop)([this, PipeBits, TaskEvent = MoveTemp(TaskEvent), NumPipesToStopRecording](FRHICommandListImmediate& RHICmdList)
{
TArray<FRHICommandListImmediate::FQueuedCommandList, FConcurrentLinearArrayAllocator> QueuedCommandLists;
TaskEvent.Wait(); // 等批次执行完,不是等 GPU
for (FRenderCommandPipeSetBitIterator BitIt(PipeBits); BitIt; ++BitIt)
{
FRenderCommandPipe* Pipe = AllPipes[BitIt.GetIndex()];
if (Pipe->RHICmdList)
{
Pipe->RHICmdList->FinishRecording();
QueuedCommandLists.Emplace(Pipe->RHICmdList);
Pipe->RHICmdList = nullptr;
}
Pipe->bReplaying = false;
}
RHICmdList.QueueAsyncCommandListSubmit(QueuedCommandLists);
RHIResourceLifetimeReleaseRef(RHICmdList, NumPipesToStopRecording);
});
// Wait to unlock the mutex until the sync command has been submitted to the render thread. This avoids
// race conditions where a command meant for a specific pipe might be inserted to the render thread pipe
// prior to the actual wait command.
for (FRenderCommandPipeSetBitIterator BitIt(PipeBits); BitIt; ++BitIt)
{
AllPipes[BitIt.GetIndex()]->Mutex.Unlock();
}几个动作各有讲究:
ResetContext():旧Context里还有命令,说明有任务在路上但还没取走它们。把旧 Context 标成bDeleteAfterExecute交给那个任务回收,Pipe 换上新 Context;下一个窗口的命令写进新 Context,不会被旧任务顺手带走。- 锁持到 Stop 入队之后:如果关掉入口就放锁,别的线程会在”入口已关、Stop 还没入队”的空隙里发命令,被退回渲染线程 Pipe,于是排在 Stop 之前,越过了这条 Pipe 里更早的命令。上面那段源码注释说的就是这个竞态,Epic 在 2023 年 10 月修过它。
TaskEvent.Wait()等的是”各 Pipe 的批次都执行完、RHI 命令都录进了各自的命令表”。Trigger()只是激活事件,前置未完成时事件本身仍未完成。- ⚠️
Wait会先尝试把还没开始的前置任务撤回到当前线程执行(retraction),Pipe 命令也可能在渲染线程上跑。Pipe 保证的是顺序和依赖,不是某个 OS 线程。 - 收集顺序按 Pipe 的注册序号,不按谁先跑完。不同 Pipe 之间的业务依赖仍要调用方自己表达。
- 返回位图:
StopRecording返回”这次真正停掉了哪些 Pipe”,FSyncScope、FenceBundler 据此只重启原本在录的那些。2023 年 9 月的一次修复就是为此:之前同步点结束时,会把本来没在录的 Pipe 也启动起来。 - 游戏线程不等:
StopRecording只是把汇合点插进渲染线程时间线。
图 5:一个录制窗口的因果链。Start 保证异步工作不越过之前的渲染线程工作,Stop 保证之后的渲染线程工作不越过必要的异步结果。
六、交给 RHI 之后
汇合点之后还有好几层”完成”,不能混为一谈:
| 完成点 | 已经保证 | 还不保证 |
|---|---|---|
Stop 里 TaskEvent.Wait() 返回 | 各 Pipe 的命令正文执行完,RHI 命令都录进了命令表 | 这些 RHI 命令被翻译、提交,GPU 执行 |
FinishRecording() | 这张命令表录制结束,不能再往里写 | 翻译、平台提交 |
QueueAsyncCommandListSubmit 返回 | 命令表已按顺序挂进 RHI 的 dispatch 链 | 翻译完成、GPU 执行 |
| GPU fence / GPU idle | GPU 执行到对应位置 | —— |
FinishRecording() 的核心是完成命令表的 DispatchEvent,把它从”生产中”切到”可消费”。它顺带做两个检查:命令表里还有没关闭的 RHI fence scope 会 checkf;还有未完成的 buffer/texture upload(PendingBufferUploads / PendingTextureUploads 非空)直接 Fatal。
QueueAsyncCommandListSubmit 在 5.8 里只是一层转发,两个老参数(翻译优先级、MinDrawsPerTranslate)都已标成 unused:
// Engine/Source/Runtime/RHI/Private/RHICommandList.cpp
RHI_API void FRHICommandListImmediate::QueueAsyncCommandListSubmit(TArrayView<FQueuedCommandList> CommandLists, ETranslatePriority /*unused ParallelTranslatePriority*/, int32 /*unused MinDrawsPerTranslate*/)
{
TArray<FRHICommandListBase*> BaseCmdLists;
BaseCmdLists.Reserve(CommandLists.Num());
for (auto& CmdList : CommandLists)
{
BaseCmdLists.Add(CmdList.CmdList);
}
GRHICommandList.Submit(BaseCmdLists, ERHISubmitFlags::None);
}FRHICommandListExecutor::Submit 先把 immediate 命令表里已录的内容搬进一张新表排在最前,保证并行录制的表不会插到这些命令前面,再依次接上 Pipe 交来的表。之后的 dispatch 任务按提交顺序串成一条链,等每张表的录制完成事件,再发翻译任务;不同 context 的翻译可以并行,最终按顺序交给平台 RHI。
这里有两层互不相干的并行:RenderCommandPipe 并行的是”执行渲染 lambda、录制 RHI 命令”,RHI parallel translate 并行的是”把 RHI 命令翻译成平台命令”。
Submit(..., None) 不带 SubmitToGPU,但 Stop 紧接着调用 RHIResourceLifetimeReleaseRef:最后一个寿命引用释放时会触发 ImmediateFlush(DispatchToRHIThread, DeleteResources),而 ImmediateFlush 对非 WaitOnly 的调用一律补上 SubmitToGPU,于是这里会推动一次提交和资源删除。ImmediateFlush 的声明注释写明它只把工作派给 RHI 线程和 GPU,可选地等 RHI 线程,不等 GPU。
七、RHI 资源的延迟删除
异步 Pipe 最直接的风险是 use-after-free:命令在 worker 上引用 RHI 资源,渲染线程这边可能正好放掉它的最后一个引用。对策是一个全局寿命计数加两条删除队列:
// Engine/Source/Runtime/RHI/Private/RHIResources.cpp
void FRHIResource::MarkForDelete() const
{
if (!AtomicFlags.MarkForDelete(std::memory_order_release))
{
if (bAllowExtendLifetime)
{
PendingDeletesWithLifetimeExtension.ProduceItem(const_cast<FRHIResource*>(this));
}
else
{
PendingDeletes.ProduceItem(const_cast<FRHIResource*>(this));
}
}
}
int32 GRHIResourceLifetimeRefCount = 0;
void RHIResourceLifetimeReleaseRef(FRHICommandListImmediate& RHICmdList, int32 NumRefs)
{
check(IsInRenderingThread());
int32 RefCount = GRHIResourceLifetimeRefCount -= NumRefs;
check(RefCount >= 0);
if (!RefCount)
{
RHICmdList.ImmediateFlush(EImmediateFlushType::DispatchToRHIThread, ERHISubmitFlags::DeleteResources);
}
}// Engine/Source/Runtime/RHI/Private/RHICommandList.cpp(FRHICommandListExecutor::Submit,节选)
if (EnumHasAnyFlags(SubmitFlags, ERHISubmitFlags::SubmitToGPU))
{
extern int32 GRHIResourceLifetimeRefCount;
SubmitState->bIncludeExtendedLifetimeResources = GRHIResourceLifetimeRefCount == 0;
// ...
}每条 Pipe 进入回放态时在 RenderCommandPipe_Start 里 AddRef,在 RenderCommandPipe_Stop 里 ReleaseRef。计数不为零时,删除只处理 PendingDeletes,进了 PendingDeletesWithLifetimeExtension 的资源一直留着;最后一个引用放掉时顺手 flush 一次,把攒下的删除放出去。
bAllowExtendLifetime默认为 true,绝大多数资源都走可延寿队列。DisableLifetimeExtension()能让单个资源退出(DumpGPU 的 staging buffer、GPU Skin 的光追几何在用)。- 同一个计数也被
FRHICommandListScopedExtendResourceLifetime使用,FRDGBuilder里就有一个这样的成员。
❗ 它只延后 FRHIResource 对象的删除。lambda 捕获的 FRenderResource、场景代理、裸指针、UObject* 都不在保护范围内,它们的寿命要靠命令顺序、BeginCleanup 和 fence 自己保证,第十节的 CPU Skin 就是例子。
八、同步点家族
异步 Pipe 打开后,游戏线程先把 A 发进 Pipe、再把 B 发给渲染线程,不代表 A 先于 B 执行完:两条命令进了不同的执行域,调用顺序不构成跨域的完成依赖。凡是”之后的代码要依赖之前的命令已生效”的地方,都要显式建立同步点。
8.1 FSyncScope
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(去掉了 trace region)
FSyncScope::FSyncScope()
{
PipeBits = StopRecording(); // 停掉所有在录的 Pipe,记下是哪些
}
FSyncScope::FSyncScope(TConstArrayView<FRenderCommandPipe*> Pipes)
{
PipeBits = StopRecording(Pipes); // 只停指定的 Pipe
}
FSyncScope::~FSyncScope()
{
StartRecording(PipeBits); // 只重启原本在录的那些
}它在渲染线程时间线上夹出一段串行区间:旧窗口的 Stop(等齐、交出 RHI 命令表)→ 作用域内的命令(发往已停 Pipe 的命令也退回渲染线程)→ 新窗口的 Start。换成使用规则:作用域之前发往 Pipe 的命令,在作用域开头就被等齐;作用域之后发往 Pipe 的命令,要等渲染线程把之前的命令都处理完才会开始。
❗ FSyncScope 不阻塞游戏线程,构造只是往渲染线程时间线里插一个 Stop 命令。游戏线程要读回 Pipe 的结果、或者销毁仍被捕获的对象,还得用 fence 等。
引擎里的调用点规律很清楚,都是之后的代码要依赖之前的命令已经生效:
| 场景 | 位置 |
|---|---|
| 场景渲染入口 | FRendererModule::BeginRenderingViewFamilies(SceneRendering.cpp)、FSceneRenderProcessor::Execute(SceneRenderBuilder.cpp) |
| 不渲染场景时的每帧清理 | FRendererModule::PerFrameCleanupIfSkipRenderer |
| 光追几何管理器的 Tick | FRendererModule::PostRenderAllViewports(RHI_RAYTRACING) |
| 场景销毁、世界原点平移 | FScene::Release、FScene::ApplyWorldOffset(RendererScene.cpp) |
| 材质变更后重建网格绘制命令 | UpdateStaticMeshesForMaterials(RendererScene.cpp)、FMaterialUpdateContext 析构(MaterialShared.cpp) |
| 组件重注册后刷新 primitive scene info | UpdateAllPrimitiveSceneInfosForSingleComponent 等(ActorComponent.cpp) |
| 只同步 SkeletalMesh 一条 Pipe | NiagaraGpuComputeDispatch.cpp、ComputeFramework.cpp |
| 编辑器与工具 | RVT 构建、Lightmass、纹理编辑器视口、Movie Render Queue、nDisplay 等 |
PostRenderAllViewports 那一处是 5.8 周期里补上的,修的是 GRayTracingGeometryManager->Tick() 与 Cable Pipe 上逻辑之间的竞态。光追几何管理器的锁也是同一个原因:
// Engine/Source/Runtime/Engine/Public/Rendering/RayTracingGeometryManager.h
// Operations such as registering geometry/groups can be done from different render command pipes (eg: SkeletalMesh)
// so need to use critical section in relevant functions
FCriticalSection MainCS;把一条命令挪进异步 Pipe,就是把它的执行线程挪走。所有默认”只会在渲染线程上被访问”的共享状态都要重新审视,这是接入时最容易漏掉的一件事。
8.2 FRenderCommandFence
fence 的语义是”等这一刻之前发出的渲染工作”,异步 Pipe 上的命令当然也算:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(FRenderCommandFence::BeginFence,节选)
if (GRenderCommandPipeMode == ERenderCommandPipeMode::All)
{
for (FRenderCommandPipe* Pipe : UE::RenderCommandPipe::GetPipes())
{
// Skip pipes that aren't recording or replaying any work.
if (Pipe->IsRecording() && !Pipe->IsEmpty())
{
UE::Tasks::FTaskEvent PipeEvent { UE_SOURCE_LOCATION };
Event.AddPrerequisites(PipeEvent);
ENQUEUE_RENDER_COMMAND(BeginFence)(Pipe, [PipeEvent = MoveTemp(PipeEvent)](FRHICommandList&) mutable
{
PipeEvent.Trigger();
});
}
}
}手法是把”触发事件”本身作为一条命令发进 Pipe:事件被触发的位置,就是这条 Pipe 在此之前的命令都执行完的位置。不需要新的同步原语,直接复用 Pipe 自己的顺序保证。IsEmpty() 看的是在途命令数和在途命令列表数,空 Pipe 不挂事件。随后渲染线程上还会入队一条 BeginFence 命令触发总事件;ESyncDepth::RHIThread / Swapchain 时再往更深处挂依赖。
两个边界:fence 只覆盖 BeginFence 之前发出的命令,不会关闭生产入口;对异步 Pipe,它保证的是”命令执行完”,这些命令录出的 RHI 命令要到 Stop 才交给 RHI,所以即使是 RHIThread 深度的 fence,也不覆盖它们的翻译和提交。
8.3 FenceBundler
FPendingCleanupObjects 析构时批量 delete 延迟清理对象,这些析构函数里有大量 BeginFence。Bundler 让这段时间里 RenderThread 深度的 fence 共用一个事件,开始合批前先把 Pipe 停掉:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(StartRenderCommandFenceBundler / StopRenderCommandFenceBundler,节选)
// Stop render command pipes so that the bundled render command fence is serialized with other render commands.
GRenderCommandFenceBundlerState.RenderCommandPipeBits = UE::RenderCommandPipe::StopRecording();
// ...
// Restart render command pipes that were previously recording.
UE::RenderCommandPipe::StartRecording(GRenderCommandFenceBundlerState.RenderCommandPipeBits);理由很实在:合批期间释放资源的命令必须和合批 fence 站在同一条时间线上,不能被异步 Pipe 抢跑或拖后。Bundler 生效期间,RenderThread 深度的 BeginFence 直接复用合批事件,连上面那段 Pipe 循环都不走,因为 Pipe 已经被停掉了。位图保存与恢复和 FSyncScope 是同一套。相关开关是 r.RenderCommandFenceBundling 和 g.bEnablePendingCleanupObjectsCommandBatching,默认都开。
8.4 FlushRenderingCommands
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(FlushRenderingCommands,节选)
FRenderCommandList::FFlushScope FlushScope; // 先处理当前线程正在录的 FRenderCommandList(9.4)
// ...
UE::RenderCommandPipe::StopRecording();
ENQUEUE_RENDER_COMMAND(FlushPendingDeleteRHIResourcesCmd)([](FRHICommandListImmediate& RHICmdList)
{
RHICmdList.ImmediateFlush(EImmediateFlushType::FlushRHIThreadFlushResources);
//double flush to flush out the deferred deletions queued into the ImmediateCmdList
RHICmdList.ImmediateFlush(EImmediateFlushType::FlushRHIThread);
});
FPendingCleanupObjects* PendingCleanupObjects = GetPendingCleanupObjects();
FFrameEndSync::Sync(FFrameEndSync::EFlushMode::Threads); // 等 CPU 侧渲染工作全部完成,不与 GPU 同步
delete PendingCleanupObjects;❗ FlushRenderingCommands 只 Stop 不 Start。帧中间调一次,这一帧剩下的时间里所有异步 Pipe 都退回渲染线程,直到下一帧 FEngineLoop::Tick 的 StartRecording(嵌套在 FSyncScope 或 FenceBundler 里时,由它们按位图恢复)。频繁 Flush 的代价不只是阻塞,还会让本帧的 Pipe 并行直接失效。它也不是 GPU idle:EFlushMode::Threads 的注释写明不与 GPU 同步。
8.5 StopRecording 委托
UE::RenderCommandPipe::GetStopRecordingDelegate() 在每次 StopRecording 之后广播被停掉的 Pipe 位图,帧尾、FSyncScope、FenceBundler、Flush、改 r.RenderCommandPipeMode 都算。位图为空(没有 Pipe 在录,甚至模式不是 All)时也照样广播。
FSkeletalMeshUpdater 就挂在这里,把游戏线程攒下的骨骼网格更新推给渲染线程:
// Engine/Source/Runtime/Engine/Private/SkeletalMeshUpdater.cpp(FSkeletalMeshUpdater 构造函数,#if !WITH_STATE_STREAM,节选)
DelegateHandle = UE::RenderCommandPipe::GetStopRecordingDelegate().AddLambda([this] (const FRenderCommandPipeBitArray&)
{
if (bInAsyncPushCommandsRegion)
{
return;
}
// 从各 channel 取出攒下的 op 队列,放进 ChannelsToPush(省略)
ENQUEUE_RENDER_COMMAND(FSkeletalMeshUpdater_PopFromQueues)([this, ChannelsToPush = MoveTemp(ChannelsToPush)](FRHICommandList& RHICmdList) mutable
{
for (auto& KeyValue : ChannelsToPush)
{
KeyValue.Key->PushToStream(MoveTemp(KeyValue.Value));
}
});
});SkeletalMeshUpdater.h 里的设计说明是:同步点已经标出了渲染前的关键位置,直接复用。这是同步语义的一个副产品,一批免费的、全引擎一致的提交点。异步 EndOfFrameUpdate 区间内(bInAsyncPushCommandsRegion)不走委托,由 AddPushCommandsTask 发一个推送任务,再入队一条先 Wait 这个任务的渲染线程命令。
8.6 对照
| 机制 | 用途 | 不要误解为 |
|---|---|---|
FSyncScope | 在渲染线程时间线上夹出串行区间 | 游戏线程立刻拿到 Pipe 的结果 |
FRenderCommandFence | 观察某一时刻之前的命令是否执行完 | 关闭生产入口,覆盖之后的命令 |
| FenceBundler | 批量释放时合并 RenderThread 深度的 fence | 与 Pipe 录制无关 |
FlushRenderingCommands | 排空 CPU 侧渲染与 RHI 工作,清理延迟对象 | GPU idle |
九、FRenderCommandList 按线程录制
9.1 要解决什么
异步 Pipe 解决的是”某些命令换个线程执行”,生产侧的问题还在:游戏侧本身在 ParallelFor 里并行产生命令时,每条命令都要抢一次 Pipe 锁,而且多个线程往同一队列写,先后取决于谁先抢到锁。FRenderCommandList(5.7 引入)让每个生产线程先录进自己的列表,最后按确定的位置拼回命令流。
它内部是 GetPipes().Num() + 1 个 FCommandList:每条 Pipe 一格,最后一格给渲染线程。FRecordScope 把列表绑到 TLS,此后这个线程上的 ENQUEUE_RENDER_COMMAND,不论带没带 Pipe,都落进对应格子,不加锁、不发任务。FRecordScope 带引用计数检查,同一个列表被嵌套绑定或多线程同时录制会直接 checkf。
9.2 Close 与 Submit
| 操作 | 含义 |
|---|---|
Close() | 录制结束,触发 DispatchTaskEvent |
FRenderCommandDispatcher::Submit(List, Parent) | 把列表放进命令流:Parent 为空时取当前线程绑定的列表,仍为空就提交给全局 Pipe |
两者可以分开。默认标志的列表构造时就带 DispatchTaskEvent,允许先 Submit 占住位置、后 Close:渲染线程回放前会 Wait 这个事件,异步 Pipe 则把它当成批次任务的前置。
ERenderCommandListFlags::CloseOnSubmit 是快速路径:Submit 时顺便 Close;懒初始化,从没写过命令的列表在 Submit 时直接自删;挂进父列表时空格子直接跳过。它是给”每个 worker 一个列表、其中很多是空的”这种 ParallelFor 用法准备的。
9.3 挂进父列表,或者投给全局 Pipe
有父列表:每个格子作为一个 FExecuteCommandListCommand 节点挂进父列表的同一格,父列表消费时原地展开;父列表接管子列表的析构;子列表的 DispatchTaskEvent 作为前置传播给父列表。
没有父列表:先把 NumPipeRefs 设成最大可能的引用数(源码注释强调这一步必须在前,避免与 Pipe 线程上的释放竞争),再逐格 Pipe->Enqueue(this);投不进去的(Pipe 不在录)记进 PipeEnqueueFailedBits,交给渲染线程和它自己那一格一起执行;最后释放没用上的引用。每个执行域用完调用 ReleasePipeRef(),减到 0 才 delete this。5.8 周期里修过一个 ReleasePipeRefs 传入 0 时的崩溃。
渲染线程这一侧的回放很短:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp
void FRenderThreadCommandPipe::ExecuteCommands(FRenderCommandList* CommandList)
{
// Wait for recording of commands to be complete prior to replay.
if (const UE::Tasks::FTask* DispatchTask = CommandList->TryGetDispatchTask())
{
DispatchTask->Wait();
}
// Execute commands for pipes that failed to enqueue due to not being in a recording state.
if (!CommandList->PipeEnqueueFailedBits.IsEmpty())
{
for (TConstSetBitIterator<FRenderCommandPipeBitArrayAllocator> BitIt(CommandList->PipeEnqueueFailedBits); BitIt; ++BitIt)
{
ExecuteCommands(CommandList->Get(BitIt.GetIndex()));
}
}
ExecuteCommands(CommandList->GetRenderThread());
CommandList->ReleasePipeRef();
}❗ 同一个 FRenderCommandList 里发往不同格子的命令之间没有相对顺序。哪怕所有 Pipe 都没在录、全部命令最终都在渲染线程上执行,顺序也是”先各 Pipe 格子(按序号),再渲染线程格子”,不是录制顺序。需要保序的命令就得发到同一格。
异步 Pipe 收到带 DispatchTaskEvent 的列表时,先 ResetContext(),再发一个以 {RecordTask, DispatchTask} 为前置的任务:列表之前的普通命令留在旧 Context 的任务里,列表之后的命令进新 Context、排在列表任务之后,前后顺序不乱。不带事件的列表则包成一条普通命令入队。
9.4 FFlushScope
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp
FRenderCommandList::FFlushScope::FFlushScope()
{
CommandList = SetInstanceTLS(nullptr);
if (CommandList)
{
CommandList->Flush();
}
}当前线程正在录一个列表时调用 FRenderCommandFence::Wait 或 FlushRenderingCommands,fence 命令本身会被录进这个还没提交的列表,等待永远不会结束。FFlushScope 先把 TLS 解绑;如果列表是 CloseOnSubmit 且还没提交,就把已录内容搬进一个新列表立即提交;作用域结束再绑回去。这是 2025 年 5 月为修这个死锁加的,FRenderCommandFence::Wait 和 FlushRenderingCommands 开头都有它。非 CloseOnSubmit 的列表 Flush() 直接返回,只解绑、不提交。
9.5 典型用法:EndOfFrameUpdate
// Engine/Source/Runtime/Engine/Private/LevelTick.cpp(UWorld::SendAllEndOfFrameUpdatesInternal,节选)
FRenderCommandList* RootRenderCommandList = FRenderCommandList::Create(ERenderCommandListFlags::CloseOnSubmit);
FRenderCommandList::FRecordScope RecordScope(RootRenderCommandList, FRenderCommandList::EStopRecordingAction::Submit);
if (bLaunchAsyncTask)
{
FRenderCommandList* TaskRenderCommandList = FRenderCommandList::Create(); // 默认标志,带 DispatchTaskEvent
EndOfFrameUpdateTasks.Emplace(UE::Tasks::Launch(UE_SOURCE_LOCATION, [Context, ParallelWork, TaskRenderCommandList, NumContexts, ParallelForFlags]
{
FRenderCommandList::FRecordScope RecordScope(TaskRenderCommandList, FRenderCommandList::EStopRecordingAction::Close);
FRenderCommandList::FParallelForContext ParallelForContext(TaskRenderCommandList, NumContexts);
ParallelForWithExistingTaskContext(TEXT("EndOfFrameUpdate"), ParallelForContext.GetCommandLists(), Context->Components.Num(), 1, ParallelWork, ParallelForFlags);
}, EndOfFrameUpdatePrerequisiteTask, UE::Tasks::ETaskPriority::High));
FRenderCommandDispatcher::Submit(TaskRenderCommandList, RootRenderCommandList); // 先占位,任务结束时才 Close
Context->Update_GameThread(Scene, RootRenderCommandList);
}
else
{
// 同步分支:每个 worker 一个 CloseOnSubmit 列表,FParallelForContext 析构时挂进根列表
FRenderCommandList::FParallelForContext ParallelForContext(RootRenderCommandList, Context->Components.Num(), 1, ParallelForFlags);
ParallelForWithPreWorkWithExistingTaskContext(TEXT("EndOfFrameUpdate"), ParallelForContext.GetCommandLists(), Context->Components.Num(), 1, ParallelWork, GameThreadWork, ParallelForFlags);
}每个 worker 在 UpdateComponent_Parallel 里用 FRecordScope 绑定自己的列表,组件 DoDeferredRenderUpdates_Concurrent 一路发出的命令全部无锁地落进去。异步分支主要由 UWorld::StartAsyncSendAllEndOfFrameUpdates 触发,需要 AllowAsyncRenderThreadUpdates=2(默认 1)。
9.6 名字相近的几样东西
| 名字 | 所在层次 | 说明 |
|---|---|---|
FRenderCommandList | 渲染命令录制 | 装渲染 lambda,按 Pipe 分格 |
FRHICommandList | RHI 命令录制 | 装 RHI 命令,异步 Pipe 回放时往里录 |
FRenderThreadCommandPipe / FRenderCommandPipe | 渲染命令回放 | 渲染线程 Pipe 与异步 Pipe |
UE::Tasks::FPipe | 通用任务系统 | 串行执行域;带前置依赖的任务可能改变进入顺序;RenderCommandPipe 早期用过它 |
FRHICommandListExecutor::FTaskPipe | RHI dispatch / translate / submit | RHI 自己的 FIFO 工作项链 |
十、实例 CPU Skin 的一次更新
CPU Skin 是 5.8 里仍直接走 SkeletalMesh Pipe、前后路径都完整的例子:
游戏线程 / EndOfFrameUpdate 的 worker
USkinnedMeshComponent::SendRenderDynamicData_Concurrent
└─ FSkeletalMeshObjectCPUSkin::Update
new FDynamicSkelMeshObjectDataCPUSkin 本次更新的数据快照
ENQUEUE_RENDER_COMMAND(SkelMeshObjectUpdateDataCommand)(SkeletalMesh, ...)
SkeletalMesh Pipe 回放(worker,EParallelRenderingThread)
FSkeletalMeshObjectCPUSkin::UpdateDynamicData_RenderThread
delete 旧 DynamicData,换入新数据
CacheVertices check(IsInParallelRenderingThread())
CPU 蒙皮,结果写进 Position / StaticMesh 顶点缓冲的 CPU 数据
PositionVertexBuffer.UpdateRHI(RHICmdList) 录进 Pipe 的 FRHICommandList
重新绑定 FLocalVertexFactory,SetData + InitResource
渲染线程
BeginRenderingViewFamilies / FSceneRenderProcessor::Execute 里的 FSyncScope
RenderCommandPipe_Stop:等齐批次,交出 RHI 命令表
场景渲染使用更新后的顶点缓冲与 VF
生产端:
// Engine/Source/Runtime/Engine/Private/SkeletalRenderCPUSkin.cpp(FSkeletalMeshObjectCPUSkin::Update,节选)
FDynamicSkelMeshObjectDataCPUSkin* NewDynamicData = new FDynamicSkelMeshObjectDataCPUSkin(InDynamicData, InSkinnedAsset, SkeletalMeshRenderData, LODIndex, InActiveMorphTargets, InMorphTargetWeights);
FSkeletalMeshObjectCPUSkin* MeshObject = this;
ENQUEUE_RENDER_COMMAND(SkelMeshObjectUpdateDataCommand)(UE::RenderCommandPipe::SkeletalMesh,
[MeshObject, FrameNumberToPrepare, RevisionNumber, NewDynamicData](FRHICommandList& RHICmdList)
{
FScopeCycleCounter Context(MeshObject->GetStatId());
MeshObject->UpdateDynamicData_RenderThread(RHICmdList, NewDynamicData, FrameNumberToPrepare, RevisionNumber);
}
);几个值得注意的点:
_RenderThread后缀的函数实际跑在 Pipe 的 worker 上。CacheVertices检查的是IsInParallelRenderingThread(),所以不用改。- Init、Update、Release 在同一条 Pipe 上。 顶点缓冲用
BeginInitResource(&PositionVertexBuffer, &UE::RenderCommandPipe::SkeletalMesh)初始化,VF 初始化命令、BeginReleaseResource(&VertexFactory, &UE::RenderCommandPipe::SkeletalMesh)等释放也都指定这条 Pipe。BeginInitResource/BeginUpdateResourceRHI/BeginReleaseResource都有一个可选的FRenderCommandPipe*参数。同一对象的生命周期命令落在同一条串行链上,天然有序。 - C++ 对象的寿命另算。 命令捕获的是
MeshObject裸指针。组件销毁渲染状态时先MeshObject->ReleaseResources(),再BeginCleanup(MeshObject),真正的 delete 由延迟清理在这些 Release 命令执行完之后才做,而不是 Release 一入队就删。 - 并行度的边界。 所有角色的 CPU Skin 更新都在 SkeletalMesh 这一条链上串行。收益来自和游戏线程、渲染线程、其他 Pipe 的重叠,不是”一个角色一个 worker”。
GPU Skin 走的是另一条路:FSkeletalMeshObjectGPUSkin::Update 最后调用 UpdateHandle.Update(NewDynamicData),进入 FSkeletalMeshUpdater,借 8.5 的 StopRecording 委托把更新推给渲染线程,再在后续场景渲染里分阶段处理:Mesh Deformer → Skin Cache → 直接写网格绘制命令的 inline skinning,最后一个后台任务做骨骼缓冲、VF 这类 RHI 更新。GPU Skin 资源和 VF 的初始化、释放仍走 SkeletalMesh Pipe。
十一、版本演进
5.4 到 5.8 的主线没变,状态组织和周边能力一直在补:
| 时间(主干) | 变化 |
|---|---|
| 2023 年 8 月(5.4 周期) | 首次进入主干:DEFINE/DECLARE_RENDER_COMMAND_PIPE、FSyncScope、r.RenderCommandPipeMode 三档,渲染线程与异步 Pipe 都改成 MPSC 队列加批量回放 |
| 2023 年 9–10 月 | 同步点只重启原本在录的 Pipe;Fence 开始覆盖 Pipe,加上 Insights region;修掉 StopRecording 与并发入队的竞态 |
| 2024 年 1 月 | 同 Pipe 重入就地执行,跨 Pipe 入队加 ensure |
| 2024 年 4 月 | 异步批次不再用 UE::Tasks::FPipe 串行(WaitUntilEmpty 有竞态),改为自己维护尾任务 |
| 2025 年 1 月 | StopRecording 委托 |
| 2025 年 5 月(5.7 周期) | FContext 加侵入式链表取代 TArray 队列与每窗口一个的 FFrame;引入 FRenderCommandList、FFlushScope |
| 2026 年 1 月(5.8 周期) | 命令 tag 增加 ERenderCommandCategory 与 State Stream 钩子;AutoRTFM 事务内的命令推迟到提交时入队 |
5.6 和 5.8 的对照,方便在两个版本之间来回读代码:
| 维度 | 5.6 | 5.8 |
|---|---|---|
| 入口 | ENQUEUE_RENDER_COMMAND 展开成 FRenderCommandPipe::Enqueue<Tag> 静态模板 | FRenderCommandDispatcher::Enqueue<Tag>,外加 TLS 录制与 AutoRTFM 分支 |
| 渲染线程 Pipe | 两个 TArray<FCommand> 双缓冲,ProduceIndex 交替 | FContext(FMemStackBase 加侵入式链表),整体 Move |
| 异步 Pipe | 每个录制窗口一个 FFrame(TArray 队列、LastTask、RHI 命令表),Frame_GameThread / Frame_RenderThread | FContext 加 RecordTask,ResetContext() 原地换上下文,RHI 命令表挂在 Pipe 上 |
| 按线程录制 | 无 | FRenderCommandList、FRecordScope、FParallelForContext、FFlushScope |
| 命令 tag | 名字、stat id、spec id | 另加 ERenderCommandCategory |
| 录制窗口、位图恢复、Fence 覆盖 Pipe、FenceBundler 停启、RHI 资源寿命计数、StopRecording 委托 | 已有 | 相同 |
十二、调试与开关
| CVar | 默认 | 说明 |
|---|---|---|
r.RenderCommandPipeMode | 2 | 0:每条命令一个渲染线程任务;1:只启用渲染线程 Pipe,异步 Pipe 的命令转给它;2:全部启用 |
r.RenderCommandPipe.<Name> | 1 | 单条 Pipe 的开关,下一次 StartRecording 起生效 |
r.RenderCommandFenceBundling | 1 | 是否允许 fence 合批 |
g.bEnablePendingCleanupObjectsCommandBatching | 1 | 批量析构延迟清理对象时是否启用 FenceBundler |
r.RenderCommandPipeMode 的值要经过一次收敛,渲染线程启动后和 CVar 改变时各算一次:
// Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp(GetValidatedRenderCommandPipeMode)
const bool bAllowThreading = !GRHICommandList.Bypass() && FApp::ShouldUseThreadingForPerformance() && GIsThreadedRendering;
if (Mode == ERenderCommandPipeMode::All && !bAllowThreading)
{
Mode = ERenderCommandPipeMode::RenderThread;
}
if (!FApp::CanEverRender() || IsMobilePlatform(GMaxRHIShaderPlatform))
{
Mode = ERenderCommandPipeMode::None;
}❗ 当前 shader 平台是移动平台时强制 None:移动端的 render command 仍是逐条任务,既没有批处理也没有异步 Pipe,PC 上测得的渲染线程 render command 开销不能直接套到移动端。RHI Bypass 或不适合多线程的环境下,2 会降成 1。运行时改这个 CVar,回调里先 StopRecording() 再切模式,异步 Pipe 从下一次 StartRecording 起按新模式工作。
Unreal Insights。相关事件都在 RenderCommands 这个 trace channel 上(channel 名大小写不敏感,启动参数可写 -trace=default,rendercommands):
- 三个 region:
Render Command Pipe Recording(录制窗口)、Render Command Pipe Synced(FSyncScope区间)、Render Command Fence Bundler(合批区间),只有打开这个 channel 才会发出。 - 异步 Pipe 的批次任务以 Pipe 名作为 named event,里面还有
RenderCommandPipe LaunchTask(生产端发批次)和RenderCommandPipe ReplayCommands(回放一批)两个 CPU scope;每条命令本身的 CPU scope 也挂在这个 channel 上。 - 游戏线程上有
FRenderCommandPipe_StartRecording/FRenderCommandPipe_StopRecording两个 named event。
值得看的是:SkeletalMesh 的批次是否真和渲染线程重叠;RenderCommandPipe_Stop 里 Wait 的时长,最慢的 Pipe 就是汇合点的等待来源;帧中间的 Flush 之后,本帧还有没有 Pipe 批次。
⚠️ 粗略的成本模型:N 条需要排队的命令合成 K 个批次,逐条任务的调度开销与 N 成正比,批处理后与 K 成正比,但入队和函数调用仍是 N 次。生产越密、消费越慢,K 越小;生产节奏恰好让每条命令都单独成批时,收益趋近于零。异步 Pipe 的理想收益是把各域回放时间从”求和”变成”取最长”,再扣掉开门延迟、Stop 等待和 RHI 交接;工作都挤在一条 Pipe 上时,开到 2 也切不开这条串行链。评估时在同一场景下对比 0 / 1 / 2 三档,看渲染线程上的 render command 时间、各 Pipe 的回放区间、Stop 的等待和 GT→RT 延迟,而不是只数任务数。
十三、接入异步 Pipe 的检查清单
把一条命令搬进异步 Pipe,或者给自己的系统定义一条新 Pipe 之前,逐条过一遍:
- 状态归谁。 命令只读写自己捕获的数据和 RHI 资源吗?读写渲染线程当前状态(场景代理上的实例列表、immediate 命令表、RDG)的命令不能进。同一对象的 Init / Update / Release 放在同一条 Pipe 上。
- 签名。 用
void(FRHICommandList&)(FRHICommandListBase&也行)或void();写成FRHICommandListImmediate&编译就过不去。 - 执行线程。 命令会在 worker 上执行,包括其中的
delete。它碰到的共享容器要自带同步(参考光追几何管理器的MainCS);断言用IsInParallelRenderingThread(),IsInRenderingThread()在这里是 false。 - 可见性。 消费方在不在同步点之后?“Pipe 与 Pipe""Pipe 与渲染线程”之间的顺序都要靠
FSyncScope/ fence 建立;游戏线程需要结果时自己等 fence。 - 寿命。 捕获的裸指针、
FRenderResource、UObject*要活到命令执行完。RHI 资源寿命计数只管FRHIResource。 - 不跨 Pipe,不在 Pipe 里 Flush。 Pipe 命令里向另一条在录的 Pipe 发命令会触发 ensure;在 Pipe 命令里调
FlushRenderingCommands(),可能和正在 Stop 里等这条 Pipe 的渲染线程互相等待。 - 子任务。 命令里再
Launch的任务不会自动计入批次完成,需要参与 Stop 的等待就用AddNested或显式依赖;同一张 RHI 命令表也不能被多个任务并发录制。 - 录制收尾。 Stop 时的
FinishRecording会检查未关闭的 fence scope 和未完成的 buffer/texture upload,命令里开了就要在命令里收。 - 工作量。 一次入队是一次加锁、一次链表追加,可能再加一次任务发射。只改一个指针的命令放进 Pipe 省不了多少时间,骨骼网格资源创建、CPU 蒙皮这类重活才是异步 Pipe 的主力客户。
- ❗ 模块加载时机。
UE::RenderCommandPipe::Initialize()只在FEngineLoop::PreInitPostStartupScreen末尾给已构造的 Pipe 分配一次序号。之后才加载的模块里定义的 Pipe 没有序号,永远不会进入录制状态,命令会一直静默退回渲染线程。
十四、设计回顾与源码坐标
回头看,RenderCommandPipe 推翻了三个”理所当然”:
- 命令和任务不必一一对应。 把多条命令录进一段连续内存,用一次调度带走,调度成本按批次而不是按条数付。前提是命令可录制、不依赖当下的即时副作用,这正是
FRHICommandList那套录制语义。 - 顺序不必由线程提供。 批内顺序由链表保证,批间顺序由任务前置链保证,同一批命令放到哪个线程上执行都不影响观察到的顺序。Pipe 之间刻意不保证顺序,这也是跨 Pipe 发命令被拦下的原因。
- 引入并行,就必须把同步点显式化。 Start 门闩、Stop 汇合、位图恢复、fence 向 Pipe 投递触发命令、FenceBundler 前后停启、RHI 资源寿命计数,这套系统里最重的不是 Pipe 本身,而是围绕同步点建立的一致性保护。
一句话:RenderCommandPipe 让”必须按顺序发生的状态更新”不再等于”必须在同一个线程上逐条调度”,但每一份被放出去的工作,都得在正确的消费和销毁边界重新建立完成关系。
| 内容 | 位置 |
|---|---|
Pipe、FRenderCommandList、分发器、宏的声明 | Engine/Source/Runtime/RenderCore/Public/RenderingThread.h |
| 注册表、批量回放、Fence、FenceBundler、Flush | Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp |
| 命令 tag 与 category | Engine/Source/Runtime/RenderCore/Public/RenderCommandTag.h |
| State Stream 钩子(默认不编译) | Engine/Source/Runtime/RenderCore/Private/RenderCommandStateStream.cpp |
| RHI 资源寿命计数与删除队列 | Engine/Source/Runtime/RHI/Private/RHIResources.cpp |
Submit、QueueAsyncCommandListSubmit、FinishRecording | Engine/Source/Runtime/RHI/Private/RHICommandList.cpp |
| 帧首帧尾的录制窗口 | Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp(FEngineLoop::Tick) |
| 按线程录制的典型用法 | Engine/Source/Runtime/Engine/Private/LevelTick.cpp |
| SkeletalMesh Pipe 与 CPU Skin | Engine/Source/Runtime/Engine/Private/Rendering/RenderCommandPipes.cpp、Engine/Source/Runtime/Engine/Private/SkeletalRenderCPUSkin.cpp |
| StopRecording 委托的使用方 | Engine/Source/Runtime/Engine/Private/SkeletalMeshUpdater.cpp |
按”声明 → 注册表 Start/Stop → Pipe 回放 → 调用点”的顺序读最省力。
十五、参考链接
🔗 Epic 官方文档 - Threaded Rendering in Unreal Engine
🔗 Epic 官方文档 - Parallel Rendering Overview for Unreal Engine
🔗 Epic 官方文档 - Tasks Systems in Unreal Engine
🔗 Epic API 文档 - RenderCore 模块(FStopRecordingDelegate、FRenderCommandFunctionVariant 等类型索引)
🔗 Epic API 文档 - FRenderCommandList::FParallelForContext
🔗 Epic 官方文档 - Unreal Insights Reference(trace channel 与 -trace 参数)
🔗 Epic 官方文档 - Timing Insights
🔗 Epic 教程 - New GPU Profiler and RHI Submission Pipeline