来源:UE5 渲染线程源码阅读笔记(RenderCommandPipe 部分)
引擎版本:UE 5.8
面向读者:UE 图形程序员


ENQUEUE_RENDER_COMMAND 在 UE5 里早就不是”一条命令一个 TaskGraph 任务”了。命令先被录进一条链表,由一个任务成批回放;骨骼网格、毛发、缆绳、Niagara 动态数据这几类命令还能离开渲染线程,在 worker 上按各自的顺序回放,再在帧里的几个同步点汇回渲染线程时间线。这套机制叫 RenderCommandPipe。它不碰 GPU 队列,也不改任何 Shader,改的是 CPU 侧渲染命令的调度粒度和执行位置。代码路径均相对引擎根目录。

目录

  1. 先说结论
  2. 整体结构
  3. 入口与分流
  4. 批处理内核
  5. 录制窗口 Start 与 Stop
  6. 交给 RHI 之后
  7. RHI 资源的延迟删除
  8. 同步点家族
  9. FRenderCommandList 按线程录制
  10. 实例 CPU Skin 的一次更新
  11. 版本演进
  12. 调试与开关
  13. 接入异步 Pipe 的检查清单
  14. 设计回顾与源码坐标
  15. 参考链接

一、先说结论

旧模型里,一条 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 的命令
SkeletalMeshEngine/Source/Runtime/Engine/Private/Rendering/RenderCommandPipes.cpp七十多处骨骼网格 LOD 资源(顶点、索引、蒙皮权重缓冲)的 Init/Release,CPU Skin 更新,PoseWatch 数据
GroomEngine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Private/GroomResources.cpp6 处,另有 11 处被注释掉毛发资源与绑定数据的释放,GroomCache 更新
CableEngine/Plugins/Runtime/CableComponent/Source/CableComponent/Private/CableComponent.cpp3 处缆绳顶点/索引缓冲初始化,动态数据下发
NiagaraDynamicDataEngine/Plugins/FX/Niagara/Source/Niagara/Private/NiagaraComponent.cpp3 处组件动态数据设置、渲染开关、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:一条命令按判定顺序可能落到的四个去处。

  1. 当前线程绑定了 FRenderCommandList(FRecordScope 生效中):追加到对应格子,不加锁、不发任务。三组重载都先检查这一点(不带 Pipe 的版本之前还有一个 AutoRTFM 判断,见 3.3)。
  2. 带 Pipe,GRenderCommandPipeMode == All,且 Pipe->Enqueue 接受:进这条 Pipe。Enqueue 先看当前线程是否正在回放这条 Pipe,是就当场执行(4.4);否则加 Pipe 锁,检查 RecordTask 是否有效,只有录制窗口内才有效。
  3. 其余情况进 FRenderThreadCommandPipe::Enqueue。带 Pipe 的命令在这之前被包一层 [Function](FRHICommandListImmediate&) { ... }。
  4. 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 idleGPU 执行到对应位置——

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
光追几何管理器的 TickFRendererModule::PostRenderAllViewports(RHI_RAYTRACING)
场景销毁、世界原点平移FScene::Release、FScene::ApplyWorldOffset(RendererScene.cpp)
材质变更后重建网格绘制命令UpdateStaticMeshesForMaterials(RendererScene.cpp)、FMaterialUpdateContext 析构(MaterialShared.cpp)
组件重注册后刷新 primitive scene infoUpdateAllPrimitiveSceneInfosForSingleComponent 等(ActorComponent.cpp)
只同步 SkeletalMesh 一条 PipeNiagaraGpuComputeDispatch.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 分格
FRHICommandListRHI 命令录制装 RHI 命令,异步 Pipe 回放时往里录
FRenderThreadCommandPipe / FRenderCommandPipe渲染命令回放渲染线程 Pipe 与异步 Pipe
UE::Tasks::FPipe通用任务系统串行执行域;带前置依赖的任务可能改变进入顺序;RenderCommandPipe 早期用过它
FRHICommandListExecutor::FTaskPipeRHI dispatch / translate / submitRHI 自己的 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.65.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_RenderThreadFContext 加 RecordTask,ResetContext() 原地换上下文,RHI 命令表挂在 Pipe 上
按线程录制无FRenderCommandList、FRecordScope、FParallelForContext、FFlushScope
命令 tag名字、stat id、spec id另加 ERenderCommandCategory
录制窗口、位图恢复、Fence 覆盖 Pipe、FenceBundler 停启、RHI 资源寿命计数、StopRecording 委托已有相同

十二、调试与开关

CVar默认说明
r.RenderCommandPipeMode20:每条命令一个渲染线程任务;1:只启用渲染线程 Pipe,异步 Pipe 的命令转给它;2:全部启用
r.RenderCommandPipe.<Name>1单条 Pipe 的开关,下一次 StartRecording 起生效
r.RenderCommandFenceBundling1是否允许 fence 合批
g.bEnablePendingCleanupObjectsCommandBatching1批量析构延迟清理对象时是否启用 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 之前,逐条过一遍:

  1. 状态归谁。 命令只读写自己捕获的数据和 RHI 资源吗?读写渲染线程当前状态(场景代理上的实例列表、immediate 命令表、RDG)的命令不能进。同一对象的 Init / Update / Release 放在同一条 Pipe 上。
  2. 签名。 用 void(FRHICommandList&)(FRHICommandListBase& 也行)或 void();写成 FRHICommandListImmediate& 编译就过不去。
  3. 执行线程。 命令会在 worker 上执行,包括其中的 delete。它碰到的共享容器要自带同步(参考光追几何管理器的 MainCS);断言用 IsInParallelRenderingThread(),IsInRenderingThread() 在这里是 false。
  4. 可见性。 消费方在不在同步点之后?“Pipe 与 Pipe""Pipe 与渲染线程”之间的顺序都要靠 FSyncScope / fence 建立;游戏线程需要结果时自己等 fence。
  5. 寿命。 捕获的裸指针、FRenderResource、UObject* 要活到命令执行完。RHI 资源寿命计数只管 FRHIResource。
  6. 不跨 Pipe,不在 Pipe 里 Flush。 Pipe 命令里向另一条在录的 Pipe 发命令会触发 ensure;在 Pipe 命令里调 FlushRenderingCommands(),可能和正在 Stop 里等这条 Pipe 的渲染线程互相等待。
  7. 子任务。 命令里再 Launch 的任务不会自动计入批次完成,需要参与 Stop 的等待就用 AddNested 或显式依赖;同一张 RHI 命令表也不能被多个任务并发录制。
  8. 录制收尾。 Stop 时的 FinishRecording 会检查未关闭的 fence scope 和未完成的 buffer/texture upload,命令里开了就要在命令里收。
  9. 工作量。 一次入队是一次加锁、一次链表追加,可能再加一次任务发射。只改一个指针的命令放进 Pipe 省不了多少时间,骨骼网格资源创建、CPU 蒙皮这类重活才是异步 Pipe 的主力客户。
  10. ❗ 模块加载时机。 UE::RenderCommandPipe::Initialize() 只在 FEngineLoop::PreInitPostStartupScreen 末尾给已构造的 Pipe 分配一次序号。之后才加载的模块里定义的 Pipe 没有序号,永远不会进入录制状态,命令会一直静默退回渲染线程。

十四、设计回顾与源码坐标

回头看,RenderCommandPipe 推翻了三个”理所当然”:

  1. 命令和任务不必一一对应。 把多条命令录进一段连续内存,用一次调度带走,调度成本按批次而不是按条数付。前提是命令可录制、不依赖当下的即时副作用,这正是 FRHICommandList 那套录制语义。
  2. 顺序不必由线程提供。 批内顺序由链表保证,批间顺序由任务前置链保证,同一批命令放到哪个线程上执行都不影响观察到的顺序。Pipe 之间刻意不保证顺序,这也是跨 Pipe 发命令被拦下的原因。
  3. 引入并行,就必须把同步点显式化。 Start 门闩、Stop 汇合、位图恢复、fence 向 Pipe 投递触发命令、FenceBundler 前后停启、RHI 资源寿命计数,这套系统里最重的不是 Pipe 本身,而是围绕同步点建立的一致性保护。

一句话:RenderCommandPipe 让”必须按顺序发生的状态更新”不再等于”必须在同一个线程上逐条调度”,但每一份被放出去的工作,都得在正确的消费和销毁边界重新建立完成关系。

内容位置
Pipe、FRenderCommandList、分发器、宏的声明Engine/Source/Runtime/RenderCore/Public/RenderingThread.h
注册表、批量回放、Fence、FenceBundler、FlushEngine/Source/Runtime/RenderCore/Private/RenderingThread.cpp
命令 tag 与 categoryEngine/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、FinishRecordingEngine/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 SkinEngine/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