Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(上)
- 作者:admin
- /
- 时间:2022年06月10日
- /
- 浏览:8466 次
- /
- 分类:厚积薄发
《Unity移动端游戏性能优化简谱》从Unity移动端游戏优化的一些基础讨论出发,例举和分析了近几年基于Unity开发的移动端游戏项目中最为常见的部分性能问题,并展示了如何使用UWA的性能检测工具确定和解决这些问题。内容包括了性能优化的基本逻辑、UWA性能检测工具和常见性能问题,希望能提供给Unity开发者更多高效的研发方法和实战经验。
今天向大家介绍文章第二部分:资源内存、Mono堆内存等常见游戏内存控制,共13小节,包含了纹理资源、网格资源、动画资源、音频资源、材质资源等多个资源内存以及Mono堆内存等常见的游戏内存控制讲解。
本章包含1-7小节,8-13小节请点击《Unity移动端游戏性能优化简谱之 分门别类地控制运行时内存(下)》。
(全文长约11400字,预计阅读时间约20分钟)
文章第一部分《Unity移动端游戏性能优化简谱之 前言》可戳此回顾,完整内容可前往UWA学堂查看。
1. 总览
1.1 运行时内存占用
首先,在讨论内存相关的各项参数和制定标准之前,我们需要先理清在各种性能工具的统计数据中常出现的各种内存参数的实际含义。
在包括UWA在内的多种性能工具中,我们最常见到和关心的PSS(Proportional Set Size)Total内存,其含义为一个进程在RAM中实际使用的空间地址大小,即实际使用的物理内存(比例分配共享库占用的内存,按照进程数等比例划分),也即我们游戏进程的实际运行时内存占用。
不过,有的时候也会出现使用RSS Total参数的工具。它和PSS同样代表实际使用的物理内存,不过包含了共享库占用的全部内存,有时会偏大,参考性相对下降;而共享库内存是不会导致杀进程的。在下文它出现的时候,会额外标记。
当然,在实践过程中这两个值还会受到安卓系统版本、具体硬件机型等因素的影响。
无论如何,游戏运行过程中闪退,或暂时切出(如玩家游戏过程中切到社交软件回复信息)就被杀进程,对玩家体验显然是一种严重的破坏。而在UWA经验中,上述两个现象绝大多数情况下都是过高的运行时内存导致的。
当系统内存触发高占用告警时,系统会根据进程优先级自动终止部分进程以释放内存。这也是许多开发者在闪退日志中看到的Out of Memory(OOM)类型闪退的主要原因。
在系统的进程优先级体系中,包括游戏进程在内的玩家应用通常处于较低的一档;而在同一优先级层级中,内存占用越高的进程,被系统终止的概率也越大。因此,游戏进程往往成为被系统杀死的高风险对象。
由于我们几乎无法在引擎层面对系统进程优先级进行重新调度或调整,因此在实际项目中,控制PSS Total的整体占用,仍然是最常见且最有效的优化手段。
有了这样一个总值,当我们发现总值过高时,就需要予以拆解才能定位到问题。接下来,我们从两个视角来拆解PSS Total:
首先是很多开发者都有在用、相对熟悉的Memory Profiler。该工具对内存的划分和命名也一直在随版本调整,因此本文就比较常用的Memory Profiler 0.7.1和1.1.0版本进行分析,在笔者看来,两者各有千秋,实际使用时甚至经常两个一起用。以下是同一个Snapshot结果在两个不同版本上的解析结果:
0.7.1

老版的Summary界面把内存以三个维度进行拆分,比较清晰。其中还能看到In use和Reserved部分分别占了多少,体现出了预留内存。

但老版的细节分析页整合地较差,甚至Take Example时都能清晰呈现的Manager、SerializedFile等类型都没有区分出来。且还需要自己点击分类、隐藏不需要的字段、按大小排序,才能看到上图这样相对有价值的画面。
1.1.0

新版的Summary将原本最上层相对价值较低的维度删除,反之对五种资源内存做了拆分。但主要问题在于移除了预留值的呈现,全部(甚至包括堆内存的预留值)都被计入了Native。


新版的细节整合页明显要好的多,分类更全面清晰,且已经做好了排序、还有简单的可视化。但上面提到的预留值,包括IL2CPP虚拟机的内存都被算进了Unknown里。在UWA的一些排查经验中,还发现会有个别特殊情况会导致这里的Unknown特别高,比如项目的AssetBundle使用LZMA格式进行打包时。尽管知道是不怎么需要针对性优化的部分,但看到这几百MB的占用总还是比较糟心的;更别说很多开发者不理解这部分占用时产生的困惑了。所以,有时候两个版本都看一下作对比就能发现问题。

接下来我们还是取0.7.1的Summary进行详细介绍。可以看到,在图中的第一部分,Unity把统计到的运行时内存总值(此处Unity抓取了RSS内存)分成了两大部分:Tracked Memory和Untracked Memory。
Tracked Memory为Unity引擎分配的、引擎自身可捕捉定位的内存,其实也就等同于我们一直以来讨论的Reserved Total内存,也往往是游戏进程内存占用的大头;而Untracked Memory则恰恰相反,可能是系统层分配的、引擎无法捕捉定位的内存,甚至包括一些共享库内存。前者为Memory Profiler主要支持分析的部分。
而Tracked Memory还有In use和Reserved两种数据, 一般来说,引擎在分配内存时并不是向操作系统 “即拿即用”,而是首先获取一定量的连续内存,然后供自己内部使用,待空余内存不够时,引擎才会向系统再次申请一定量的连续内存进行使用。这表示除了实际使用的内存外,还会有一部分预留,两者加起来才是最终占用的内存,这种情况后面还会出现,不再赘述。一般来说,In use的部分越高,预留的部分也会越高,因此还是需要优先优化实际In use的部分。
在图中的第二部分,Unity把Tracked Memory进一步拆解。其中,需要我们关注的是以下四个部分:Managed Heap、Graphics&Graphics Driver、Audio和Other Native Memory。而其他的如虚拟机、Profiler、DLLs内存在最终IL2CPP的Release包中可能就不存在或占用很低了,一般不需要予以关注。
其中需要解释的是Graphics&Graphics Driver主要包含纹理、网格、RenderTexture等需要提交给GPU渲染的相关资源;而Other Native Memory则包含除了上述渲染资源和音频外其他的资源内存,如字体、粒子系统等,还包含了Scene Object、序列化文件等其它类型的资源。
上述所有内存,都可以在Tree Map\Object and Allocations中进行更详细地分类和分析。但Memory Profiler虽然信息较为完整,但局限性也在于只能截取单帧、最多能做到两帧比较。而实际分析内存问题的时候,内存对象的生命周期、内存统计量的变化情况都是极为重要的信息,而UWA GOT Online Resource模式提供的自动采样功能和衍生出的一系列资源列表的交互设计就要更有优势。
不过,我们在越来越多的项目中发现,Untracked Memory的部分占用出乎意料的高,甚至达到总内存50%左右的水平,这就需要我们通过第二个视角来分析。
虽然用Android Studio也能完成类似分析,但我们还是以adb指令获取的最直接的数据为例。连接真机后,输入“adb shell dumpsys meminfo 项目包名/进程号”指令,就能采样一次内存拆分如图例:

显然地,图中就主要展示了当前采样点下,前文提及的PSS Total及其组成部分。一般而言,需要开发者关注的有三个部分:Native Heap、Gfx dev/EGL mtrack/GL mtrack和Unknown。
其中,前文讨论的Unity Tracked Memory部分仅渲染资源能被系统层识别,所以其内存占用统计分散在了Gfx dev/EGL mtrack/GL mtrack和Unknown这两部分中。而前文讨论的Unity Untracked Memory部分则往往体现在一些外部代码、第三方库、插件等占用的内存上,他们则会分散在Native Heap和Unknown这两部分中。
因此,当我们发现项目中内存高压点存在Gfx dev/EGL mtrack/GL mtrack+Unknown远大于Tracked Memory,或Native Heap过高时,就有必要作进一步的排查了。
若研发团队对这些外部代码和插件的管理较为明确,则可以用对比测试的方式、开关一些插件后打包来看内存的变化;或者直接用一些插件自带的性能分析工具,如Wwise;若Lua使用比较重度,则使用UWA的GOT Online Lua模式进行针对性分析。
不过,在越来越多项目中会发现Native Heap这一部分的内存较高且有升高趋势,这就需要尝试用Perfetto工具协查。
综上,我们就详细解剖了一个移动端Unity项目的运行时内存,以此基础出发再去治疗病灶,就能做到对症下药。而通过以上排查流程在项目中发现、定位和解决内存问题的案例,会在后文相应小节中更加详细地呈现。
1.2 内存参数标准
在我们了解了内存相关的各项参数的含义之后,知道了避免游戏闪退的重点在于控制PSS内存峰值。而PSS内存的大头又在于Reserved Total中的资源内存和Mono堆内存。对于使用Lua的项目来说,还应关注Lua内存。
根据UWA的经验,只有当PSS内存峰值控制在硬件总内存的0.5-0.6倍以下的时候,闪退风险才较低。举例而言,对于2GB的设备而言,PSS内存应控制在1GB以下为最佳,3GB的设备则应控制在1.5GB以下。
特别的,UWA还认为Mono堆内存需要予以关注,因为在很多项目中,Mono堆内存除了存在本身驻留偏高或存在泄露风险的问题外,其大小还会影响GC耗时。
下表为UWA提供的细化到每一种资源内存的推荐标准,制定较为严格。不过,仍需要开发者根据自身项目的实际情况予以调整。比如某个2D项目节省了几乎所有网格资源的使用,那么其他资源的标准就可以放宽很多。

关于更多的细化标准,大家可以直接在UWA线上产品中进行对应查看。


基于项目实情制定内存标准后,一般需进一步与美术、策划协商,给出合理的美术规范参数,并撰写成文档。
定好规范后,定时检查项目里的所有美术资源是否符合规范,及时修改和更新。检查美术是否合规的过程,可以请程序同学帮忙在引擎中编写自动化工具,提高效率;也可以利用下文提到的UWA本地资源检测功能筛查存在设置问题的资源。
如果资源若不能批量处理成高中低配版本,就需要美术为各个画质等级制作不同的资源。
1.3 本地资源检测服务-项目资源检测
各项资源内存的引擎设置项繁琐且并不都能在运行时被采集,下文即将提到的内容虽然是众多项目中常见且重要的问题,但实际项目中的情况更加复杂。通过本地资源检测服务的项目资源检测界面,往往能发现更多资源设置项的问题。它们不光影响相关资源的内存占用,还会根据情况对CPU耗时和GPU造成不同程度的压力。
为此UWA根据经验设计了检测规则和阈值,以此为依据采集和统计了存在这些问题的资源,并给出了对应的优化建议,帮助开发者针对资源进行更加深入的排查和优化。

2. 常见的共通性问题
这一部分提到的问题没有特定性,不仅仅出现在一种资源内存中。所以,为了避免赘述,此处统一予以讨论。
2.1 疑似冗余现象
在UWA GOT Online Resource模式报告的具体资源列表(下文简称资源列表)中,我们常能看到某一项资源的数量峰值大于1且被标红。数量峰值同样是资源使用中非常重要的一项指标。所谓 “数量峰值”,是指同一资源在同一帧中出现的最大数量。理论上,数量峰值这一参数不应大于1,当数量峰值大于1时,列表中会将其标红,我们称之为疑似冗余资源。

一般情况下,出现这种问题有几种可能:最常见的冗余是由AssetBundle资源加载导致的,即在制作AssetBundle文件时,部分共享资源(比如Texture、Mesh等)被同时打入到多份不同的AssetBundle文件中但没有进行依赖打包,从而当加载这些AssetBundle时,内存中出现了多份同样的资源,即资源冗余,建议对其进行严格的检测和完善。
针对排查出的疑似冗余现象,可以使用UWA在线AssetBundle检测工具排查是否确实存在AssetBundle冗余的问题,尽量减少AssetBundle的冗余。建议根据冗余资源的内存大小来决定对冗余问题的优化优先级。

值得一提的是,所谓 “疑似冗余资源”,是指在检测过程中,我们尝试搜索项目运行时的冗余资源并将其反馈给用户。但是,我们并无法保证该项检测的100%正确性。这是因为,我们判断的标准是当两个资源的名称、内存占用等属性均一致时,我们认为这两个资源可能为同一资源,即其中一个为 “冗余” 资源。但项目中确实也存在资源不同但各项属性都相同的情况。因此,我们将通过以上规则提取出的资源归为 “疑似冗余资源”。所以,是否确实为冗余资源,还需要结合项目实情和在线AssetBundle检测报告才能下结论。
GOT Online为此问题还做了进一步的功能更新。现在,Resource报告和Overview报告都新增了关联AssetBundle报告的功能,以便开发者在Resource数据中发现资源冗余时,更便利地排查冗余是否与AssetBundle打包有关。
在利用包体版本号关联成功后,具体资源使用情况表中的资源会和AssetBundle报告中的资源进行匹配。对于匹配成功的参数,系统将以醒目的蓝色链接形式进行标识,方便开发者快速查看和追踪相关资源的详细信息。
通过资源名称旁的AssetBundle打包冗余Tag,开发者可以快速判断Resource资源冗余和AssetBundle打包冗余的情况。若发现异常,可以点击资源名称上的链接查看资源详情,查阅资源的详细信息,包括其名称、涉及的AssetBundle文件数量以及相应的AssetBundle文件名等,从而更全面地了解资源的状态和使用情况。

还有一种常见的冗余是加载和缓存策略导致的。举例而言,当一个资源被加载后,被某个容器引用着,而其相应的AssetBundle包通过Unload(False)卸载了,但这个资源并没有被卸载。当该AssetBundle包重新加载时,显然该资源就会存在多份。
当然,还有一些其他的冗余问题,比如首场景引用的资源又通过AssetBundle加载了一份;Always Include Shader引入后又通过AssetBundle加载的Shader等等,需要具体问题具体分析。
2.2 未命名资源
在资源列表中,有时发现存在资源名称为N/A的资源。一般来说名为N/A的资源都是在代码中new出来但是没有予以命名的。建议通过.name方法对这些资源进行命名,方便资源统计和管理,尤其是其中冗余比较严重的或者个别内存占用非常大的N/A资源应予以关注和严格排查。

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

对于资源是否常驻的考量涉及内存压力和CPU耗时压力之间的取舍。简单来说,如果当前项目内存压力较大,而场景切换时的CPU耗时压力较小,则可以考虑改变缓存策略,在场景切换时及时卸载下一个场景用不到的资源,在需要时再重新加载。
特别地,针对纹理和网格这两种在多数项目中非常重要、内存占比也很高的渲染资源,UWA提供了一种更科学的方式来判断是否存在这类浪费。
在GOT Online GPU模式中,“渲染资源分析”这一功能通过GPU底层接口,实时采样获取了纹理和网格这两种资源在测试流程中参与渲染的情况。

首先,UWA提出了“渲染利用率”的概念。即测试采样过程中,捕捉到某一纹理或网格资源参与渲染的采样点数量占其在内存中采样点数量的比例。举例来说,一次测试总共进行了10000次固定间隔有规律的采样,其中6000次采样点中,A纹理被发现在内存中;而仅3000次采样点中,A纹理参与了GPU渲染。则A纹理的渲染利用率被判为50%。
事实上,在实际项目中,尤其现在很多移动端游戏也有3D大地图的设计,一些渲染物体被放置在场景的某个角落,玩家游玩过程中不总是能看到它、自然也就很难参与渲染。所以在测试中,一些渲染资源的渲染利用率即便只有百分之几甚至百分之零点几都是很正常的现象。但若发现整个测试流程、或单个测试场景里存在大量渲染利用率根本为0的资源,则存在浪费的可能性非常大。
这可能是因为:一些跨场景的资源被缓存住,在后续场景实际已经用不到没有及时卸载;打包加载了一些开发中已经被弃用的资源;打包和加载的机制过于激进,粒度太大,没有做好按场景和类型分包;场景设计中存在完全不可见或可见概率极低的不合理的渲染物体等等等等。
总之,导致浪费的原因多种多样,举例来说,UWA就发现很多项目会把一开始登录界面、Loading界面的高精度过场图一直缓存到后面的战斗场景。这就说明有些看似比较低级的错误,往往只有利用好的工具和排查流程才能予以根除。

只要在渲染资源分析页面中勾选“渲染利用率为0的资源数量”这一项,就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此,开发者再回到工程中进行排查即可。(纹理和网格这一项概念的定义和检测没有本质差别。)

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

出现这类问题的原因主要有以下几种:存在一些“漏网之鱼”,比如美术命名不规范导致没有被回调函数修改,或者是代码中创建的资源没有设置其纹理格式;硬件或纹理资源本身不支持目标格式纹理,导致被解析为未压缩格式的纹理。
对于前一种情况,在资源列表中发现有问题的资源后,需要回到项目中自行排查修改;对于后一种情况,UWA推荐的硬件支持的纹理格式主要有ASTC和ETC2。
其中ETC2格式需要对应的纹理分辨率为4的倍数;而即便是对于ASTC格式的纹理,在对应的纹理开启了Mipmap时更是严格要求其分辨率为2的次幂。否则,该纹理还是会被解析成未压缩格式。
3.2 Mipmap
Mipmap设置本身并不降低内存,甚至还会使内存上升。但这里提到它的主要原因在于这个设置将作为后续大量讨论的前提,因此需要先对它有基本的认识。
当一张纹理开启Mipmap时,它的内存占用会上升为原始数据的1.33(4/3)倍。这是因为,引擎将它自动生成了多个Mipmap通道进入内存,实际上是它各个通道所占用的内存之和。举例而言,一个具有原大小为1MB的纹理(10241024分辨率、ASTC44格式),在开启Mipmap后获得了11个通道,其内存占用为1+1/4+1/16+...的11项等比数列之和,即约4/3。等比数列的各项就对应了Mipmap0层、Mipmap1层、Mipmap2层......各个Mipmap通道的内存占用。
其价值在于,移动端GPU在渲染采样纹理时,对于开启了Mipmap设置的纹理,总是会根据渲染物体到相机的距离,在不同的像素采样该纹理合适的Mipmap层级。
反过来距离可能更加好理解:如果在移动设备上,一个渲染物体的贴图高达20482048甚至更高,但实际画面中这个物体离相机非常远、绘制的像素区域很小,而GPU在绘制一个像素时,会同时保存NN范围的像素在OnChip高速缓存中,若在绘制下一个像素时还落在NN范围内,就不用向更远端的内存重新获取,从而降低Cache Miss概率。但此时20482048这一不合适的超高分辨率,使得实际绘制的两个像素在这个纹理中间隔得很远,GPU不得不总是要向更远的内存去取新的N*N,这样显然开销就增大了。于是就有了Mipmap这一机制供GPU自己选择合适的分辨率层级进行采样,这样一来,GPU带宽就能够得到大幅下降。
对于3D对象,比如场景中的地形、物件、人物,乃至3D空间中的粒子系统、Spine对象的贴图,只要是和相机存在不固定距离的,其纹理的Mipmap功能是建议开启的;即便是与相机距离恒定的UI纹理,即便一般不需要开启Mipmap,但也要求美术总是设计合理的分辨率。
回到内存的视角,开启Mipmap尽管会使一部分纹理的内存上升共几十MB,但考虑其到对带宽的优化和基于Mipmap能做的一系列后续优化手段,对于多数项目来说都是利大于弊的。
3.3 分辨率
纹理资源的分辨率(即资源列表中的长度和宽度参数)同样也是造成内存占用过大的主要原因。一般来说,分辨率越高,其内存占用则越大。其中最为需要关注的是占据较大分辨率(一般为 ≥ 1024)的纹理。对于移动平台来说,过于精细的表现通过玩家的肉眼很难分辨出差异,而过大的分辨率往往意味着不必要的浪费。

但是,降低纹理分辨率这种优化方式究竟是减少了浪费还是其实是牺牲了表现?我们的程序同学如何去说服美术同学调整纹理的分辨率呢?这里我们就要提到UWA在GOT Online GPU模式的渲染资源分析功能中提到的第二个概念:Mipmap0层采样率过低的资源数量。
与前文提到的渲染利用率概念类似地,工具在测试过程中进行频繁地采样,在渲染该纹理的采样点中,统计各Desired Mip层级所占的比例。举例来说,一次测试总共进行了10000次固定间隔有规律的采样,其中3000次采样点中,A纹理被发现参与了GPU渲染;而仅300次采样点的Desired Mip为0,也就是这些采样点中GPU尝试选择第0层Mipmap进行采样(因为对于未开启Mipmap的纹理同样能采集该值,所以称之为“尝试选择”);另外2700次采样点的Desired Mip为1。则A纹理的Mipmap0层采样率判为10%。
事实上,在实际项目中,在3D空间下,玩家游玩过程中相机和一个渲染物体的距离可能时而远时而近。但若发现整个测试流程、或单个测试场景里,某一个纹理的Mipmap0层采样率小于5%,则说明GPU极少有必要用到分辨率最高层级的纹理,在多数时候这部分纹理造成了内存占用但根本不参与渲染,可能是一种浪费。更极端来说,一个分辨率过高的纹理可能实际上0层、1层、2层......加起来的利用率也不足5%,浪费更加严重。

只要在渲染资源分析页面中勾选“Mipmap0层采样率过低的资源数量”这一项,就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此,开发者再回到工程中进行排查即可。举例而言,如下图红框圈出的Tex_B3_Songshu这一资源,从名称上来看大概率是一个松鼠模型的贴图资源,也确实开启了Mipmap。但作为一张10241024的高分辨率纹理,其实际渲染过程中Desired Mips0和1层的占比都为0,直到2层才有85.71%。这也就意味着该纹理直接改为256256也不会对表现有任何影响,同时还能节省绝大部分的内存占用。

当然,在不同档位的机型上使用不同分辨率大小的纹理资源本身是非常实用且易操作的分级策略。
这一点即便对于图集纹理也同样适用,特别地,Unity针对SpriteAltas提供了Variant功能,可以快捷的复制一份原图集并根据Scale参数降低该变体图集的分辨率,以供较低的分级使用。
3.4 Global Mipmap Limit & Texture Streaming
除了通过上述方式筛查存在浪费的纹理并一一修改外,对于纹理大量开启Mipmap并且纹理内存压力较大的项目来说,还有两种自动控制纹理总内存的方式。即Project Settings-Quality设置下的Texture Quality/Global Mipmap Limit和Texture Streaming。

- Texture Quality/Global Mipmap Limit
在一直以来的很多Unity版本中提供的Texture Quality设置,实际上是一种颇为“暴力”的手段。开启后,引擎会在相应分档上强制移除所有开启Mipmap的纹理的相应层级。比如若设置为Half Res,则所有Mipmap 0层的纹理都不会进入内存。这种一刀切的做法一半在内存压力极大的低端机上出现、降低内存的效果非常好,但很多项目并不会选择这个做法,因为可能对表现的影响较大。


不过,在较新的Unity版本中(图中为2022.3.60版本),该功能被重置为Global Mipmap Limit,其按Mipmap层级强制移除的逻辑和之前是一样的。不过除了这种全局影响所有Mipmap纹理的设置外,多出了按照“Mipmap Limit Groups”分组设置移除层级的方式。在Quality中设置不同组别移除不同数量的层级后,在纹理的高级设置里勾选应用Mipmap Limits并选择所在分组,即可对不同纹理应用不同的Mipmap层级移除策略。用过UE的开发者可能会比较眼熟,不过Unity这边是单独维护了一套纹理组来专门处理Mipmap的使用。
- Texture Streaming
在项目资源量很大且需要针对不同分档应用不同分级策略时,上述功能很可能仍不足以满足开发者的需求,维护成本太高、又显得不够灵活。
Texture Streaming则是基于一种流式加载场景控制资源的思路,由引擎自行判断哪些Mipmap层级该进内存,从而节省一部分纹理占用。
但Texture Streaming的使用过程中是有些坑存在的:
注意点一:在Project Settings-Quality中开启Texture Streaming选项。然而实验发现有时Editor中开启该选项、真机上却会失效,导致所有纹理的Streaming Mipmap设置全部失效。应该在代码中调用API全局地开启这个选项,才能确保Texture Streaming可用。
注意点二:正确的参数才能使该功能起到好的效果。比较重要的参数是Memory Budget参数和Max Level Reduction参数:Memory Budget表示纹理资源的预算,默认值是512MB,但对于比较常规的移动端分档策略来看,中低端机上一般200MB左右就够了。它的数值代表的是所有纹理资源的预算——即,它既包括了非流式的纹理、又包括了我们想要采用流式的纹理——但这个”预算”并不代表纹理资源可占用的上限,只是Unity判断对于一个开启Streaming Mipmap的纹理到底采用它的哪些Mipmap通道的参考值。而Max Level Reduction则是代表Unity至少要保留的Mipmap通道(如设置为2时,尽管内存已经超过了预算,引擎仍最多只能移除0、1层的Mipmap纹理);同时Max Level Reduction也代表了纹理流式系统在初始加载一张Texture时加载的Mipmap等级(如设置为2时,纹理刚加载时是不包括0和1层的,只有当预算富余时才会加载)。
所以整个判断的流程是,先将所有非流式纹理正常加载;将流式纹理按照Max Level Reduction剔除高分辨率后的部分加载进内存中。然后将此时所有纹理内存之和和Memory Budget比较,如果已经饱和甚至超过,则不再变化;如果距离Memory Budget的值还有盈余空间,则会酌情加入流式纹理中更高分辨率的Mipmap通道,使纹理的表现更好。
也就是说,如果预算值设置的较高,或者项目中纹理占用的内存本来就比较低、远低于预算值,则Texture Streaming实际上几乎完全不起效、所有开启Streaming Mipmap的纹理仍将保留它们的全部Mipmap通道。
注意点三:设置开启Streaming Mipmap的纹理。官方文档只说了该设置会对开启了Streaming Mipmap设置的纹理生效。从实际操作来看,Texture Streaming的生效对象其实更苛刻,需要同时满足:
- 开启了Streaming Mipmap且开启了Generate Mipmap的纹理(显然开启Generate Mipmap才会生成Mipmap通道供Streaming Mipmap剔除);
- 被即时加载的纹理(如通过AssetBundle加载或Resources.Load()加载的纹理);
- 传向GPU部分内存(这里指的是纹理资源开启Read/Write选项时,复制到CPU端的那一部分内存是不受Texture Streaming影响的)。
注意点四:勾选Texture Streaming设置后,运行时会出现一个名为TextureStreamingManager.Update的函数持续造成开销。这是因为引擎要持续计算哪些层级的纹理能够被加载进内存,若项目使用的纹理数量多,则该耗时在低端机上颇为可观。因此要确定当前的Texture Streaming设置是否正确、是否正常生效;节省的内存是否值得这额外的耗时开销。
3.5 Read/Write Enabled
上文提到过,纹理资源的内存占用是计算在GFX内存中的,也就是传向GPU端的部分。而开启Read/Write Enabled选项的纹理资源还会保留一份内存在CPU端,从而造成该资源内存占用翻倍。
UWA GOT Online Resource模式报告资源列表或是本地资源检测报告中都直接展示了哪些纹理开启了Read/Write Enabled选项。实际上,不需要在运行时进行修改的资源是不需要开启Read/Write Enabled选项的,开发者应排查并关闭不必要的设置从而降低内存开销。

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

即便不出现上述这个较为极端的现象,很多项目中也会出现“牵一发而动全身”的现象。即明明只用图集中的一张或几张小图,却将内存占用颇大的整个纹理都加载进了内存。
为此,在制作打包图集时,严格按照小图的使用场景、分类进行打包是非常重要的策略。选用合适的分辨率从而避免纹理没有被填充满而导致浪费,也是开发者需要注意的点。
3.7 使用TextMeshPro的情况
TextMeshPro能为UI组件提供更好的表现和便利的功能,使得其受到不少开发者的青睐。但使用TMP而产生的TMP字体图集纹理(名称中带有SDF Atlas,格式为Alpha 8的纹理)也有一些坑值得注意。
有时,结合字体资源列表注意到内存中还存在TMP图集纹理对应的.ttf字体文件。说明该TMP字体图集为动态字体。可以考虑在项目开发结束、确保游戏要用到的字符都已添加到动态字体的Altas纹理中后,将动态TMP重新设置为静态TMP,并且解除对.ttf文件的依赖。这样一来,对应的字体资源将不会出现在内存中。不过,如果这种字体还被用作用户输入,则不建议采用此方法。
Atlas字体纹理的分辨率较大。此时建议在引擎中排查字符有没有填满图集纹理,纹理的制作生成是否合理。对于动态TMP,如果没有填满,如只占据了纹理的3/4不到,则可以考虑开启Multi Atlas Textures选项,并设置纹理大小,举例而言就可以使1张40964096的纹理变为3张20482048的纹理,节省32MB-3*8MB=8MB的空间。

- 资源列表中有TMP相关的资源(LiberationSans SDF Atlas、EmojiOne),它们都是TMP的默认设置,可以在Project Settings-TextMesh Pro Settings中解除对这些默认资源的依赖,就不会出现在内存中了。
由于Multi Atlas Textures是动态TMP的选项,所以第1、2点无法同时使用,可以根据项目实情酌情选用。
4. 网格资源
4.1 顶点和面片数
顶点和三角形面片数过多的网格资源不仅会造成较高的内存占用,同时也不利于裁剪,容易增加渲染面数,在渲染时对GPU和CPU造成压力。针对这些网格,一方面可以简化网格,减少顶点数和面数,制作低模版本,供中低端机型分级使用;设计制作好合理的Level of Details系统;而对于单个顶点数过高的静态网格,比如一些复杂的地形和建筑,可以考虑拆分成若干个重复的小网格重新拼接,能够节省内存。如果能通过SRP Batcher合批,或规划好作运行时静态合批,又或对大量重复物体用GPU Instancing制作,则渲染耗时也还是能得到良好控制。

但是,降低模型面数这种优化方式究竟是减少了浪费还是其实是牺牲了表现?我们的程序同学如何去说服美术同学调整模型的复杂度呢?又或者怎样的LOD设计才是比较合理的呢?对于这个有点眼熟的问题,我们需要引出GOT Online GPU模式的渲染资源分析功能中提到的第三个概念:渲染顶点密度过高的网格。
同理,工具在测试过程中进行频繁地采样,在渲染该网格的采样点中,将该网格每次参与绘制的顶点数量和绘制的像素数量作比值,当这个比值最小的时候都超过每10000像素画1000个顶点这个水平时,认为其渲染密度过高。形象地说,10000像素为100*100的范围,实际为大拇指指甲盖大小的、屏幕中很有限的一个范围内,却至少绘制了超过1000个网格顶点,可以直观地感受到这显然是肉眼难以分辨的精细程度,而在实际的GPU渲染过程中,部分三角形也很可能被GPU进行微小面裁剪剔除,对表现毫无贡献。
这种规范来自于移动端GPU硬件厂商提出的最佳渲染实践概念,认为:一个合理均匀的网格,其顶点数量和三角形数量的比例应在1.5:1最为合理;每个三角形至少由10-20个像素进行绘制最为合理。UWA将这两个因素综合,提出了具有可行性和现实意义的标准。

只要在渲染资源分析页面中勾选“渲染顶点密度过高的资源数量”这一项,就能在下面的资源列表中筛查出本次测试捕捉到的疑似存在浪费的资源。基于此,开发者再回到工程中进行排查即可;也同样适用于排查Level of Distance的各层级模型是否仍精度过高。

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中会进行使用的网格数据。
4.3 Read/Write Enabled
在资源列表中,常常统计到大量顶点属性不显示为-1(或“-”)的网格资源。只有网格资源开启Read/Write时,UWA报告才能采集到顶点属性信息。此时,顶点属性不显示为-1,且会使得网格占用内存上升。一般而言,不需要在CPU端进行修改的网格是不需要开启Read/Write的。可以在编辑器中通过API修改这些网格的Read/Write属性,或者对于FBX中的网格可以直接在Inspector窗口中修改。

5. 动画资源
动画资源的内存占用与运行时性能开销,核心取决于动画类型选择、压缩策略配置及曲线精度控制,不合理的设置易导致内存冗余或CPU/GPU额外消耗,需结合项目场景与机型分级针对性优化。其中:
动画类型选型。现在Unity的主流方案为Generic动画类型(基于Mecanim系统,耗时体现在Animators.Update函数),其多线程计算支持与资源利用率优于Legacy类型(耗时体现在Animation.Update函数),但这种优势尤其体现在骨骼动画上,一些简单的动画(如UI动画),其性能表现可能反而不如Legacy;其中,人形角色可选用Humanoid类型,支持动画重定向但需遵循人形骨骼规范,避免无意义使用高开销类型。
动画压缩策略。将Anim. Compression设置为Optimal,Unity会自动选择曲线优化算法(如关键帧合并、数值量化),在保证视觉效果的前提下最小化内存占用;对精度要求较低的动画(如背景特效、次要角色动作),可选用KeyframeReduction模式,手动调整Reduction Tolerance参数(建议0.01-0.1区间),减少冗余关键帧。一般不建议使用默认的Off模式,否则动画数据无任何压缩,内存占用可能翻倍。
精简动画内容。剔除无效曲线(如未使用的Scale曲线、冗余的骨骼动画通道),删除空白关键帧或合并相似帧;缩短非必要动画时长,移动端单段动画内存占用建议控制在200KB以内,复杂动画可拆分为多个短片段按需加载。
精度与采样率控制。通过API压缩动画精度,例如使用AnimationUtility.SetAnimationClipSettings调整动画帧率(移动端建议15-30fps,一般不建议用60帧动画);对循环动画(如呼吸、待机动作),可减少循环段的关键帧密度,利用插值计算保证流畅度。参考Unity官方优化方案,可通过代码遍历动画曲线,合并差值小于阈值的关键帧,进一步降低数据量。
还可以结合项目实际情况关注:
动画资源复用。将通用动画(如行走、攻击、待机)提取为公共资源,通过Animator Controller的状态机复用,避免重复创建相似动画片段;利用动画重定向功能,使同一套动画适配不同模型,减少资源冗余。
内存与性能权衡。压缩策略(如Optimal)可能导致轻微精度损失,需通过真机测试验证核心角色动画、关键技能特效的表现是否符合预期;对中低端机型,可关闭次要动画的Root Motion、IK等功能,降低CPU计算压力。
资源检测与筛选。通过UWA本地资源检测工具筛查精度过高、冗余关键帧过多的动画资源,重点关注单帧数据量过大、动画时长与实际需求不匹配的资源;对内存占用超500KB的非核心动画,应考虑要求美术重新优化或拆分。
分档策略。复杂动画(如多骨骼联动、物理驱动动画)建议分档处理,高端机使用完整精度版本,中低端机替换为简化版动画(减少骨骼数量、降低帧率)。
6. 音频资源
音频资源的内存占用与运行时性能开销,核心取决于加载方式、压缩格式、声道类型及采样率配置,不合理的设置易导致内存冗余、加载卡顿或播放时的CPU额外消耗,需结合音频类型(BGM/音效/语音)与机型分级针对性优化。其中:
声道类型优化
优先开启Force To Mono选项,将双声道音频自动混合为单声道。该设置不会丢失核心音效信息,却能使音频内存占用直接减半,是移动端音频优化的 “性价比之王”。仅对核心立体声需求的音频(如3D空间音效、环绕声BGM)保留双声道,其余音频(如UI音效、角色语音)强制单声道。选择合理加载方式
短音效(时长<3秒,短促且会反复播放,如点击、碰撞音效):选用Compressed In Memory加载模式,音频以压缩格式驻留内存,播放时快速解压,平衡内存与耗时;
长音频(时长≥30秒,时间较长,如背景音乐;或10<时长<30,时间适中但不怎么会重新播放,如剧情语音):选用Streaming流式加载模式,仅加载音频头部信息与缓存片段,内存占用可稳定控制在200KB以内,避免一次性加载导致的内存峰值;
一般不建议使用默认的Decompress On Load模式,该模式会在加载时完全解压音频数据,导致内存占用暴涨(如10MB的压缩音频解压后实际占用可能达50MB以上)。压缩格式与质量调整。
对于Compressed In Memory模式的音频,优先选择高压缩比格式:Android平台推荐Vorbis,iOS平台推荐MP3,两者压缩率均高于Unity默认格式,可降低30%-50%的内存占用;
调整Quality参数进一步优化:非核心音效可将质量设为50%-70%,语音类音频设为60%-80%,BGM设为80%-90%,具体需结合实际项目中声音效果,在音质可接受范围内最大化压缩收益;
尽量避免使用PCM等无压缩格式,其内存占用是压缩格式的5-10倍,仅适用于对音质要求极高的短时长核心音频(如关键剧情语音片段)。采样率与位深精简。
移动端音频采样率建议按类型分级设置,不过也同样需要结合实际的声音表现进行调整。
还可以结合项目实际情况关注:
音频资源复用与裁剪
提取通用音效(如按钮点击、道具拾取)作为公共资源,避免重复创建相似音频文件;
裁剪音频首尾的空白片段,缩短无效时长,减少内存占用与加载耗时。分级策略适配
高端机型:保留双声道、高采样率与高压缩质量,优先保证音质表现;
中低端机型:强制单声道、降低采样率、关闭非核心音频(如环境音效),优先保证内存与帧率稳定。加载缓存策略
集中加载大量音频资源的场景需要结合使用需要调整加载缓存策略。例如,语音类音频建议采用分段加载策略,避免一次性加载完整剧情语音包,减少内存压力。
7. 材质资源
材质资源本身内存占用较小,我们一般更加关注如何优化其数量,因为它的数量过多会影响之后会提到的Resource.UnloadUnusedAssets API的耗时。
材质资源数量过多,往往主要是因为Instance类型的冗余Material资源过多。一般来说,该种情况的出现是因为通过代码访问并修改了meshrender.material的参数,因此Unity引擎会实例一份新的Material来达到效果,进而造成内存上的冗余。对此,建议通过MaterialPropertyBlock的方式来进行优化,具体相关操作和例子见如下文章《使用MaterialPropertyBlock来替换Material属性操作》。不过这种方法在URP下不适用,会打断SRP Batcher。除此之外,则需要关注和优化非Instance的材质资源的疑似冗余现象。
除了数量上的问题外,材质资源往往还涉及到一些纹理采样和Shader使用相关的问题,导致一些额外的内存和GPU性能浪费,如:
对于使用纯色纹理采样的材质,可以将纹理采样替换为一个颜色参数,从而节省一张纹理采样的开销;而对于空纹理采样的材质,Unity会采样内置提供的纹理,但是计算得到的颜色是一个常数,仍然属于浪费。

上述问题也都可以通过UWA本地资源检测筛查静态资源筛查,比起人工排查大量材质资源高效地多。
本文内容就介绍到这里啦,更多内容可以前往UWA学堂进行阅读。课程将从内存、CPU、GPU三个维度讨论当前游戏项目中经常出现的一些性能问题。


