优化工作的第一个步骤便是通过性能分析来收集性能数据,这也是移动端优化的第一步。
Unity Profiler可提供应用关键的性能信息,因此是优化必不可少的一部分。尽早对项目进行性能分析,不要拖到发售前。对每一个故障或性能尖峰彻查到底。对你自己的项目性能有一个清晰的认知,可帮助你更轻松地发现新问题。
Unity编辑器内的性能分析可以揭示出游戏不同系统的相对性能,而在运行设备上进行分析可让你获取更为准确的性能洞察。经常性地在目标设备上分析开发版。同时为最高配置与最低配置的设备进行性能分析和优化。
除了Unity Profiler,你还可以使用iOS与Android的原生工具来进一步测试引擎在平台上的表现。
部分硬件更是带有额外的分析工具(例如Arm Mobile Studio、Intel VTune,以及Snapdragon Profiler)。详情请见Profiling Applications Made with Unity教程。
如果游戏出现性能问题,切忌自行猜测或揣测成因,一定要使用Unity Profiler和平台专属工具来准确找出卡顿的问题来源。
不过,这里所说的优化并不都适用于你的应用。在某个项目中适用的方法不一定适用于你的项目。找出真正的性能瓶颈,将精力集中在有实际效用的地方。
Unity Profiler可帮助你在运行时检测出卡顿或死机的原因,更好地了解特定帧或时间点上发生了什么。工具默认启用CPU和内存监测轨,你也可以根据需要启用额外的分析模块,包括渲染器、音频和物理(如极度依赖物理模拟的游戏或音游)。
勾选Development Build便能为目标设备构建应用,勾选Autoconnect Profiler或者手动关联分析器,来加快其启动时间。
选中需要分析的目标平台。按下Record(录制)按钮可记录应用在几秒钟内的运行(默认为300帧)。打开Unity > Preferences > Analysis > Profiler > Frame Count界面可修改录制帧数,最长录制帧数可以增加到2000帧。当然更长的录制帧数会让Unity编辑器占用更多的CPU资源和内存,但其在特定情形下的作用非常大。
该分析器采用标记框架,可分析以ProfileMarkers(如MonoBehaviour的Start或Update方法,或特定API调用)划分出的代码运行时。在使用Deep Profiling时,Unity可以分析出每次函数调用的开始与结尾,准确地呈现出导致应用性能放缓的代码部分。
在分析游戏时,我们建议同时分析性能高峰与帧平均成本。在分析帧率过低的应用时,较为有效的方法是分析并优化每一帧中运行成本较高的代码。在尖峰处首先分析繁重的运算(如物理、AI、动画)和垃圾数据收集。
点击窗口中的某帧,接着使用Timeline或Hierarchy视图进行分析:
完整的Unity Profiler概述可在此处了解。初来乍到的用户也可以观看这段Introduction to Unity Profiling教学。
注意,在优化任意项目之前,一定要保存Profiler的.data 文件,这样你就能在修改后比较优化前后的不同了。剖析、优化和比较,清空再重复,如此循环往复来提高性能。
该工具可以汇总多帧Profiler数据,由用户来挑选出那些问题较大的帧。如果你想了解项目更改后Profiler的相应改变,可使用Compare视图分别加载和比较两个数据集,从而完成测试与优化。Profile Analyzer可在Unity Package Manager中下载。
你可以设立一个目标帧率,为每帧划定一个时间预算。理想情况下,一个以30 fps运行的应用每帧应占有约33.33毫秒(1000毫秒/30帧)。同样地,60 fps每帧约为16.66毫秒。
设备可以在短时间内超过预算(如过场动画或加载过程中),但绝不能长时间如此。
对于移动设备而言,长时间占用最大时间预算可能会导致设备过热,操作系统可能会启动CPU与GPU降频保护。我们建议每帧仅占用约65%的时间预算,保留一定的散热时间。常见的帧预算为:30 fps为每帧22毫秒,60 fps为每帧11毫秒。
大多数移动设备不像桌面设备那样有主动散热功能,因此环境温度可以直接影响性能。
如果设备发热严重,Profiler可能会察觉并汇报这块性能低下的部分,即使其只是暂时性问题。为了应对分析时设备过热,分析应分成小段进行。这样便能允许设备散热、模拟出真实的运行条件。我们的建议是,在进行性能分析前后,预留10-15分钟用于设备散热。
Profiler可在CPU耗时或GPU耗时超出帧预算发出警告,它将弹出下方以Gfx为前缀的标记:
Unity会采取自动化内存管理来处理由用户生成的代码与脚本。值类型本地变量等小型数据会被分配到内存堆栈中,大型数据和持久性存储数据则会被分配到托管内存中。
垃圾数据收集器会定期识别并删除未被使用的托管内存,这个自动流程在检查堆的对象时可能导致游戏卡顿或运行放缓。
这里,优化内存便是指关注托管内存的分配与删除时机,将内存垃圾回收的影响降到最低。详情 请在Understanding the managed heap中了解。
Memory Profiler属于一个独立的分析模块,可以截取托管数据堆内存的状态,帮助你识别出数据碎片化和内存泄漏等问题。
在Tree Map视图中点击一个变量便可跟踪其在内存原生对象上的状态。你可在此处找出由纹理过大或资源重复加载而导致的常见内存消耗问题。
请在这里了解如何使用Unity的Memory Profiler优化内存占用。你也可以查看官方Memory Profiler文档。
Unity使用的是Boehm-Demers-Weiser垃圾回收器 ,它会中止主线程代码运行,在垃圾回收工作完成后再让其恢复运行。
请注意,部分多余的托管内存分配会造成GC耗能高峰:
如果你确定垃圾回收带来的卡顿不会影响游戏特定阶段的体验,你可以使用System.GC.Collect来启动垃圾数据收集。
请在Understanding Automatic Memory Management(自动化内存管理)中了解怎样妥善地使用这项功能。
增量式垃圾回收不会在程序运行期间长时间地中断运行,而会将总负荷分散到多帧,形成零碎的收集流程。如果垃圾数据收集对性能产生了较大的影响,可以尝试启用这个选项来降低GC的处理高峰。你可以使用Profile Analyzer来检验此功能的实际作用。
Unity的PlayerLoop包含许多可与引擎核心互动的函数。该结构包含一些负责初始化和每帧更新的系统,所有脚本都将依靠PlayerLoop来生成游戏体验。
在分析时,你会在PlayerLoop下看到用户使用的代码(Editor代码则位于EditorLoop下)。
请在这里了解PlayerLoop和脚本生命周期 。
你可以使用以下技巧和窍门来优化脚本。
我们需要掌握Unity帧循环的执行顺序 。每个Unity脚本都会按照预定的顺序运行事件函数,这要求我们了解Awake、Start、Update以及其他运行周期相关函数之间的区别。
请在Script Lifecycle Flowchart(脚本生命周期流程图)中了解函数的执行顺序。
有许多代码并非要在每帧上运行,这些不必要的逻辑完全可以在Update、LateUpdate和FixedUpdate中删去。这些事件函数可以保存那些必须每帧更新的代码,任何无须每帧更新的逻辑都不必放入其中,只有在相关事物发生变化时,这些逻辑才需被执行。
如果必须要使用Update,可以考虑让代码每隔n帧运行一次。这种划分运行时间的方法也是一种将繁重工作负荷化整为零的常见技术。在下方例子中,ExampleExpensiveFunction将每隔三帧运行一次。
当首个场景加载时,每个对象都会调用如下函数:
在应用完成第一帧的渲染前,我们须避免在这些函数中运行繁重的逻辑。否则,应用的加载时间会出乎意料地长。
请在Order of execution for event functions(事件函数的执行顺序)中详细了解首个场景的加载。
即使是空的MonoBehaviours也会占用资源,因此我们应该删除空的Update及LateUpdate方法。
如果你想用这些方法进行测试,请使用预处理指令(preprocessor directives):
如此一来,在编辑器中的Update测试便不会对构建版本造成不良的性能影响。
Log声明(尤其是在Update、LateUpdate及FixedUpdate中)会拖慢性能,因此我们需要在构建之前禁用Log语句。
你可以用预处理指令编写一条Conditional属性来轻松禁用Debug 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(实例化预制件)一般来说性能更强。
调用GameObject.Find、GameObject.GetComponent和Camera.main(2020.2以下的版本)会产生较大的运行负担,因此这些方法不适合在Update中调用,而应在Start中调用并缓存。
下方例子展示了一种低效率的GetComponent多次调用:
其实GetComponent的结果会被缓存,因此只需调用一次即可。缓存的结果完全可在Update中重复使用,不必再度调用GetComponent。
Instantiate(实例化)和Destroy(销毁)方法会产生需要垃圾回收数据、引发垃圾回收(GC)的处理高峰,且其运行较为缓慢。与其经常性地实例化和销毁GameObjects(如射出的子弹),不如使用对象池将对象预先储存,再重复地使用和回收。
在游戏特定时间点(如显示菜单画面时)创建可复用的实例,来降低CPU处理高峰的影响,再用一个集合来形成“对象池”。在游戏期间,实例可在需要时启用/禁用,用完后可返回到池中,不必再进行销毁。
这一来你就可以减少托管内存分配的次数、防止产生垃圾回收的问题。
请在此处了解如何在Unity中创建一个简单的对象池系统。
固定不变的值或配置信息可以存储在ScriptableObject中,不一定得储存于MonoBehaviour。ScriptableObject可由整个项目访问,一次设置便可应用于项目全局,但它并不能直接关联到GameObject上。
我们可在ScriptableObject中用字段来存储值或设定,然后在MonoBehaviours中引用该对象。
下方的ScriptableObject字段可有效防止多次MonoBehaviour实例化产生的数据重复。
请在Introduction to ScriptableObjects教程中了解如何使用ScriptableObjects。你也可以参考此处的文档。
在系列的下一篇章,我们将仔细研究图形和GPU的优化。如果你希望一睹全部技巧和窍门,可在此处下载完整版电子书。
如果你想进一步了解Integrated Support服务,或希望直接从Unity工程师和专家处获取建议、指导及最佳实践,可在此处了解Unity Success Plans。
我们希望帮助广大用户发挥出Unity的最大性能,如有任何想要了解的优化主题,请在评论中提出。
Is this article helpful for you?
Thank you for your feedback!