望蓟门 - 唐·祖咏
燕台一去客心惊,箫鼓喧喧汉将营。
万里寒光生积雪,三边曙色动危旌。
沙场烽火连胡月,海畔云山拥蓟城。
少小虽非投笔吏,论功还欲请长缨。
![](https://img.haomeiwen.com/i19200103/9b8b683fed555d5c.png)
Unreal Open Day有不少干货,今天准备入坑盘一盘,将学习的心得体会总结记录下来,这是第一篇。
这篇文章主要是对UE的多线程并行性渲染架构做一个介绍跟梳理,包括:
- 跟多线程提交相关的一些基础概念与数据结构
- 多线程提交的具体实现原理,其中当然也不可避免的会提到UE的RHI Thread跟Render Thread的一些实现流程
- 多线程提交的适用场景与使用注意事项
下面我们逐步来看下各个部分的具体内容,如果文中有描述不清楚的,烦请点击文末的参考链接,前往原文一探究竟。
1. 基础概念
![](https://img.haomeiwen.com/i19200103/1a38820c9ccd3000.png)
什么是并行渲染?总结来说,指的是充分利用多核硬件优势来提升渲染效率的技术,这里有两个关键要点:
- RHI Command的多线程并行生成与翻译
- RHI Resources的多线程并行创建与更新
并行渲染本身是有一些overhead消耗的,且实现架构也会更复杂,如果本身并不存在GPU或者CPU瓶颈,那么还是推荐使用单线程渲染,如果这二者存在较重负载压力,就建议试试并行渲染。
虽然是并行渲染,但实际上RHI Commands的提交顺序是需要保证跟单线程完全一致的(渲染指令之间存在一定的依赖关系,顺序差异可能导致性能或者效果的差异)。
![](https://img.haomeiwen.com/i19200103/84a5d4692dfdface.png)
这里先介绍一下什么是RHI。
RHI是Rendering Hardware Interface的缩写,指的是对底层渲染API的接口封装,以统一的上层接口进行调用,而不同平台会对这套接口进行重载来实现不同平台、不同渲染API的兼容;
![](https://img.haomeiwen.com/i19200103/df9bf0aaf796507e.png)
并行渲染有两种实现思路:
- 前端并行:平台无关的并行渲染实现
- 后端并行:基于平台独有特性实现的并行渲染
![](https://img.haomeiwen.com/i19200103/4ce8add6aabf55f7.png)
目前市面上大部分的游戏,所使用的游戏引擎,都会将游戏线程跟渲染线程区分开,渲染线程基于上一帧游戏线程的数据做渲染,以延迟一帧的方式来实现两者的并行计算。
在这种架构下,游戏线程执行完成后,就可以直接把数据发给渲染线程进行渲染,不用等待渲染结果的返回,就可以继续下一帧(N+1)的计算,只有当上一帧(N-1)还没有渲染完成时,需要等待(否则可能就会出现游戏线程超前越来越多,中间有大部分计算被丢弃而浪费)
![](https://img.haomeiwen.com/i19200103/e750ab072d87fa47.png)
并行渲染在指令提交给驱动时,也有两种做法:
- 由渲染线程直接将RHI指令提交给驱动
- 将RHI逻辑单拆出来,做成一个单独的线程,完成指令的提交
由于RHI指令需要跟驱动产生紧密的交互,部分指令甚至需要等待驱动的返回才能继续执行。
如果采用渲染线程提交的方式,就会大大拖慢渲染线程的工作效率,导致大量的等待,同时还会拖慢游戏线程,导致帧率的下滑。
因此目前现代引擎基本上会考虑采用单独RHI线程的方式完成RHI指令的提交,在这样的设计下,渲染线程就只需要关注跟渲染相关的裁剪、剔除等上层工作逻辑,底层的指令相关功能就移交给RHI线程代为完成
![](https://img.haomeiwen.com/i19200103/30d196dc8921ea6b.png)
结合前面的图,我们就有了这样的三条线程并行工作架构图,可以看到,渲染线程到RHI线程的指令发送,并不是在帧尾进行的,而是可以在执行过程中触发。
一般来说,渲染线程在一帧之内会多次向RHI线程发送指令,并在下一帧帧头等待RHI线程的结束,从而在较大程度上保证了两者的并行(用量化概念描述,并行度不如游戏线程跟渲染线程的并行度)。
这里需要补充一下指令发送的细节,渲染线程向RHI线程发送指令需要通过Task(异步)来完成,因此出于效率考虑,并不会一条一条的发送,而是会通过一个链表对指令进行搜集,并在合适的时机发送给RHI线程。
![](https://img.haomeiwen.com/i19200103/4be40c17736b2fae.png)
为了记录与管理这些指令,UE提供了一系列的数据结构,如上图所示的前端并行设计方案中的多个数据结构:
- FRHICommandBase:存储执行RHI API所需的数据的数据结构,并提供了一个可重载的Execute接口,这个类是RHI指令的基类,下面对应众多的实现子类
- FRHICommandList:
2.1 这个是RHI指令的容器类,用于装载那些用于延后在RHI线程上执行的RHI指令
2.2 在这个List类中提供了众多关于RHI指令的接口,接口执行前会先判断是否是Bypass模式,如果是的话,就直接调用RHI接口,否则就会创建RHI指令,并将指令填入前面说的链表中,这链表在实现上,实际上是这个类的一个成员变量CommandLink
2.3 这个结构中管理的所有RHI指令都是不需要立即返回结果的,如果需要比如LockTexture2D,就应该使用下面介绍的子类 - FRHICommandListImmediate:这是FRHICommandList的实现子类,这个单例类会用来完成一些需要在渲染线程立即执行的渲染指令,这些指令的执行需要触发RHI线程的flush
![](https://img.haomeiwen.com/i19200103/1f9667440e8bcd36.png)
FRHICommandListImmediate中接管的指令由于是立即执行的,而前面说过,我们提交的RHI指令是需要保序的,因此这里才需要将FRHICommandListImmediate做成单例。
前面说过,渲染线程向RHI线程发送的指令会变成一个Task,为了实现保序的目的,在创建Task的时候会将前面的Task作为当前Task的依赖项。
![](https://img.haomeiwen.com/i19200103/5ceaf78d6df477c1.png)
前面提到过,并行渲染的一个特点是RHI指令的并行生成。
如上图所示:我们可以将RHI指令的生成放到多个工作线程(每个线程可以理解为一个AsyncTask)去完成:
- 在渲染主线程中为每个AsyncTask创建一个RHICommandList,并为之分配多个渲染指令
- 经由这些工作线程的记录,我们就得到了多个记录完成的RHICommandList
- 在提交的时候,渲染主线程会发起一个特殊的RHI指令,这个指令的作用是先等待前面发起的工作线程的结束(需要澄清一下,RHICommand的执行实际上是在RHI线程上完成的,渲染线程做的只是记录,因此这个特殊的指令中的等待部分实际上是在RHI线程中完成的),之后执行工作线程中记录的RHI指令。
这里介绍的多线程记录没有跟硬件相关的特性,因此是可以跨平台支持的,属于前面介绍的前端并行,下面来看下基于设备特性的后端并行,这个主要用于实现RHI指令的并行翻译。
![](https://img.haomeiwen.com/i19200103/6bbe24591bb276e4.png)
这里也先介绍两个数据结构:
-
IRHIComputeContext:包含若干用于实现Compute计算的接口
-
IRHICommandContext
2.1 继承自IRHIComputeContext,包含了额外的用于实现图形渲染工作的接口
2.2 这个是用于实现RHI指令翻译的主要的接口
2.3 这个是一个基类,每个平台有自己的实现子类
2.4 这个结构还会负责State的缓存、API参数有效性验证逻辑
2.5 那些只支持immediate context的平台(OpenGL)则会直接将RHI指令发送给GPU
2.6 那些支持deferred context的平台,则会将RHI指令写入到一个指令Buffer中,且这个过程也可以做到并行执行
![](https://img.haomeiwen.com/i19200103/85a2ed82c5dc5fac.png)
关于Deferred Context,这里以Vulkan为例进行介绍,其他的API是相似的,Vulkan有两个跟并行渲染相关的概念:
- Queue:这是一个包含了指令buffer的队列,负责向GPU提交工作
1.1 Queue有三个种类,分别是Compute、Graphics以及Transfer(Copy),不同类型用于不同的工作,不同类型的Queue可以并行执行
1.2 前面说的指令Buffer会被提交到一个Queue,之后再发送给GPU进行处理 - DescriptorSet
2.1 从一个DescriptorPool中进行分配,Pool可以容纳多个如sampler、uniform buffer等不同类型的DescriptorSet
2.2 从同一个Pool来的DescriptorSet可以被不同的线程进行写入(即创建、修改、销毁可以分别由不同线程操控完成),这样有利于并行渲染(与之相对的是,OpenGL是直接写入一个Context,Context只能绑定一个线程,因此只能单线程执行)
![](https://img.haomeiwen.com/i19200103/5dfc0b79b16fc192.png)
RHI指令翻译成渲染指令之后,会下记录在一个Command Buffer里,之后再提交到Queue。一个Queue可以支持多个Command Buffer的提交,各个Command Buffer的指令执行顺序,跟提交到Queue的顺序是一致的。
需要注意,多个Command Buffer中的指令是相互独立的,即不存在Buffer A的指令依赖Buffer B的指令的情况,所以每个Command Buffer都应该包含一个完整的渲染流程(begin pass...end pass)。
在设计上,我们通常会有两种不同类型的Command Buffer。
前面说的Buffer特指这里的Primary Command Buffer,也就是说,如果我们说Command Buffer,通常就是说的Primary Command Buffer,这种Buffer可以用于记录RHI指令,但是当GPU占用此Buffer的时候,就不能继续往里写入指令,支持单线程或多线程写入。
Secondary Command Buffer可以基于某个(parent,Primary)Command Buffer创建得到,同样可用于记录RHI指令,不过如果我们用了Secondary Buffer来记录指令,除了少数几个API,比如BeginRenderPass、EndRenderPass以及ExecuteCommands等,Primary Buffer就不能用来记录指令了。
一个Primary Command Buffer可以创建多个Secondary Buffer用于并行提交之类,多个Secondary Buffer之间的指令的执行顺序跟vkCmdExecuteCommands(将指令提交给Primary Command Buffer)调用顺序保持一致。
Secondary Buffer在创建的时候,除了RenderPass State之外,并不会从Parent Buffer继承其他的State。
有了这些信息,我们就可以来看看什么是多线程翻译了。
![](https://img.haomeiwen.com/i19200103/bc107f24c6e046d0.png)
跟多线程生成RHI指令类似,前面是通过多线程将RHI指令写入到RHICommandList,而这里则是是通过多线程将RHICommandList写入到RHICommandBuffer里。
不过需要注意的是,如果我们想用多线程来记录一个RenderPass中的指令,就需要在每个Command Buffer中写入BeginRenderPass跟EndRenderPass指令,这样就会需要在一个Pass中进行多次的RenderTarget的Load/Store操作,这个对带宽会有额外的消耗,在移动端上可能会对性能带来较大的影响,因此建议尽量避免这种用法。
![](https://img.haomeiwen.com/i19200103/2b6bddecf5764ff1.png)
为了避免上述的负面影响,在移动端就建议采用多个Secondary Command Buffer记录的方式来兼顾多线程指令翻译的消耗跟移动端带宽敏感的特性。
![](https://img.haomeiwen.com/i19200103/adbed4b42d5561db.png)
在UE5.1的版本中,目前主机平台、D3D12以及Vulkan都是支持多线程翻译的,而D3D11跟Metal在理论上是支持的,只不过现在没有加上,OpenGL是Immediate Context,只支持单线程翻译。
另外,vulkan虽然支持多线程,但是目前没有通过Secondary Buffer来实现,所以对于移动端来说,就相当于不可用。
![](https://img.haomeiwen.com/i19200103/d6e8c163cf4521b3.png)
涉及到多线程,一个绕不开的话题就是线程之间的同步。
前端并行中,多线程的管理是通过UE的TaskGraph实现的,基于Prerequisite task的特性可以实现各个线程之间的先后顺序控制,此外,在必要的时候,我们也可以触发对某个Task的等待,实现数据的正确同步。
在后端并行中,就需要借助各个平台或渲染API的特性,如Barrier、Fence以及Semaphore实现。
有了这些基本概念的介绍,下面我们来看下UE中的并行渲染具体是怎么工作的。
2. UE工作流程
![](https://img.haomeiwen.com/i19200103/a262ffd5b8d6b5e4.png)
先来看下UE渲染的数据单元:
- FPrimitiveSceneProxy,这是PrimitiveComponent在渲染线程的数据表示,通过对GetDynamicMeshElements以及DrawStaticElements等接口的调用,可以将之转化为更小粒度的FMeshBatch数据
- FMeshBatch,这个是用作渲染的基本单元,包含了Shader Binding以及Render State数据,包含了任何Pass渲染需要用到的数据。不同Pass的FMeshPassProcessor通过对这个数据结构调用Process接口就能将之转化为最终RHI线程需要用到的FMeshDrawCommand数据
- FMeshDrawCommand,存储了RHI线程所需要的一切信息,比如Shader、顶点Buffer,贴图绑定信息等,可以支持DrawCommand的缓存(静态物体,渲染状态信息不会发生变化,就不用重复生成)与合并(需要开启GPU Scene)
![](https://img.haomeiwen.com/i19200103/09c00eecad9f6dbf.png)
下面来看下,具体的工作流程。
- UE的渲染是通过SceneRenderer完成的,Renderer每帧会调用Render接口完成具体的渲染工作
- 在Render接口中会先通过InitViews接口完成各个View的Upate与渲染数据的准备,其中会通过ComputeViewVisibility计算各个View中可见的FMeshBatch,并通过SetupMeshPass完成Render Pass的相关性计算
- 遍历所有Render Pass对FMeshBatch进行处理,得到对应的Draw Command List,之后在RenderMeshPasses等接口调用的时候,触发DispatchDraw接口,完成FMeshDrawCommand到CommandList的记录。
![](https://img.haomeiwen.com/i19200103/7c7e07d7f95a5c9f.png)
在InitViews接口调用的时候,就有很多工作可以通过异步线程并行执行:
- 光照可见性计算ComputeLightVisibility会交由工作线程异步执行
- 物件剔除逻辑PrimitvieCull则是通过多线程并行执行
- 物件可见性计算逻辑ComputeAndMarkRelevance也是通过多线程并行完成
- MeshPass相关性计算SetupMeshPass则是会将每个MeshPass交由一个异步线程处理,在这个线程中会生成渲染需要用的MeshDrawCommand。
- 在InitViews结束之前需要等待灯光可见性计算的完成,但是由于目前暂时用不到MeshDrawCommand的数据,因此不需要等到MeshPass Setup工作的完成
金色字体的字符串都是引擎中的关键字(下同),大家可以搜索做进一步了解。
![](https://img.haomeiwen.com/i19200103/5b20ba2391c5e6af.png)
Render Pass在UE中是通过RenderDependencyGraph(简称RDG)来管理的,RDG在搜集完Render Pass之后,会在Pass Setup阶段完成对各个Pass需要用到的RHI资源的准备,如创建Uniform Buffer、分析Pass之间的依赖关系、创建PipelineBarrier等,这些准备工作都是发送到异步线程执行的。
这里没有涉及到前面介绍的RHI Command的多线程生成与翻译,只是使用了多线程来创建与更新RHI资源
![](https://img.haomeiwen.com/i19200103/dfe17baf945af6ee.png)
除了Pass的Setup,在Pass执行的时候,也有并行计算的设计。比如在Pass执行的时候,会调用SetupParallelExecute接口,这个接口会将多个在时间上连续的使用RHICommandList的Pass添加到一个ParallelPassSet中,因为如果一个指令是通过RHICommandList而非RHICommandListImmediate存储的,那么就代表这个指令是可以并行执行的(不用等待,可以立即返回结果)。
一个Set中可以包含的可以并行执行的Pass数目受r.RDG.ParallelExecute.PassMin/Max控制。
![](https://img.haomeiwen.com/i19200103/86ba6913f21e726e.png)
SetupParallelExecute接口执行完后,我们就得到了若干ParallelPassSet,这时候会调用DispatchParallelExecute接口,这个接口会为每个Set创建一个异步任务,用于将这个Set中的每个Pass的Render Commands记录到一个单独的RHICommandList中。
如上图所示,为什么不分别为234679各创建一个异步记录任务,而是需要为234、67、9各创建一个呢?这是因为,使用RHICommandList的Pass都是一些轻量的Pass,如PostProcess等,这些Pass中需要处理的Command数目较少,拆成多线程并行执行的话,收益不高(甚至负优化)。
这里也说一句,任务比较重的Pass如MeshPass,通常使用的是RHICommandListImmediate。
![](https://img.haomeiwen.com/i19200103/869d868f2b8e8bdf.png)
接下来会对每个Pass进行遍历处理,如果这个Pass是一个Immediate Pass,就会将指令直接记录在RHICommandListImmediate中(MeshPass较重,为了避免影响性能,会做特殊处理,这个后面会讲),而如果这个Pass是之前异步处理过的ParallelPassSet中的数据,就会将此前异步记录好的RHICommandList添加到RHICommandListImmediate中,如此保证RHICommandList中的渲染顺序不变,同时还借助并行计算加速了RHICommandList的生成效率。
![](https://img.haomeiwen.com/i19200103/7425d3e6ab0932e6.png)
前面说过,MeshPass会比较重,又需要使用Immediate来进行记录,为了避免这里的低效,需要做特殊处理,即将整个MeshDrawCommands列表拆成多个部分来并行处理。
为了做到各线程的负载平衡,这里做了一些设计,具体参见上面的计算逻辑,经过这个处理之后,我们就将整个Mesh Pass拆成多个异步Task,并在每个Task中完成指令的记录,之后按照Task的顺序将这些指令组装起来塞入RHICommandListImmediate即可。
![](https://img.haomeiwen.com/i19200103/eb7e47a9a3db98d7.png)
在上面针对MeshPass的并行记录中,我们会用到一个叫做FParallelCommandListSet的类,这个类会按照前面的计算来创建AsyncTask,并为每个Task分配一个RHICommandList,之后会将这个List添加到FQueuedCommandList数组中,后面会通过RHICommandListImmediate::QueueAsyncCommandListSubmit接口将这个数组Dispatch到RHICommandListImmediate中。
这里记录的异步线程是FDrawVisibleMeshCommandsAnyThreadTask,这个Task的执行需要依赖于FMeshDrawCommandPassSetupTask(Command搜集任务)。
![](https://img.haomeiwen.com/i19200103/4c208e4da872e609.png)
再来看下,在RHICommandListImmediate::QueueAsyncCommandListSubmit接口接口中拿到一系列的RHICommandList之后,我们是怎么将这些List转移到RHICommandListImmediate中的。
在RHICommandListImmediate::QueueAsyncCommandListSubmit接口中,我们会先做一个判断,看看当前情况是否支持并行,如果不满足条件,就直接单线程串行处理完了。
当满足并行翻译的条件时,会调用ExecuteAndReset接口将已经记录在RHICommandListImmediate中的RHI指令发送给RHI线程去执行(添加到Queue中),避免后面各个CommandList在调用Execute指令的时候,导致渲染顺序的异常。
之后会为每个RHICommandList创建一个异步任务用于完成RHICommand的翻译,之后会将这个异步任务添加到一个任务列表中,并发起一个命令来等待这个任务列表处理完成,最后将处理好的CommandBuffer通过RHICmdList->Execute添加到Queue中。
![](https://img.haomeiwen.com/i19200103/8d498c7174e34115.png)
最后对并行渲染做一个总结,所谓的并行主要是三种计算逻辑的并行:
- 资源处理的并行
- CommandList生成的并行
- CommandList翻译(执行)的并行
这些并行逻辑在UE中的对应接口在上图中有给出。
![](https://img.haomeiwen.com/i19200103/bde1154e27693f0c.png)
移动平台目前暂不支持并行渲染,但会在后续的版本里添加支持。
多线程的生成在移动平台上支持的,只是目前没有添加这块的逻辑,但实现难度不大。
Vulkan的多线程翻译不太适合移动端,可能会导致多次RT的切换,导致带宽消耗,不过可以通过SecondaryCommandBuffer来优化这个问题,ios也可以通过ParallelRenderCommandEncoder来实现支持。
这里还有一些其他的注意事项:
- 使用多个CommandBuffer提交来支持Mesh Pass的渲染会导致较高的带宽消耗
- Vulkan上的Secondary Command Buffer在部分硬件上有较大的使用代价,只有Snapdragon 865或者更新设备才有较好收益
- 在Metal上的ParallelRenderCommandEncoder的执行顺序跟subordinate command encoder的创建顺序是一致的
- 其他的如CPU使用率上升导致的能耗问题、计算逻辑发送到小核导致的等待等问题
3. 使用建议
- 建议用于复杂场景:
并行性渲染本身有一个overhead(启动消耗),因此对于简单场景来说可能会是负收益,而对于一些复杂场景,如City Sample这种具有大量的角色、载具、建筑的场景,由于GPU有并行计算能力,而CPU如果本身仍是串行提交的话就会构成瓶颈,而优化CPU瓶颈的一个常用手段就是采用多线程并行处理
![](https://img.haomeiwen.com/i19200103/e6cde16cc07c72fd.png)
- 通过一些Debug指令对功能进行分析验证:
![](https://img.haomeiwen.com/i19200103/93061a5b624e7625.png)
- ByPass:开关ByPass
- GRHISupportsRHIThread:各个平台有自己的开关,当然也可以用下面的统一开关
- GRHISupportsParallelRHIExecute:
- Task Insight可以将Task的依赖关系可视化,方便进行多线程并行计算的调试
![](https://img.haomeiwen.com/i19200103/21b0b9ea4db7f023.png)
参考
![](https://img.haomeiwen.com/i19200103/c7e6e3f55c828f4d.png)
网友评论