添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

任何一个玩家在游玩游戏过程中都会碰到类似的疑问:为什么我玩一段时间就会闪退?为什么我的游戏这么卡?为什么我的手机这么烫?为什么手机电量下得这么快?

对于玩家而言,无关手里这款游戏本身的美术是否精良、玩法是否有趣,当上述这些疑问出现时,其游戏体验会自然而然地变差,并不可避免地导致黏性下降。这是每个游戏开发者都不想看到的结果。

而实情是,对于我们今天着重讨论的移动端游戏市场而言,国内市面上各类硬件机型性能跨度很大;如果项目要支持海外市场,则往往项目所要承担的性能压力更大。那么,如何让持有不同硬件设备的玩家,尤其是通过中低端设备接触到我们游戏项目的玩家也能顺利流畅地体验游戏内容,就是我们的主要目标之一。

1.2 开发者关注的游戏性能

在游戏项目的开发过程中,一个非常常见的现象是:对于一个规模较小或经验不够丰富的开发团队,在新项目的中前期往往把全部精力投入到实现功能中去,而忽略了性能优化的工作;到了游戏中后期甚至临近上线的时候进行测试,才发现项目充斥着大量严重的性能问题,影响到了游戏的正常体验,但却不知道从何改起,时间非常窘迫。足以见得,性能问题是一款游戏项目重要的生命线,需要开发者树立良好的优化意识和优化知识体系,才能让项目少走弯路。

和玩家视角不同的是,像闪退、卡顿、发热、耗电这种感官层面的性能问题,UWA认为在开发者的视角中应该将其主要分为内存、CPU和GPU三个大类的问题。本文的主要内容旨在围绕这三个大类的常见典型问题进行例举和讨论,从而形成一份移动端游戏性能优化概述。

那么,既然已经发现了若干性能问题,如何排查确定问题和解决问题就成了最关键的两个步骤。

1.3 如何确定问题

发现了性能问题后的第一大步骤就是确定问题。

举例而言,我们的游戏项目运行时间一长就会闪退,是因为我们的缓存策略做的不好或者存在冗余现象,导致内存不断上升造成的闪退?还是因为我们的美术资源内存控制的不好,几个较大的资源就轻易把整体内存给撑大了?

又或者我们发现一个游戏场景卡顿严重、帧率很低,是因为这里的同屏渲染面片数和DrawCall数都太高导致渲染模块压力大?还是因为此处的UI过于复杂且变化频繁造成的更新耗时?

还或者我们感到手里的手机烫的惊人,是不是因为当前场景GPU压力过大?我们的带宽、Shader复杂度、Overdraw、后处理等是不是控制的不够好?我们在当前机型上做的分级策略并不合适?

以上只是众多千奇百怪层出不穷的性能问题中的寥寥几点。后文还将详细讨论一些比较常见的问题。总之,确定问题的工作本身也是一项挑战。

1.3.1 计算机语言/图形学/游戏引擎知识

知识和经验无疑是一个开发者在优化项目性能时最宝贵的武器,不断地学习和更新也是每一个程序员武装起自己的过程。完善而饱满的知识自然不仅仅有利于确定问题,同样有助于解决问题。放在这里说是因为它是我们有能力着手优化工作的重要前提。

1.3.2 多种多样的辅助工具

工欲善其事必先利其器。使用工具获得直接精确的性能数据,能更加直观地反映出性能瓶颈,从而达到事半功倍的效果。

随着业界的发展和成熟,各大引擎、IDE和硬件厂家都推出并更新着自己的性能分析辅助工具,也有不少开发者或团队喜欢根据自身项目的情况开发自己的性能监控插件。

以引擎而言,Unity Profiler是很多开发者都会用且在用的工具,可以记录并查看CPU/渲染/内存等各模块的实时消耗参数。而Unreal 4中也已提供了类似的功能。

以IDE而言,Windows、MAC OS/iOS、Andriod平台都分别有适用的工具,如Visual Studio的性能探查器、XCode的Instruments、Android Studio的Profiler等。

以硬件而言,SnapDragon Profiler、XCode Metal Frame Capture、Mali Graphics Debugger等种类就更多了,不一一赘述。

总而言之,根据开发者的使用熟练度、不同硬件平台的兼容性、工具功能的覆盖面等等因素,开发者在挑选合适的性能优化辅助工具时很容易挑花眼。

1.4 如何解决问题

性能的第二大步骤就是解决问题。在有了知识经验和上述工具的加持下,我们的项目越是复杂、越是来到后期,就越是会面临一条长长的优化问题清单。在有限时间里合理地规划并缩短这条清单,就是我们面临的下一项挑战。

1.4.1 优化工作优先级

在进行具体的优化工作前,合理的规划往往能节省大量总体时间。

规划工作的第一点就是要抓住问题的主要矛盾。在一条长长的优化清单中,把每一项条目按顺序完全优化完毕往往是不现实的。此时就需要重新整理这份清单,按照优先级重新进行排序,为优化性价比更高的问题投入更多。

对于优先级的判断,UWA认为主要参考两点:一是重要性,如果一个问题对当前场景的性能压力影响远大于其他因素、显然是主要矛盾,如果不予修正很难维持正常的游戏体验,那么就应该投入更多的人力时间集中优先解决;二是易操作性,如果一个问题造成的性能压力相对来说并不大,但只是耗费极少功夫的举手之劳,比如只是在引擎设置中关闭一个开关,就应该优先把它改掉。

1.4.2 性能和效果的权衡

第二点则是一种取舍,针对资源和渲染等部分的优化往往会影响到游戏的表现效果。为此,程序员往往需要和策划、美工同事加强沟通,在两者之间找到一个平衡点。这种平衡是一种动态平衡,需要结合实际情况操作。比如,一款吃鸡游戏画面变化频繁、且需要流畅的战斗操作,那么对表现的要求可以适当低一些;而一款养成类游戏把精美的角色作为卖点,其模型和动画就需要非常精致,而牺牲一部分性能。哪怕在同一款游戏中也可以在过场动画和角色展示界面中注重表现,而在战斗场景中关注性能,需要具体问题具体分析。

总的来说,游戏项目的优化最终目标是能够在玩家硬件上顺利运行,而为此放弃很大一部分表现效果,也是大多数开发团队的实际选择。

1.4.3 分级策略和制定标准

第三点,在每一类机型上都达成上述的平衡就是我们的分级策略。我们大可以在高端机乃至旗舰机上开启后处理、使用高分辨率高模;但在中低端机型上则使用另外一套标准

2|资源内存、Mono堆内存等常见游戏内存控制

2.1 总览

2.1.1 概念解释

首先,在讨论内存相关的各项参数和制定标准之前,我们需要先理清在各种性能工具的统计数据中常出现的各种内存参数的实际含义。

在安卓系统中,我们最常见到和关心的PSS(Proportional Set Size)内存,其含义为一个进程在RAM中实际使用的空间地址大小,即实际使用的物理内存。就结果而言,当一个游戏进程中PSS内存峰值越高、占当前硬件的总物理内存的比例越高,则该游戏进程被系统杀死(闪退)的概率也就越高。

而在PSS内存中,除了Unused部分外,我们一般 比较关心Reserved Total内存和Lua、Native代码、插件等系统缓存、第三方库的自身分配等内存 。Reserved Total占比一般较高,故其大小和走势,也是性能分析工具的主要统计对象。

Reserved Total和Used Total为Unity引擎在内存方面的总体分配量和总体使用量 。 一般来说,引擎在分配内存时并不是向操作系统 “即拿即用”,而是 首先获取一定量的连续内存,然后供自己内部使用,待空余内存不够时,引擎才会向系统再次申请一定量的连续内存进行使用

注意:对于绝大多数平台而言, Reserved Total内存 = Reserved Unity内存 + GFX内存 + FMOD内存 + Mono内存

(1) Reserved Unity内存

Reserved Unity和Used Unity为Unity 引擎自身各个模块内部的内存分配 ,包括各个Manager的内存占用、序列化信息的内存占用和部分资源的内存占用等等。

通过针对大量项目的深度分析,发现 导致Reserved Unity内存分配较大的原因主要有以下几种

  • 序列化信息内存占用 :Unity引擎的序列化信息种类繁多,其中最为常见且内存占用较大的为SerializedFile。该序列化信息的内存分配主要是项目通过特定API(WWW.LoadFromCacheOrDownload、CreateFromFile等)加载AssetBundle文件所致。
  • 资源内存占用 :主要包括Mesh、AnimationClip、RenderTexture等资源。对于未开启“ Read/Write Enable” 选项的Mesh资源,其内存占用是统计在GFX内存中供GPU使用的,但开启该选项后,网格数据会在Reserved Unity中保留一份,便于项目在运行时对Mesh数据进行实时的编辑和修改。同时,如果研发团队同样开启了纹理资源的 “Read/Write Enable” 选项(默认情况下为关闭),则纹理资源同样会在Reserved Unity中保留一份,进而造成其更大的内存占用。
  • (2) GFX内存

    GFX内存为底层显卡驱动所反馈的内存分配量,该内存分配 由底层显卡驱动所控制 。一般来说,该部分内存占用主要由渲染相关的资源量所决定,包括纹理资源、Mesh资源、Shader资源传向GPU的部分,以及解析这些资源的相关库所分配的内存等。

    (3) 托管堆内存

    托管堆内存表示项目运行时代码分配的托管堆内存分配量。对于使用Mono进行代码编译的项目,其托管堆内存主要由Mono分配和管理;对于使用IL2CPP进行代码编译的项目,其托管堆内存主要由Unity自身分配和管理。

    2.1.2 内存参数标准

    在我们了解了内存相关的各项参数的含义之后,知道了 避免游戏闪退的重点在于控制PSS内存峰值 。而PSS内存的大头又在于 Reserved Total中的资源内存和Mono堆内存 。对于使用Lua的项目来说,还应关注Lua内存。

    根据经验,只有当 PSS内存峰值控制在硬件总内存的0.5-0.6倍以下的时候,闪退风险才较低 。举例而言,对于2GB的设备而言,PSS内存应控制在1GB以下为最佳,3GB的设备则应控制在1.5GB以下。

    而对于大多数项目而言,PSS内存大约高于Reserved Total 200MB-300MB左右,故2GB设备的Reserved Total应控制在700MB以下、3GB设备则控制在1GB以下。

    特别的,Mono堆内存需要予以关注,因为在很多项目中, Mono堆内存除了存在本身驻留偏高 存在泄露风险 的问题外,其大小还会 影响GC耗时 。通常认为控制在80MB以下为最佳。

    下表为细化到每一种资源内存的推荐标准,制定较为严格。不过,仍需要开发者根据自身项目的实际情况予以调整。比如某个2D项目节省了几乎所有网格资源的使用,那么其他资源的标准就可以放宽很多。

    基于项目实情制定内存标准后,一般需进一步与美术、策划协商,给出合理的美术规范参数,并撰写成文档。

    定好规范后,定时检查项目里的所有美术资源是否符合规范,及时修改和更新。检查美术是否合规的过程,可以利用Unity提供的回调函数写自动化工具,提高效率。

    如果资源若不能批量处理成高中低配版本,就需要美术为各个画质等级制作不同的资源。

    2.1.3 本地资源检测服务-项目资源检测

    各项资源内存的引擎设置项繁琐且并不都能在运行时被采集,下文即将提到的内容虽然是众多项目中常见且重要的问题,但实际项目中的情况更加复杂。通过本地资源检测服务的项目资源检测界面,往往能发现更多资源设置项的问题。它们不光影响相关资源的内存占用,还会根据情况对CPU耗时和GPU造成不同程度的压力。

    2.2 常见的共通性问题

    这一部分提到的问题没有特定性,不仅仅出现在一种资源内存中。所以,为了避免赘述,此处统一予以讨论。

    一般情况下,出现这种问题是由AssetBundle资源加载导致的,即在制作AssetBundle文件时,部分共享资源(比如Texture、Mesh等)被同时打入到多份不同的AssetBundle文件中但没有进行依赖打包,从而当加载这些AssetBundle时,内存中出现了多份同样的资源,即 资源冗余 ,建议对其进行严格的检测和完善。

    值得一提的是,所谓 “疑似冗余资源”,是指在检测过程中,我们尝试搜索项目运行时的冗余资源并将其反馈给用户。但是,我们并无法保证该项检测的100%正确性。这是因为,我们判断的标准是根据资源的名称、内存占用等属性(因资源类型不同可能有格式、Read/Write、时长等属性,以报告资源列表中呈现的属性为准)而定,当两个资源的名称、内存占用等属性均一致时,我们认为这两个资源可能为同一资源,即其中一个为 “冗余” 资源。但项目中确实也存在资源不同但各项属性都相同的情况。因此,我们将通过以上规则提取出的资源归为 “疑似冗余资源”。所以,是否确实为冗余资源,还需要结合项目实情和在线AssetBundle检测报告才能下结论。

    2.2.2 未命名资源

    在资源列表中,有时发现存在资源名称为N/A的资源。一般来说名为N/A的资源都是在代码中new出来但是没有予以命名的。建议通过.name方法对这些资源进行命名,方便资源统计和管理,尤其是其中冗余比较严重的或者个别内存占用非常大的N/A资源应予以关注和严格排查。

    2.2.3 常驻资源内存占用大

    在资源列表中,有时结合资源的生命周期曲线发现,一部分本身内存占用较大的资源在被加载进内存后,驻留在内存中,直到测试流程结束都没有被卸载,可能造成越到游戏后期资源内存占用越大、峰值越高。建议排查这些资源是否有常驻在内存中的必要。如果不再需要被使用,则应检查为什么场景切换时没有卸载;对于持续时间久的单场景中持续驻留的资源,则可以考虑手动卸载。

    对于资源是否常驻的考量涉及内存压力和CPU耗时压力之间的取舍。简单来说,如果当前项目内存压力较大,而场景切换时的CPU耗时压力较小,则可以考虑改变缓存策略,在场景切换时及时卸载下一个场景用不到的资源,在需要时再重新加载。

    2.3 纹理资源

    2.3.1 纹理格式

    纹理格式设置不合理通常是造成纹理资源占据较大内存的主要原因之一。即便是对于很多已经建立过美术资源标准并统一修改过纹理格式的项目而言,仍然很容易统计到存在大量的RGBA32、ARGB32、RGBA Half、RGB24等格式的纹理资源。这些格式的纹理不但内存占用较大,还会导致游戏包体较大、加载这些资源的耗时较高、纹理带宽较高等等问题。

    现这类问题的原因主要有以下几种:

  • 存在一些“漏网之鱼”,比如美术命名不规范导致没有被回调函数修改;
  • 代码中创建的资源没有设置其纹理格式;
  • 硬件或纹理资源本身 不支持目标格式纹理,导致被解析为未压缩格式的纹理
  • 对于前一种情况,在资源列表中发现有问题的资源后,需要回到项目中自行排查修改;对于后一种情况,U推荐的硬件支持的纹理格式主要有 ASTC和ETC2

    其中 ETC2格式需要对应的纹理分辨率为4的倍数 ,在对应的纹理开启了 Mipmap时更是严格要求其分辨率为2的次幂 。否则,该纹理将被 解析成未压缩格式

    2.3.2 分辨率

    纹理资源的分辨率(即资源列表中的长度和宽度参数)同样也是造成内存占用过大的主要原因。一般来说,分辨率越高,其内存占用则越大。其中最为需要关注的是占据较大分辨率(一般为 ≥ 1024)的纹理。对于移动平台来说,过于精细的表现通过玩家的肉眼很难分辨出差异,而过大的分辨率往往意味着不必要的浪费。

    在不同档位的机型上使用不同分辨率大小的纹理资源是非常实用且易操作的分级策略。这一点即便对于图集纹理也同样适用,特别地, Unity针对SpriteAltas提供了Variant功能,可以快捷的复制一份原图集并根据Scale参数降低该变体图集的分辨率,以供较低的分级使用

    2.3.3 Read/Write Enabled

    上文提到过,纹理资源的内存占用是计算在GFX内存中的,也就是传向GPU端的部分。而开启Read/Write Enabled选项的纹理资源还会 保留一份内存在CPU端 ,从而造成该资源 内存占用翻倍

    2.3.4 Mipmap

    当一张纹理开启Mipmap时,它的内存占用会上升为原始数据的1.33倍。 对于3D对象,比如场景中的地形、物件或人物,其纹理的Mipmap功能是建议开启的 ,可以在运行时降低带宽。但值得注意的是,在真人真机测试报告中的Mipmap页面中,统计了游戏过程中开启Mipmap纹理的各个Mipmap通道的屏占比变化趋势。如果场景中的3D物体大面积地使用1/2乃至1/4、1/8的Mipmap通道,说明该3D物体使用的纹理分辨率偏高,存在浪费现象。可以改用更低分辨率的纹理。

    但如果是 2D项目或UI界面资源,则建议将对应纹理的Mipmap功能关闭 ,从而避免不必要的内存开销。

    2.3.5 各向异性与三线性过滤

    开启纹理的各向异性滤波有利于地面等物体的显示效果,但会导致GPU渲染带宽上升。其中的原理是,纹理压缩采样时会去读缓存里面的信息,如果没读到就会往离GPU更远的地方去读System Memory,因此所花的时钟周期也就会增多。当开启各向异性导致采样点增多的时候,发生Cache Miss的概率就会变大,从而导致带宽上升的更多。在引擎中可以通过脚本关闭纹理资源的各向异性;或者对于需要开启各向异性的纹理,引擎中可以设置其采样次数为1-16,也建议尽量设为较低的值。

    将纹理设置为三线性过滤,纹理会在不同的Mipmap通道之间进行模糊,相比双线性过滤GPU渲染带宽将会上升。三线性插值采8个采样点(双线性采4个采样点),同样会使Cache Miss的概率变大,从而导致带宽上升,应尽量避免使用三线性过滤。

    2.3.6 图集制作

    图集制作不够科学也是项目中常会发生的问题。资源列表中有时会出现数量峰值较高的图集纹理,但不一定是冗余。一种情况是,大量小图被打包到同一图集中,导致该图集纹理资源设置的最大分辨率(比如2048*2048)一张装不下这么多小图,该资源就会生成更多的纹理分页来打包这些小图。因此,只要游戏过程中依赖某一张纹理分页中的某一张小图,就会将该资源、也即该资源下所有的分页都全部加载进内存中,从而造成不必要的浪费。所以一般建议控制到2-3张分页以内较为合理。

    即便不出现上述这个较为极端的现象,很多项目中也会出现“牵一发而动全身”的现象。即明明只用图集中的一张或几张小图,却将内存占用颇大的整个纹理都加载进了内存。

    为此,在制作打包图集时,严格按照小图的使用场景、分类进行打包是非常重要的策略。选用合适的分辨率从而避免纹理没有被填充满而导致浪费,也是开发者需要注意的点。

    2.3.7 使用TextMeshPro的情况

    TextMeshPro能为UI组件提供更好的表现和便利的功能,使得其受到不少开发者的青睐。但使用TMP而产生的TMP字体图集纹理(名称中带有SDF Atlas,格式为Alpha 8的纹理)也有一些坑值得注意。

    (1)有时,结合字体资源列表注意到内存中还存在TMP图集纹理对应的.ttf字体文件。说明该TMP字体图集为动态字体。可以考虑在项目开发结束、确保游戏要用到的字符都已添加到动态字体的Altas纹理中后,将动态TMP重新设置为静态TMP,并且解除对.ttf文件的依赖。这样一来,对应的字体资源将不会出现在内存中。不过,如果这种字体还被用作用户输入,则不建议采用此方法。

    (2)Atlas字体纹理的分辨率较大。此时建议在引擎中排查字符有没有填满图集纹理,纹理的制作生成是否合理。对于动态TMP,如果没有填满,如只占据了纹理的3/4不到,则可以考虑开启Multi Atlas Textures选项,并设置纹理大小,举例而言就可以使1张4096 4096的纹理变为3张2048 2048的纹理,节省32MB-3*8MB=8MB的空间。

    (3)资源列表中有TMP相关的资源(LiberationSans SDF Atlas、EmojiOne),它们都是TMP的默认设置,可以在Project Settings-TextMesh Pro Settings中解除对这些默认资源的依赖,就不会出现在内存中了。

    由于Multi Atlas Textures是动态TMP的选项,所以(1)、(2)无法同时使用,可以根据项目实情酌情选用。

    2.3.8 使用本地资源检测排查纹理问题

    在本地资源检测中包含了“使用非压缩格式的纹理”、“尺寸过大的纹理”、“开启Read/Write选项的纹理”、“开启Mipmap选项的Sprite纹理”、“开启各向异性过滤的纹理”、“过滤模式为Trilinear的纹理”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的纹理资源。

    2.4 网格资源

    2.4.1 顶点和面片数

    顶点和三角形面片数过多的网格资源不仅会造成较高的内存占用,同时也不利于裁剪,容易增加渲染面数,在渲染时对GPU和CPU造成压力。针对这些网格,一方面可以简化网格,减少顶点数和面数,制作低模版本,供中低端机型分级使用;而另一方面针对单个顶点数过高的静态网格,比如一些复杂的地形和建筑,可以考虑拆分成若干个重复的小网格重新拼接。只要做好合批操作,就能以付出一点Culling计算耗时为代价,减少同屏渲染面片数。

    2.4.2 顶点属性

    如果没有统一美术资源标准且在导入时没有进行处理,则项目中的网格很有可能包含大量“多余”的顶点数据。这里的“多余”数据是指网格数据中包含了渲染时Shader中所不需要的数据。举例而言,如果网格数据中含有Position、UV、Normal、Color、Tangent等顶点数据,但其渲染所用的Shader中仅需要Position、UV和Normal,则网格数据中的Color和Tangent则为“多余”数据,从而造成不必要的内存浪费。其中, 一个小网格资源带有顶点属性,会使所在的Combined Mesh也带有顶点属性 ,需要予以注意。

    针对这个问题,一个比较简单的方法是,尝试开启“ Optimize Mesh Data ”选项。该选项位于Player Setting的Other Settings中。勾选后,引擎会在发布时遍历所有的网格数据,将其“多余”数据进行去除,从而降低其数据量大小。但是,需要注意的是,对于在Runtime情况下有修改Material需求的网格,建议研发团队对其进行额外的注意。如果Runtime时需要为某一个GameObject修改更为复杂、需要访问更多顶点属性的Material,则建议先将这些Material挂载在相应的Prefab上再进行发布,以免引擎去除Runtime中会进行使用的网格数据。

    2.4.3 Read/Write Enabled

    一般而言,不需要在CPU端进行修改的网格是不需要开启Read/Write的。可以在编辑器中通过API修改这些网格的Read/Write属性,或者对于FBX中的网格可以直接在Inspector窗口中修改。

    2.4.4 使用本地资源检测排查网格问题

    在本地资源检测中包含了“面片数过大的网格”、“包含Color属性的网格”、“包含Tangent属性的网格”、“包含Normal属性的网格”、“包含UV3或UV4属性的网格”、“开启Read/Write选项的网格”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的网格资源。

    2.5 动画资源

    一般来说, 内存占用大于200KB ,且时长较短的动画资源就可以被认为是内存占用偏大的动画资源,有一定的优化空间。针对动画资源的优化方法有:

    (1) 将Animation Type改成Generic。 相比另一种Legacy类型,Generic实际上使用了Unity新版的Mecanim动画系统,整体性能要好很多,一般不建议使用老版的动画系统,而第三种Humanoid同样是新版动画系统提供给人性角色的特殊工作流,具有灵活复用性的优点,但对模型的骨骼数量有要求(即人形骨骼),可以根据项目需要选用。

    (2) 将Anim. Compression改成Optimal。 Optimal实际上就是让Unity在数个算法中自动选择最优的曲线表达方式,从而占用最小的存储空间。而Keyframe Reduction则是一个相对稳定保守的算法,对动画的表现效果产生影响的概率更小。

    (3) 关闭Resample Curves选项。 官方文档中称开启该选项会有一定的性能提升,但事实上根据 《自动化规范Unity资源的实践》 中的说法,上文提到的开启Resample Curves的性能提升体现在播放时而非加载时、且效果微乎其微;反倒是还可能造成错误的动画表现。所以结合实验数据,大部分情况下,这个选项是建议关闭的。

    (4) 考虑使用API剔除动画资源的Scale曲线和压缩动画的精度。 其中,压缩动画精度的做法可以参考 《Unity动画文件优化探究》

    以上四种方法都可以有效降低动画资源的内存占用,但(2)、(4)两种理论上会造成动画精度的损失,但不一定会看得出来。建议研发团队自行调试,在确保动画表现不受影响的情况下尽量优化其内存占用。

    在本地资源检测中包含了“Compression != Optimal的动画资源”、“动画的导入设置未关闭ResampleCurve”、“包含Scale曲线的动画片段”、“精度过高的动画片段”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的动画资源。

    2.6 音频资源

    对于时长较长的BGM和一些常规的时长较短但内存大的音频资源,有一定的优化空间。针对音频资源的优化方法有:

    (1)开启 Force To Mono 。开启音频资源的Force To Mono会使音频被自动混合为单声道,而并非丢失一个声道,从而在对表现效果影响较小的前提下大幅降低音频内存。

    (2)修改其加载方式(Load Type)为Compressed In Memory或Streaming。Compressed In Memory适用于大部分常规音频,而Streaming则适合时常较长且内存占用大的背景音乐。

    (3)对于Compressed In Memory的音频,修改其压缩格式(Compression Format)为压缩率更大的格式,如Vorbis、MP3;

    (4)对于Vorbis、MP3压缩格式的音频,还可以继续调低其Quality参数,进一步压缩其内存。

    以上方式都可以有效减少音频资源内存(其中Streaming可以稳定降至200KB左右),但会造成一定的耗时代价或音质降低,可以酌情选用。

    在本地资源检测中包含了“双声道的音频”、“未使用Streaming加载的长音频”、“该音频中使用了Quality过高的Vorbis与MP3压缩”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的音频资源。

    2.7 材质资源

    材质资源本身内存占用较小,我们一般更加关注如何优化其数量,因为它的数量过多会影响之后会提到的Resource.UnloadUnusedAssets API的耗时。

    材质资源数量过多,往往主要是因为Instance类型的冗余Material资源过多。一般来说,该种情况的出现是因为通过代码访问并修改了meshrender.material的参数,因此Unity引擎会实例一份新的Material来达到效果,进而造成内存上的冗余。对此,建议通过MaterialPropertyBlock的方式来进行优化,具体相关操作和例子见如下文章 《使用MaterialPropertyBlock来替换Material属性操作》 。不过这种方法在URP下不适用,会打断SRP Batcher。除此之外,则需要关注和优化非Instance的材质资源的疑似冗余现象,不再赘述。

    除了数量上的问题外,材质资源往往还涉及到一些纹理采样和Shader使用相关的问题,导致一些额外的内存和GPU性能浪费。

    对于使用纯色纹理采样的材质,可以将纹理采样替换为一个颜色参数,从而节省一张纹理采样的开销;而对于空纹理采样的材质,Unity会采样内置提供的纹理,但是计算得到的颜色是一个常数,仍然属于浪费;又对于包含无用纹理采样的材质,由于Unity的机制,材质球会自动保存其上的纹理采样,即使更换Shader也不会把原来依赖的纹理去除,所以可能会造成误依赖实际不需要的纹理带进包体的情况,从而造成内存的浪费。

    2.8 Render Texture

    2.8.1 渲染分辨率

    资源列表中的一些RT资源能反映项目当前的渲染分辨率。对于GPU和渲染模块压力较大的项目,在中低端机型上降低其渲染分辨率是非常直观有效的分级策略。一般低端机型上可以考虑不采用真机分辨率,降到0.8-0.9倍,甚至很多团队会选择0.7倍或720P。

    如果一些其他的RT资源分辨率过高也应引起注意,尤其是2048*2048以上的资源。应当排查是否有必要用到如此精细的RT,在低端机上考虑采用更低分辨率的效果。

    2.8.2 抗锯齿

    资源列表中展示了RT资源的AA倍数。开启多倍AA会使RT占用内存成倍上升,并对GPU造成压力。建议排查是否有必要开启AA,尤其在中低端机上,可以考虑关闭此效果。

    特别的是,在华为部分机型上2倍的AA会失效。即已经造成了性能消耗但没有实际起到抗锯齿效果。

    2.8.3 后处理

    一些常见的后处理相关的RT(如Bloom、Blur)是从1/2渲染分辨率开始采样,可以考虑改从1/4开始采样、并减少下采样次数,从而节省内存并降低后处理对渲染的压力。

    站在性能优化的角度,在中低端机型上甚至最好完全关闭各类后处理。围绕一些常见后处理效果的讨论会在下文GPU部分中进一步展开。

    2.8.4 URP下的RT

    使用URP时,内存中会多出_CameraColorTexture和_CameraDepthAttachment两份RT资源作为渲染目标,而开启URP相机的CopyDepth和CopyColor设置时会额外产生_CameraDepthTexture和_CameraOpaqueTexture作为中间RT。当资源列表中出现这两种RT时,需要排查确实是否用到CopyDepth和CopyColor,否则应予以关闭以避免不必要的浪费。

    2.9 Shader资源

    2.9.1 ShaderLab

    Unity 2019.4.20是Shader内存统计方法的一个转折点。在此之前,Shader的内存主要统计在ShaderLab中,而之后则主要统计在Shader资源自身身上。

    对于Unity 2019.4.20之前的版本的项目,查看ShaderLab的内存需要在Unity Profiler中TakeSample。无论是Shader资源本体还是ShaderLab内存占用过高,都要着手于控制Shader的数量和变体数量。

    2.9.2 变体数

    变体数过多是造成一个Shader资源内存占用过大、占用包体过大的主要原因。在项目迭代中可能会出现已经被弃用或者没有被实际使用到的关键字,导致变体成倍上升;又或者Shader写的比较复杂,其中一些关键字组合永远不会被用到,从而导致很多变体是多余的。

    针对上述情况,Unity提供了回调函数,在项目打AssetBundle包或者Build时线剔除用不到的关键字或关键字组合相关的变体。剔除Shader变体的方法可以参考 《Stripping scriptable shader variants》

    2.9.3 冗余

    Shader冗余尤其需要予以关注,Shader的冗余不光导致内存上升,还可能造成重复解析,即运行时不必要的Shader.Parse和Shader.CreateGPUProgram API调用耗时。

    2.9.4 Standard Shader

    在资源列表中发现Standard、ParticleSystem/Standard Unlit。这两种Shader变体数量多,其加载耗时会非常高,内存占用也偏大,不建议直接在项目中使用。出现的原因一般是导入的FBX模型中或者Unity自身生成的一些3D对象使用了自带的Default Material,从而依赖了Standard Shader,建议予以排查精简。如果确实要使用Standard Shader或ParticleSystem/Standard Unlit,应考虑自己重写一个Shader并只包含自己需要用到的变体。

    2.9.5 使用本地资源检测排查Shader问题

    在本地资源检测中包含了“项目中:全局关键字过多的Shader”、“项目中:可能生成变体数过多的Shader”、“Build后:生成变体数过多的Shader”、“使用了Standard Shader的材质”等上文已经提及的检测规则,方便开发者精确定位存在潜在性能问题的Shader资源。

    2.10 字体资源

    若单个字体资源内存占用超过10MB,可以认为该字体资源内存偏大。可以考虑使用 FontPruner 字体精简工具 或其他字体精简工具,对字体进行瘦身,减小内存占用。

    我们也需要关注项目中字体数量过多的情况,因为每个Font会对应一个Font Texture字体纹理,所以字体资源数量多了,Font Texture的数量也多了,从而占用较多内存。

    2.11 粒子系统

    将资源列表结合粒子系统曲线来看,很多项目的内存中粒子的数量会远远高于实际Playing的粒子数量。

    此时一方面需要检查是否是在迭代过程中有被弃用但未删除的粒子资源或制作过程中测试过的组件但未解除依赖;另一方面则可以考虑优化对粒子的缓存策略,减少不必要的粒子缓存。

    2.12 Mono堆内存

    2.12.1 持续/峰值分配堆栈

    在堆内存具体分配页面中,可以排查高堆内存分配函数的具体堆栈。我们主要关注两种形式的堆内存分配。

    一种是单次过高的堆内存分配。这种峰值一般出现在游戏初期的读表操作导致的大量分配,需要开发者结合具体堆栈信息排查是否合理。而游戏运行过程如果还出现堆内存分配峰值则需要着重关注。

    另一种则是持续偏高的堆内存分配。如果项目中存在每帧或者每隔几帧就分配较多堆内存的现象需要引起注意。持续的高堆内存分配会导致GC频率增高,从而在游戏中形成频繁的卡顿,可以结合堆栈排查是什么子节点在持续分配堆内存。

    2.12.2 泄露分析

    在泄露分析页面中排查项目中各个函数的堆内存驻留情况。选中图表中前后两处采样帧进行比较,就可以从堆栈中查看堆内存驻留情况的变化,查看驻留上升主要是什么堆栈分配造成的。

    一方面可以避免堆内存持续上升造成泄露的风险,另一方面针对驻留高的函数进行优化,予以及时释放,可以降低单次GC的耗时。我们一般推荐测试时长尽量长一些,比如1个小时,否则泄露问题往往难以被暴露。

    2.13 其他内存

    2.13.1 Lua

    UWA GOT Online Lua模式提供了针对Lua脚本语言的性能测试。

    其中出现的函数名称格式为:函数名称@文件名:行号。

    可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z,其中X是其函数名,在无法获取时,X会变为默认的unknown;Y是该函数定义的文件位置;Z则是该函数被定义的行号。需要注意的是,当Lua脚本以字节码运行时,该值将始终为0,因此建议在测试时尽可能使用Lua源码来运行。

    针对Lua分配的内存,报告中的折线图选取了30帧内的数据最大值作为数据点。根据折线图走势,帮助开发者对项目运行过程中的堆内存分配情况有大致的了解。其中,堆内存的下降意味着发生了一次GC。查看内存具体分配和泄露分析和功能和Mono模式报告大同小异。

    Lua模式报告中还有一个重要功能,即Mono对象引用统计。

    从原理层面上,Unity Mono虚拟机中维护了一个对象池,用于链接Unity Object对象和Lua对象。当场景中的Unity Object对象被Destroyed之后,场景中没有了,但是由于Lua层还持有Usedata引用,导致对象池无法释放该Unity Object,如果该对象引用了Texture、Mesh等相关资源,会造成泄露。这时需要将Lua层的相关对象置空(nil),解除引用后,在下一次GC发生后,就可以回收该Unity Object对象。该功能的意义就在于辅助开发者排查此类泄露风险。

    报告提供了Mono对象引用柱状图,其中黑色部分表示未被Destroyed的对象数目,由于受到Lua端GC的影响,导致会有一些Destroyed对象。这时候就要注意它是否是趋于稳定的,如果持续上涨就需要引起重视。

    在柱状体选择对应帧后,列表中会显示该帧的Mono对象类型列表。其中:

    对象类型:表示Unity Object对象的具体类型;

    对象个数:表示这种类型的对象个数;

    Destroyed对象个数:表示已经被Destroyed,但Lua层还有相关引用的这种类型的对象个数。需要关注Destroyed对象个数,如果数目较大,C#堆内存存在泄露风险。

    2.13.2 插件和第三方库

    Wwise等插件和第三方库的使用相当普遍,但一般无法在运行时定量直观地统计。不过一般它们占用的内存不大,只有在上文这些内存优化点都排查完毕后仍然发现PSS内存和Reserved Total的值之间有加大差距时,再结合插件或第三方库的文档或其开发者提供的方法进行针对性优化,甚至考虑采取性能更优的替代方案。

    3|以引擎模块为划分的CPU耗时调优

    3.1 总览

    3.1.1 模块划分

    将CPU中工作内容明确、耗时占比一般较高的函数整理划分为:渲染、UI、物理、动画、粒子、加载、逻辑等模块。但这并不意味着模块之间的工作互相独立毫无关联。举例而言,渲染模块的性能压力势必受到复杂的UI和粒子影响,而加载模块的很多操作实际上都是在逻辑中调用并完成的。

    划分模块有利于我们确认问题、找到重点。与此同时,也要建立起模块之间的关联,有助于更高效地解决问题。

    3.1.2 耗时瓶颈

    当一个项目由于CPU端性能瓶颈而产生帧率偏低、卡顿明显的现象时,如何提炼出哪个模块的哪个问题是造成性能瓶颈的主要问题就成了关键。尽管我们已经对引擎中主要模块做了整理,各个模块间会出现的问题还是会千奇百怪不可一以概之,而且它们对CPU性能压力的贡献也不尽相同。那么我们就需要对什么样的耗时可以认为是潜在的性能瓶颈有准确的认知。

    在移动端项目中,我们 CPU端性能优化的目标是能够在中低端机型上大部分时间跑满30帧的流畅游戏 过程。为了达成这一目标,简单做一下除法就得到我们的 CPU耗时均值应控制在33ms以下 。当然,这并不意味着CPU均值已经在33ms以下的项目就已经把CPU耗时控制的很好了。游戏运行过程中性能压力点是不同的,可能一系列UI界面中压力很小、但反过来游戏中最重要的战斗场景中帧率很低、又或者是存在大量几百毫秒甚至几秒的卡顿,而最终平均下来仍然低于33ms。

    为此认为,在一次测试中,当33ms及以上耗时的帧数占总帧数的10%以下时,可以认为项目CPU性能整体控制在正常范围内。而这个占比越高,说明当前项目的CPU性能瓶颈越严重。

    以上的讨论内容主要是围绕着我们对CPU性能的宏观的优化目标,和内存一样,我们仍要结合具体模块的具体数据来排查和解决项目中实际存在的问题。

    3.2 渲染模块

    围绕渲染模块相关优化更全面的内容可以参考 《Unity性能优化系列—渲染模块》

    3.2.1 多线程渲染

    一般情况下,在单线程渲染的流程中,在游戏每一帧运行过程中, 主线程(CPU1)先执行Update ,在这里做大量的逻辑更新,例如游戏AI、碰撞检测和动画更新等; 然后执行Render ,在这里做渲染相关的指令调用。在渲染时,主线程需要调用图形API更新渲染状态,例如设置Shader、纹理、矩阵和Alpha融合等,然后再执行DrawCall,所有的这些图形API调用都是与驱动层交互的,而驱动层维护着所有的渲染状态,这些API的调用有可能会触发驱动层的渲染状态地改变,从而发生卡顿。由于驱动层的状态对于上层调用是透明的,因此卡顿是否会发生以及卡顿发生的时间长短对于API的调用者(CPU1)来说都是未知的。而此时其它CPU有可能处于空闲等待的状态,从而造成浪费。因此可以将渲染部分抽离出来,放到其它的CPU中,形成单独的渲染线程,与逻辑线程同时进行,以减少主线程卡顿。

    其大致的实现流程是, 在主线程中调用的图形API被封装成命令,提交到渲染队列 ,这样就可以节省在主线程中调用图形API的开销,从而提高帧率;渲染线程从渲染队列获取渲染指令并执行调用图形API与驱动层交互,这部分交互耗时从主线程转到渲染线程。

    而Unity在Project Settings中支持且默认开启了Multithreaded Rendering,一般建议保持开启。在大量测试数据中,还是发现有部分项目关闭了多线程渲染。开启多线程渲染时, CPU等待GPU完成工作的耗时会被统计到Gfx.WaitForPresent函数中 ,而 关闭多线程渲染时这一部分耗时则被主要统计到Graphics.PresentAndSync 中。所以,项目中是否统计到Gfx.WaitForPresent函数耗时是判断是否开启了多线程渲染的一个依据。特别地,在项目开发和测试阶段可以考虑暂时性地关闭多线程渲染并打包测试,从而更直观地反映出渲染模块存在的性能瓶颈。

    对于正常开启了多线程渲染的项目,Gfx.WaitForPresent的耗时走向也有相当的参考意义。测试中局部的GPU压力越大,CPU等待GPU完成工作的时间也就越长,Gfx.WaitForPresent的耗时也就越高。所以,当Gfx.WaitForPresent存在数十甚至上百毫秒地持续耗时时,说明对应场景的GPU压力较大。

    另外,根据大量项目和测试经验,GPU压力过大也会使得渲染模块CPU端的主函数耗时(Camera.Render和RenderPipelineManager.DoRenderLoop_Internal)整体相应上升。我们会在最后专门讨论GPU部分的优化。

    3.2.2 同屏渲染面片数

    影响渲染效率的两个最基本的参数无疑就是Triangle和DrawCall。

    通常情况下,Triangle面片数和GPU渲染耗时是成正比的,而对于大部分项目来说,不透明Triangle数量又往往远比半透明Triangle要多,尤其需要关注。一般建议在低端机型上将同屏渲染面片数控制在25万面以内,即便是高端机也不建议超过60万面。当使用工具发现局部同屏渲染面片数过高后,可以结合Frame Debugger对重点帧的渲染物体进行排查。

    常见的优化方案是,在制作上需要严格控制网格资源的面片数,尤其是一些角色和地形的模型,应严格警惕数万面及以上的网格;另外,一个很好的方法是一通过LOD工具减少场景中的面片数——比如在低端机上使用低模、减少场景中相对不重要的小物件的展示——进而降低渲染的开销。

    3.2.3 Batch(DrawCall)

    在Unity中,我们需要区分DrawCall和Batch。在一个Batch中会存在有多个DrawCall,出现这种情况时我们往往更关心Batch的数量,因为它才是把渲染数据提交给GPU的单位,也是我们需要优化和控制数量的真正对象。

    降低Batch的方式通常有动态合批、静态合批、SRP Batcher和GPU Instancing这四种,围绕Batch优化的讨论较为复杂,再写一篇文章也不为过,所以本文不再展开来讨论,但在UWA DAY 2020中我们详细讨论和分享了DrawCall与Batch的关系以及这4种Batching的使用详解,供大家参考: 《Unity移动游戏项目优化案例分析(上)》

    下面简单总结静态合批、SRP Batcher和GPU Instancing的合批条件和优缺点。

    (1)静态合批

    条件:不同Mesh,只要使用相同的材质球即可。

    优点:节省顶点信息地绑定;节省几何信息地传递;相邻材质相同时, ,节省材质地传递。

    缺点:离线合并时,若合并的Mesh中存在重复资源,则容易使得合并后包体变大;运行时合并,则生成Combine Mesh的过程会造成CPU短时间峰值;同样的,若合并的Mesh中存在重复资源,则会使得合并后内存占用变大。

    (2)SRP Batcher

    条件:不同Mesh,只要使用相同的Shader且变体一样即可。

    优点:节省Uniform Buffer的写入操作;按Shader分Batch,预先生成Uniform Buffer,Batch内部无CPU Write。

    缺点:Constant Buffer(CBuffer)的显存固定开销;不支持MaterialPropertyBlock。

    (3)GPU Instancing

    条件:相同的Mesh,且使用相同的材质球。

    优点:适用于渲染同种大量怪物的需求,合批的同时能够降低动画模块的耗时。

    缺点:可能存在负优化,反而使DrawCall上升;Instancing有时候被打乱,可以自己分组用API渲染。

    3.2.4 Shader.CreateGPUProgram

    该API常常在渲染模块主函数的堆栈中出现,并造成渲染模块中的大多数函数峰值。它是Shader第一次渲染时产生的耗时,其耗时与渲染Shader的复杂程度相关。当它在游戏过程中被调用并且造成较高的耗时峰值时应引起注意。

    对此,我们可以将Shader通过ShaderVariantCollection收集要用到的变体并进行AssetBundle打包。在将该ShaderVariantCollection资源加载进内存后,通过在游戏前期场景调用

    ShaderVariantCollection.WarmUp来触发Shader.CreateGPUProgram,并将此SVC进行缓存,从而避免在游戏运行时触发此API的调用、避免局部的CPU高耗时。

    然而即便是已经做过以上操作的项目也常会检测到运行时偶尔的该API耗时峰值,说明存在一些“漏网之鱼”。开发者可以结合Profiler的Timeline模式,选中触发调用Shader.CreateGPUProgram的帧来查看具体是哪些Shader触发了该API,可以参考 《一种Shader变体收集和打包编译优化的思路》

    3.2.5 Culling

    绝大多数情况下,Culling本身耗时并不显眼,它的意义在于反映一些与渲染相关的问题。

    (1)相机数量多

    当渲染模块主函数的堆栈中Culling耗时的占比比较高(一般项目中在10%-20%左右)。

    (2)场景中小物件多

    Culling耗时与场景中的GameObject小物件数量的相关性比较大。这种情况建议研发团队优化场景制作方式 ,关注场景中是否存在过多小物件,导致Culling耗时增高。可以考虑采用动态加载、分块显示,或者Culling Group、Culling Distance等方法优化Culling的耗时。

    (3)Occlusion Culling

    如果项目使用了多线程渲染且开启了Occlusion Culling,通常会导致子线程的压力过大而使整体Culling过高。

    由于Occlusion Culling需要根据场景中的物体计算遮挡关系,因此开启Occlusion Culling虽然降低了渲染消耗,其本身的性能开销却也是值得注意的,并不一定适用于所有场景。这种情况建议开发者选择性地关闭一部分Occlusion Culling去测试一下渲染数据的整体消耗进行对比,再决定是否需要开启这个功能。

    (4)包围盒更新

    Culling的堆栈中有时出现的FinalizeUpdateRendererBoundingVolumes为包围盒更新耗时。一般常见于Skinned Mesh和粒子系统的包围盒更新上。如果该API出现很频繁,则要通过截图去排查此时是否有较大量的Skinned Mesh更新,或者较为复杂的粒子系统更新。

    (5)PostProcessingLayer.OnPreCull/WaterReflection.OnWillRenderObject

    PostProcessLayer.OnPreCull这一方法和项目中使用的PostProcessing Stack相关。可以在PostProcessManager.cs中添加静态变量GlobalNeedUpdateSettings,在切场景的时候通过设置PostProcessManager.GlobalNeedUpdateSettings为true来UpdateSettings。这样就可以避免每帧都做UpdateSettings操作,从而减少一部分耗时。

    WaterReflection.OnWillRenderObject则是项目中使用到的水面反射效果的相关耗时,若该项耗时较高,可以关注一下实现方式上是否有可优化的空间,比如去除一些不必要的粒子、小物件等的反射渲染。

    3.3 UI模块

    在Unity引擎中,主流的UI框架有UGUI、NGUI以及使用越来越多的FairyGUI。本文主要从使用最多的UGUI来进行说明。围绕UGUI相关优化更全面的内容可以参考 《Unity性能优化 — UI模块》

    3.3.1 UGUI EventSystem.Update

    EventSystem.Update函数为UGUI的事件系统耗时,其耗时偏高时主要关注以下两个因素:

    (1)触发调用耗时高

    作为UGUI事件系统的主函数,该函数主要是在触摸释放时触发,当本身有较高的CPU开销时,通常都是因为调用了其它较为耗时的函数引起。因此需要通过添加Profiler.BeginSample/EndSample打点来对所触发的逻辑进行进一步地检测,从而排查出具体是哪一个子函数或者代码段造成的高耗时。

    (2)轮询耗时高

    所有UGUI组件在创建时都默认开启了Raycast Target这一选项,实际上是为接受事件响应做好了准备。事实上,大部分比如Image、Text类型的UI组件是不会参与事件响应的,但仍然会在鼠标/手指划过或悬停时参与轮询,所以通过模拟射线检测判断UI组件是否被划过或悬停,造成不必要的耗时。尤其在项目中UI组件比较多时,关闭不参与事件响应的组件的Raycast Target设置,可以有效降低EventSystem.Update()耗时。

    3.3.2 UGUI Canvas.SendWillRenderCanvases

    Canvas.SendWillRenderCanvases函数的耗时代表的是UI元素自身变化带来的更新耗时,这是需要和Canvas.BuildBatch(见下文)的网格重建的耗时所区分的。

    持续的高耗时往往是由于UI元素过于复杂且更新过于频繁造成。UI元素的自身更新包括:替换图片、文本或颜色发生变化等等。 UI元素发生位移、旋转或者缩放并不会引起该函数有开销 。该函数的耗时取决于UI元素发生更新的数量以及UI元素的复杂度,因此要优化此函数的开销通常可以从如下几点着手:

    (1)降低频繁更新的UI元素的频率

    比如小地图的怪物标记、角色或者怪物的血条等,可以控制逻辑在变动超过某个阈值时才更新UI的显示,再比如技能CD效果,伤害飘字等控制隔帧更新。

    (2)尽量让复杂的UI不要发生变动

    如某些字符串特别多且又使用了Rich Text、Outline或者Shadow效果的Text,Image Type为Tiled的Image等。这些UI元素因为顶点数量非常多,一旦更新便会有较高的耗时。如果某些效果需要使用Outline或者Shadowmap,但是却又频繁的变动,如飘动的伤害数字,可以考虑将其做成固定的美术字,这样顶点数量就不会翻N倍。

    (3)关注Font.CacheFontForText

    该函数往往会造成一些耗时峰值。该API主要是生成动态字体Font Texture的开销,在运行时突发高耗时,很有可能是一次性写入很多新的字符,导致Font Texture纹理扩容。可以从减少字体种类、减少字体字号、提前显示常用字以扩充动态字体FontTexture等方式去优化这一项的耗时。

    3.3.3 UGUI Canvas.BuildBatch

    Canvas.BuildBatch为UI元素合并的Mesh需要改变时所产生的调用。通常之前所提到的Canvas.SendWillRenderCanvases()的调用都会引起Canvas.BuildBatch的调用。另外, Canvas中的UI元素发生移动也会引起Canvas.BuildBatch的调用。

    Canvas.BuildBatch是在主线程发起UI网格合并,具体的合并过程是在子线程中处理的,当子线程压力过大,或者合并的UI网格过于复杂的时候,会在主线程产生等待,等待的耗时会被统计到EmitWorldScreenspaceCameraGeometry中。

    这两个函数产生高耗时,说明发生重建的Canvas非常复杂,此时需要将Canvas进行细分处理,通常是将静态的元素放在一个Canvas中,将发生更新的UI元素放入一个Canvas中,这样静态的Canvas由于缓存不会发生网格更新,从而降低网格更新的复杂度,减少网格重建的耗时。

    3.3.4 UGUI CanvasRenderer.SyncTransform

    我们常注意到有些项目的部分帧中CanvasRenderer.SyncTransform调用频繁。如下图,CanvasRenderer.SyncTransform调用次数多达1017次。当Canvas.SyncTransform触发次数非常频繁时,会导致它的父节点UGUI.Rendering.UpdateBatches产生非常高的耗时。

    在Unity 2018版本及以后的版本中,C anvas下某个UI元素调用SetActive(false改成true)会导致该Canvas下的其它UI元素触发SyncTransform,从而导致UI更新的整体开销上升 ,在Unity 2017的版本中只会导致该UI元素本身触发SyncTransform。

    所以,针对UI元素(如Image、Text)特别多的Canvas,需要注意是否存在一些UI元素在频繁地SetActive,对于这种情况建议使用SetScale(0或者1)来代替SetActive(false或者true)。或者,也可以将Canvas适当拆分,让需要进行SetActive(true)操作的元素和其它元素不在一个Canvas下,就不会频繁调用SyncTransform了。

    3.3.5 UGUI UI DrawCall

    通常战斗场景中其它模块耗时压力大,此时UI模块更要仔细控制性能开销。一般而言,战斗场景中的UI DrawCall控制到40-50左右为最佳。

    在不减少UI元素的前提下,控制DrawCall的问题,其实也就是如何使得UI元素尽量合批的问题。一般的合批要求材质相同,而在UI中却常常会发生明明是使用同一材质、同一图集制作的UI元素却无法合批的现象。这其实和UGUI DrawCall的计算原理有关。详细的原理介绍可以参考UWA学堂的这篇课程 《详解UGUI DrawCall计算和Rebuild操作优化》

    在UGUI的制作过程中,建议关注以下几点:

    (1)同一Canvas下的UI元素才能合批。不同Canvas即使Order in Layer相同也不合批,所以UI的合理规划和制作非常重要;

    (2)尽量整合并制作图集,从而使得不同UI元素的材质图集一致。图集中的按钮、图标等需要使用图片的比较小的UI元素,完全可以整合并制作图集。当它们密集地同时出现时,就有效降低了DrawCall;

    (3)在同一Canvas下、且材质和图集一致的前提下,避免层级穿插。简单概括就是,应使得符合合批条件的UI元素的“层级深度”相同;

    (4)将相关UI的Pos Z尽量统一设置为0,Z值不为0的UI元素只能与Hierarchy中相邻元素尝试合批,所以容易打断合批。

    (5)对于Alpha为0的Image, 需要勾选其CanvasRender组件上的Cull Transparent Mesh选项,否则依然会产生DrawCall且容易打断合批。

    3.4 物理模块

    围绕物理模块相关优化更全面的内容可以参考 《Unity性能优化 — 物理模块》

    3.4.1 Auto Simulation

    在Unity 2017.4版本之后,物理模拟的设置选项Auto Simulation被开放并且默认开启,即项目过程中总是默认进行着物理模拟。但在一些情况下,这部分的耗时是浪费的。

    判断物理模拟耗时是否被浪费的一个标准就是Contacts数量,即游戏运行时碰撞对数量。一般来说,碰撞对的数量越多,则物理系统的CPU耗时越大。但在很多移动端项目中,我们都检测到在整个游戏过程中Contacts数量始终为0。

    在这种情况下,开发者可以关闭物理的自动模拟来进行测试。如果关闭Auto Simulation并不会对游戏逻辑产生任何影响,在游戏过程中依然可以进行很好地对话、战斗等,则说明可以节省这方面的耗时。同时也需要说明的是,如果项目需要使用射线检测,那么在关闭Auto Simulation后需要开启Auto Sync Transforms,来保证射线检测可以正常作用。

    3.4.2 物理更新次数

    Unity物理模拟过程的主要耗时函数是在FixedUpdate中的,也就是说,当每帧该函数调用次数越高、物理更新次数也就越频繁,每帧的耗时也就相应地高。

    物理更新次数,或者说FixedUpdate的每帧调用次数,是和Unity Project Settings的Time设置中最小更新间隔(Fixed Timestep)以及最大允许时间(Maximum Allowed Timestep)相关的。这里我们需要先知道物理系统本身的特性,即当游戏上一帧卡顿时,Unity会在当前帧非常靠前的阶段连续调用N次FixedUpdate.PhysicsFixedUpdate,Maximum Allowed Timestep的意义就在于限制物理更新的次数。它决定了单帧物理最大调用次数,该值越小,单帧物理最大调用次数越少。现在设置这两个值分别为20ms和100ms,那么当某一帧耗时30ms时,物理更新只会执行1次;耗时200ms时也只会执行5次。

    所以一个行之有效的方法是调整这两个参数的设置,尤其是控制更新次数的上限(默认为17次,最好控制到5次以下),物理模块的耗时就不会过高;另一方面则是先优化其它模块的CPU耗时,当项目运行过程中耗时过高的帧很少,则FixedUpdate也不会总是达到每帧更新次数的上限。这对于其它FixedUpdate中的函数是同理的,也是基于这种原因,我们一般不建议在FixedUpdate中写过多游戏逻辑。

    3.4.3 Contacts

    就像上面提到的,如果我们确实用到物理模拟,则一般碰撞对的数量越多,物理系统的CPU耗时也就越大。所以,严格控制碰撞对数量对于降低物理模块耗时非常重要。

    首先,很多项目中可能存在一些不必要的Rigidbody组件,在开发者不知情的地方造成了不必要的碰撞,从而产生了耗时浪费;另外,可以检查修改Project Settings的Physics设置中的Layer Collision Matrix,取消不必要的层之间的碰撞检测,将Contacts数量尽可能降低。

    3.5 动画模块

    围绕动画模块相关优化更全面的内容可以参考 《Unity性能优化 — 动画模块》

    3.5.1 Mecanim动画系统

    Mechanic动画系统是Unity公司从Unity 4.0之后开始引入的新版动画系统(使用Animator控制动画),相比于Legacy的Animation控制系统,在功能上,Mecanim动画系统主要有以下几点优势:

    (1)针对人形角色提供了一套特殊的工作流,包括Avatar的创建以及Muscles肌肉的调节;

    (2)动画重定向(Retarting)的能力,可以非常方便地把一个动画从一个角色模型应用到其他角色模型上;

    (3)提供了可视化的Animator编辑器,可以快捷预览和创建动画片段;

    (4)更加方便地创建状态机以及状态之间Transition的转换;

    (5)便于操作的混合树功能。

    在性能上,对于骨骼动画且曲线较多的动画,使用Animator的性能是要比Animation要好的,因为Animator是支持多线程计算的,而且Animator可以通过开启Optimized GameObjects进行优化,具体细节可以参考UWA学堂的课程 《Unity移动游戏中动画系统的性能优化》 。相反,对于比较简单的类似于移动旋转这样的动画,使用Animation控制则比Animator要高效一些。

    3.5.2 BakeMesh

    对于一两千面这样面数较少且动画时长较短的对象,如MOBA、SLG中的小兵等,可考虑用SkinnedMeshRenderer.BakeMesh的方案,用内存换CPU耗时。其原理是将一个蒙皮动画的某个时间点上的动作,Bake成一个不带蒙皮的Mesh,从而可以通过自定义的采样间隔,将一段动画转成一组Mesh序列帧。而后在播放动画时只需选择最近的采样点(即一个Mesh)进行赋值即可,从而省去了骨骼更新与蒙皮计算的时间(几乎没有动画,只是赋值的动作)。整个操作比较适合于面片数小的人物,因为此举省去了蒙皮计算。其作用在于:用内存换取计算时间,在场景中大量出现同一个带动画的模型时,效果会非常明显。该方法的缺点是内存的占用极大地受到模型顶点数、动画总时长及采样间隔的限制。因此,该方法只适用于顶点数较少,且动画总时长较短的模型。同时,Bake的时间较长,需要在加载场景时完成。

    3.5.3 Active Animator数量

    Active状态的Animator个数会极大地影响动画模块的耗时,而且是一个可量化的重要标准,控制其数量到一个相对合理的值是我们优化动画模块的重要手段。需要开发者结合画面排查对应的数量是否合理。

    (1)Animator Culling Mode

    控制Active Animator的一个方法是针对每个动画组件调整合理的Animator.CullingMode设置。该项设置一共有三个选项:AlwaysAnimate、CullUpdateTransforms和CullComplete。

    默认的AlwaysAnimate使得当前物体不管是不是在视域体内,或者在视域体被LOD Culling掉了,Animator的所有东西都仍然更新;其中,UI动画一定要选AlwaysAnimate,不然会出现异常表现。

    而设置为CullUpdateTransforms时,当物体不在视域体内,或者被LOD Culling掉后,逻辑继续更新,就表示状态机是更新的,动画资源中连线的条件等等也都是会更新和判断的;但是Retarget、IK和从C++回传Transform这些显示层的更新就不做了。所以,在不影响表现的前提下把部分动画组件尝试设置成CullUpdateTransforms可以节省物体不可见时动画模块的显示层耗时。

    最后,CullComplete就是完全不更新了,适用于场景中相对不重要的动画效果,在低端机上需要保留显示但可以考虑让其静止的物体,分级地选用该设置。

    (2)DOTween插件

    很多时候,UI动画也会贡献大量的Active Animator。针对一些简单的UI动画,如改变颜色、缩放、移动等效果,UWA建议改用DOTween制作。经测试,性能比原生的UI动画要好得多。

    3.5.4 开启Apply Root Motion的Animator数量

    在Animators.Update的堆栈中,有时会看到Animator.ApplyBuiltinRootMotion占比过高,这一项通常和项目中开启了Apply Root Motion的模型动画相关。如果其动画不需要产生位移,则不必开启此选项。

    3.5.5 Animator.Initialize

    Animator.Initialize API会在含有Animator组件的GameObject被Active和Instantiate时触发,耗时较高。因此尤其是在战斗场景中不建议过于频繁地对含有Animator的GameObject进行Deactive/Active GameObject操作。对于频繁实例化的角色和UI,可尝试通过缓冲池的方式进行处理,在需要隐藏角色时,不直接Deactive角色的GameObject,而是Disable Animator组件,并把GameObject移到屏幕外;在需要隐藏UI时,不直接Deactive UI对象,而是将其SetScale=0并且移出屏幕的方式,也不会触发Animator.Initialize。

    3.5.6 Meshskinning.Update和Animators.WriteJob

    网格资源对于动画模块耗时的影响是十分显著的。

    一方面,Meshskinning.Update耗时较高时。主要因素为蒙皮网格的骨骼数和面片数偏高,所以可以针对网格资源进行减面和LOD分级。

    另一方面,默认设置下,我们经常发现很多项目中角色的骨骼节点的Transform一直都是在场景中存在的,这样在Native层计算完它们的Transform后,会回传给C#层,从而产生一定的耗时。

    在场景中角色数量较多,骨骼节点的回传会产生一定的开销,体现在动画模块的主函数之一PreLateUpdate.DirectorUpdateAnimationEnd的Animators.WriteJob子函数上。

    对此开发者可以考虑勾选FBX资源中Rig页签下的Optimize Game Objects设置项,将骨骼节点“隐藏”,从而减少这部分的耗时。

    3.5.7 GPU Skinning/Compute Skinning

    特别地,对于Unity引擎原生的GPU Skinning设置项(新版Unity中为Compute Skinning),理论上会在一定程度上改变网格和动画的更新方法以优化对骨骼动画的处理,但从针对移动平台的多项测试结果来看,无论是在iOS还是安卓平台上,多个Unity版本提供的GPU Skinning对性能的提升效果都不明显,甚至存在负优化的现象。在Unity的迭代中已对其逐步优化,将相关操作放到渲染线程中进行,但其实用性还需要进一步考察。

    对于大量同种怪物的需求,可以考虑使用自己实现的 《GPU Skinning 加速骨骼动画》 ,和UWA开源库中的GPU Instancing来进行渲染,这样既可以降低Animator.Update耗时,又能达到合批的效果。

    3.6 粒子系统

    围绕粒子系统相关优化更全面的内容可以参考 《粒子系统优化——如何优化你的技能特效》

    3.6.1 Playing粒子系统数量

    UWA统计了粒子系统数量和Playing状态的粒子系统数量。前者是指内存中所有的ParticleSystem的总数量,包含正在播放的和处于缓存池中的;后者指的是正在播放的ParticleSystem组件的数量,这个包含了屏幕内和屏幕外的,我们建议在一帧中出现的数量峰值不超过50(1GB机型)。

    针对这两个数值,我们一方面关注粒子系统数量峰值是否偏高,可选中某一峰值帧查看到底是哪些粒子系统缓存着、是否都合理、是否有过度缓存的现象;另一方面关注Playing数量峰值是否偏高,可选中某一峰值帧查看到底是哪些粒子系统在播放、是否都合理、是否能做些制作上的优化(具体见下文GPU部分中的讨论)。

    3.6.2 Prewarm

    ParticleSystem.Prewarm的耗时有时也需要关注。当有粒子系统开启了Prewarm选项,其在场景中实例化或者由Deactive转为Active时,会立即执行一次完整的模拟。

    但Prewarm的操作通常都有一定的耗时,经测试,大量开启Prewarm的粒子系统同时SetActive时会造成耗时峰值。建议在不必要的情况下,将其关闭。

    3.7 加载模块

    围绕加载模块相关优化更全面的内容可以参考 《Unity性能优化系列—加载与资源管理》

    3.7.1 Shader加载

    (1)Shader.Parse

    Shader.Parse是指Shader加载进行解析的操作,如果此操作较为频繁,通常是由于Shader的重复加载导致的,这里的重复可以理解为2层意思。

    第一层是由于Shader的冗余导致的,通常是因为打包AssetBundle的时候,Shader被被动打进了多个不同的AssetBundle中而没有进行依赖打包,这样当这些AssetBundle中的资源进行加载的时候,会被动加载这些Shader,就进行了多次“重复的”Shader.Parse,所以同一种Shader就在内存中有多份了,这就是冗余了。

    要去除这种冗余的方法也很简单,就是把这些会冗余的Shader依赖打包进一个公共的AssetBundle包。这样就会主动打包了,而不是被动进入某些使用了这个Shader的包体中。如果对这个Shader进行了主动打包,那么其它使用了这个Shader的AssetBundle中就只会对这个Shader打出来的公共AssetBundle进行引用,这样在内存中就只有一份Shader,其它用到这个Shader的时候就直接引用它,而不需要多次进行Shader.Parse了。

    第二层意思是同一个Shader多次地加载卸载,没有缓存住导致的。假设AssetBundle进行了主动打包,生成了公共的AssetBundle,这样在内存中只有这一份Shader,但是因为这个Shader加载完后(也就是Shader.Parse)没有进行缓存,用完马上被卸载了。下次再用到这个Shader的时候,内存里没有这个Shader了,那就必须再重新加载进来,这样同样的一个Shader加载解析了多次,就造成了多次的Shader.Parse。一般而言,经过变体优化以后的开发者自己写的Shader内存占用都不高,可以统一在游戏开始时加载并缓存。

    特别地,对于Unity内置的Shader,只要是变体数量不多的,可以放进Project Settings中的Always Included中去,从而避免这一类Shader的冗余和重复解析。

    (2)Shader.CreateGPUProgram

    该API也会在加载模块主函数甚至UI模块、逻辑代码的堆栈中出现。相关的讨论上文已经涉及,优化方法相同,不再赘述。

    3.7.2 Resources.UnloadUnusedAssets

    该API会在场景切换时被Unity自动调用,一般单次调用耗时较高,通常情况下不建议手动调用。

    但在部分不进行场景切换或用Additive加载场景的项目中,不会调用该API,从而使得项目整体资源数量和内存有上升趋势。对于这种情况则可以考虑每5-10min手动调用一次。

    Resources.UnloadUnusedAssets的底层运作机理是,对于每个资源,遍历所有Hierarchy Tree中的GameObject结点,以及堆内存中的对象,检测该资源是否被某个GameObject或对象(组件)所使用,如果全部都没有使用,则引擎才会认定其为Unused资源,进而进行卸载操作。简单来讲,Resources.UnloadUnusedAssets的单次耗时大致随着((GameObject数量+Mono对象数量)*Asset数量)的乘积变大而变大。

    因此,该过程极为耗时,并且场景中GameObject/Asset数量越高,堆内存中的对象数越高,其开销也就越大。对此,我们的建议如下:

    (1)Resources.UnloadAsset/AssetBundle.Unload(True)

    研发团队可尝试在游戏运行时,通过Resources.UnloadAsset/AssetBundle.Unload(True)来去除已经确定不再使用的某一资源,这两个API的效率很高,同时也可以降低Resources.UnloadUnusedAssets统一处理时的压力,进而减少切换场景时该API的耗时;

    (2)严格控制场景中材质资源和粒子系统的使用数量。

    专门提到这两种资源,因为在大多数项目中,虽然它们的内存占用一般不是大头,但往往资源数量远高于其他类型的资源,很容易达到数千的数量级,从而对单次Resources.UnloadUnusedAssets耗时有较大贡献。

    (3)降低驻留的堆内存。

    堆内存中的对象数量同样会显著影响Resources.UnloadUnusedAssets的耗时,这在上文也已经讨论过。

    3.7.3 加载AssetBundle

    使用AssetBundle加载资源是目前移动端项目中比较普遍的做法。

    而其中,应尽量用LZ4压缩格式打包AssetBundle,并用LoadFromFile的方式加载。经测试,这种组合下即便是较大的AssetBundle包(包含10张1024*1024的纹理),其加载耗时也仅零点几毫秒。而使用其他加载方式,如LoadFromMemory,加载耗时则上升到了数十毫秒;而使用WebRequest加载则会造成AssetBundle包的驻留内存显著上升。

    这是因为,LoadFromFile是一种高效的API,用于从本地存储(如硬盘或SD卡)加载未压缩或LZ4压缩格式的AssetBundle。

    在桌面独立平台、控制台和移动平台上,API将只加载AssetBundle的头部,并将剩余的数据留在磁盘上。AssetBundle的Objects会按需加载,比如:加载方法(例如:AssetBundle.Load)被调用或其InstanceID被间接引用的时候。在这种情况下,不会消耗过多的内存。

    但在Editor环境下,API还是会把整个AssetBundle加载到内存中,就像读取磁盘上的字节和使用AssetBundle.LoadFromMemoryAsync一样。如果在Editor中对项目进行了分析,此API可能会导致在AssetBundle加载期间出现内存尖峰。但这不应影响设备上的性能,在做优化之前,这些尖峰应该在设备上重新再测试一遍。

    要注意,这个API只针对未压缩或LZ4压缩格式,因为如果使用LZMA压缩,它是针对整个生成后的数据包进行压缩的,所以在未解压之前是无法拿到AssetBundle的头信息的。

    由于LoadFromMemory的加载效率相较其他的接口而言,耗时明显增大,因此我们不建议大规模使用,而且堆内存会变大。如果确实有对AssetBundle文件加密的需求,可以考虑仅对重要的配置文件、代码等进行加密,对纹理、网格等资源文件则无需进行加密。因为目前市面上已经存在一些工具可以从更底层的方式来获取和导出渲染相关的资源,如纹理、网格等,因此,对于这部分的资源加密并不是十分的必要性。

    3.7.4 加载资源

    有关加载资源所造成的耗时,若加载策略比较合理,则一般发生在游戏一开始和场景切换时,往往不会造成严重的性能瓶颈。但不排除一些情况需要予以关注,那么可以把资源加载耗时的排序作为依据进行排查。

    对于单次加载耗时过高的资源,比如达到数百毫秒甚至几秒时,就应考察这类资源是否过于复杂,从制作上考虑予以精简。

    对于反复频繁加载且耗时不低的资源,则应该在第一次加载后予以缓存,避免重复加载造成的开销。

    值得一提的是,在Unity的异步加载中有时会出现每帧进行加载所能占用的最高耗时被限制,但主线程中却在空转的现象。尤其是在切场景的时候集中进行异步加载,有时会耗费几十甚至数十秒的时间,但其中大部分时间是被空转浪费的。这是因为控制异步加载每帧最高耗时的API Application.backgroundLoadingPriority默认值为BelowNormal,每帧最多只加载4ms。此时一般建议把该值调为High,即最多50ms每帧。

    3.7.5 实例化和销毁

    实例化同样主要存在单个资源实例化耗时过高或某个资源反复频繁实例化的现象。根据耗时多少排列后,针对疑似有问题的资源,前者考虑简化,或者可以考虑分帧操作,比如对于一个较为复杂的UI Prefab,可以考虑改为先实例化显眼的、重要的界面和按钮,而翻页后的内容、装饰图标等再进行实例化;后者则建立缓存池,使用显隐操作来代替频繁的实例化。

    3.7.6 激活和隐藏

    激活和隐藏的耗时本身不高,但如果单帧的操作次数过多就需要予以关注。可能出于游戏逻辑中的一些判断和条件不够合理,很多项目中往往会出现某一种资源的显隐操作次数过多,且其中SetActive(True)远比SetActive(False)次数多得多、或者反之的现象,亦即存在大量不必要的SetActive调用。由于SetActive API会产生C#和Native的跨层调用,所以一旦数量一多,其耗时仍然是很可观的。针对这种情况,除了应该检查逻辑上是否可以优化外,还可以考虑在逻辑中建立状态缓存,在调用该API之前先判断资源当前的激活状态。相当于使用逻辑的开销代替该API的开销,相对耗时更低一些。

    在UWA GOT Online Resource模式下的资源管理页面中可以排查激活隐藏操作较频繁的资源,从而排查和优化相关逻辑和调用。

    3.8 逻辑代码

    逻辑代码的CPU耗时优化更多是结合项目实际需求、考验程序员本人的过程,很难定量定性进行讨论。不过UWA SDK中提供了方便开发者在逻辑代码中进行打点的 API&UWA GOT Online ,从而将复杂的函数拆解开,在报告中排查堆栈耗时、更快速地验证优化效果。

    我们发现有越来越的团队在使用JobSystem将主线程中的部分逻辑代码放入子线程中来进行处理,对于可以并行运算的逻辑,非常推荐将其放入到子线程中来处理,这样可以有效降低主线程CPU处理逻辑运算的压力。

    3.9 Lua

    GOT Online Lua模式提供的分析Lua造成的CPU耗时工具可视化程度高,堆栈清晰明了,还提供了实用且特色的倒序调用分析功能。以下结合一个Lua报告Demo简单介绍使用该工具分析Lua耗时的方法。

    重申:Lua报告中出现的函数名称格式为:函数名称@文件名:行号。

    可以通过报告提供的Lua文件名/行号/函数名来定位CPU耗时的瓶颈函数和CPU耗时峰值的具体原因。Lua函数的命名格式为X@Y:Z,其中X是其函数名,在无法获取时,X会变为默认的unknown;Y是该函数定义的文件位置;Z则是该函数被定义的行号。需要注意的是,当Lua脚本以字节码运行时,该值将始终为0,因此建议在测试时尽可能使用Lua源码来运行。

    4|画面表现与GPU压力的权衡

    4.1 总览

    现在,越来越多的移动端游戏开发团队对游戏的画面表现效果追求也越来越高。GPU端已经超越了CPU端,成了许多项目性能压力的最主要来源,但GPU端往往更多地涉及到硬件层面的问题,市面上不同的厂商不同的芯片会反应出不同的现象,为开发者进行机型分档和标准制定造成了困扰;从另一个角度来说,针对GPU性能的检测往往不是Unity引擎相关的工具所能提供的,而硬件厂商提供的各类工具则繁多而难用,看得人眼花缭乱而抓不住重点,实质上为GPU性能问题的排查和优化带来了巨大的困难。

    4.2 带宽

    4.2.1 GPU压力与发热/耗能

    虽然UWA内部做过一些带宽与发热/耗能关系的定量实现,但芯片底层的情况远比我们想象的复杂。我们从经验和跟芯片厂家的专业硬件工程师沟通后得出的结论是:移动设备上GPU带宽的压力还是比较影响能耗的,特别是在发热这一方面也有不小的影响。但这是定性的说法,目前我们和芯片厂商都没有特别定量的公式来具体说明其影响大小。因此,当一个项目的发热或者能耗较大,带宽是开发者特别需要关注的地方。

    工具能够监控游戏测试过程中硬件温度的变化,一般而言长时间维持在55℃以上就是需要警惕的温度了。

    4.2.2 GPU压力与帧率

    上文中已提到过,GPU压力大会使得CPU等待GPU完成工作的耗时增加,与此同时,也会使得渲染模块CPU端的主要函数耗时上升,从而影响帧率、造成卡顿。

    除此之外,由于移动端硬件客观上存在体积较小、散热难的问题,使得GPU发热会物理上显著影响到CPU芯片温度同时上升,严重的即产生降频现象。

    4.2.3 优化带宽

    带宽数据是衡量GPU压力的重要参考。以相对高端的小米10机型而言,全分辨率情况下,如果需要跑满30帧并发热情况稳定,则需要将总带宽控制到3000MB/s以下。为此,常见的优化手段有:

    (1)压缩格式

    在内存的章节中已经或多或少地讨论过,使用合理的压缩格式,能够有效降低纹理带宽。

    (2)纹理Mipmap

    对于3D场景而言,对其中的物体所用到的纹理开启Mipmap设置,能够用一小部分内存上升作为代价有效降低带宽。当物体离相机越远,引擎就会使用更低层级的Mipmap进行纹理采样。但Mipmap设置也与合理的纹理分辨率挂钩,一个常见的现象是在实际渲染过程中只用到或者绝大部分用了Mipmap的第0层进行采样,从而浪费了内存,所以要考虑降低这类纹理的分辨率。

    (3)合理的纹理采样方式

    除了合理使用Mipmap非0层采样外,还应关注项目中各向异性采样和三线性插值采样。概括来说,纹理压缩采样时会去读缓存里面的东西,如果没读到就会往离GPU更远的地方去读System Memory,因此所花的周期越多。采样点增多的时候,cache miss的概率就会变大,造成带宽上升。各向异性采样次数在Unity中设置有1-16,应尽量设置为1;三线性采样采8个顶点,相对于双线性采样是翻倍的。

    (4)修改渲染分辨率

    直接修改渲染分辨率为0.9倍乃至更低,减少参与纹理采样的像素,更加有效地降低带宽。

    此外,还应注意读顶点的带宽。相比纹理,它的占比一般会比较小。但不同于纹理的是,修改渲染分辨率可以有效降低读纹理的带宽,但读顶点的带宽不会受到影响。所以在上文中针对网格资源和同屏渲染面片数的控制卓有成效后,读顶点的带宽值应该占总带宽的10%-20%较为合理。

    4.3 Overdraw

    Overdraw,即多次绘制同一像素造成的GPU开销。在场景中渲染顺序控制合理的理想状况下,不透明物体的Overdraw应控制在1层。所以,造成Overdraw的主要元凶就是半透明物体,也即粒子系统和UI。

    4.3.1 粒子系统

    一种做法是,建立一个专门的空的测试场景,在其中顺次播放我们项目中要用到的粒子系统,然后使用,就可以在GPU耗时曲线处,结合测试截图找到播放时GPU耗时较高的粒子系统了。

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

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

    限制Max Particles为10后:

    2)在中低端机型上只保留“重要的”的粒子系统,比如对于一个火焰燃烧的特效,只保留火焰本身,而关闭掉周边的烟尘效果;

    (3)尽可能降低粒子特效在屏幕中的覆盖面积,覆盖面积越大,越容易产生重叠遮盖,从而造成更高的Overdraw。

    关于粒子系统的优化,我们曾在UWA DAY 2018中对移动游戏的GPU性能优化中做了些剖析,通过从Fillrate和Shader等几方面出发,结合大量优化过程中的实际案例分析游戏在GPU端的性能瓶颈: 《移动游戏的GPU性能优化》

    4.3.2 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。

    4.4 渲染效果

    除了粒子特效外,我们往往还喜欢用一些炫酷的渲染效果来丰富游戏的表现,比如体积雾、体积光、水体、次表面反射等等,然而场景中用到的此类效果越多,Shader越复杂,给GPU带来的压力越是大到远远超出接受范围的程度。

    优化和权衡是决定最后留下哪些渲染效果的主要手段。

    一方面,从多个方案中对比选取效果和性能较优的,对开源方案根据自身项目需要进行精简优化;另一方面,根据机型分档和当前GPU压力,取重点而舍次要。可以在UWA社区的博客、学堂和开源库中发现一些优化良好、通过实践检验的优秀方案。

    4.5 后处理

    Bloom几乎是最受开发者喜爱、最为常见的后处理效果了。常见的一个问题是,Bloom默认是从1/2渲染分辨率开始进行下采样的。对此,可以考虑在中低端机型上从1/4分辨率开始进行下采样,或减少下采样次数。

    各种后处理效果的性能开销和实际使用场景并不相同,在实际项目中遇到的问题也往往各不相同。UWA社区中也有大量后处理相关的文章和资源,比如 《屏幕后处理效果系列之常见后处理效果篇》

    4.6 渲染策略

    4.6.1 绘制顺序

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

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

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

    4.6.2 无效绘制

    存在一些视觉效果不明显可以关闭,或者可以用消耗更低的绘制方案的情况。

    比如一种较为常见的情况是,某些绘制背景的DrawCall,本身屏占比较大,开销不小,但在引擎中开关这个DrawCall没有明显的视觉变化,可能是在制作过程中被弃用或者被其他DrawCall完全掩盖了的效果,则可以考虑予以关闭。

    还有一种情况是,一些背景是用模型绘制的并带有模糊、雾效等额外的渲染效果。但场景中视角固定、这些背景也几乎不发生变化,则可以考虑用静态图替代这些复杂的绘制作为背景,在低端机上把更多性能留给主要的游戏逻辑和表现效果。

    4.6.3 渲染面积

    渲染面积过大造成的性能问题已经在粒子特效中有所反映和讨论了。但事实上对于不透明物体也适用。对于一个DrawCall而言,当它的渲染面积较大、且渲染资源多而复杂时,两者遍呈现出一种乘积的作用,它意味着有更多的像素参与纹理采样,参与Shader计算,给GPU带来更高的压力。

    4.7 Shader复杂度

    除了纹理、网格、Render Texture,还有一种对GPU压力贡献极大的渲染资源,也就是Shader。尤其关注Fragment Shader的屏占比、指令数和时钟周期数,渲染的像素越多、复杂度越高,说明该Shader资源越需要予以优化。

    中会详细测试项目中所有使用率较高的Shader在不同关键字组合下变体的复杂度,从而定位需要着重优化的Shader资源及其变体。

    4.8 总结

    这篇文章之所以称为简谱,实在是因为这些笔墨远不能达到面面俱到,很多内容还未涉及到,或者限于篇幅和重点不能深入讨论。它更多地是立足于如何以用好一套完善完整的性能工具为基础,构建发现问题-解决问题-监控问题的优化思维和优化体系,使得性能优化的工作事半而功倍。