美文网首页
【UOD2022】Unreal的多线程并行渲染架构

【UOD2022】Unreal的多线程并行渲染架构

作者: 离原春草 | 来源:发表于2023-10-27 17:19 被阅读0次

望蓟门 - 唐·祖咏
燕台一去客心惊,箫鼓喧喧汉将营。
万里寒光生积雪,三边曙色动危旌。
沙场烽火连胡月,海畔云山拥蓟城。
少小虽非投笔吏,论功还欲请长缨。

Unreal Open Day有不少干货,今天准备入坑盘一盘,将学习的心得体会总结记录下来,这是第一篇。

这篇文章主要是对UE的多线程并行性渲染架构做一个介绍跟梳理,包括:

  1. 跟多线程提交相关的一些基础概念与数据结构
  2. 多线程提交的具体实现原理,其中当然也不可避免的会提到UE的RHI Thread跟Render Thread的一些实现流程
  3. 多线程提交的适用场景与使用注意事项

下面我们逐步来看下各个部分的具体内容,如果文中有描述不清楚的,烦请点击文末的参考链接,前往原文一探究竟。

1. 基础概念

什么是并行渲染?总结来说,指的是充分利用多核硬件优势来提升渲染效率的技术,这里有两个关键要点:

  1. RHI Command的多线程并行生成与翻译
  2. RHI Resources的多线程并行创建与更新

并行渲染本身是有一些overhead消耗的,且实现架构也会更复杂,如果本身并不存在GPU或者CPU瓶颈,那么还是推荐使用单线程渲染,如果这二者存在较重负载压力,就建议试试并行渲染。

虽然是并行渲染,但实际上RHI Commands的提交顺序是需要保证跟单线程完全一致的(渲染指令之间存在一定的依赖关系,顺序差异可能导致性能或者效果的差异)。

这里先介绍一下什么是RHI。

RHI是Rendering Hardware Interface的缩写,指的是对底层渲染API的接口封装,以统一的上层接口进行调用,而不同平台会对这套接口进行重载来实现不同平台、不同渲染API的兼容;

并行渲染有两种实现思路:

  1. 前端并行:平台无关的并行渲染实现
  2. 后端并行:基于平台独有特性实现的并行渲染

目前市面上大部分的游戏,所使用的游戏引擎,都会将游戏线程跟渲染线程区分开,渲染线程基于上一帧游戏线程的数据做渲染,以延迟一帧的方式来实现两者的并行计算。

在这种架构下,游戏线程执行完成后,就可以直接把数据发给渲染线程进行渲染,不用等待渲染结果的返回,就可以继续下一帧(N+1)的计算,只有当上一帧(N-1)还没有渲染完成时,需要等待(否则可能就会出现游戏线程超前越来越多,中间有大部分计算被丢弃而浪费)

并行渲染在指令提交给驱动时,也有两种做法:

  1. 由渲染线程直接将RHI指令提交给驱动
  2. 将RHI逻辑单拆出来,做成一个单独的线程,完成指令的提交

由于RHI指令需要跟驱动产生紧密的交互,部分指令甚至需要等待驱动的返回才能继续执行。

如果采用渲染线程提交的方式,就会大大拖慢渲染线程的工作效率,导致大量的等待,同时还会拖慢游戏线程,导致帧率的下滑。

因此目前现代引擎基本上会考虑采用单独RHI线程的方式完成RHI指令的提交,在这样的设计下,渲染线程就只需要关注跟渲染相关的裁剪、剔除等上层工作逻辑,底层的指令相关功能就移交给RHI线程代为完成

结合前面的图,我们就有了这样的三条线程并行工作架构图,可以看到,渲染线程到RHI线程的指令发送,并不是在帧尾进行的,而是可以在执行过程中触发。

一般来说,渲染线程在一帧之内会多次向RHI线程发送指令,并在下一帧帧头等待RHI线程的结束,从而在较大程度上保证了两者的并行(用量化概念描述,并行度不如游戏线程跟渲染线程的并行度)。

这里需要补充一下指令发送的细节,渲染线程向RHI线程发送指令需要通过Task(异步)来完成,因此出于效率考虑,并不会一条一条的发送,而是会通过一个链表对指令进行搜集,并在合适的时机发送给RHI线程。

为了记录与管理这些指令,UE提供了一系列的数据结构,如上图所示的前端并行设计方案中的多个数据结构:

  1. FRHICommandBase:存储执行RHI API所需的数据的数据结构,并提供了一个可重载的Execute接口,这个类是RHI指令的基类,下面对应众多的实现子类
  2. FRHICommandList:
    2.1 这个是RHI指令的容器类,用于装载那些用于延后在RHI线程上执行的RHI指令
    2.2 在这个List类中提供了众多关于RHI指令的接口,接口执行前会先判断是否是Bypass模式,如果是的话,就直接调用RHI接口,否则就会创建RHI指令,并将指令填入前面说的链表中,这链表在实现上,实际上是这个类的一个成员变量CommandLink
    2.3 这个结构中管理的所有RHI指令都是不需要立即返回结果的,如果需要比如LockTexture2D,就应该使用下面介绍的子类
  3. FRHICommandListImmediate:这是FRHICommandList的实现子类,这个单例类会用来完成一些需要在渲染线程立即执行的渲染指令,这些指令的执行需要触发RHI线程的flush

FRHICommandListImmediate中接管的指令由于是立即执行的,而前面说过,我们提交的RHI指令是需要保序的,因此这里才需要将FRHICommandListImmediate做成单例。

前面说过,渲染线程向RHI线程发送的指令会变成一个Task,为了实现保序的目的,在创建Task的时候会将前面的Task作为当前Task的依赖项。

前面提到过,并行渲染的一个特点是RHI指令的并行生成。

如上图所示:我们可以将RHI指令的生成放到多个工作线程(每个线程可以理解为一个AsyncTask)去完成:

  1. 在渲染主线程中为每个AsyncTask创建一个RHICommandList,并为之分配多个渲染指令
  2. 经由这些工作线程的记录,我们就得到了多个记录完成的RHICommandList
  3. 在提交的时候,渲染主线程会发起一个特殊的RHI指令,这个指令的作用是先等待前面发起的工作线程的结束(需要澄清一下,RHICommand的执行实际上是在RHI线程上完成的,渲染线程做的只是记录,因此这个特殊的指令中的等待部分实际上是在RHI线程中完成的),之后执行工作线程中记录的RHI指令。

这里介绍的多线程记录没有跟硬件相关的特性,因此是可以跨平台支持的,属于前面介绍的前端并行,下面来看下基于设备特性的后端并行,这个主要用于实现RHI指令的并行翻译。

这里也先介绍两个数据结构:

  1. IRHIComputeContext:包含若干用于实现Compute计算的接口

  2. 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中,且这个过程也可以做到并行执行

关于Deferred Context,这里以Vulkan为例进行介绍,其他的API是相似的,Vulkan有两个跟并行渲染相关的概念:

  1. Queue:这是一个包含了指令buffer的队列,负责向GPU提交工作
    1.1 Queue有三个种类,分别是Compute、Graphics以及Transfer(Copy),不同类型用于不同的工作,不同类型的Queue可以并行执行
    1.2 前面说的指令Buffer会被提交到一个Queue,之后再发送给GPU进行处理
  2. DescriptorSet
    2.1 从一个DescriptorPool中进行分配,Pool可以容纳多个如sampler、uniform buffer等不同类型的DescriptorSet
    2.2 从同一个Pool来的DescriptorSet可以被不同的线程进行写入(即创建、修改、销毁可以分别由不同线程操控完成),这样有利于并行渲染(与之相对的是,OpenGL是直接写入一个Context,Context只能绑定一个线程,因此只能单线程执行)

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。

有了这些信息,我们就可以来看看什么是多线程翻译了。

跟多线程生成RHI指令类似,前面是通过多线程将RHI指令写入到RHICommandList,而这里则是是通过多线程将RHICommandList写入到RHICommandBuffer里。

不过需要注意的是,如果我们想用多线程来记录一个RenderPass中的指令,就需要在每个Command Buffer中写入BeginRenderPass跟EndRenderPass指令,这样就会需要在一个Pass中进行多次的RenderTarget的Load/Store操作,这个对带宽会有额外的消耗,在移动端上可能会对性能带来较大的影响,因此建议尽量避免这种用法。

为了避免上述的负面影响,在移动端就建议采用多个Secondary Command Buffer记录的方式来兼顾多线程指令翻译的消耗跟移动端带宽敏感的特性。

在UE5.1的版本中,目前主机平台、D3D12以及Vulkan都是支持多线程翻译的,而D3D11跟Metal在理论上是支持的,只不过现在没有加上,OpenGL是Immediate Context,只支持单线程翻译。

另外,vulkan虽然支持多线程,但是目前没有通过Secondary Buffer来实现,所以对于移动端来说,就相当于不可用。

涉及到多线程,一个绕不开的话题就是线程之间的同步。

前端并行中,多线程的管理是通过UE的TaskGraph实现的,基于Prerequisite task的特性可以实现各个线程之间的先后顺序控制,此外,在必要的时候,我们也可以触发对某个Task的等待,实现数据的正确同步。

在后端并行中,就需要借助各个平台或渲染API的特性,如Barrier、Fence以及Semaphore实现。

有了这些基本概念的介绍,下面我们来看下UE中的并行渲染具体是怎么工作的。

2. UE工作流程

先来看下UE渲染的数据单元:

  1. FPrimitiveSceneProxy,这是PrimitiveComponent在渲染线程的数据表示,通过对GetDynamicMeshElements以及DrawStaticElements等接口的调用,可以将之转化为更小粒度的FMeshBatch数据
  2. FMeshBatch,这个是用作渲染的基本单元,包含了Shader Binding以及Render State数据,包含了任何Pass渲染需要用到的数据。不同Pass的FMeshPassProcessor通过对这个数据结构调用Process接口就能将之转化为最终RHI线程需要用到的FMeshDrawCommand数据
  3. FMeshDrawCommand,存储了RHI线程所需要的一切信息,比如Shader、顶点Buffer,贴图绑定信息等,可以支持DrawCommand的缓存(静态物体,渲染状态信息不会发生变化,就不用重复生成)与合并(需要开启GPU Scene)

下面来看下,具体的工作流程。

  1. UE的渲染是通过SceneRenderer完成的,Renderer每帧会调用Render接口完成具体的渲染工作
  2. 在Render接口中会先通过InitViews接口完成各个View的Upate与渲染数据的准备,其中会通过ComputeViewVisibility计算各个View中可见的FMeshBatch,并通过SetupMeshPass完成Render Pass的相关性计算
  3. 遍历所有Render Pass对FMeshBatch进行处理,得到对应的Draw Command List,之后在RenderMeshPasses等接口调用的时候,触发DispatchDraw接口,完成FMeshDrawCommand到CommandList的记录。

在InitViews接口调用的时候,就有很多工作可以通过异步线程并行执行:

  1. 光照可见性计算ComputeLightVisibility会交由工作线程异步执行
  2. 物件剔除逻辑PrimitvieCull则是通过多线程并行执行
  3. 物件可见性计算逻辑ComputeAndMarkRelevance也是通过多线程并行完成
  4. MeshPass相关性计算SetupMeshPass则是会将每个MeshPass交由一个异步线程处理,在这个线程中会生成渲染需要用的MeshDrawCommand。
  5. 在InitViews结束之前需要等待灯光可见性计算的完成,但是由于目前暂时用不到MeshDrawCommand的数据,因此不需要等到MeshPass Setup工作的完成

金色字体的字符串都是引擎中的关键字(下同),大家可以搜索做进一步了解。

Render Pass在UE中是通过RenderDependencyGraph(简称RDG)来管理的,RDG在搜集完Render Pass之后,会在Pass Setup阶段完成对各个Pass需要用到的RHI资源的准备,如创建Uniform Buffer、分析Pass之间的依赖关系、创建PipelineBarrier等,这些准备工作都是发送到异步线程执行的。

这里没有涉及到前面介绍的RHI Command的多线程生成与翻译,只是使用了多线程来创建与更新RHI资源

除了Pass的Setup,在Pass执行的时候,也有并行计算的设计。比如在Pass执行的时候,会调用SetupParallelExecute接口,这个接口会将多个在时间上连续的使用RHICommandList的Pass添加到一个ParallelPassSet中,因为如果一个指令是通过RHICommandList而非RHICommandListImmediate存储的,那么就代表这个指令是可以并行执行的(不用等待,可以立即返回结果)。

一个Set中可以包含的可以并行执行的Pass数目受r.RDG.ParallelExecute.PassMin/Max控制。

SetupParallelExecute接口执行完后,我们就得到了若干ParallelPassSet,这时候会调用DispatchParallelExecute接口,这个接口会为每个Set创建一个异步任务,用于将这个Set中的每个Pass的Render Commands记录到一个单独的RHICommandList中。

如上图所示,为什么不分别为234679各创建一个异步记录任务,而是需要为234、67、9各创建一个呢?这是因为,使用RHICommandList的Pass都是一些轻量的Pass,如PostProcess等,这些Pass中需要处理的Command数目较少,拆成多线程并行执行的话,收益不高(甚至负优化)。

这里也说一句,任务比较重的Pass如MeshPass,通常使用的是RHICommandListImmediate。

接下来会对每个Pass进行遍历处理,如果这个Pass是一个Immediate Pass,就会将指令直接记录在RHICommandListImmediate中(MeshPass较重,为了避免影响性能,会做特殊处理,这个后面会讲),而如果这个Pass是之前异步处理过的ParallelPassSet中的数据,就会将此前异步记录好的RHICommandList添加到RHICommandListImmediate中,如此保证RHICommandList中的渲染顺序不变,同时还借助并行计算加速了RHICommandList的生成效率。

前面说过,MeshPass会比较重,又需要使用Immediate来进行记录,为了避免这里的低效,需要做特殊处理,即将整个MeshDrawCommands列表拆成多个部分来并行处理。

为了做到各线程的负载平衡,这里做了一些设计,具体参见上面的计算逻辑,经过这个处理之后,我们就将整个Mesh Pass拆成多个异步Task,并在每个Task中完成指令的记录,之后按照Task的顺序将这些指令组装起来塞入RHICommandListImmediate即可。

在上面针对MeshPass的并行记录中,我们会用到一个叫做FParallelCommandListSet的类,这个类会按照前面的计算来创建AsyncTask,并为每个Task分配一个RHICommandList,之后会将这个List添加到FQueuedCommandList数组中,后面会通过RHICommandListImmediate::QueueAsyncCommandListSubmit接口将这个数组Dispatch到RHICommandListImmediate中。

这里记录的异步线程是FDrawVisibleMeshCommandsAnyThreadTask,这个Task的执行需要依赖于FMeshDrawCommandPassSetupTask(Command搜集任务)。

再来看下,在RHICommandListImmediate::QueueAsyncCommandListSubmit接口接口中拿到一系列的RHICommandList之后,我们是怎么将这些List转移到RHICommandListImmediate中的。

在RHICommandListImmediate::QueueAsyncCommandListSubmit接口中,我们会先做一个判断,看看当前情况是否支持并行,如果不满足条件,就直接单线程串行处理完了。

当满足并行翻译的条件时,会调用ExecuteAndReset接口将已经记录在RHICommandListImmediate中的RHI指令发送给RHI线程去执行(添加到Queue中),避免后面各个CommandList在调用Execute指令的时候,导致渲染顺序的异常。

之后会为每个RHICommandList创建一个异步任务用于完成RHICommand的翻译,之后会将这个异步任务添加到一个任务列表中,并发起一个命令来等待这个任务列表处理完成,最后将处理好的CommandBuffer通过RHICmdList->Execute添加到Queue中。

最后对并行渲染做一个总结,所谓的并行主要是三种计算逻辑的并行:

  1. 资源处理的并行
  2. CommandList生成的并行
  3. CommandList翻译(执行)的并行

这些并行逻辑在UE中的对应接口在上图中有给出。

移动平台目前暂不支持并行渲染,但会在后续的版本里添加支持。

多线程的生成在移动平台上支持的,只是目前没有添加这块的逻辑,但实现难度不大。

Vulkan的多线程翻译不太适合移动端,可能会导致多次RT的切换,导致带宽消耗,不过可以通过SecondaryCommandBuffer来优化这个问题,ios也可以通过ParallelRenderCommandEncoder来实现支持。

这里还有一些其他的注意事项:

  1. 使用多个CommandBuffer提交来支持Mesh Pass的渲染会导致较高的带宽消耗
  2. Vulkan上的Secondary Command Buffer在部分硬件上有较大的使用代价,只有Snapdragon 865或者更新设备才有较好收益
  3. 在Metal上的ParallelRenderCommandEncoder的执行顺序跟subordinate command encoder的创建顺序是一致的
  4. 其他的如CPU使用率上升导致的能耗问题、计算逻辑发送到小核导致的等待等问题

3. 使用建议

  1. 建议用于复杂场景:

并行性渲染本身有一个overhead(启动消耗),因此对于简单场景来说可能会是负收益,而对于一些复杂场景,如City Sample这种具有大量的角色、载具、建筑的场景,由于GPU有并行计算能力,而CPU如果本身仍是串行提交的话就会构成瓶颈,而优化CPU瓶颈的一个常用手段就是采用多线程并行处理

  1. 通过一些Debug指令对功能进行分析验证:
  • ByPass:开关ByPass
  • GRHISupportsRHIThread:各个平台有自己的开关,当然也可以用下面的统一开关
  • GRHISupportsParallelRHIExecute:
  1. Task Insight可以将Task的依赖关系可视化,方便进行多线程并行计算的调试

参考

[1]. [UOD2022]多线程渲染 | Epic 刘炜

相关文章

网友评论

      本文标题:【UOD2022】Unreal的多线程并行渲染架构

      本文链接:https://www.haomeiwen.com/subject/rsgcidtx.html