前言
游戏的优化能从各个角度入手,渲染、逻辑、内存、IO、GC、功耗等等,未经过优化的游戏往往在每个方面都会出现问题,需要分别找出问题并逐个解决。这篇文章将分享我在项目中遇到的问题、优化的方法、使用的工具等。
一、如何查找瓶颈
优化渲染效率,首先是要找出约束当前效率的瓶颈,是CPU,还是GPU,还是IO。
首先要尽量开放帧率,假如游戏只能跑到43帧,那就把帧率开放到60,或者用4、5年前的低配机运行。游戏可能最终只需要跑到30帧,但更高的帧率更容易暴露问题。
CPU的工作时,准备好一帧的渲染数据和指令,交给渲染线程,等待GPU渲染完空出资源后,将指令和数据提交给GPU。GPU没空出资源时,渲染线程还需要等待GPU渲染完毕。
GPU需要CPU传递过来的数据才能工作,如果CPU没有及时将指令和数据传递到GPU,则GPU无法工作。
CPU与GPU异步执行,传递数据、指令时需要同步,出现瓶颈时,肯定有一方等待另一方,至于怎么查看,我们可以用Unity自带的Profiler看Wait信号量,然后去Google看看到底是谁等谁,问题出在CPU还是GPU。当然Unity的性能检查工具UPR也可以,在CPU-线性时序中查看。
找到了问题在哪里,就要着手优化。
二、渲染相关瓶颈
优化时要确定一个优化手段能解决什么样的问题,最好是通过实验确定,不然程序、美术废了很大劲去改资源、逻辑,效率却没有上升,那就是白忙活。
1. Batch
Unity有三个Batch:Static Batch、Dynamic Batch、SRP Batch以及一个Instance。
搞清楚Batch前,要清楚什么是DrawCall,什么是SetPassCall,这一点可以用RenderDoc查看。
DrawCall就是一个API、一个指令,告诉GPU要画几个索引指定的顶点:
而具体是哪些索引、哪些顶点,用什么Shader、Shader用的数据存在哪里,就需要其他API、指令来指定了,这就是SetPassCall。
DrawCall就是一个指令而已,如果渲染1000个完全一样的物体,那就可以只用一个SetPassCall,然后设置1000个DrawCall给GPU(假定不用Instance),这些DrawCall全看作数据,其实也不是很大。
但如果每个物体的网格、Shader、常量缓冲数据都不同,一个DrawCall前都要加一堆SetPassCall,不但数据很大,GPU切换渲染状态时也会费力。
所以我们一般不优化DrawCall,而是优化SetPassCall。
1.1 Static Batch
顾名思义,静态Batch,把静态网咯提前合并成一个,多个DrawCall可以用一个DrawCall完成,SetPassCall自然也少了,但很少会用。
你网格A使用ShaderA,网格B使用ShaderB,Shader都不同,怎么用一个DrawCall一起渲?那Shader相同呢?网格A使用材质A,网格B使用材质B。那么这一个DrawCall到底是用哪个材质的纹理和数据?理论上能做到区分,但Unity显然不会“智能”到这种程度。
所以Static Batch只适用于不同网格(相同网格用Instance),材质相同的静态物体。
理论上会合并DrawCall,但根据测试,在Unity2019中,一个不变物体加上两个可以切换LOD的物体,不变物体和其中之一组合可以一个DrawCall渲染,与另一个组合需要两个DrawCall,第二个DrawCall不需要SetPass。在Unity2021中,不变物体可以与两个LOD物体分别组合,只需要一个DrawCall即可渲染出来。
1.2 Dynamic Batch
Dynamic Batch会把你相机中使用相同材质的网格,实时合并并上传到GPU。
因为是合并DrawCall,所以缺陷和Static Batch的差不多,不可能合并不同材质。并且因为要实时上传网格数据,还对网格顶点大小有要求。
1.3 Instance
Instance是底层图形API提供的功能,同一网格,同一Shader,即可通过一次调用进行多个物体渲染,往往用来渲染草地。
Instance对材质不做要求,可以是使用同一Shader的不同材质。
1.4 SRP Batch
前面两个合并DrawCall的Batch有那么多限制,相比之下SRP Batch只合并SetPassCall。
常量缓冲区
首先明确常量缓冲区,是指一次渲染时,所有像素共用的数据。用renderdoc能看到当前阶段所用到的常量缓冲区:
常量缓冲区名字可自定义,但Unity SRP项目的常量缓冲区一般用UnityPerDraw、UnityPerMaterial和$Globals。
UnityPerDraw一般是ObjectToWorld、WorldToObject矩阵,每个物体的数据都不同,由管线源码定义、传输数据。
UnityPerMaterial用于SRPBatch,我们需要将UnityPerMaterial和Shader的Properties中定义的变量一一对应,才能进行SRPBatch,所以这个缓冲区由我们定义并编写。
没被任何常量缓冲区包含的变量则通通被包含进$Globals(Opengl是Uniform)。
当两个物体使用同一材质时,代表两者使用的Shader相同,两者材质数据相同,唯一不同的是PerDraw。所以当物体A切换到物体B时,只需要上传PerObject常量缓冲,设置PerObject常量缓冲,DrawCall即可。
不过每次渲染上传这一步也是可以省去的,使用Shader相同,代表常量缓冲区大小相同,完全可以在第一个物体时,将所有物体需要的PerObject以数组的方式串联到一起上传,设置常量缓冲区时,在地址后加一个偏移即可。
以这种想法考虑,即使物体使用的不是一个材质,只要是同一Shader的,即
可进行SRP Batch,因为虽然材质数据不同,但PerMaterial常量缓冲区大小是一样的,一样是一起上传,在渲染时传递一个索引即可。
可以看一个栗子:
渲染第一个立方体时,调用了这些API:
第二次DrawCall时,只调用如下API:
对比三次DrawCall时VSSetConstantBuffersAPI:
注意,同一Shader还要是同一变体,不同变体Define可能导致常量缓冲区大小不同。
SRP Batch需要Shader编写规范,是否能SRP Batch,以及不能SRP Batch的原因可以看Shader面板:
FrameDebug里能看到SRP Batch被打断的原因:
原因有很多,甚至就算使用同一变体也有可能被打断,原因可能是排序or使用了不同Job准备渲染命令等等。所以建议场景中的变体数尽量少。
上面讲了一堆Batch,但当出现GPU瓶颈时,优化Batch未必有很大效果,原因是,Batch减少的是SetPassCall,也就是CPU记录渲染命令、传递数据的次数,以及GPU切换状态的次数。如果状态切换不是瓶颈,那么Batch并不能看到良好的优化效果。
当然,该Batch还是要Batch的,毕竟能减少一些CPU消耗,只是不要对为什么SetPassCall少了,帧率却没有增长之类的事情抱有疑惑。
如果要确定性能瓶颈是否出在Batch,可以写个按钮,运行时将所有物体的材质换成统一的一个,这样肯定都能Batch上(除了超过一定数量自动打断),看下帧率有没有提升即可。
2. 渲染效率
实时渲染对渲染效率要求很高,如果游戏想要以60帧运行,那么留给每帧的时间只有16ms,如果以30帧运行,那么时间有33ms。
上图是我们游戏在PC下一帧所用时间,WaitForTargetFPS是为了将帧率锁在60,一帧总体时间是16ms。
如果没有限定时间内CPU没有进行下一帧的逻辑,说明主线程没运行完毕,或被其他问题阻塞,例如IO、GPU。
如果是GPU的问题,就要想办法优化游戏渲染效率。
2.1 剔除
能不渲染的不渲染,就是剔除,游戏中剔除的方案多种多样,视锥体剔除、遮挡剔除、Hiz剔除,还有UE5的网格级别剔除,虽然花样多,但大多数移动游戏只能做到物体级别的剔除。
Unity自带了遮挡剔除,可以先试试。在打开Window->Rendering->Occlusion Culling打开遮挡剔除窗口,进入Bake标签
点击Bake烘焙,处于Occlusion窗口时,Scene窗口右下角会出现遮挡剔除标签,点击Portals前面的小方块,能看到烘焙后的包围盒:
然后勾选相机组件的Occlusion Culling即可。
遮挡剔除的效果需要测试,假如你视野内有非常多的物体,遮挡关系不明显(例如天空相机俯视角),那么未必会有提升,甚至有性能下降的可能。
2.2 Overdraw
很多时候,像素并不能最终呈现到屏幕上,但依旧需要走一遍片元着色器,这就是Overdraw。为了解决这个问题,有很多现有的解决方案。
2.2.1 Early-Z
标准管线是执行完片元着色器后再进行深度测试、深度比较,这样可能有些浪费,因为当前片元的深度已知,可以提前知道片元是否无效,会不会显示到最后的渲染目标纹理中,所以大多数硬件都支持Early-Z,即先进行深度测试,通过后再执行片元着色器,不需要任何操作,硬件能自动支持。
但有几种情况Early-Z是失效的。例如在片元着色器进行深度写入:
struct FragOutput
{
float4 color : SV_Target;
float depth : SV_Depth;
}
FragOutput frag(Varying i)
{
FragOutput o = (FragOutput)0;
o.depth = 1;
}
或者进行AlphaTest,执行了Clip或discard语句。
我的理解是,深度测试和深度写入是在一起的,Early-Z已经进行了深度写入,在片元着色器进行深度写入或片元丢弃,都会导致深度不一致。
虽然有上述问题,但大多数的物体都是能正常走Early-Z的。
2.2.2 排序
Early-Z只能将当前片元深度和当前深度缓冲区比较,此时我们想一个最坏的可能——物体从远向近渲染。第一个物体渲染到RT上,第二个物体比第一个物体近,由于当前深度缓冲区只有第一个物体,Early-Z也无法知道未来有没有像素能覆盖当前物体的像素,所以第二个物体的全部像素也全部渲染到RT上了,以此类推,Early-Z完全没有生效。因此想要更好的利用Early-Z,需要让物体从近向远渲染,这样渲染当前物体的像素时才能知道更大概率的知道当前像素有没有被遮挡,有更好的剔除效果。
2.2.3 Perpass-Z
排序是基于物体的,如果物体本身存在遮挡关系,或物体间存在穿插关系,排序也未定好用。
所以一种想法是先渲染一遍所有物体的深度,然后再渲染物体,这样能更好的利用Early-Z。
缺点是顶点着色器的消耗直接翻倍,优点是,除了可以进行Early-Z外,部分效果可以依靠这张Perpass-Z buffer生成,例如屏幕空间阴影。
2.2.4 HFS
手机上GPU渲染架构和PC的GPU架构不同,手机的渲染基础单位是一个Tile而不是整个RT,并且没有显存,而是非常小的高速缓存On-chip Memory。执行完顶点着色器后,将数据传递到内存中,记录每个三角形影响的Tile。
渲染Tile时取得当前Tile中的顶点,光栅化、插值并渲染。
上述是TBR(Tile-Based Rendering)架构,个别芯片支持在像素渲染前进行可见性判断,就是TBDR(Tile-Based Deferred Rendering)架构,也是用来解决Overdraw问题,这一操作称为隐藏面削除HSR(HiddenSurfaceRemoval)。
2.2.5注意事项
上述的操作中,大多数不用手动实现,大多数Unity都已经处理好了,而是要我们知道后,注意Shader编写和代码实现,例如:
①不要滥用AlphaTest材质,可能导致Early-Z、HSR失效,我们主界面有一个挺大的建筑,因为有植物,所以整体用了植物的Shader,而植物经常使用AlphaTest+双面渲染+顶点风场动画,导致建筑的渲染时钟数是同面积同光照模型其他物体的好几倍,这种情况建议把植物和建筑分离。
②Perpass-Z在移动端性能未必好。因为部分手机有HSR。但反过来说,当HSR失效时,用Perpass-Z可能会有提升空间,例如大量草,可以先渲一遍Depth Only Pass,然后再渲染草本身,要注意的一点是,渲染草本身时,需要关闭Alpha Test,并且深度检测设置为Equal。
③有HSR功能的手机不做物体深度排序,这个看URP的源码:
var commonOpaqueFlags = SortingCriteria.CommonOpaque;
var noFrontToBackOpaqueFlags = SortingCriteria.SortingLayer | SortingCriteria.RenderQueue |
SortingCriteria.OptimizeStateChanges | SortingCriteria.CanvasOrder;
bool hasHSRGPU = SystemInfo.hasHiddenSurfaceRemovalOnGPU;
bool canSkipFrontToBackSorting = (baseCamera.opaqueSortMode == OpaqueSortMode.Default && hasHSRGPU) ||
baseCamera.opaqueSortMode == OpaqueSortMode.NoDistanceSort;
cameraData.defaultOpaqueSortFlags = canSkipFrontToBackSorting ? noFrontToBackOpaqueFlags : commonOpaqueFlags;
是用SystemInfo.hasHiddenSurfaceRemovalOnGPU
这个Unity API检查的,我测试时,小米手机的Adreno GPU没有深度排序(支持HSR),华为的Arm Mali GPU不支持HSR。
不过我查的资料显示是苹果、PowerVR芯片才支持HSR,有点迷。
2.3 着色效率
着色效率也和渲染时间有很大关系。
简单衡量着色效率,就看Shader行数,但这样并不直观,如果Shader翻译成dxbc,矩阵和向量mul能翻译成4行代码,pow能翻译成3行代码,而乘加只用一行代码。
dxbc行数也不能完全代表Shader的着色效率,因为dxbc指令的效率也不同,sincos和简单的加减不能直接比较。
更有代表性的是每个指令的时钟数。
另外一点影响着色效率的是物体的屏幕面积占比,一个物体占用屏幕面积越大,就需要更长的时间渲染
RenderDoc点击小闹钟图标能看到一个DrawCall消耗时间的大致时长,但这只能比较物体之间渲染效率,并不能当作标准,因为渲染耗时由多部分组成,DrawCall之间也有并行部分。不同移动设备的架构也可能导致渲染效率的不同,建议根据GPU硬件厂商选择Profile工具,例如小米手机常用的AdrenoGPU,可以用SnapDragon抓取,查看物体渲染时钟数。
连接设备,然后新的快照捕捉:
Launch Application,选择你应用的包名,Launch运行,选择要抓取的项,这里演示只选择Clocks
然后用Take Snapshot抓取快照,即可抓取。右侧可看到Clocks:
举个我们项目的栗子,我们项目的狐妖尾巴毛是用20个Pass渲染的,当屏幕占比大时特别卡顿,甚至单个妖怪跑不到60帧(低端机),后来发现尾巴的光照模型用的是Charlie,dxbc比普通pbr多了一倍,更换后帧率显著上升。
另一个栗子是主场景的地板,普通PBR材质,但占地面积大,渲染时钟数特别高,将地板换成Blinn-Phong后,效果没有明显变化,但时钟数少了一个数量级。
2.4 LOD
LevelOfDetail,老生常谈的优化方法,近距离模型,远距离低模,更远距离面片,有效降低场景面数,如果是玩家接近不了的地方,直接用面片代替即可。
2.5 带宽
带宽衡量一定时间(每秒)不同硬件设备传递数据的多少。带宽高不一定会导致设备卡顿,但有可能导致设备发热严重,所以尽量减少带宽。
2.5.1 频率、分辨率
游戏中优化最有效果的,就是降低帧率、降低分辨率。降低分辨率能轻松提升帧率,宽高缩减一半,渲染压力就只有原来的1/4,宽高缩减四分之一,渲染压力就只有原来的1/16。降低帧率能显著降低带宽,减少发热。但这是没有办法的办法,对画质影响不小,那些拿手机降到400P跑PC 3A大作到处跟别人比,然后吹优化优劣,是没有意义的。
主RT降分辨率、帧率对效果影响较大,但其他方面可以视情况缩减一些。
比如半透特效、毛发、水面等半透物体,可以RT减半渲染,然后再Blend回ColorAttachment中;以及Cascading Shadowmap,前几级每帧更新,而渲染物体较多的后几级可以分帧更新。
这些缩减也不是单纯的缩减,降采样渲染混合回颜色RT时,可能需要对边缘做淡入淡出,否则会出现锯齿。分频渲染Cascading Shadowmap倒是不用额外做什么,因为采样高级别阴影图的像素离相机更远,意味着像素小、不清晰、变动慢,更新慢一些也看不出有什么问题。
我在项目中也实现了懒渲染功能,有一个大俯视场景有25万面,光照没有变化,影子是烘焙的,变化的只有水面和会飘的树,所以我将静态物体渲染完后,将颜色和深度保存下来,相机不动、分辨率不变的情况下,每帧Blit这张纹理到颜色和深度Attachment RT,继续渲染动态物体。帧率有效提升,更重要的是减少了渲染压力,使得发热和耗电降低很多。
其他游戏也有类似的操作,比如阴阳师的町中,一开始是43帧,5分钟不操作变成20帧,再次操作变为30帧。
降采样渲染水体半透特效在原神手机端中有实现,分帧更新ShadowMap也是,并且PC分8级,手机分4级。
可以感觉到降低频率、分辨率的优化方式往往有很大限制,但如果条件刚好能满足,往往能得到不少的优化,可以审视下自己的项目,看看有没有符合这些条件的场景。
但也要注意不要过犹不及,复杂的管线也会使得通用性不如常规管线,出现新功能时很容易出现BUG。
2.5.2 发热、耗电
温度其实分为CPU温度、电池温度等。CPU温度可以用PerfDog测试,或者用ADB代码adb shell cat /sys/devices/virtual/thermal/thermal_zone0/temp
,得到的单位是毫摄氏度
‘
电池温度可以用SnapDragon测试。
发热一般是渲染的东西太多、像素绘制率过大、更新频率过高,我对发热也是没什么特殊方法,建议就是限帧,测试我们自己的项目时,将60帧限制到30帧,CPU温度能从60°降低到48°上下。
上面的PerfDog工具也可衡量耗电。
上图中Power是功耗,Voltage是电压,Current是电流,遵从公式P=UI。手机的电压恒定(但外部条件不同,例如运行了其他app,会导致电压不同),电流越大,功耗越高。
运行时点右上角的三角Play键可以记录数据,结束后数据会上传到云端,点击右上角进入云端管理界面:
电池容量的单位是mAh:
所以如果不讨论其他应用、硬件设备,只看应用本身能持续多久,用电池容量除以平均电流即可。
除了限帧降频外,我还建议检查管线,减少不必要的全屏Blit操作,例如相机复制出的_CameraOpaqueTexture\_CameraDepthTexture,当没有效果使用这些纹理时可以勾掉;或者其他自定义的Blit,后处理Blit次数也尽量减少,例如URP低版本的Bloom有25个Pass(2340x1080分辨率),可以想办法减少升降RT,做到效果和效率均衡。全屏操作即使什么都不做,只是单纯拷贝纹理,也有不小的性能消耗。
三、其他
1. 卡顿
不同于前面提到的帧率、带宽,卡顿往往就是突发的出现一次或几次,但也会影响用户体验。
点击CPU->CPU函数性能占用->流畅度,能看到小卡顿(100ms左右)和大卡顿,点击下面的show on charts可以看到下面的Timeline中将卡顿点标记出来。
缩放区间,选择具体帧:
向下拉可以看到这一帧的耗时分布图和各函数及调用堆栈性能占用:
做卡顿检查时,要把Log关闭,因为Log太多也会导致卡顿和GC,对我们优化造成影响。
刚开始时我们将资源加卸载集中到场景加载时,导致场景加载时间很长,于是我们让管理这方面的人将资源加卸载平摊,降低了不少场景加载时间。
还有一点Shader加载相关的问题,Shader被首次使用时从Bundle中加载并编译,这样可能导致运行时卡顿,所以用Unity的变体收集功能,在使用前统一预热(Warmup)。
但这一步没有正确使用,可能导致加载时间过长。变体收集尽量只收集项目中使用的,不使用的变体尽量去掉。我们有一个Shader剔除变体前Shader.Parse要60ms,剔除后只要0.2ms。
其他造成卡顿的原因也有,需要具体情况具体分析。
2. 内存
2.1 GC
平时写程序没注意到,就可能产生GC,字符串拼接、一直new class、拆装包、Linq、Lambda表达式捕获外部引用等,甚至低版本UPR都存在实时GC,先用Unity的Profiler找到每帧实时GC干掉。
2.2 检查资源
UPR工具可以截取内存快照和对象快照,可以看到截取时的内存占用:如果有资源超乎预料的大,或者在截取时应该被卸载却依然驻留内存,就需要排查资源状况了。
有问题的资源加卸载、错误的纹理压缩格式、未经优化的Shader变体、未优化的动画、未减面的网格,都可能导致内存占用过大。
2.3内存泄漏
2.3.1 初步排查
一般内存泄漏是指资源由程序申请,但因为某些原因程序失去了资源的句柄,导致无法释放内存。比如C++一直new对象赋值一个指针变量,之前申请的对象没有delete,也没有被任何指针持有,就导致泄漏。
PerfDog、UPR等工具都能看到内存占用曲线,如果感觉内存不正常的增长,有可能是内存泄漏问题导致。可以用多个工具尝试解决问题。
首要问题是找到内存泄漏的大致区域,为了减少干扰,建议用打出包的真机程序分析。
首先用PerfDog找到使内存升高的大致操作,记录下来。
然后用Unity的Profiler工具,点击Memory,模式改成Simple:
其中Reserved Total是Unity向系统申请的内存,而Used Total是其中被使用的内存,当Used Total中内存超过Reserved Total中内存时,内存会进行扩容。
理论上反复运行同一功能,资源会重复加载卸载(或者缓存),内存峰值应该是大致不变的,但如果某个地方泄漏出去,Used Total会变高,导致内存使用峰值变高,最后Reserved Total也不断升高。
其中各项说明可以看Unity的官方文档,注意不同Unity版本可能会有区别。
2.3.2 内存快照
查到了大致泄漏处,就要继续往深排查,这个地方最好是有对项目资源特别了解的人帮忙协助,知道什么地方应该有什么资源,什么时候某个资源应该被卸载。
Unity的Profiler可以截取内存,用真机截取时将不会看到那么多编辑器对象。
截取后可以查一查哪个对象在当前场景不应该存在,无论是网格、粒子、音频、RT等等。
其次还可以做两个快照的对比,Unity有一个专门用于检查内存的工具MemoryProfiler,这是个Package,需要到PackageManager中安装。
可以用上面的表头分组,先用Diff列分组,找到New那一组,然后用Type分组。
2.3.3 Mono
Mono是被程序GC管理的内存,这方面增长可用UWA GOT工具排查。
模式选择Persistent,显示总值,根据persistentBytes进行排序,当函数的selfPersistentCounts不为0时,右侧会显示当前快照中当前函数的堆内存占用。