【GDC2019】UE4 Mesh Drawing Pipeline Refactor
https://math.jianshu.com/math?formula=%E5%A5%BD%E5%A5%87%E5%BF%83%E6%AF%94%E9%9B%84%E5%BF%83%E8%B5%B0%E5%BE%97%E6%9B%B4%E8%BF%9C%20%E2%80%94%E2%80%94%20%E4%B8%B9%E5%B0%BC%E5%B0%94%5Ccdot%20%E5%87%AF%E6%9B%BC
今天这里介绍的是GDC 2019上Epic团队分享的UE4.22在渲染管线上的大调整,相关链接在文末的参考部分中有给出,如有任何疑问欢迎评论。
首先,Epic团队解释了对渲染管线动大刀的动机,动机分为两个部分:
第一个部分是为了跟上时代,满足越来越流行的开放世界概念:
实现超远视距、高清细节、无缝边界;模块化的世界搭建方式(Modular Construction,即通过积木的方式完成世界的搭建,可以支持手动的或者PCG的方式)以降低content creation cost由于未来可能会需要支持大量的dynamic lights & shadows,因此这里的一个设计目标是希望能够做到以尽量低的时间成本实现尽可能多的draw call。
第二个部分则是为了应对越来越快的技术变革,支持更多高精尖的技术概念:
比如需要支持DXR(Ray tracing),那么就需要为整个场景(而非可见部分)构建Shader Binding Table,从而保证即使射线打在不可见的区域,依然能够保证光线追踪能够拿到正确的结果需要在未来支持GPU-Driven Culling,即所有的剔除工作都放在GPU完成(UE5目前的做法),这样就要能够支持在CPU不知道可见性的前提下完成Draw Call的提交。
基于上面的一些考虑,我们显然就不能将整个场景的Draw Call放在一帧之内提交,或者说就不能每一帧都提交整个场景的Draw Call,否则就无法做到实时了,那么我们要如何实现这个目标呢?
首先,我们需要一套基于RHI能力的Draw Call合并框架(即Instancing,这个方法在不同的RHI中的实现策略略有不同);这里以DX11举个例子,在新的框架下,我们不再根据每帧中每个Draw Call的Shader Parameters来区分Draws是否相同,而是直接根据InstanceID来判断,Shader Parameters会提前上传到一个GPU的Buffer中,并通过PrimitiveID来索引,从而拿到对应的Shader进行渲染。
按照这种说法,我们可以将更多的具有相同Shader Parameters的Triangle或者Primitive放在一个Draw Call中完成渲染,从而在提交Draw Call的时候,会自动将同一个InstanceID的Mesh合并到一起?或者更进一步,我们将多个使用不同材质的Mesh Instance放在一起进行渲染,就可以提交一个Draw Call就行(因为在GPU中会自动根据InstanceID检索到对应的材质或者Shader完成后续的处理)?
一个更贪心的策略就是添加缓存机制,对于那些在加入场景之后就不会变化的物件,比如StaticMeshes,我们完全可以将很多工作放在AddToScene的时候完成,这样就可以跳过每帧的重复计算,降低消耗。
这些可以节省的工作包括为DXR完成Shader Binding Table的更新,以及为DX12完成Pipeline State Object的构建等。
通过这种方式,我们可以很大程度上移除每帧Renderer为StaticMesh构建Draw Call时的消耗。
在介绍新的管线之前,我们先来看下此前的管线逻辑。
首先,在UE4中,游戏线程跟渲染线程是分开的,且渲染线程通常要落后于游戏线程一帧。
为了保证线程安全,这里会在渲染线程中为游戏线程中的所有物件创建一份渲染数据,即FPrimitiveSceneProxy。
而渲染时使用的数据为FMeshBatch,为了将渲染数据转换成渲染时使用的数据,对于动态物件,我们有GetDynamicMeshElements接口。
FMeshBatch是什么呢?这是一种用于将FPrimitiveSceneProxy的实现逻辑与Mesh Pass(这个是放在Renderer Module中的)解耦的结构,这个结构包含了每个Mesh Pass渲染所需要的所有数据(Vertex Buffer,Shaders,Parameters等),而这些数据不只是针对单个Mesh Pass的,而是针对整个渲染过程中的所有Pass的,比如会同时包含GBuffer的Base Pass的相关数据,Shadow Pass的相关数据,Velocity Pass的相关数据等,而对于Proxy而言,则完全不需要关注其最终会被哪个Mesh Pass所渲染。
在拿到FMethBatch之后,我们就要考虑如何渲染了,在这里,我们有一个DrawList的结构,这个结构中包含了所有的FMeshBatch以及Proxy数据,此外,还有一个叫做DrawingPolicy的结构,这个结构包含了如何完成某个Pass(Base Pass,Shadow Pass等)渲染的所有代码逻辑,这个DrawingPolicy跟FMeshBatch结合,通过对DrawingList的遍历,最终输出跨平台(即针对不同的API输出不同的)RHI Command List。
上面的Command List在D3D中就通过最终的DrawIndexedPrimitive接口调用完成渲染。
这里来对Policy以及DrawList的遍历逻辑做一下仔细说明。DrawingPolicy会首先构建一个完整的Pipeline State(包含Shader与对应的Graphics State,这里面任何一个参数的变化,都会导致需要另起一个Draw Call),之后对所有的Shader Bindings进行搜集,即找到每个使用的Shader,并整理出其对应的Shader Parameters:
这里需要对Material Chain进行遍历,找到Material Instance上的数据,比如从Parent继承的数据等,从Vertex Factories中搜集到VS所需要的数据,从Scene & View中获取到Global数据,等等,将所有的数据加起来就得到渲染所需要的所有数据了。
DrawList有哪些不好的地方呢?
使用bit array来表示物件的可见性,这个bitarray对应的是整个场景中的所有物体,从而导致在部分渲染逻辑中我们无法快速找到视线最前面的可见物体,从而导致性能问题,这种问题对于大型的开放世界而言尤其不利无法实现静态物件跟动态物件的Draw Sorting,从而无法做进一步的性能提升DrawList是以Shader的Permutation(变体)为模板参数创建的,从而导致我们会有大量的不同种类的DrawList,从而使得代码看起来很丑很难维护只有单个硬编码的Frequency数据可以用来完成State Sharing,这也就意味着,我们只能通过硬编码来实现instancing。
简单来说,现有的设计使得我们无法做到高效的Draw Merging:
Drawing Policy跟DrawList存在紧密的耦合Drawing Policy将Shader Parameters直接设置到了Command List中的每个Draw上面
下面来看下新的渲染管线是怎样处理这些问题的:
首先,这里移除了DrawingPolicy跟Draw List的相关逻辑
这些移除的逻辑将被新的FMeshDrawCommand结构取代,而为了将新的结构跟此前的FMeshBatch以及RHICommandList等概念衔接起来,这里添加了FMeshPassProcessor跟SubmitMeshDrawCommands等结构或接口。
首先,我们来看下,什么是DrawCommand。上图给出了FMeshDrawCommand的数据结构描述,可以看到跟MeshBatch这种包含了所有Mesh Pass可能需要到的信息的数据结构相比,DrawCommand中存储了RHI用于调用一个或者说单个Mesh Pass Draw Call所需要的所有信息,也就是说,DrawCommand是对MeshBatch的简化,这个结构是standalone的,即本身不带有任何状态信息,即整个数据结构是面向数据驱动的,不需要了解渲染的Context信息,不需要知道DrawCommand的数据来源信息(当然,如果需要的话,这些信息可以通过debug指针拿到,但是这个指针在shipping包中是不存在的)等。
这个数据结构可以用来做什么呢?如上图所示:
[*]我们可以用来实现更为高效的Cache机制,因为DrawCommand是跟单个Mesh Pass绑定的
所以,我们可以在AddToScene的时候就为StaticMesh完成DrawCommand的构建同时,我们可以通过这个结构来更好的实现Retained Mode,即可以做到在加载的时候,就明确清楚PSO跟Shader Bindings数据(当然,即使如此,在这个时候,我们还是没有办法做到完全的Retained Mode,因为还有一些信息我们暂时还拿不到,比如我们不清楚是否有一个stationary skylight)
我们可以实现更为稳定的Draw Call Merging(对于D3D11而言,也就是我们可以实现Dynamic Instancing),因为在DrawCommand中已经没有了high level的数据结构比如材质,所以我们可以直接通过对DrawCommand的对比就知道是否能够合并了。我们可以根据Draw Call本身的内容(而不需要考虑DrawList,DrawPolicy等外物)来决定是否能够进行合并,这个原因跟上一条原因是一致的
这里来说下,如果我们想要创建一个自定义的Pass,要如何生成一个对应的DrawCommand。这里我们就需要实现一套自定义的MeshPassProcessor,这个结构的代码逻辑跟此前的Drawing Policy是非常相似的,下面我们来看一下内部细节。
比如我们希望从一个MeshBatch中构建出一个或者多个DrawCommands(多个Command的原因是我们可能需要为不同的LOD生成一份DrawCommand数据,此外某种几何数据可能会对应于不同的Command,比如Niagara粒子就可能会对应于任意数目的DrawCommand?),而Processor会决定这个Mesh Pass需要使用哪个shader,同时准备好对应的shader bindings(这个数据可能来自于不同的地方,如Vertex Factory,Material Bindings等)。
这里给出了一个示例代码片段,实际上需要我们定制的代码量不大,只需要实现一个AddMeshBatch函数即可,我们可以看到,整个函数实现不再是基于模板的,且对于静态(Cache)或者动态渲染路径而言,整体的逻辑是完全共用的。
这里给出了Depth Prepass的示例代码,这里给出了构造函数中的相关逻辑,包括setup功能与processor的初始化逻辑
再来看下AddMeshBatch函数的实现逻辑,这里真正做的事情是,需要判定此物件是否需要在当前Mesh Pass中进行渲染,即这里的Pass Filter的逻辑,之后如果需要渲染,就需要根据当前Pass的情况比如material来选择最对应的Shader。
在Process接口中,我们会完成Shader Bindings数据的搜集过程,并据此完成DrawCommand的构建,这个是通过BuildMeshDrawCommands接口完成的,这个接口在后面会介绍,是多个Mesh Pass Processor共用的一个接口。
以上就是单个Mesh Pass Processor的主要逻辑(这里举例使用的是Depth Pass),如果我们有其他的自定义Pass,就可以参照这个过程建立一个新的Mesh Pass Processor,因为BuildMeshDrawCommands无需另外定制,因此这个过程会十分容易。
这里对比了前后两套渲染管线的代码复杂度,可以看到新的渲染管线在定制开发时的成本更低,扩展性与可维护性也更好。
下面来讲讲BuildMeshDrawCommands接口,前面说过这个接口是多个pass共用的,用于实现shader bindings信息的搜集的,通过这个接口可以移除此前管线中创建一个新的pass时的大量的模板代码。
作为对比,这是此前的管线中DrawingPolicy的工作逻辑,在这个逻辑中,Parameter的设置是直接作用到Command List上面的,而新的管线中,则是将所有的信息搜集到MeshDrawSingleShaderBindings结构中,这个结构后面在DrawCall Sorting & Merging过程中会用到。
DrawCommands生成完成之后,我们就可以考虑对其进行排序了。由于Static/Dynamic Draws都是使用统一的DrawCommands结构进行表达的,因此在新的管线中,我们可以将两者统一到一起进行排序。
此外,在新的管线中,我们将拿到单一一个对应于可见物件的物件数组,而非老管线中的整个场景中所有物件的数组(多个)。
排序完成后,我们就可以进行Submit了,SubmitMeshDrawCommands这个接口也是固定的,无需改动的,所以这里可以再一次强调,添加新的Processor会非常容易。
下面来看下SubmitMeshDrawCommands接口的实现细节。
前面说过,由于我们通过Processor拿到了一个可见物件的DrawCommands数组,因此这个接口在处理的时候,对每个DrawCommand进行处理的时候是无依赖的,且计算时间基本上是相同的,因此也就可以并行计算(而不需要从数组开头进行遍历直到末尾),并可以轻松的实现各个线程task负载的均衡,从而使得整个过程具有较好的伸缩性。
而作为对比,老的管线中,对Static Draw List,我们是需要从头到尾进行遍历的,从而获取到整个计算过程有多少工作量,以实现均衡的thread task分配。
此外,由于DrawCommands已经是最终数据了(finalized),不需要再通过额外的接口来对数据做进一步细分与处理,因此这个接口的处理逻辑就不需要依赖或者改动到外部数据,所以也就是没有副作用的,而老的管线中,由于相关数据的处理需要从其他地方获取参数,在这个过程中甚至会修改到外部的数据,因此就没有办法保证side effect free了,而这个过程也就阻碍了任务的并行。
总结起来,也就是说,在新的管线中这个过程是很容易通过并行来加速的。
这里再来介绍另一个通用(或者说Processor中共用)的接口,MeshDrawCommand的SubmitDraw接口,在这个接口中,会(在高于CommandList的逻辑层次上)自动完成State的过滤,
举个例子,我们有可能会碰到这种情况,比如某两个DrawCommand因为其中某个State不同而无法合并,除了这个State之外的其他State是完全相同的,在这种情况下,如果我们直接将DrawCommand按照这种顺序提交给CommandList,就会出现添加很多无意义的State切换Command,而如果我们在提交过去之前,先进行一遍判断处理,就能够剔除掉这部分浪费,在fortnite上测试可以实现20%的性能优化。
最后值得一提的是,SubmitDrawCommand接口还具有很好的缓存一致性:
DrawCommand是Tightly Packed的SubmitDraw所需要的数据在内存中是连续的
这个特性使得整个逻辑在现代CPU上会有很好的性能表现。
下面来介绍一下,为什么我们需要以及为什么我们能够实现Draw Commands的缓存,理论上我们是可以在每帧中对Draw Commands进行更新与重新生成的,但是实际上,我们是希望尽可能的降低生成过程的执行频率的。
由于组成游戏世界的绝大部分物件都是Static的,因此完全可以一次DrawCommand的生成,多次使用。
缓存最大的敌人是变化,即缓存的内容如果发生了变化,就要被设置成invalidated,而如果经常变化,那么缓存的意义就不存在了,因此这里需要注意的是,被缓存的DrawCommand引用的资源或者数据,变化频率不宜过高,这里可以通过使用Uniform Buffer作为间接引用(中转)来实现,下面给个示例。
以View Uniform Buffer(UB)为例,Renderer在每一帧都需要从View UB中获取到诸如Projection Matrix之类的数据。
在之前的管线中,如上图左侧示意小图所示,每帧都需要重新创建新的UB,并将每个DrawCall的数据塞进去,在这种情况下,因为我们需要将UB指针塞到DrawCommand中,那么我们就需要为每个Draw进行一次Update,这种高频Update对于海量物件或者DXR的场景来说是不可接受的。
一种更好的缓存策略则是,每一帧我们不再重新创建UB,而是直接在上一帧的UB上对发生了变化的参数进行更新,这样每个DrawCommand都不需要重新指定UB,从而将高频的更新转换成了单个UB的更新。
所以在UE4.22中添加了一个用于对RHI UB进行更新的接口
在引擎中,我们有多个UB需要更新:
PrimitiveUniformBuffer中存放的的物件的Transform StatesMaterialUniformBufferMaterialParameterCollectionsPrecomputedLightingUniformBufferPassUniformBuffers
当AddMeshBatch引用的内容发生变化后,我们就需要对DrawCommand进行invalidated处理,而这个处理过程在此前的管线中其实基本上已经处理的差不多了,其中skylight是其中最主要的一个,因为这个的变化会导致很多内容的invalidation。
这里也有一些内容是此前管线没有处理的,如shader bindings的缓存,这里也会导致一些新的invalidation的发生,所以如果我们对引擎做了扩展,其中需要对材质的shader bindings进行修改,那么就需要添加一个新的invalidation函数。
如果不确定我们对invalidation的处理是否完备了,可以通过UE提供的这个宏进行检验,从而将问题的发生时机锁定在第一现场,以避免后续发生的各种乱七八糟的问题而我们完全不知道是什么原因导致。
此外,这里的缓存是只对部分类型生效的,拿UE4的Vertex Factory(指定Vertex Shader以及对应的parameters)距离,我们对于StaticMesh与SkinnedMesh各有一个VF,而只有StaticMesh的VF(即LocalVertexFactory)添加了缓存(因为这个VF不需要SceneView这个会每帧变化的参数)
而其他VF,由于会用到动态变化的SceneView参数,因此即使在AddToScene的时候添加了对MeshBatch的缓存指令,我们实际上还是无法完成对DrawCommand的缓存,所以依然会每帧重新生成DrawCommands。
在这种情况下,UE中的缓存逻辑就有三条代码执行路径:
动态物件渲染逻辑,MeshBatch -> DrawCommand -> CommandList都是每帧动态更新调整的需要SceneView的静态物件,这种情况下,我们可以完成MeshBatch的缓存,但是DrawCommand需要每帧生成。不需要SceneView的静态物件,MeshBatch跟DrawCommand都可以缓存下来。
这里是一个上层逻辑说明图。
在调用AddToScene的时候(新增物件),会完成对静态物件的MeshBatch的缓存处理,同时会生成MeshDrawCommand并存储。
当需要对天光进行重设的时候,会需要将已经缓存的DrawCommands的有效状态重置为无效。
在调用InitViews接口(每帧)的时候,会遍历每个Primitive,如果此Primitive是静态的,就计算其需要的LOD,并将此LOD对应的DrawCommand缓存数据添加到visible set中;如果是动态的,就需要完成MeshBatch的重建。
最后,在RenderDepth接口(当前示意的Render Depth Pass)中,会调用visible set中的每个Mesh DrawCommand完成绘制。
上面说的是缓存的逻辑,下面快速介绍一下合并的相关逻辑。
D3D11中Instance的渲染接口只有一个,即DrawInstancedIndirect,在这个接口中,不同Draws之间唯一能够变化的只有InstanceID,Render States,Shader Bindings等都是不能更改的,这是实现起来最简单的一种,大部分的图形API都支持这类接口。
DX12提供了一个功能更强大的接口ExecuteIndirect,在这个接口中,我们可以做到在不同的Draws中设置不同的RenderStates,但是这个接口在此talk发生的时间还没有被添加到UE的实现中,在最新的UE5中已经可以看到这个接口的调用痕迹,看起来应该是加上了对应的处理了:
ID3D12GraphicsCommandList1Vtbl::ExecuteIndirect <- FD3D12CommandContext::RHIDrawIndexedIndirect <- FRHICommandList::DrawIndexedIndirect <- FRHICommandDrawIndexedIndirect::Execute
使用DrawCommands将可以很容易就能实现Dynamic Instancing,因为DrawCommands本身是存储在一个Flat Array中,不包含上层的代码逻辑,是十分自立的,所以只需要简单的比对就能判断DrawCommands是否能够合并在一起渲染。
更重要的是,这种合并逻辑不需要美术同学做一些额外的设置(比如ISM跟HISM要想实现合批,就得美术同学添加一些配置之类一样),是完全自动的,大大加速了渲染与开发效率。
另外,需要说明的是,我们如果在每帧都对每个DrawCommands进行Merge比对,这个消耗跟老管线中的实现逻辑相比起来也不会低到哪里去,因此这里基于不同的States生成若干个bucket,如上图中的TSet就对应着所有的Buckets,由于我们对静态物件的DrawCommand做了缓存,那么我们就可以在缓存完成的时候,根据State的不同塞到不同的Bucket中,这样在使用的时候就可以实现快速检索,从而将比对消耗前移来降低运行时的每帧消耗(只是不知道,Bucket的存在是为了缓存,还是为了将全量的比对拆解成若干局部比对?)
对代码进行分析,发现在FCachedPassMeshDrawListContextImmediate::FinalizeCommand & FCachedPassMeshDrawListContextDeferred::DeferredFinalizeMeshDrawCommands接口中会对Bucket中的DrawCommand进行添加,对前者进行分析,发现调用入口为FMeshPassProcessor::BuildMeshDrawCommands
对后者进行追踪分析,发现调用堆栈为:
FPrimitiveSceneInfo::CacheMeshDrawCommands
FPrimitiveSceneInfo::AddStaticMeshes/UpdateStaticMeshes
也就是说,是在创建DrawCommand的时候就完成对这个Bucket的初始化与更新的,即这个Bucket结构本身也是带有缓存性质的。
那么同一个Bucket中的DrawCommand是否需要进行排序?这个则要看Bucket的key绑定的是complete key,还是partial key,如果是complete key,那么每个bucket中的DrawCommands对应的就是同一个State,就无需排序,这种方式性能好,不需要运行时的比对了,但是Bucket数目会很多,简单搜了下,没有搜到排序的逻辑,看起来应该就是这种模式了。
由于DrawCommand已经排过序了,所以处在同一个Bucket中的DrawCommands就可以用一个Instanced DrawCommand来替代,即所谓的Merge通过一个简单的数据移动搬迁就可以做到了。
在老的管线中,每次绘制都需要完成Shader Parameters的Binding操作,而很多Parameters在DrawCommand中是共用的,因此在新的管线中会对这部分逻辑进行分析,将共用的Parameters的Binding逻辑往上提,降低Binding频率,比如改成每帧Binding一次等。
除了一些共用的Parameters之外,还有些针对单个物体的参数,比如每个物体的Transform数据,这些参数并不会在多个DrawCommand中共用,对于这部分参数,为了避免每个DrawCommand的单独设置,减少对RenderStates的打断,在新的管线中直接创建了一个针对整个场景的Unified Buffer,相当于一个很大的GPU Array,这部分数据就单独存放在这里,并在需要的时候进行动态修改。
由于这类的GPU Array有好几个,因此这里直接实现了一个GPU版本的TArray结构,这个结构支持动态扩缩容,渲染线程会追踪Primitive的添加、更新与删除操作,并在下一帧使用CS来完成这个TArray的更新(增量更新)。
经过上述处理后,在shader中如果我们想要获取某个物件的参数,就需要通过PrimitiveID来实现,虽然有部分Vertex Factory不支持缓存,底层获取数据的操作有一些不同,但是从上层使用的角度来看,都是通过GetPrimitiveData这个接口完成的。
对于支持缓存的VF而言,这里就可以将数据传入到PrimitiveSceneData这个结构中。
这里的一个问题是,对于通过Instanced Draw触发的渲染而言,我们只有InstanceID,比如10个Cube,我们能够拿到0 - 9这几个数字,那么要怎么将这几个数字转换成PrimitiveID呢?
这里,我们可以存储每个InstancedDraw的PrimitiveID偏移,后续在Shader中只需要将这个偏移加上InstanceID即可拿到每个Primitive的实际ID,不过这种做法就需要为每个InstanceDraw传入单独的偏移参数,这就违背了前面说的不动RenderStates的原则,从而降低缓存效率。
而由于SetStreamSource接口可以接受一个动态偏移,这个偏移逻辑不影响shader bindings,因此更好的做法就是以Instance为粒度添加一个Primitive Vertex Input Stream,在这个Stream中设置每个Instance Draw的PrimitiveID偏移。
在VS中就可以将这个参数取出来使用,由于现在的所有图形API都支持这项特性,因此这个逻辑不需要做太多的适配处理。
为了测试管线改动后的性能表现,这里用GPUPerfTest加一个从fortnite中截取的一个小型城市场景来做验证,可以看到管线改动前后的性能对比还是十分明显的。
这里为了测试大场景(更高Draw Call)下的性能比对数据,一个简单的方法就是将场景复制三次,并关闭distance culling(否则新复制的数据就被剔除了)再来测试,可以看到这种复制情况下的Draw Call Merging力度更大。
一般来说,具有相同MaterialInstance跟StaticeMesh的StaticMeshComponent都能够合并,除了如下的一些情况:
每个StaticMeshComponent有自己的Lightmap,这个是会打断合并的(这里指的是使用不同的Lightmap,即Lightmap Index不同,而不是同一个Lightmap上的不同区域)每个StaticMeshComponent有自己的Vertex Color(但是我理解,Vertex Color一般是针对StaticMesh存在的?)Speedtree wind node在talk发生的时候还没做合并,不是出于技术考虑,只是还没做而已,不知道UE5最新代码是否已经完成处理了在动态物体上使用了老版本Sparse Volume Lighting采样方法,新版本的Volumetric Lightmaps已经做了处理,不会打断合并
下面来看下新的管线在PS4(使用PS4是因为硬件是恒定的,不会存在差异,因此可以用绝对数值来衡量性能)上的性能表现,这里的性能数据针对的是渲染线程的Draw Pass而非全帧,可以看到,这部分有6到7倍的加速。
如果将场景复制三倍,这个数据就更惊人了。
当然,这里也要说明的是,这里的测试数据能这么优秀是因为场景使用的是fortnite的模块化mesh搭建的,在普通场景中的数据可能会差一些。
此外,这里给出的是渲染时候的加速,而非全场景的加速,Visibility检测部分由于没有调整,依然会可能成为性能瓶颈。
而由于渲染是做了并行处理的,因此如果DrawCall数本身就比较少,那么性能提升也会比较有限,经测试发现只有当DrawCall数大于2000的时候效果会比较好。
最后,InitViews依然会是整个渲染线程的瓶颈。
部分此前的优化逻辑由于无法跟新管线的缓存机制相兼容而被移除,比如只对可见物件进行的延迟更新策略(即将更新放在可见性检测之后),不过由于性能提升依然很明显,所以这个移除导致的性能下降并不容易注意到。
对于一些老版本中的材质蓝图,如果使用了自定义节点,那么可能会存在问题,需要对其中部分逻辑进行更正,使用PrimitiveID来访问PrimitiveData。
在前向渲染管线中,将只支持单个平面反射组件了,如果使用了多个,引擎会自动选择最近的一个。
对于现有的UE4项目而言,可能收益不会很高,比如前面说过的,如果DrawCall较少那么收益就基本上没有,另外如果我们每个物件都有独立的材质,那么就没法做合并,也就没有收益;此外,在talk发生的时间节点上,还没有对移动渲染管线(移动管线FMobileSceneRenderer有RenderForward跟RenderDeferred两条实现路径)进行合并处理,这个后面会补上,在UE5中查看代码,根据调用逻辑进行追溯:
FInstanceCullingContext::BuildRenderingCommands <- FParallelMeshDrawCommandPass::BuildRenderingCommands <- BuildMeshPassInstanceCullingDrawParams <- FMobileSceneRenderer::BuildInstanceCullingDrawParams <- FMobileSceneRenderer::RenderForward/RenderDeferred
可以看到,在移动管线中已经添加了对应的DrawCall Compaction逻辑,将多个DrawCall合并到一起(没有深入去分析BuildRenderingCommands接口的实施细节,从其中的注释以及代码函数的名字来看,应该就是对应的Draw Call合并逻辑)
参考文献
. Refactoring the Mesh Drawing Pipeline for Unreal Engine 4.22 Slides
. Refactoring the Mesh Drawing Pipeline for Unreal Engine 4.22 - GDC 2019
. Refactoring the Mesh Drawing Pipeline for Unreal Engine 4.22 - Unreal Fest Europe 2019
页:
[1]