虚幻引擎4 制作多人大地型游戏的优化
王祢,Epic Games 资深开发者技术支持,管理虚幻引擎技术支持的程序员团队,拥有近15年虚幻引擎使用经验。
本文基于王祢于第二届腾讯游戏开发者大会所发表的主题演讲《UE4制作多人大地型游戏的优化》内容整理而成。王祢详细地用PPT与文字分享了如何从游戏线程、渲染线程、GPU、内存等各方面进行优化,从而提升游戏品质。
在上个月的Unreal Circle(虚幻引擎巡回技术分享沙龙)上海站上,同来自Epic Games的资深开发者技术支持郭春飚基于本文介绍的内容略作改动进行了演讲,并与来到现场的UE开发者们互动问答。未能参加活动的开发者可参考以下PPT及详解,或点击上方视频观看当日演讲实况!
在移动设备上做大地型多人游戏的挑战
首先我们来看一下在移动设备上做大地型多人游戏的挑战。大地形肯定是开放的地图,视野比较宽,视距比较远,地图比较大。在很大的世界里还会有比较多的风格变换,会导致绘制内容的种类比较多,资源的使用、变化比简单一些的游戏复杂非常多。
对于同样的移动硬件来看,优化的压力会大非常多。我们来看看优化分为哪几部分,主要的优化包括有大量的角色需要跟场景发生交互,角色的动画之类的计算以及与场景交互的计算发生在游戏线程,因此游戏线程承担了非常重的优化任务。所以首先我们讲游戏线程的优化。
游戏线程的优化
引擎里面有一个东西,我知道这个是比较偏向于游戏逻辑业务的概念,可能一般大家不太认为会在引擎里面实现,我们叫做重要度管理系统,大家知道游戏的常规优化手段叫做LOD,不管是面数、更新频率,我们都会根据在屏幕上所占比进行调整,这是很通用的,延用很久的优化手段。
Significance Manager
我们怎么样让各个游戏模块从游戏逻辑层去修正LOD的计算?这时我们引入Significance Manager,我们会分配针对每个平台的 Bucket,大家可以看到右下示意图中蓝色的小点代表玩家控制的角色,边上的小点是别的玩家和交互的动态对象。我们根据离主角玩家的距离,在屏幕上的尺寸或者可见性,决定使用什么Bucket。例如基于可见性的计算,虽然离我很近,但是因为在我的背后,可能很多时候我都感受不到,Bucket就可以分得不一样,通过Bucket我们会用来控制、修正LOD的各种计算。
这里是一个例子,我们这个系统本身用于我们自己比较火热的游戏《堡垒之夜》它在手机、掌机、电脑都可以跑,我们兼容所有的平台可以连机玩,游戏在不同平台上的场景、复杂程度其实是一样的。这种情况下,硬件的计算能力有非常大的差别,所以我们针对移动平台和主机Bucket也不一样,除了自身控制的角色给的Bucket比较高,剩下的角色的比较低,主机有四个,手机有一个,这个设置不仅按平台来,也可以按设备来,移动设备好的和差的硬件计算能力差很多,我们可以在Device profile指定当前这台设备Bucket的规划。
刚才是比较全局的系统,接下来我们看游戏线程里开销最大的部分就是我们的动画,动画系统大部分角色是可以定制的,角色会分为几个部分,绘制调用的数量、动画骨骼更新、不同部件的不同动画计算量非常大,针对Fortnite这样的游戏有一些特殊的游戏模式,例如50V50,这种情况下,最终在缩圈以后,同屏会出现超过50甚至80个角色,每个角色还分了好几个部件,背包、武器都有不同的动画,这个时候计算量非常大,我们需要对动画做非常大量的优化。
动画
刚刚我们已经说到角色可能分为几个部分,有一些不同的策略,引擎提供各种方式,一种是将不同的部位的Mesh合成为一个,这个模型有一个问题,材质是要合并起来的,你的表情的动画就没有了,在这个方案上我们做了一些取舍,最终决定不在Fortnite用这种方式。
另一种,身上不需要动画的刚体挂件可以方便的挂在角色骨骼的Socket上面,这是比较简单的方式。还有Master-Slave的方式,主体动画是一套完整的骨骼,身上挂载的动画是这个骨骼的子支,这个时候我们可以把这些挂载的部件的动画完全跳过自己的动画更新计算,完全用Master驱动。这样的骨骼动画直接使用Master的骨骼矩阵,没有办法扩展,比如Master Skeleton没有尾巴或是披风的骨骼,尾巴或是披风的独立动画或者物理模拟就没办法做。针对这种情况,我们还有一个解决方案是Copy Pose,可以把主体的计算完的骨骼矩阵拷贝给附属的骨骼矩阵,只要保持目标骨骼和原骨骼的层级结构一致,就可以在目标骨骼上增加扩展性的骨骼,可以根据自己的状态播自己的动画,也可以模拟物理。这是四种多部件角色setup的方案,无论使用哪一种,都需要对骨骼模型和骨架设置LOD,这是下面提到多种优化的前提。
第一步比较直观的是在动画更新的时候会有大量的逻辑事件的计算,我们称之为Event Graph,这是UE4提供的图形化的脚本功能,Event Graph是需要经过图形化的脚本虚拟机,这个调用在动画逻辑比较复杂的时候开销有点高,我们把在虚拟机上计算的Event Grape转到C++,省掉了大量开销。
Anim Graph
再有一个是Anim Graph,我们根据当前的状态选择不同的骨骼层级,播放哪个动画,或是经过哪些骨骼控制节点,比如说IK、物理模拟最终的POSE的计算。在这个计算中间有一些步骤会用到数学计算,因为是在Graph,会有一些额外的开销。我们做了一些优化,我们把所有这些独立计算的模块通通纳入到一些基础的骨骼动画混合节点,包括偏移和缩放,这样可以减少虚拟机的调用开销,我们把这些包含简单计算项的动画混合节点叫做Fast Path节点(右上角有闪电小图标),骨骼混合的计算逻辑通通是用Fast Path可以完全消除在虚拟机上的开销。
同屏有那么多的角色要做骨骼动画计算,大家知道移动设备是多核设备,为了更好的利用多核的定性,我们需要把刚刚这种虚拟机上的调用更好的平摊到不同的线程。基于上面两个优化方向,我们不要使用Event Graph,把游戏逻辑更新的部分放在AnimInstanceProxy上,这样引擎会自动判断这个Event Graph是不是可以放在别的线程上更新。如果你用了Fast Path,我们就可以把骨骼的update和evaluation都放到working thread上面去,例如有50个角色,在任意一角色更新开始,就把计算分到别的线程上面,主线程继续往下走。
即使我们能利用多线程,计算量还是非常大的,我们要减少动画更新的数据量,已经有些设置可以帮助动画在不渲染的时候跳过 Tick pose,也可以通过Singnificance Manager跳过附属武器、背包的更新,除了自己的主角,别的角色离你远一些,信息不更新其实你是注意不到的。
我们的掉落物会模拟物理,是骨骼物体。骨骼计算有一个问题,是走的Dynamic Path。我们引擎的中的静态对象,会在加到场景中的时候就直接排序分组到自己的Drawing Policy,绘制的时候可以很大程度减少渲染状态的切换。而动态的单位,是每一帧在渲染开始的InitViews阶段动态获取到数据,它和静态获取数据的方式不一样,不会进入到静态排序的表里,绘制的效率比较低。针对这种实际每一帧渲染数据不发生变化的骨骼物体,我们把这些物体额外加到了一个StaticRenderPath,加速了这些物体的渲染。
URO(Update Rate Optimization)
URO(Update Rate Optimization),我们其实没有必要对所有的角色在每一帧都做骨骼计算。比如画面中一个角色的POSE上半身动作是怎么样,下半身动作是怎么样,是否需要融合,什么频率融合,中间是不是要插值,这些设置可以非常大程度决定骨骼更新的计算量。大家可以看到下面的图,左一是每一幀都更新,左边二是每四幀更新一次,中间用插值,第三张图是每十幀更新一次,中间用插值,最后一张图是每四幀更新一次,不用插值。大家可以看到当角色占屏面积比较小,离得比较远的时候其实是没有大差别的。
刚才讲的这些是针对骨骼动画更新的优化,其实伴随着骨骼LOD的设置,我们在AnimGraph中可以设置骨骼控制节点从某一级LOD下不计算,比如说IK、物理模拟。
Scene Component
说完动画的优化,接下来游戏线程还有大量的Scene Component,Scene Component是指世界中有坐标位置的对象,它的Transform更新都是在游戏线程中计算。当你大地图、大场景动态更新对象非常多,同时每个对象身上会挂很多Scene Component的时候,计算量是非常大的。尽管我们会把Scene Component的计算踢到异步线程,但是计算量依然很大。我们做了一些改进,针对一些挂载在人物身上,不是处于激活状态的Scene Component做了自动的管理。
打开Auto Manage Attachment,对于音频和粒子特效,可以自动根据它是否激活的状态,决定是否挂在父级Scene Component。如果Detach掉,它的Transform就不会再更新。
当Scene Component发生位置变化的时候会触发Overlap的检查,每一帧有大量运动对象时会产生大量Overlap事件,耗费比较大的开销。优化的原则是尽可能把不需要产生Overlap的事件关掉,注意引擎默认是打开的。我们对层级结构比较复杂的做了子Component是否打开overlap事件的引用计数,会看自己是不是打开了Overlap事件,以及自己的子对象有没有打开。这个时候我们在做Overlap检查的时候可以很快的跳过,这个节点往下都没有,就不需要再检查自己的子节点,这在场景的对象结构比较复杂的情况下是可观的优化。
Character Movement
Character Movement,因为角色比较多,角色的移动更新是非常大的游戏线程的计算。针对这个计算,一部分是角色在移动的时候要检查新的位置是不是能站立,要做一些扫描,要做一些碰撞,还要找落脚点是不是斜坡,这个斜坡的斜率是不是角色可以站上的,往前走的高度变化是不是可以超过跨过阶梯最大的高度,角色一多计算量就非常大。所以除了玩家自己控制的角色,需要比较精确的计算外,其余角色分到的Significance Manager的Bucket我们最终是用了插值,通过网络同步过来的位置做简单的插值来模拟预测计算,在大部分时候都不容易注意到明显的差异,只有在帧数较低或者网络带宽受限比较严重的时候,对于落地点会有显著的偏差,大家可以对比看到这两个视频中左边是预测计算,右边是插值。
Physics
Physics,我们会尽可能的用一些替代的Physics优化物理注册的对象,有一组对象,比如说边界,不需要很细致的碰撞模型,我们可以用简单的volume来表达物理碰撞对象,减少注册到物理场景中的对象数量。物理的一个场景会有两个树,一个用以做Query,一个用于做Simulation,我们要尽可能保证注册进去的对象最优化。因此需要尽可能的简化每个物理对象的复杂度,以及减少整个场景注册的物理对象数。可以同时以比较小的内存开销打开异步的物理场景,Physics注册的对象是一样的,只不过他会用Shared Shape的方式加到Async Scene里,这样在场景做物理模拟的同时,他可以在异步的scene里做其他的query。
另外我还尝试过把同样mesh的不同实例对象用Shared shapes减少注册的物理对象的内存开销,在内存敏感的场景下也可以尝试。
还有一个思路是我们可以把物理对象和视觉对象解耦,默认的情况下,引擎的Mesh对象打开碰撞就会注册物理对象到PhysX Scene,增加了物理场景的复杂度和物理的内存占用。因此当你的Mesh加载到内存里,即使不被渲染出来,这些开销就在了,但是其实很多情况下视觉会看得更远一些,实际需要物理计算交互的距离在有些游戏中没那么远,我们可以用一些手段把视觉上对象的物理关掉,把这个物理属性转到一些新的Component和Actor上面放到新的Streaming Level里,用更近的加载卸载距离来管理,这样实际的物理场景复杂度和内存占用都会小很多。
另外移动端的布料,计算量和网格数量相关,在移动端会不太推荐使用那么复杂的模拟,引擎也就没有提供移动端的NvCloth的lib,所以我们一般会用刚体来模拟。
Ticking
Ticking,也即所有动态逻辑更新的对象,引擎的图形化脚本可以让美术策划和GamePlay程序很方便的在Event Graph做Tick更新,但是需要付出一定虚拟机的调用开销,当Tick事件触发的执行队列非常长,每一幀付出虚拟机的成本就会比较高一些。一个方法是转到C++,另外一个方法是减低Tick的频率,更有一些特殊的,例如每一幀只是视觉上在转动的风车或是旗帜在飘、树在摆动,其实可以不需要用骨骼动画、或者在Tick做旋转,可以用顶点动画来做。
引擎还有个功能较TextureStreaming,这个系统会在游戏线程计算用到贴图的精度,用以决定更新给渲染线程的资源的精度再提交给GPU,对于这个每帧分析画面贴图Wanted Mip的计算量每幀还是占比较多的,游戏线程吃紧的情况下可以降低Texture Streaming的分析计算的频率。
UI
如果游戏HUD有大量的UI对象,它的位置计算会比较复杂,在游戏线程的计算量就会比较大,可以多利用我们新出的SlatLayoutCaching和Invalidation Box来Cache Prepass减少widget transform更新的计算,这些Cache可以把计算的位置和大小记录下来,有一些可以把顶点Buffer Cache下来。
另外,我们也需要尽量让UI的Widget可以Batching起来。引擎的一些布局空间会自动帮你布局子控件,例如Horizental和Vertical Box,Grid等,这时候子控件是在同一层上,引擎会优先Batch起来。
当使用比较灵活的Canvas Panel时,会导致引擎默认的行为会把每个加入的子空间的Implicit Zorder自动增一,这时候如果你确定这些子Widget不重叠,其实可以手动控制这个ZOrder。当然Batch的前提还是你用了同样的材质和贴图。那么如果做一个背包界面,里有很多不同东西的图标,我们又希望这些图标有一些特效,我们可以用同一个材质,这只同一个Texture Altas,针对每个子控件设置不同的Vertex Color,在Vertex Shader里通过VC的值做为uv来使得这些子控件可以被Batching起来。
音频和特效,音频是比较大的开销,我们之前的堡垒之夜又是从主机到移动端兼容的项目,为了优化音频在移动端的开销,我们增加了做了很多设置,使得在移动端不同的设备可以设置不同的SoundCue并发的数量,以及SoundSource的数量。
其中SoundSource默认在移动端上总数是16个,主机上可能是32个。简单说明一下什么是SoundCue,这就是原始的SoundWave资源拿过来做一些实时处理封装后的音频资源,例如可以在多个SoundWave中做一些随机、拼接,以及一些声音效果的实时处理,这些处理效果对计算量要求比较大,我们可以针对不同的硬件设备做一些LOD的设置,比如说在比较差的CPU移动设备上,可以把Reverb,EQ等关掉,或者减少随机的Wave的数量等。
Particle比较显著的开销是Overdraw,我们在PC上有自动把贴图的Alpha切割出八面体,减少Overdraw的功能,但是这个功能之前在移动端无法使用,最近我发现其实只要支持SRV的设备,是完全可以用这个功能的,移动端上也可以打开。
另外,所有的半透也可以以独立的RenderPass以低分辨率绘制在upscale回来以减少overdraw带来的大量的fragment的开销。
Level Streaming
为什么用Level Streaming?其实道理很简单,因为场景非常大时,我们不可能把所有的场景加载到内存里面,这时候我们可以把地图拆得非常碎,每次只加载视距内的一小部分,使得内存的占用变得比较低。
这样一来场景在内存里的东西比较小,场景遍历的开销也会比较小。同时也可以在设计上增加场景可使用的物件的种类,丰富了场景的复杂度。整个Level Streaming总共分为三个步骤:
IO这一步我们是放在Worker thread做的。
第二个步骤是反序列化,在启用Event Driven Loader后,IO和Deserialization可以并行,其中反序列化也可以由打开s.AsyncLoadingThreadEnabled放到异步的ALT去做。
最后一步是Postload,这个有很多时候需要对游戏线程注册对象,需要在主线程做,在引擎里可以用Time Slice的方式分帧异步来做,同时,对于PostLoad中某些不影响游戏线程的行为,我们也挪到了ALT里,很大提升了Level Streaming的效益。
服务器
其实刚才针对客户端的优化,都会惠及服务器的优化。在新版本中,我们加入了Replication Graph,在集中的类里做了ServerReplicateActors的计算,总体思路就是减少PerConnection,PerActor的relevancy以及priority的计算量,通过把Net Actor注册到以空间位置划分的grid中,每次针对当前Connection只检查所在Grid内对象的信息来大大降低整个Replication的计算量。
另外,对于不同Connection见的部分对象,我们也会Cache下来需要replicate的数据结果针对别的connection复用。这个改动优化使得在我们的项目中我们服务器的整个CPU用以做replication的开销降到原先的1/4。
另外一些服务器优化手段有,这是降低所有对象Net relevancy distance的距离;把以移动的RPC包做优化,如果连续的几个移动方向和速度是一致的,可以把几个移动RPC包合并起来只发一个,减少网络带宽的占用和包的序列化等计算量。
服务器我们也可以关掉大量动画的计算,只在播一些特殊动画的蒙太奇的时候才会打开动画的更新。在Server上也可以把一些只关注渲染视觉和实际游戏逻辑计算没有关系的Component在Server上去掉。
Rendering Thread
看完大量游戏线程的优化手段,接下来我们来看看渲染线程,渲染线程的第一个开销取决于场景的复杂度,即使实际绘制出来的内容很少,但是场景遍历的开销却是正比于场景在内存里的Primitive数量的。
如果我这个遍历时间很长,那么实际绘制调用发出的时间就会比较晚。这个时候,我们就要利用好Streaming Level来最小化Scene Tranversal的开销。另外,动态的对象每一幀重新获取要绘制的渲染数据,也会有不小的开销,同时也会降低静态对象的渲染状态排序的优势。这也是上面提到过的加入了特殊的Static Render Path的优化手段的原因。
Culling
场景遍历后的大头是Culling,包括预计算的Precomputed visibility Volume,场景针对每个场景的可见性,不是特别大的地图比较适用,在runtime几乎没有开销,代价是离线计算的时间和一部分内存。然后是并行的视锥体裁剪和基于距离的裁剪,都是很常规的Culling手段。
移动端的occlusion是比较头痛的问题,我们在支持ES3.1的设备上,使用了Hardware occlusion query,在3.1以下的设备我们提供了一个Software occlusion的解决方案。当然要注意这并不是万能的,有些情况下还多了绘制的三角形面数及大量bounds transform的CPU开销,却没有实际occlude掉什么对象。
Draw Calls
剔除完就到了最终头的开销来源:Draw Calls,减少DC的手段多种多样,譬如引擎提供了刷foliage的工具,对于石头、树之类大量复用的对象,用这种方式刷出的HISCM,会做gpu instancing大大减少DC数。
然后一个有用的方案是HLOD,可以把一组Mesh甚至是一个关卡合并成一个Proxy Mesh,在最低级LOD后,可以切换到这个合并的Mesh,大大的减少远处物件的Draw Call并依然保持很远的视距。HLOD依然可以做多级的LOD帮助进一步减少DrawCall和减少面数,这些工具都是引擎内建,可以很方便部署自动化。
Dynamic Instancing,我们有一些特殊的方案,也做了一些整合,接下来的引擎版本会有非常大的渲染pipeline的重构,会对这个有更天然支持,甚至支持带光照烘焙的Dynamic Instancing,在光照图计算的时候就把可以instancing到一起的对象优先并到一张光照图上。
Drawing Policies
另外一个和DrawCall开销息息相关的是渲染状态切换的数量,引擎里有个接近的概念叫Drawing Policies,刚才说静态的对象我们会按Drawing Policies分组排序,现在的版本中,我们针对这个分组排序的规则做了一些改进,可以更好的减少渲染线程的渲染绘制调用的状态切换,同时也一定程度兼顾gpu的overdraw。
刚才说到的新的mesh draw command pipeline要到今年年底,明年年初才上线,在目前的测试场景中,对于渲染线程的优化,可能有近十倍的改善,当然最终在移动端上表现如何还不能下定论。整个新管线的思路是尽可能使得渲染线程在cpu端没有什么开销的,场景资源管理等的开销都在GPU上。
RHI Thread
在OpenGL ES上,GraphicAPI的调用必须和glcontext在一个线程,于是,我们把所有的gl command都enqueue到了一个叫RHI Thread的线程,这样一来,实际渲染驱动的开销和引擎渲染线程的工作就可以有一部分并行化,减少整个渲染的frame time,以及变向降低渲染线程所在核的主频,这样可能在部分设备上还能减少一些功耗开销。
讲完渲染线程,我们来看看Hitches,卡顿主要分为四块。
Loading
当量启用streaming level异步加载以后,如果游戏逻辑发生了阻塞加载,由于引擎并不知道加载数据的依赖性,所以会导致引擎Flush异步线程,造成卡顿。其中普通游戏逻辑触发的加载我们可以比较容易的察觉并改正,但是另一个情况是在网络同步的时候,当服务器第一次同步回来一个新的Actor时,客户端会创建Actor Channel,并需要实际Spawn Actor,可能会依赖阻塞加载的数据,进而导致flush造成卡顿。
我们可以通过打开net.AllowAsyncLoadingEnabled,使得触发的加载变成一个异步加载,并且这个Actor Channel的创建过程,也会加入一个pending的队列,等到加载资源都到了以后的那帧才可以实际的创建。
Compile Shader
由于ogl es没有固定的shadercache标准,引擎提供了ShaderCache,在新版本中改进成了ShaderPipelineCache的功能,该系统可以在离线环境下先跑一遍游戏,在这个过程中用到的Shader,绘制的状态记录都会在Log文件中。Runtime的时候,我们会先读log,分一些批次预先Compile完以减少runtime发生compile的情况。
另外,一旦compile,可以配合另一个ProgramBinaryCache的功能,引擎会把link完的program保存下来,以后再需要加载Shader的时候,如果发现这个link program存在,会直接加载program。
这样不但能省去compile和link的过程,还跳过了shader code的加载过程和节省了内存。除了compile,这个cache系统还会做warmup,也就是预先绘制,以减少第一次使用的额外开销。
Spawning
降低spawn的开销一个是减少每个components的数量,再者,尽可能用C++的Component。如果你是BP components,引擎项目设置中有一个选项,可以在cook的时候把components的序列化,初始化的结果存下来,spawn的时候直接拿这个数据做实例化就行了。
然后Component注册到游戏线程可以做分时。当然最常规的减少spawn卡顿的方法还是做pooling,如果有大量同类型Actor的Spawn,建议这样做。
GC
GC主要分为两步,先是引用分析,然后分析完标记可以destruct的对象会在这时开始发出BeginDestroy,而实际的Destroy会分幀去做,因为有些对象渲染线程的资源还在访问,不能当场删掉,所以只是发出一个render fence,渲染线程回收掉,我们才在下一帧主线程purge的阶段把对象删掉。
在整个GC过程中最费的,是引用分析,因为这个必须在当前这幀做完,新版本中我们把标记和引用分析都做了多线程并行,利用所有的核计算,可以比较好的提高引用分析的效率。还有一种手段是可以跳过大量的常驻内存的对象,我这里列了一个参数,MaxObjectNotConsideredByGC,设置这个参数范围内的对象是不会在引用分析的时候做检测的。
再有一点是Clustering,一组对象永远是共生的,可以规划在Clustering里面,这样的场景下GC效率可能提升十几倍。最后新版本中,我们把BeginDestroy也放到的发生GC的后一帧去做。
解析来我们快速的过一下GPU。
渲染分辨率,我们可以逐设备的通过MobileContentScaleFactor设置BackBuffer的分辨率。我们也可以通过r.ScreenPercentage把单独的3D的分辨率改小。改分辨率是显而易见提升GPU的手段,因为大部分时候我们都是pixel shader bound。
当然,带宽也是很大的因素,引擎还可以灵活的设置SceneColor的格式,默认HDR下我们使用FP16的RGBA,在有些项目里我们可以用r.Mobile.SceneColorFormat来调整成R11G11B10或者RGBE的方式减少带宽的占用。
当然要注意,移动端有些特性一来DepthBuffer,而支持DepthStencil fetch扩展的设备并不算太多,所以引擎默认会把Depth存到SceneColor的A通道,所以采用R11G11B10这样的格式,可能就会使得某些依赖读回深度的feature发生问题。
Material
材质,也就是shader复杂度,我们可以设置Quality Switch使用不同复杂度的材质针对设备做优化。也可以直接使用fully rough,non metal之类的材质优化选项。当然滥用的话会使得最终生成的shader permutation的分裂数量很多,需要注意一下。
Shadow
Shadow主要分为两种。Modulate shadow我们已经不太适用,不过因为是单对象一个shadow volume,所以可以设置的shadow map利用率和精度比较高一些,在某些角色展示场景中可能比较有用。
CSM是全场景的动态shadow,非全动态光照时,移动端默认只对动态对象投射。可以通过Device Profile控制,例如可以在低端设备上没有shadow,中等的设备上可以不做PCF filtering,好的设备上才开filtering做多次采样。
Landscape
我们在近期版本中也做了一些改进,不同层LOD的计算以前是根据距离,现在改成根据屏幕占比,顶点shader的计算量会小很多。另外现在新的版本中移动端的材质不再受三层的限制,当然三层的时候,两个weightmap和normal共享一张贴图,依然是比较优化的情况。
地形本来占屏范围就广,采样多的话pixel shader开销很高,所以还是尽量推荐使用三层以内的混合。
Base Pass pixel shader
Base Pass pixel shader 效果上我们做了一些改进,sky light和refleciton的计算都做了修正,Specular换成了GGX,以前GGX在半精度的情况下,NoH接近1时会有比较大误差,我们做了一些改进。
另外,在MobileBasePassPixelShader中的各个模块,项目组也可以根据需要去除不需要的,例如IBL或者lightmap或者shadowmap的部分。
后处理,可以根据不同的设备做不同功能的开关。
Mask,在移动硬件上比较费的原因是因为如果写depth时,某个像素发生clip/discard,硬件的earlyz就会失效,导致overdraw。
一个方案是开启prepass画mask,basepass做z equel;还有一个是引擎的LOD transition,在发生LOD时,不是直接换模型,会把两个LOD模型都画一下,通过一个dither的mask慢慢的渐变过去,这个时候可以采用类似于mask的行为,我们可以把LOD的结果dither的结果画到Stencil,在BasePass时做stenciltest减少不必要的discard。
内存
内存我们针对不同的设备,独立于其他的优化选项,单独有一组Bucket设置,可以针对不同设备的可用内存决定自己使用的Memory Bucket设置。
Texture Streaming
除了Streaming Level,引擎还有一个内建的很强大的功能是Texture Streaming,刚才已经介绍过一些,IOS上的实现利用了Apple的GL扩展,安卓有些设备没有扩展,我们可以做完整的贴图资源抛弃和重新的创建。
在cpu上根据物件bounds的屏幕尺寸×材质中用到的对应贴图的uv scale系数×一个可以由美术tweak的scalar值来决定实际贴图提交的mip数,可以用r.Streaming.PoolSize在不同设备上很方便设置全局的贴图资源的内存Budget。
Shader code
Shader code,我们会利用Shared Shader code的功能,将大量静态的分裂导致产生的Shader有重复的去除,将实际的Shader code存入ShaderLibrary,在每个MaterialInstance对象上只存ShaderCode的GUID,大大减小了实际的ShaderCode大小。
在有些项目里可以减掉80%。另外,不使用的rendering功能一定要在项目设置中关掉,可以大大减少shader分裂的组合数量。
RHI
UI的贴图比较大,由于默认情况下贴图资源被CDO(Class Default Object)引用住无法GC掉,可以用弱引用技术的方式来缓解这个问题。
其次,Slate altas Size可以小一点,可以减少冗余的空掉的贴图内存。GPU Particle不用的时候可以把fx.AllowGPUParticles关掉,我们会用到两张128位1024的RT存gpu particle的position和velocity,有将近60兆的大小。
再次,FSlateRHIResoureceManage,FrenderTargetPool里polling起来的资源,可以适时主动调释放的接口,以减少之前用过,之后短期内不会用到的资源。
近期我们还发现在使用UniformBuffer的时候,在一些gles的驱动里会有非常可观的内存开销,因此我们现在改成了在ES3也会用pack过的UniformArray的形式。
Other
还有很多比较散内存优化点,碍于时间关系,这里就不展开细说了,例如在clang下TCHAR是4字节的,我们改成了二字节,也把相关的字符串函数做了一些自己的实现。
最后,我们简单看一些引擎关于适配和迭代的设置手段。
这是引擎大量依赖的scalability系统,引擎所有可以控制的属性,都可以放到Scalability Group,引擎内建了一些分组,我列在这里了,项目组也可以定义任意的分组,每个分组里面可以有我们不同的参数控制,配合有继承关系的Device profile系统,可以很方便的针对不同的设备使用不同的scalability设置,单独可使用的设置项非常多,可能有上千个。
Device Profile
下面的这个Device Profile的例子是iPhoneX,大家可以看到iPhoneX的设置是继承自IOS高配的并做了一些override,而ios高配又继承自IOS,而IOS继承自移动设备的Profile,一个项目可以适配任意多的硬件和平台。
不同的Device Profile的选择依靠不同平台的Selector,安卓上可以根据正则表达式或者严格匹配等方案去匹配SoC,GPU Family,Device Module或者GL Version等。
再来我们看下项目Iterating的步骤,数据转换过程我们叫做Cook,cook分为两种方式,一种是你设备跑起来的时候,设备上是没有资源的,设备的资源访问不是访问本地,而是访问网络磁盘,编辑器的一个commandlet会作为server端持续提供你要访问的数据,这个数据如果没有经过转换会先阻塞的cook完再发过去,迭代的时候非常有用,叫cook on the fly。
还有一个是把资源全部转化完发到手机上,在不-iterate时,即使资源不改,也会先都load出来再save回去做检查。项目大了会用很久,如果资源变化了,在DDC(Derived Data Cache)中找不到,需要发生资源转换的过程,则会更慢。当用了-iterate后就会跳过这个步骤,但是有时候依然会load+save,是因为ini文件发生了变动,引擎不知道这个变动会不会影响cook结果,只能重新load/save.
这时候引擎有一些优化选项,可以让你配置一些特殊的字段告诉引擎,当这些字段发生变化时cook也会不做检查,例如项目版本号之类的字段。当迭代测试的时候只要改变启动命令行参数的时候,可以push一个UE4Commandline.txt文件到设备上,就可以免除重新打包的时间。
Debug
Debug没什么好说的,新版本中,为了加速迭代,我们开始使用Android Studio做debug,可以同时debug native和java代码。当native代码改动后,可以在vs里编译,UBT会自动更新build.gradle,使得Android Studio会自动识别并更新,改完后直接去android studio中启动就能debug了,不需要再打包了。
Profiling
Profiling方面,gpu上细节的profiling主要靠移动gpu厂商工具;另外引擎有大量的内建的工具,例如常用的stat系列的命令以及showflag系列命令可以快速帮忙定位问题,cpu的profiling,引擎有自带的工具,近期还加入了第三方工具framepro的支持,可以以很小的overhead做基于namedevent的profiling。
我们也正在和腾讯合作,在做一些新的Profiling工具供大家使用。关于内存的profiling,引擎也有一些Memreport和llm的命令和对应的Memory Profiler工具辅助检查内存的使用状况,以及查找内存泄露和优化的方案。
III 近期焦点 III