Unity移动端游戏性能优化简谱之 达到GPU压力与画面表现间的平衡

Unity移动端游戏性能优化简谱之 达到GPU压力与画面表现间的平衡

《Unity移动端游戏性能优化简谱》共分为五个部分,今天向大家介绍文章的第四部分:达到GPU压力与画面表现间的平衡,共7小节,包含了GPU Clocks与GPU Bound、GPU顶点阶段计算压力、GPU片元阶段计算压力、着色器复杂度、后处理、带宽等多项常见的游戏画面表现讲解。(全文长约4400字,预计阅读时间约15分钟)

前三部分可点击以下对应文章查看:

一、《Unity移动端游戏性能优化简谱之 前言》
二、《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(上)》
《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(下)》
三、《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优(上)》
《Unity移动端游戏性能优化简谱之 以模块思维划分的CPU耗时调优(下)》

1. 总览

近几年中,市场上部分画面质量很高的成功项目正逐渐使得行业变得更“卷”,甚至一定程度上跨越了游戏类型和赛道的限制,让更多开发者发现优质的画面和诱人的玩法一样,成为吸引和留住玩家不可或缺的重要手段。伴随着这种倾向,许多团队在开发过程中把大量重心倾斜到了美工制作和渲染效果的实现上。但随之而来就产生了很多问题:渲染压力越来越高,尤其是很多开发者对配套的渲染性能监控和维护不够了解,产生了很多低效的流程甚至后期弥补造成的大量成本。

而其中,GPU端更是已经超越了CPU端,成了许多项目性能压力的最主要来源之一。但GPU端往往更多地涉及到硬件层面的问题,市面上不同的厂商不同的芯片会反应出不同的现象,为开发者进行机型分档和标准制定造成了困扰。从另一个角度来说,针对GPU性能的检测往往不是Unity引擎相关的工具所能提供的,而硬件厂商提供的各类工具则繁多而难用,看得人眼花缭乱而抓不住重点,实质上为GPU性能问题的排查和优化带来了巨大的困难。那么,我们有没有一个相对具有共性和普遍性的思路或方法论,来判断一个项目是否有GPU压力——如果有,是什么类型的压力、又分别该如何处理呢?

基于市面上这类工具的缺位和日益迫切的GPU性能优化的需求,UWA在2023年下半年开始推出了GOT Online全新的第五大模式:专注GPU性能的GPU模式,力求做到一个工具兼容市面上所有移动端硬件厂商的设备,并且给出对实际优化工作具有现实指导意义的参数。

但在讨论GPU模式如何处理分析GPU性能问题之前,我们不妨先回顾一下移动端游戏每一帧渲染的流程。CPU负责应用阶段,GPU则负责后续的几何阶段和光栅阶段。对于多数开启多线程渲染的项目而言,主线程中的渲染模块CPU端主函数主要处理DrawCall的准备、Culling计算等工作,而同时开辟了渲染线程,将同样非常耗性能的向GPU提交纹理、向GPU提交DrawCall、等待GPU完成工作的工作全部交由渲染线程进行。一般对于渲染压力较大的场景,当你注意观察此时的Timeline时,可能会发现如下图所示的现象:主线程必须等待渲染线程完成上一帧的工作才能开始这一帧渲染模块的工作,而此时渲染模块中主要在进行DrawCall的提交和等待GPU完成上一帧的工作。

此处,主线程中的Gfx.WaitForPresentOnGfxThread即为等待渲染线程完成工作的标记,而渲染线程中的Gfx.PresentFrame则代表了等待GPU完成工作的标记。它们本身不代表实际的耗时开销,而仅仅是多线程渲染和CPU-GPU同步等待机制下的性能标记。

图中的性能情况即为非常典型的GPU Bound。此时CPU压力不大,各个模块的耗时总和小于目标30帧率所需的33ms,但GPU压力却使得游戏此时仅能跑十几帧,而此时对CPU作常规的优化将并无任何效果。比如你这一帧中CPU耗时为30ms,总计实际要等待GPU 80ms,你此时的同步等待标记显示为50ms,而你现在将CPU耗时大幅优化至20ms,你的帧率不会有所上升,因为此时同步等待标记要花80-20=60ms了。当然,反过来说,当一个项目的CPU耗时有80ms,此时完全检测不到等待标记耗时,但并不是就说一定就没有GPU压力了——而可能只是被CPU高压掩盖了。

就是这样一个简单的木桶效应,而它开始在近几年中大量地出现在UWA遇到的各种移动端项目中,这也是为什么上文有“GPU端更是已经超越了CPU端,成了许多项目性能压力的最主要来源”这一说法。

那么,到这里为止,我们似乎已经足以判断项目在当前测试设备和分档上的测试流程中是否存在GPU压力。但Gfx.WaitForPresentOnGfxThread本身到底只是一个没有实际耗时意义的同步等待标记,也会如上文提到的一样受CPU压力等影响,并不总是能够准确地标定究竟哪些场景哪些画面的GPU压力更大,更不用说它无法指导我们作具体的GPU性能优化和分级。

那有些性能分析工具出现的GPU Time呢?UWA实验发现,当使用完全一样的包体和测试流程,在不同分档的设备上进行测试时,经常会出现GPU芯片更好的硬件上测出来的GPU Time反而比相对差的硬件的GPU Time更高的现象;又或是在完全就没有压力的空场景跑30帧时,GPU Time也会高达33ms的情况。这可能是该参数的算法在不同硬件SOC上根本不一样,也可能因为在完全无压力或者CPU高压的情况下,GPU即便高频率运作也无法使得帧率更高,所以GPU自动出现了降频节省功耗的行为。总而言之,各种实验和实践说明了GPU Time参数也不可靠。

因此,我们要跳出引擎层概念的束缚,溯本求源地来找寻一个切实反应GPU渲染计算压力的参数。它就是GOT Online GPU模式重点围绕的核心,也是本章最主要展开解释、分析和进行相应优化建议的主题,GPU Clocks。


2. GPU Clocks与GPU Bound

GPU模式的简报页面开门见山地展示了GPU Clocks参数曲线。GPU Clocks是UWA通过硬件底层接口统计到的数值,它反映了每帧耗费在GPU计算上的时钟周期数,单位为万Cycles/帧。若将该值乘以项目的目标帧率,则得到项目运行过程中每单位时间(每秒)所需要的GPU算力。我们称之为实时需求算力。

而每款SOC出厂时,硬件厂商会给其标定一个额定最大频率,也就是其释放全部性能时,理论上每秒钟能进行多少时钟周期的运算,单位是MHz,也即100万Cycles/秒。我们称之为理论最大算力。

显然地,若实时需求算力大于当前运行机型GPU芯片的理论最大算力,则即代表硬件算力不足以满足项目渲染所需要耗费的实时算力。反映在实际的性能表现上,变成需要更长时间来完成这些工作,也就是帧率降低。这实际上可以体现为一个公式,即:实时GPU Clocks*实时帧率=实时频率。

而在实际运行过程中,GPU高压还可能伴随严重的发热,从而触发硬件保护机制,使得频率下降。而此时若渲染绘制的工作并没有变少,显然帧率也会进一步降低。

上述是比较典型的压力很大的情况。而UWA认为,即使当前GPU压力不足以使得降帧,但仍迫使GPU必须以高频运作才能完成工作时,仍是一种压力比较大的状态,耗电和发热问题可能仍然显著。

因此,满足以上两种情况的帧会被UWA判定为GPU Bound并且标记为粉红色。判断的公式为不等式:实时GPU Clocks目标帧率>=GPU额定最大频率80%。我们可以看到,在图中这一测试流程里,GPU Bound帧数占比达到了惊人的93.09%,显然地只要黄色GPU Clocks曲线越高,蓝色的帧率曲线就越低,GPU压力毫无疑问地是该Demo场景最为致命的瓶颈。

经UWA一年以来在大量实验和实践中的验证来看,通过该方法判断的GPU Bound要远比其他任何方式可靠的多。并且,将公式作简单变种即可指导项目的分级策略和优化目标。

比如:项目根据发行市场状况和游戏类型,选择了一款市场占有率高、硬件性能稳定合适的Mali-G76设备作为自己中端分档的基准测试设备,想要在该分当上稳定运行30帧。发现其GPU额定最大频率为720MHz。那么,后续的优化工作中,就需要把所有的场景的GPU Clocks总是控制在720MHz/30=2400万Cycles/帧以内,若想要使得发热也得到良好控制,则还要乘以80%。那么在后续的迭代中,只要新的场景,或局部的后处理、特效、精致模型等因素,使得GPU Clocks超过了这一标准值,就说明相关的渲染策略或渲染资源需要在当前分档上进一步优化。

那么,我们在定位到哪里有GPU Bound后,就要进入到具体定位和排查问题的阶段了。一般来说,GPU压力主要来自两大方向,即顶点阶段的计算压力和片元阶段的计算压力。


3. GPU顶点阶段计算压力

我们首先讨论顶点阶段产生的压力。

本质上,GPU在某个阶段的性能压力大不大,就等同于它在这个阶段的计算量大不大的问题,也即取决于两个乘数的大小:总共要计算多少个单位的东西,和计算每个单位的东西平均要耗费多长时间——计算次数和计算复杂度的区别。

在顶点阶段,渲染面片数就是计算量层面的概念,而计算复杂度则是顶点着色器的复杂度。一般而言主动修改顶点着色器的开发者在少数,而排查这一点的方式和排查片元着色器的方式大同小异,所以我们放到之后的章节一起说。

此处的渲染面片数,指的是实际参与运算的数量,而与“同时出现在视域体内的模型的三角面数之和”这一概念是有差异的。具体来说,除了受到模型面数的影响外,它和裁剪结果和渲染次数都有关系。当渲染面片数过高时,会体现在参数GPU Primitive,即GPU绘制的图元数量中。

其中,总图元数量Input Primitive代表CPU传给GPU进行处理的图元总数,剔除图元数Culled Primitive表示在GPU阶段剔除的图元数量(面剔除、视锥剔除、微图元剔除等),可见图元数Visible Primitives表示最终实际进入下一处理阶段的图元数(即总图元数减去剔除图元数)。不难看出,为了让GPU要进行的处理和运算越少,传入GPU进行处理的图元数量越低越好,而其中可见图元数的占比越高越好。

一般认为,较为合理的情况是可见图元数和被裁剪图元数的比值在五五开。这是因为在场景中绝大多数模型制作都是均匀的情况下,朝向相机的可见三角形数量和反之的三角形数量相近。当然,如果能够把总是不朝向相机、玩家在任何视角都看不到的三角形从模型层面就予以剔除,当然是最好的,甚至可以是可见图元数的比例超过50%、越高越好,说明利用效率高。当然,这也是很多减模软件都在争取的优化方向。

但在实际具有顶点压力项目中,往往是被裁剪图元数占比更高,如图例,很多项目甚至达到70-80%数量级,说明可能存在大量浪费。那么,被裁剪图元数为什么会这么高,就需要我们对这个参数进行进一步拆解。它由三个部分组成:XY平面视域体剔除和背面剔除XYPlane&FacingCullingPrimitives、Z平面视域体剔除ZPlaneCullingPrimitives、微小面剔除CoverageCullingPrimitives。

其中,就像我们上面讨论的,背面剔除占比高是比较正常的但也不应该总是超过总图元数的一半,说明不朝向相机的三角形占比更高了,这些模型的制作和场景设计是不科学的。

而另外两种在理想情况下应该非常低。这里需要指出的是GPU阶段的视域体剔除和CPU阶段的视域体剔除概念是不一样的,CPU阶段是以渲染物体为单位作剔除,只有当一个物体完全在视域体外时才不会提交给GPU。但如果是一个巨大模型,比如建筑或地表,即便只是冰山一脚进入了视域体,也会全部提交给GPU。而GPU阶段是以像素为单位进行的剔除,相关计算全都算完以后才把这个大模型的绝大部分的三角形剔除,就造成了大量的计算浪费。所以才说尽量把复杂、巨大的模型尽量拆小,走SRP Batcher或运行时静态合批,这样它们中的个别进入视域体时就不会全部被提交给GPU了。

而微小面剔除相对更好理解,GPU会把一些屏占比极低、密度过高、过于狭长的三角形剔除。这一项如果较高的话显然说明我们某些模型明明屏占比有限,但网格过于精细。这一项其实可以结合我们早在内存章节提到的网格渲染密度这一概念筛选优化存在此问题的网格模型。

以上数据仍主要是供我们参考哪些场景存在图元绘制的浪费、优化空间较大。但我们在进行实际优化工作时,还是需要从相应分档的硬件算力出发,来把总图元数优化到相应数量级。为了达到这个目标,我们需要更具体的方法论。

为此,我们需要回到Unity引擎中,针对性截帧排查通过Triangle曲线或图元数曲线发现的顶点压力大的场景。

一种常见的排查方法是,我们需要使用Unity自带的Frame Debugger工具对该场景进行截帧。游戏被暂停后,就可以通过Stats面板看到此时的Triangle数量。此时,我们使用上下键在Frame Debugger中截取到的DrawCall中进行切换,就可以看到画面和Stats中的Triangle数量随之而变化。此时统计的是到选中DrawCall为止的所有DrawCall绘制的Triangle数量之和,从而轻易能够定位到造成Triangle数量过高的DrawCall有哪些了。

当然,你还可以直接在层级树关闭或打开一类物体的渲染来看它们对Triangle数量的贡献。比如场景中所有人物的对象都是放在某个节点下的,我只要手动显隐这个节点,就可以通过Triangle数量的变化知道我的项目中人物模型的制作和绘制策略是否需要调整。

通过上述一系列手段定位到问题后,应根据UWA提供的不同机型分档上的推荐面片数作分级机制。比如,当前项目在极低端机型上被建议控制到15w面,其中根据功能和表现需要,认为其中人物的表现效果更加重要,场景地形等可以牺牲的比较多。因此,开发团队准备使用5w面绘制场景地形、剩下10w面用来绘制人物。然后项目是一个多人回合制对战游戏,最高有5v5的同屏需求,但是低端机不需要实时阴影,不会把人物的三角形画两遍。那么每个角色最高允许的面数就是10w/10=1w面了。制定好标准后,就要对以下导致三角形过高的问题进行具体的优化调整:

1.Level of Details。这并不是一个普适性很高的做法,但对于视野距离较远的复杂3D场景或大世界类型的游戏而言,往往比其他游戏的顶点压力更大,也适合通过把LOD做得更狠来控制渲染面片数。

2.模型过于复杂。美术资源本身过于复杂是最常见也是最本源的渲染面片数过高的原因。可能是因为项目初期没有设定相关规范、或者出于成本和工期暂时只有一套美术资源。相关的调整可能花时间花成本,但也往往是必不可少的环节,同样可以参考网格渲染密度这一功能筛选需要修改的资源,达到事半功倍的效果。

3.进行CPU阶段的裁剪剔除。就像早一些时候提到的,渲染面片数还和裁剪结果和渲染次数有紧密联系。可以在CPU端就通过一些规则剔除部分实际上看不到的物体、减少提交给GPU的渲染面片数。当然,一些相关的方案也有风险,比如前文讨论过的Occlusion Culling遮挡剔除,只适用于一些在视域体中但是被遮挡不可见的物体数量极多的情况,其他情况下反而对性能有反效果。当然,如果结合项目的场景设计实际情况用更简单的裁剪规则去掉一些确定用不到的东西,性价比还是非常高的。

4.控制渲染次数。一些常见的渲染策略如多光源、阴影、Shader多Pass、Outline、反射、SSAO等,最常见和最本质的原因是Shader的多Pass,都会使得受影响的渲染物体面片数翻倍。这些都应该通过分级策略,在中低端机型上有选择性地开启或关闭。


4. GPU片元阶段计算压力

4.1 渲染分辨率
至于片元阶段产生的GPU压力,我们同样先从计算量出发进行考虑。片元阶段的计算量体现在总共要绘制多少个像素。在GPU模式报告中。GPU Shaded中的Fragment Shaded参数直接反映了这一因素。它代表了GPU在每帧中执行Fragment Shader的总执行次数,基本就等同于GPU每帧中要绘制多少像素了。

而其中,渲染分辨率就决定了绘制一遍移动端设备的全屏要绘制多少个像素。

分级地控制渲染分辨率,也是当前移动端游戏中最常见和实用的分级策略之一。根据UWA的渲染分析实验结果来看,市面上很多运营时间颇久、用户量相当大的游戏,普遍会把低端分档下的渲染分辨率设置在0.7-0.8倍左右,中端机0.8-0.9倍,而只在高端分档下才会使用全屏分辨率。甚至一些对画面表现效果要求有限,但对战斗流畅度要求极高的竞技类游戏中,会使用0.5倍的渲染分辨率。

举例而言,对于一个1080*1920分辨率的手机硬件,默认情况下全屏渲染分辨率就总共要绘制200w左右的像素。但当长宽都变为原来的0.7倍时,实际要绘制的像素就会变成0.7*0.7=0.49倍,即仅100w左右,即一般占GPU压力大头的片段阶段的压力直接减半,是非常可观的优化方法,而一般情况下3D场景在移动端上完全可以接受轻微的模糊而不会对游戏体验产生显著影响。

不过在实践中常会遇到的问题是,玩家的硬件千奇百怪,尤其是当前市面上有很多硬件算力有限,但真机分辨率非常高的长屏、宽屏手机。比如一台1080*2360的手机,它画一遍屏幕就要约250w像素,比同分档的1080*1920的手机就要多画25%的像素、承受多25%的GPU片元计算压力。所以制定渲染分辨率更为合理的方法是按像素总数为标准,将当前硬件的真机分辨率算出像素总数后,乘以一定比例来约束在相应分档能承受的像素总数内。将这里“能承受的像素总数”作为实际的分档标准,而不是一个简单的、不够科学的零点几倍的比例。

降低渲染分辨率的操作本身也非常简单,考虑到直接改Render Scale的方法会同时使得UI变模糊,可能不符合策划和美术同学的基本需求,在URP项目中,可以利用Camera Stack,将3D场景渲染到一张Render Texture资源里面,再将该Render Texture作为RawImage的Texture渲染到UI相机的UI里面。此时只需要调整该Render Texture的分辨率,就可以做到只降低3D场景造成的GPU压力,而不会使UI也跟着一起变模糊了。当然,也有项目组修改管线代码来实现UI相机和场景相机使用不同分辨率,总之达到类似效果就可以了。

4.2 Overdraw
基于渲染分辨率敲定了绘制一遍屏幕要画多少个像素,那一个画面中总共平均要画多少次全屏、即Overdraw的概念同样非常重要。在GPU模式报告中,将上文提到代表一帧中总像素数的Fragment Shaded参数除以1080*1920的标准1080p分辨率,换算得到测试过程中“总体Overdraw”这一模拟计算值的走势。

首先,我们需要了解Overdraw的常见来源。在Unity中,渲染队列小于2500的对象被认为是不透明物体,大于2500的对象被认为是半透明物体。不透明的物体是从前往后绘制的,而半透明的物体是从后往前绘制的。

对于不透明物体,在场景中渲染顺序控制合理的情况下,由于有深度测试的存在,后面绘制的物体无法通过深度测试,就不会进行渲染,因而很大程度上减少了Overdraw。理想状况下不透明物体的Overdraw应控制在1层。

然而,造成Overdraw的主要元凶往往是半透明物体,对于大多数项目也即粒子系统和UI。

以上这些原因导致的Overdraw,都可以使用GPU模式中更进一步的Overdraw快照功能进行分析。在测试中点击Dump后,在生成的报告中就会有相应帧的快照。其中包含各个相机的Overdraw数值、截帧画面图、以相机为单位的Overdraw热力图。在Overdraw热力图中,一个像素点被绘制越多次,就会越明亮且鲜艳。因此从图中就可以判断是哪些渲染物体导致的Overdraw过高、是否存在一些造成了Overdraw但是对表现贡献极低的浪费渲染资源等等。

但事实上,除了这些看得见摸得着的渲染物体,实际上还有一部分Fragment Shaded来自于相机渲染序列之外的渲染行为,如后处理采样、Copy操作、Blit操作等,它们也做出类似把全屏复制出来经过某些计算再画一遍的做法,从而造成相应的Overdraw。这体现在,在部分项目的测试中,发现某一帧的模拟计算值总体Overdraw数值要比同样帧中Overdraw快照的各个相机Overdraw之和要高得多,这就很大概率是上述的渲染行为造成的Overdraw非常高。

接下来,我们具体讨论一些常见的导致Overdraw过高的问题和优化手段。

4.2.1 渲染顺序不合理的情况
当场景中存在先画离相机较远的不透明物体,再画离相机较近的物体,而且两者有所重合时,较远物体被较近物体所遮挡部分的像素就有可能被绘制两次,从而造成Overdraw。

这种情况常发生在地形上。本来当不透明物体的Render Queue一致时,引擎会自动判断并优先绘制离相机更近的物体。但对于地形而言往往有的部分比其他物体离相机更近,有的却更远,从而被优先绘制。

所以,需要通过对Render Queue等设置,使得离相机越近的物体(如任务、物体等)越先绘制,而较远的如地形等最后绘制。则在移动平台上,通过Early-Z机制,硬件会在片段着色器之前就进行深度测试,离得较远的物体被遮挡的像素深度检测不通过,从而节省不必要的片元计算。

不过对于渲染顺序的调试主要是在不支持HSR的低端机型上有比较好的效果,而随着硬件的迭代,市面上很多已经支持HSR的机型上,影响就没那么大了。

4.2.2 粒子系统
灵活使用UWA的性能分析工具,可以有效定位对GPU压力贡献大的粒子系统。

一种做法是,建立一个专门的空的测试场景,在其中顺次播放我们项目中要用到的粒子系统,然后使用UWA SDK进行打包测试提交GOT Online GPU模式报告,就可以查看上文提到的一系列GPU参数的变化情况,并结合测试截图找到播放时GPU开销较高的粒子系统了。

还有一种做法是直接使用UWA本地资源检测报告,可以直接看到预测会造成Overdraw较高的粒子列表作为参考。

在筛选出需要优化的粒子系统后,对于低端设备尽可能降低它们的复杂程度和屏幕覆盖面积,从而降低其渲染方面的开销,提升低端设备的运行流畅性。具体做法如下:

(1)在中低端机型上只保留“重要的”的粒子系统,比如对于一个火焰燃烧的特效,只保留火焰本身,而关闭掉周边的烟尘效果。这一部分的分级设计上文进行过讨论,此处不再赘述。

(2)在中低端机型上对粒子系统的Max Particles最大粒子数量进行限制。

修改前:

限制Max Particles为10后:

还有一种项目中常见的情况是,美术同学尝试在同一个位置叠加多个粒子同时播放来使效果更加显著明亮,但造成了大量Overdraw。实际上可以考虑只保留2-3个,但调整材质中颜色、亮度相关参数来达到类似的效果。

(3) 尽可能降低粒子特效在屏幕中的覆盖面积,覆盖面积越大,越容易产生重叠遮盖,从而造成更高的Overdraw。一个在很多项目中常见的情况是,一个粒子发射中间实心、向四周渐变透明效果的面片,又或是发射一个长条型效果的面片,但实际上使用的是一张更大面积的方形贴图,其绝大部分面积都是纯透明的。在最终发射的面片中,这些像素部分对表现毫无贡献,但是GPU仍进行全额计算。应予以裁剪。

(4)检查粒子的生命周期,及时Deactivate。有的粒子存在渐变、位移的效果,其生命周期在播放了一段时间后就不再影像表现,但相关的计算仍在进行。因此应控制这些粒子的生命周期,最好是及时Deactivate。

(5)考虑使用动画帧烘焙策略。对于项目中很多并不需要从多个视角观察、随机性也极为有限的粒子特效而言,使用动画帧烘焙是能够极大幅度控制特效性能开销的手段。一个原本复杂、叠了十几层的特效Prefab,在做成动画帧后,就只剩下了薄薄一层Overdraw,使用的Shader也可以极为简单,就几乎对GPU不构成什么压力了。事实证明,在一些对画质要求颇高、开放世界的3D项目中,也几乎可以做到所有特效都由动画帧制作,在移动端是很有效的优化手段。

(6)Offscreen离屏渲染策略。粒子系统绘制的像素多,除了从Overdraw本身出发优化外,也有回过头从渲染分辨率予以优化的思路。事实上,粒子恰好是一种渲染分辨率降低后表现很难明显变糊的美术效果。因此,产生了一种单独调整粒子渲染分辨率的做法,不过也会造成一些拷贝操作的额外开销,因此在粒子系统使用规模较大的项目中可能性价比较高。可以参考:URP下的OffScreen Particle Render

4.2.3 UI
UI作为另一种游戏项目中大量使用的半透明物体,同样需要关注:

  1. 当某个全屏UI打开时,可以将被背景遮挡住的其他UI进行关闭。
  2. 对于Alpha为0的UI,可以将其Canvas Renderer组件上的CullTransparent Mesh进行勾选,这样既能保证UI事件的响应,又不需要对其进行渲染。
  3. 尽可能减少Mask组件的使用,不仅提高绘制的开销,同时会造成DrawCall上升。在Overdraw较高的情况下,可以考虑使用RectMask2D代替。
  4. 在URP下需要额外关心是否有没必要的Copy Color或者Copy Depth存在。尤其是在UI和战斗场景中的相机使用同一个RendererPipelineAsset的情况下,容易出现不必要的渲染耗时和带宽浪费,这样会对GPU造成不必要的开销。通常建议UI相机和场景相机使用不同的RendererData。

5. 着色器复杂度

在聊完计算量的问题后,我们再讨论一下计算复杂度的问题。诚然,无论是对于顶点还是像素,计算的复杂度其实包含很多因素,但一般开放给开发者修改并相对能够定量分析的就是相应阶段的着色器的复杂度,也体现了着色器计算对GPU Clocks的贡献。在GPU模式报告中,Shader Cycles参数体现了GPU花费在Shader处理上的时钟周期有多少,作为排查相关压力尤其大的场景的参考。但事实上,该参数还会受到绘制像素数量的影响,参考性有限。在实际优化过程中,开发者也倾向于希望直接观察和分析单个Shader的复杂度情况。

使用Mali Offline Compiler工具可以获得Shader的指令数和时钟周期数。一般建议优先关注其中屏占比较高、使用率较高的Shader,从而定位需要着重优化的Shader资源。

很多情况下会发现,某个游戏角色身上用到的PBR Shader复杂度非常高,但实际上在具体的GPU耗时DrawCall分析中却发现它对性能的贡献很小,其实就是由于人物往往屏占比很低、参与绘制的像素很少,所以即便计算复杂压力也不大。而往往是一些如地表、建筑这种屏占比比较高的不透明物体、或容易造成Overdraw的半透明物体,它们的Shader复杂度仅次于人物的Shader、绘制的像素数却远远超过人物,才是真正的瓶颈。

除了控制绘制的像素数外,也要对Shader进行合理的分级和精简。

举例来说,在上文讨论的粒子系统使用的Shader中,常发现一个现象:即项目组为了开发便捷,提供了一个通用性非常强的Shader给美术同学使用。在实际开发过程中,绝大多数的特效只用了个别的Feature实现,而这个通用Shader中的法线纹理采样、光照计算等部分,即便没有给纹理,但GPU仍然会进行相关计算,造成严重的浪费。对于这种情况,应该通过关键字开关或直接拆分Shader的方式予以优化。

当然,有些使得表现效果更好但贡献其实相对有限的Shader计算,应该在低端机上予以直接关闭。制作不同的Shader或通过Shader Lod的方法就可以达到目的。


6. 后处理

后处理也是最为常见的增加GPU压力的原因之一。但它本质上也还是一批绘制的像素又多、使用的Shader还很复杂的渲染对象。这里选取几个较为常见的后处理和相应的优化、分级问题予以讨论:

Tonemapping:该后处理会大幅增加Uberpost的复杂度,一般不建议在LDR模式下使用ACES模式。而HDR模式下它会在ColorGradingLut中就烘焙到内存的Lut途中,从而避免后续Uberpost中的高开销计算。所以,如果是在LDR模式下,还尽量考虑用一些无开销低开销的后处理来达到类似的效果。

Bloom:Bloom几乎是最受开发者喜爱、最为常见的后处理效果。它不但导致Uberpost Shader复杂度高,也会根据采样次数产生很多DrawCall。它常见的一个问题是,Bloom默认是从1/2渲染分辨率开始进行下采样的。对此,可以考虑在中低端机型上从1/4分辨率开始进行下采样,将大幅度降低Bloom造成的带宽开销,并减少下采样次数来减少DrawCall数。

Depth Of Field:景深在移动端上开销较大,一般不建议长时间使用,尤其Bokeh算法的开销更高,推荐考虑使用UWA学堂中优化过的版本;高斯算法的开销小,但效果要差一些。

SMAA:是一种常见的抗锯齿算法,会使用三次Pass完成边缘检测、权重计算与混合,开销相对较高,一般不建议在中低端机上使用。

总的来说,对于后处理效果,应尽量减少使用开销较高的效果,有必要的话尝试用低耗无耗的方式进行近似替代。不要采用Global Volume一直开启,而是使用Local Volume或者在不同逻辑下开启。如果一定要开启后处理,则应做取舍,如哪种后处理优先级更高、或者通过节省其他模块的开销来换取后处理开销。对于某一些后处理效果使用额外的、特定的优化手段,如上文提到的对Bloom采样分率和采样次数的控制。

由于各种后处理效果的性能开销和实际使用场景并不相同,在实际项目中遇到的问题也往往各不相同。相比Unity原生提供的后处理方案,很多开发者摸索出的一些开源方案或优化思路可能更适合移动端游戏项目。UWA社区中也有大量后处理相关的文章和资源,比如在《Unity移动游戏性能优化案例分析-2021》中就有过相关的讨论,以供参考。


7. 带宽

前文主要围绕GPU Clocks和影响它的一系列其他参数和因素展开讨论。但GOT Online GPU模式中,还采集了一个重要参数GPU带宽,值得予以关注。

我们在讨论GPU Clocks时不难看出,该参数主要影响帧率。而该参数较高迫使GPU以高频运作时,显然也会对能耗发热造成影响。但GPU带宽目前看来是一个主要影响能耗发热、但和GPU Clocks处于不同维度的参数。

而事实上,在很多项目中,发热的重要原因就来自于GPU压力,证据就在于,在UWA检测到的硬件设备温度中,同时采集了移动硬件CPU、GPU、电池的温度情况。由于移动端设备规模较小,SOC中各个芯片物理距离非常近、且散热不佳,就会导致其中一个芯片高压发热时,带动另一个芯片一起升温,不但可能对玩家手感造成影响,还能触发硬件的发热降频,从而影响游戏的流畅度。

因此在如图曲线中发现蓝色的GPU温度曲线始终相对较高、对温度变化趋势呈主导作用时,就可以认为发热的主因是GPU压力。反之则为CPU压力。

按照以往的游戏项目发热的经验来看,CPU主线程各模块高压,或CPU子线程高压、网络传输量大、I/O压力大等因素,最终都呈现在CPU芯片发热明显。但我们却发现很多项目中是GPU芯片的温度更高。

在这种情况下,除了通过上文提到的一系列手段对GPU Clocks进行控制来减少发热外,优化GPU带宽也是极为有效的手段和方向。

在以往,我们从经验出发结合跟芯片厂家的专业硬件工程师沟通后,定性地得出GPU带宽直接影响功耗、并同时对发热产生贡献这一结论。不过,实际上也可以将带宽和功耗发热问题建立定量的关系。比如Mali的官方文档指出,约1GB/s的带宽会造成80-100mW的功率开销。而这是怎样的水平呢?

手机在自然状况或运行一些低压的应用时,整体功率开销一般在1000mW-2000mW的水平,也是一般认为硬件处于能耗较低的健康状态的数值。但是游戏作为一种高压应用,精致的渲染复合复杂的逻辑运算,需要大幅度激发CPU和GPU的算力,按照UWA经验,许多项目的功率达到4000mW-5000mW是非常常见的现象,甚至于部分项目达到更高。这种情况下耗电和发热几乎肯定会较为显著了。

我们按高一些算,部分项目中渲染开销大、在高端机上追求高帧率,从而达到了10GB/s的带宽,就造成1000mW的功率开销,就要占总功率的20%。看似这个比例还不算很夸张,但功率数值包含游戏进程以外的操作系统本体、其他进程的开销,包括游戏进程本身造成的CPU、GPU和其他硬件的运作开销。而若仅是GPU带宽传输这一项就占到了20%,哪怕只有10%之多,也已经对能耗发热的贡献非常之大了。

根据UWA对市面上大量游戏的测试,低端设备上或较为休闲简单的游戏主体带宽一般控制在2-3G/s;而在相对高端机型的高帧率高画质的测试也一般最高不超过5-6G/s。当然,带宽数值受到不同硬件厂商和具体机型的影响较大,无法给出特别明确的定值推荐,但当游戏明显存在耗电发热问题时,排查带宽是一种可行的思路。

在分析带宽时,适当地拆解同样是帮助我们理清优化思路的手段。其中读带宽一般占带宽开销的大头,而其中读纹理带宽又一般要远比读顶点带宽的占比要高。

读顶点带宽主要受传输的网格信息影响,所以其实前文针对顶点计算压力的一系列优化手段也能降低顶点带宽。很多时候不需要针对性作优化。

读纹理带宽一般主要受纹理采样和RenderTexture使用情况这两大因素影响。一些RenderTexture的相关操作还会导致大量写带宽。

为此,常见的优化手段有:

(1)采样纹理数量
当GPU需要同时采样的纹理数量过多,显然会对纹理带宽有显著影响。但参与渲染采样的纹理数量往往直接和表现需求挂钩,没有特别好的大幅度降低该数量的方法,这里只提出该影响因素,开发者也可以排查是否存在一些浪费的情况。

(2)压缩格式
在内存的章节中已经或多或少地讨论过,使用合理的压缩格式,本质能够降低传输单个像素时要传输的信息量,从而有效降低读纹理带宽。

(3)纹理Mipmap
内存章节中已经详细阐述了GPU采样开启Mipmap纹理的原理,以及建议哪些纹理开启Mipmap能够降低带宽。此处不再赘述。

(4)合理的纹理采样方式
除了合理使用Mipmap非0层采样外,还应关注项目中各向异性采样和三线性插值采样。这些采样方式为了能使纹理采样的表现更加正确美观,会增加绘制一个像素时采样点的数量,而采样点增多的时候,Cache Miss的概率就会变大,造成带宽上升。应在Project Settings的Quality设置中避免开启Anisotropic Textures的Forced On,而使用Per Texture单独地对个别确实要使用到的纹理进行设置,并尽量使用较低的等级,或索性改为Disabled。三线性采样采8个顶点,相对于双线性采样是翻倍的。

UWA的本地资源检测服务能够统计并列出开启各向异性或三线性采样的纹理资源。

(5)修改渲染分辨率
降低渲染分辨率,减少参与纹理采样的像素,能够更加简单粗暴地降低带宽。

(6)减少Copy操作、减少后处理采样
Render Texture的Copy操作的带宽开销一般也比较高,常见的应排查URP项目中的Copy Color、Copy Depth是否有必要。此外,AA、Bloom、景深、Blur等一些常见的后处理效果,会进行多次、高分辨率的采样,从而造成较高的带宽开销,应予以分级地取舍。


本文内容就介绍到这里啦,更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。