搜索 Unity

优化移动游戏性能:来自Unity顶级工程师的性能分析、内存与代码架构小贴士

2021年6月23日 类别 游戏 | 15 分 阅读
Red dragon being overtaken scene
Red dragon being overtaken scene
涵盖的主题
分享

Is this article helpful for you?

Thank you for your feedback!

他们分享了非常多的锦囊妙计,以至于一篇博文很难涵盖所有内容。因此,我们决定将这些堆积如山的知识编篡成一本完整的电子书(可在此处下载),并推出一个博文系列,重点介绍其中75个可操作性强的技巧。

在这个系列的首篇文章中,我们将着重介绍怎样借助性能分析、内存优化和代码架构来提高游戏的性能。在未来的几周内,我们将再发表两篇文章:一篇讨论UI Physics,另一篇讨论音频和资源、项目配置和图形。

如果想一睹完整的文章,请在此处免费下载电子书

话不多说,直接开讲!

性能分析

优化工作的第一个步骤便是通过性能分析来收集性能数据,这也是移动端优化的第一步。

我们要尽早在目标设备上进行性能分析,而且要经常分析。

Unity Profiler可提供应用关键的性能信息,因此是优化必不可少的一部分。尽早对项目进行性能分析,不要拖到发售前。对每一个故障或性能尖峰彻查到底。对你自己的项目性能有一个清晰的认知,可帮助你更轻松地发现新问题。

Unity编辑器内的性能分析可以揭示出游戏不同系统的相对性能,而在运行设备上进行分析可让你获取更为准确的性能洞察。经常性地在目标设备上分析开发版。同时为最高配置与最低配置的设备进行性能分析和优化。

除了Unity Profiler,你还可以使用iOS与Android的原生工具来进一步测试引擎在平台上的表现。

部分硬件更是带有额外的分析工具(例如Arm Mobile StudioIntel VTune,以及Snapdragon Profiler)。详情请见Profiling Applications Made with Unity教程。

针对性优化

如果游戏出现性能问题,切忌自行猜测或揣测成因,一定要使用Unity Profiler和平台专属工具来准确找出卡顿的问题来源。 

不过,这里所说的优化并不都适用于你的应用。在某个项目中适用的方法不一定适用于你的项目。找出真正的性能瓶颈,将精力集中在有实际效用的地方。

了解Unity Profiler工作原理

Unity Profiler可帮助你在运行时检测出卡顿或死机的原因,更好地了解特定帧或时间点上发生了什么。工具默认启用CPU和内存监测轨,你也可以根据需要启用额外的分析模块,包括渲染器、音频和物理(如极度依赖物理模拟的游戏或音游)。

Use the Unity Profiler to test performance and resource allocation for your application.
或使用Unity Profiler来测试应用程序的性能和资源分配。

勾选Development Build便能为目标设备构建应用,勾选Autoconnect Profiler或者手动关联分析器,来加快其启动时间。

Build settings in-editor

选中需要分析的目标平台。按下Record(录制)按钮可记录应用在几秒钟内的运行(默认为300帧)。打开Unity > Preferences > Analysis > Profiler > Frame Count界面可修改录制帧数,最长录制帧数可以增加到2000帧。当然更长的录制帧数会让Unity编辑器占用更多的CPU资源和内存,但其在特定情形下的作用非常大。

该分析器采用标记框架,可分析以ProfileMarkers(如MonoBehaviour的Start或Update方法,或特定API调用)划分出的代码运行时。在使用Deep Profiling时,Unity可以分析出每次函数调用的开始与结尾,准确地呈现出导致应用性能放缓的代码部分。

Timeline view in-editor
你可以借助Timeline视图来明确应用最为依赖的是CPU还是GPU。

在分析游戏时,我们建议同时分析性能高峰与帧平均成本。在分析帧率过低的应用时,较为有效的方法是分析并优化每一帧中运行成本较高的代码。在尖峰处首先分析繁重的运算(如物理、AI、动画)和垃圾数据收集。

点击窗口中的某帧,接着使用TimelineHierarchy视图进行分析:

  • Timeline可显示特定帧耗时的可视化图表,帮助你直观地看到各项活动以及不同线程之间的关系。你可使用该选项来了解项目主要依赖的是CPU还是GPU。
  • Hierarchy将显示分组的ProfileMarkers层级,并以毫秒(Time ms'总耗时'Self ms‘自执行耗时’)为单位对样本进行排序。你还可以数出帧上函数的Calls调用以及内存清理(GC Alloc)的次数。
Sorting ProfileMarkers by time cost
Hierarchy视图允许按照耗时长短对ProfileMarkers进行排序。

完整的Unity Profiler概述可在此处了解。初来乍到的用户也可以观看这段Introduction to Unity Profiling教学。

注意,在优化任意项目之前,一定要保存Profiler的.data 文件,这样你就能在修改后比较优化的不同了。剖析、优化和比较,清空再重复,如此循环往复来提高性能。

Profiler Analyzer

该工具可以汇总多帧Profiler数据,由用户来挑选出那些问题较大的帧。如果你想了解项目更改后Profiler的相应改变,可使用Compare视图分别加载和比较两个数据集,从而完成测试与优化。Profile Analyzer可在Unity Package Manager中下载。

Deeper look at the Profile Analyzer in-editor
Profiler Analyzer可以很好地补充Profiler,可以进一步深入分析帧与标记数据。

为每帧设定一个时间预算

你可以设立一个目标帧率,为每帧划定一个时间预算。理想情况下,一个以30 fps运行的应用每帧应占有约33.33毫秒(1000毫秒/30帧)。同样地,60 fps每帧约为16.66毫秒。

设备可以在短时间内超过预算(如过场动画或加载过程中),但绝不能长时间如此。

设备温度优化

对于移动设备而言,长时间占用最大时间预算可能会导致设备过热,操作系统可能会启动CPU与GPU降频保护。我们建议每帧仅占用约65%的时间预算,保留一定的散热时间。常见的帧预算为:30 fps为每帧22毫秒,60 fps为每帧11毫秒。 

大多数移动设备不像桌面设备那样有主动散热功能,因此环境温度可以直接影响性能。

如果设备发热严重,Profiler可能会察觉并汇报这块性能低下的部分,即使其只是暂时性问题。为了应对分析时设备过热,分析应分成小段进行。这样便能允许设备散热、模拟出真实的运行条件。我们的建议是,在进行性能分析前后,预留10-15分钟用于设备散热。

分清GPU与CPU依赖程度

Profiler可在CPU耗时或GPU耗时超出帧预算发出警告,它将弹出下方以Gfx为前缀的标记:

  • Gfx.WaitForCommands标记表示渲染线程正在等待主线程完成,后者可能出现了性能瓶颈。
  • Gfx.WaitForPresent表示主线程正在等待GPU递交渲染帧。

内存分析

Unity会采取自动化内存管理来处理由用户生成的代码与脚本。值类型本地变量等小型数据会被分配到内存堆栈中,大型数据和持久性存储数据则会被分配到托管内存中。

垃圾数据收集器会定期识别并删除未被使用的托管内存,这个自动流程在检查堆的对象时可能导致游戏卡顿或运行放缓。

这里,优化内存便是指关注托管内存的分配与删除时机,将内存垃圾回收的影响降到最低。详情 请在Understanding the managed heap中了解。

A look at the Memory Profiler in-editor
Memory Profiler中的帧数据记录、检视与比较。

Memory Profiler

Memory Profiler属于一个独立的分析模块,可以截取托管数据堆内存的状态,帮助你识别出数据碎片化和内存泄漏等问题。

在Tree Map视图中点击一个变量便可跟踪其在内存原生对象上的状态。你可在此处找出由纹理过大或资源重复加载而导致的常见内存消耗问题。

请在这里了解如何使用Unity的Memory Profiler优化内存占用。你也可以查看官方Memory Profiler文档

降低内存垃圾回收(GC)对性能的影响

Unity使用的是Boehm-Demers-Weiser垃圾回收器 ,它会中止主线程代码运行,在垃圾回收工作完成后再让其恢复运行。 

请注意,部分多余的托管内存分配会造成GC耗能高峰:

  • Strings(字符串):在C#中,字符串属于引用类型,而非值类型。我们需要减少不必要的字符串创建或更改操作,尽量避免解析JSON和XML等由字符串组成的数据文件,将数据存储于ScriptableObjects,或以MessagePack或Protobuf等格式保存。如果你需要在运行时构建字符串,可使用StringBuilder类。
  • Unity函数调用:部分函数会涉及托管内存分配。我们需要缓存数组引用,避免在循环进行中进行数组的内存分配,且尽量使用那些不会产生垃圾回收的函数。比如使用GameObject.CompareTag,而不是使用GameObject.tag 手动比对字符串(因为返回一个新字符串会产生垃圾数据)。
  • Boxing(打包):避免在引用类型变量处传入值类型变量,因为这样做会导致系统创建一个临时对象,在背地里将值类型转换为对象类型(如int i = 123; object o = i ),从而产生垃圾回收的需求。尽量使用正确的类型覆写来传入想要的值类型。泛型也可用于类型覆写。
  • Coroutines(协同程序):虽然yield不会产生垃圾回收,但新建WaitForSeconds对象会。我们可以缓存并复用WaitForSeconds对象,不必在yield中再度创建。
  • LINQ与Regular Expressions(正则表达式):这两种方法都会在后台的数据打包期间产生垃圾回收。如果需要追求性能,请尽量避免使用LINQ和正则表达式,转而使用for循环和列表来创建数组。

定时处理垃圾回收

如果你确定垃圾回收带来的卡顿不会影响游戏特定阶段的体验,你可以使用System.GC.Collect来启动垃圾数据收集。

请在Understanding Automatic Memory Management(自动化内存管理)中了解怎样妥善地使用这项功能。

使用增量式垃圾回收(Incremental GC)分散垃圾回收

增量式垃圾回收不会在程序运行期间长时间地中断运行,而会将总负荷分散到多帧,形成零碎的收集流程。如果垃圾数据收集对性能产生了较大的影响,可以尝试启用这个选项来降低GC的处理高峰。你可以使用Profile Analyzer来检验此功能的实际作用。

A look at the Incremental Garbage Collector
使用增量垃圾回收来降低GC处理高峰。

编程和代码架构

Unity的PlayerLoop包含许多可与引擎核心互动的函数。该结构包含一些负责初始化和每帧更新的系统,所有脚本都将依靠PlayerLoop来生成游戏体验。

在分析时,你会在PlayerLoop下看到用户使用的代码(Editor代码则位于EditorLoop下)。

Zoomed in look at a profiler
Profiler将显示在整个引擎运行过程中的自定义脚本、设置和图形。
A view of the PlayerLoop

请在这里了解PlayerLoop和脚本生命周期

你可以使用以下技巧和窍门来优化脚本。

深入理解Unity PlayerLoop

我们需要掌握Unity帧循环的执行顺序 。每个Unity脚本都会按照预定的顺序运行事件函数,这要求我们了解AwakeStartUpdate以及其他运行周期相关函数之间的区别。 

请在Script Lifecycle Flowchart(脚本生命周期流程图)中了解函数的执行顺序。

降低每帧的代码量

有许多代码并非要在每帧上运行,这些不必要的逻辑完全可以在UpdateLateUpdateFixedUpdate中删去。这些事件函数可以保存那些必须每帧更新的代码,任何无须每帧更新的逻辑都不必放入其中,只有在相关事物发生变化时,这些逻辑才需被执行。

如果必须要使用Update,可以考虑让代码每隔n帧运行一次。这种划分运行时间的方法也是一种将繁重工作负荷化整为零的常见技术。在下方例子中,ExampleExpensiveFunction将每隔三帧运行一次。

避免在Start/Awake中加入繁重的逻辑

当首个场景加载时,每个对象都会调用如下函数:

  • Awake
  • OnEnable
  • Start

在应用完成第一帧的渲染前,我们须避免在这些函数中运行繁重的逻辑。否则,应用的加载时间会出乎意料地长。

请在Order of execution for event functions(事件函数的执行顺序)中详细了解首个场景的加载。

避免加入空事件

即使是空的MonoBehaviours也会占用资源,因此我们应该删除空的UpdateLateUpdate方法。

如果你想用这些方法进行测试,请使用预处理指令(preprocessor directives):

如此一来,在编辑器中的Update测试便不会对构建版本造成不良的性能影响。

删去Debug Log语句

Log声明(尤其是在UpdateLateUpdateFixedUpdate中)会拖慢性能,因此我们需要在构建之前禁用Log语句。

你可以用预处理指令编写一条Conditional属性来轻松禁用Debug Log。比如下方这种的自定义类:

A view of ENABLE_LOG
添加自定义预处理指令可以实现脚本的切分。

用自定义类生成Log信息时,你只需在Player Settings中禁用ENABLE_LOG 预处理指令,所有的Log语句便会一下子消失。

使用哈希值、避免字符串

Unity底层代码不会使用字符串来访问Animator、Material和Shader属性。出于提高效率的考虑,所有属性名称都会被哈希转换成属性ID,用作实际的属性名称。

在Animator、Material或Shader上使用Set或Get方法时,我们便可以利用整数值而非字符串。后者还需经过一次哈希处理,并没有整数值那么直接。

使用Animator.StringToHash来转换Animator属性名称,用Shader.PropertyToID来转换Material和Shader属性名称。

选择正确的数据结构

由于数据结构每帧可能会迭代上千次,因此其结构对性能有着较大的影响。如果你不清楚数据集合该用List、Array还是Dictionary表示,可以参考C#的MSDN数据结构指南来选择正确的结构。

避免在运行时添加组件

在运行时调用AddComponent会占用一定的运行成本,Unity必须检查组件是否有重复或依赖项。 

当组件已经配置完成,Instantiating a Prefab(实例化预制件)一般来说性能更强。

缓存GameObjects和组件

调用GameObject.FindGameObject.GetComponentCamera.main(2020.2以下的版本)会产生较大的运行负担,因此这些方法不适合在Update中调用,而应在Start中调用并缓存。

下方例子展示了一种低效率的GetComponent多次调用:

其实GetComponent的结果会被缓存,因此只需调用一次即可。缓存的结果完全可在Update中重复使用,不必再度调用GetComponent

对象池(Object Pool)

Instantiate(实例化)和Destroy(销毁)方法会产生需要垃圾回收数据、引发垃圾回收(GC)的处理高峰,且其运行较为缓慢。与其经常性地实例化和销毁GameObjects(如射出的子弹),不如使用对象池将对象预先储存,再重复地使用和回收。

A zoomed in look at the ObjectPool
在这个例子中,ObjectPool创建了20个PlayerLaser实例供重复使用。

在游戏特定时间点(如显示菜单画面时)创建可复用的实例,来降低CPU处理高峰的影响,再用一个集合来形成“对象池”。在游戏期间,实例可在需要时启用/禁用,用完后可返回到池中,不必再进行销毁。

A zoomed in look at the SampleScene hierarchy
PlayerLaser对象池目前尚未激活,正等待玩家射击。

这一来你就可以减少托管内存分配的次数、防止产生垃圾回收的问题。

请在此处了解如何在Unity中创建一个简单的对象池系统。

使用ScriptableObjects(可编程对象)

固定不变的值或配置信息可以存储在ScriptableObject中,不一定得储存于MonoBehaviour。ScriptableObject可由整个项目访问,一次设置便可应用于项目全局,但它并不能直接关联到GameObject上。

我们可在ScriptableObject中用字段来存储值或设定,然后在MonoBehaviours中引用该对象。

Flowchart showing a ScriptableObject called Inventory holding settings for various GameObjects
用作“Inventory(物品栏)”的ScriptableObject可保存多个游戏对象的设定。

下方的ScriptableObject字段可有效防止多次MonoBehaviour实例化产生的数据重复。

请在Introduction to ScriptableObjects教程中了解如何使用ScriptableObjects。你也可以参考此处的文档

下载完整的性能优化技巧

在系列的下一篇章,我们将仔细研究图形和GPU的优化。如果你希望一睹全部技巧和窍门,可在此处下载完整版电子书。

Ebook Cover, "Optimize Your Mobile Game Performance"

如果你想进一步了解Integrated Support服务,或希望直接从Unity工程师和专家处获取建议、指导及最佳实践,可在此处了解Unity Success Plans。

敬请期待更多精彩内容

我们希望帮助广大用户发挥出Unity的最大性能,如有任何想要了解的优化主题,请在评论中提出。

2021年6月23日 类别 游戏 | 15 分 阅读

Is this article helpful for you?

Thank you for your feedback!

涵盖的主题
相关文章