优化移动游戏性能 | 来自Unity顶级工程师的性能分析、内存与代码架构小贴士
Unity Accelerate Solution 团队对 Unity 引擎的源代码了如指掌,可帮助客户们最大限度地利用引擎。团队的日常工作包括深入剖析客户项目,搜寻其在速度、稳定性与效率方面有待优化的部分。本次,我们请到了这支 Unity 最为资深的软件工程师团队来分享一些移动游戏优化方面的专业知识。
他们分享了非常多的锦囊妙计,以至于一篇博文很难涵盖所有内容。因此,我们将推出一个博文系列。作为此系列的首篇文章,我们将着重介绍怎样借助性能分析、内存优化和代码架构来提高游戏的性能。在未来的几周内,我们将再发表两篇文章:一篇讨论 UI Physics,另一篇讨论音频和资源、项目配置和图形。
话不多说,直接开讲!
性能分析
优化工作的第一个步骤便是通过性能分析来收集性能数据,这也是移动端优化的第一步。
我们要尽早在目标设备上进行性能分析,而且要经常分析。
Unity Profiler 可提供应用关键的性能信息,因此是优化必不可少的一部分。尽早对项目进行性能分析,不要拖到发售前。对每一个故障或性能尖峰彻查到底。对你自己的项目性能有一个清晰的认知,可帮助你更轻松地发现新问题。
Unity 编辑器内的性能分析可以揭示出游戏不同系统的相对性能,而在运行设备上进行分析可让你获取更为准确的性能洞察。经常性地在目标设备上分析开发版。同时为最高配置与最低配置的设备进行性能分析和优化。
除了 Unity Profiler,你还可以使用 iOS 与 Android 的原生工具来进一步测试引擎在平台上的表现。
部分硬件更是带有额外的分析工具(例如 Arm Mobile Studio、Intel VTune 以及 Snapdragon Profiler)。
Unity Profiler:
Xcode:
Instruments:
Android Profiler:
Arm Mobile Studio:
https://developer.arm.com/tools-and-software/graphics-and-gaming/arm-mobile-studio
Snapdragon Profiler:
针对性优化
如果游戏出现性能问题,切忌自行猜测或揣测成因,一定要使用 Unity Profiler 和平台专属工具来准确找出卡顿的问题来源。
不过,这里所说的优化并不都适用于你的应用。在某个项目中适用的方法不一定适用于你的项目。找出真正的性能瓶颈,将精力集中在有实际效用的地方。
了解 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 可以分析出每次函数调用的开始与结尾,准确地呈现出导致应用性能放缓的代码部分。
ProfileMarkers:
Deep Profiling:
在分析游戏时,我们建议同时分析性能高峰与帧平均成本。在分析帧率过低的应用时,较为有效的方法是分析并优化每一帧中运行成本较高的代码。在尖峰处首先分析繁重的运算(如物理、AI、动画)和垃圾数据收集。
点击窗口中的某帧,接着使用 Timeline 或 Hierarchy 视图进行分析:
注意,在优化任意项目之前,一定要保存 Profiler 的 .data 文件,这样你就能在修改后比较优化前后的不同了。剖析、优化和比较,清空再重复,如此循环往复来提高性能。
Profiler Analyzer
该工具可以汇总多帧 Profiler 数据,由用户来挑选出那些问题较大的帧。如果你想了解项目更改后 Profiler 的相应改变,可使用 Compare 视图分别加载和比较两个数据集,从而完成测试与优化。Profile Analyzer 可在 Unity Package Manager 中下载。
Profile Analyzer:
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 为前缀的标记:
内存分析
Unity 会采取自动化内存管理来处理由用户生成的代码与脚本。值类型本地变量等小型数据会被分配到内存堆栈中,大型数据和持久性存储数据则会被分配到托管内存中。
垃圾数据收集器会定期识别并删除未被使用的托管内存,这个自动流程在检查堆的对象时可能导致游戏卡顿或运行放缓。
这里,优化内存便是指关注托管内存的分配与删除时机,将内存垃圾回收的影响降到最低。详情请在 Understanding the managed heap 中了解。
Understanding the managed heap:
Memory Profiler
Memory Profiler 属于一个独立的分析模块,可以截取托管数据堆内存的状态,帮助你识别出数据碎片化和内存泄漏等问题。
在 Tree Map 视图中点击一个变量便可跟踪其在内存原生对象上的状态。你可在此处找出由纹理过大或资源重复加载而导致的常见内存消耗问题。
通过以下链接了解如何使用 Unity 的 Memory Profiler 优化内存占用。
Memory Profiler:
降低内存垃圾回收(GC)对性能的影响
Unity 使用的是 Boehm-Demers-Weiser 垃圾回收器 ,它会中止主线程代码运行,在垃圾回收工作完成后再让其恢复运行。
请注意,部分多余的托管内存分配会造成 GC 耗能高峰:
Boehm-Demers-Weiser 垃圾回收器:
定时处理垃圾回收
如果你确定垃圾回收带来的卡顿不会影响游戏特定阶段的体验,你可以使用 System.GC.Collect 来启动垃圾数据收集。
请在 Understanding Automatic Memory Management(自动化内存管理)中了解怎样妥善地使用这项功能。
Understanding Automatic Memory Management:
使用增量式垃圾回收(Incremental GC)分散垃圾回收
增量式垃圾回收不会在程序运行期间长时间地中断运行,而会将总负荷分散到多帧,形成零碎的收集流程。如果垃圾数据收集对性能产生了较大的影响,可以尝试启用这个选项来降低 GC 的处理高峰。你可以使用 Profile Analyzer 来检验此功能的实际作用。
Unity 的 PlayerLoop 包含许多可与引擎核心互动的函数。该结构包含一些负责初始化和每帧更新的系统,所有脚本都将依靠 PlayerLoop 来生成游戏体验。
在分析时,你会在 PlayerLoop 下看到用户使用的代码(Editor代码则位于EditorLoop下)。
通过以下链接了解 PlayerLoop 和 脚本生命周期 。
PlayerLoop:
脚本生命周期:
你可以使用以下技巧和窍门来优化脚本。
深入理解 Unity PlayerLoop
我们需要掌握 Unity 帧循环的执行顺序 。每个 Unity 脚本都会按照预定的顺序运行事件函数,这要求我们了解 Awake、Start、Update 以及其他运行周期相关函数之间的区别。
请在 Script Lifecycle Flowchart(脚本生命周期流程图)中了解函数的执行顺序。
降低每帧的代码量
有许多代码并非要在每帧上运行,这些不必要的逻辑完全可以在 Update、LateUpdate 和 FixedUpdate 中删去。这些事件函数可以保存那些必须每帧更新的代码,任何无须每帧更新的逻辑都不必放入其中,只有在相关事物发生变化时,这些逻辑才需被执行。
如果必须要使用 Update,可以考虑让代码每隔 n 帧运行一次。这种划分运行时间的方法也是一种将繁重工作负荷化整为零的常见技术。在下方例子中,ExampleExpensiveFunction 将每隔三帧运行一次。
private int interval = 3;
void Update()
{
if (Time.frameCount % interval == 0)
{
ExampleExpensiveFunction();
}
}
避免在 Start/Awake 中加入繁重的逻辑
在应用完成第一帧的渲染前,我们须避免在这些函数中运行繁重的逻辑。否则,应用的加载时间会出乎意料地长。
请在 Order of execution for event functions(事件函数的执行顺序)中详细了解首个场景的加载。
Order of execution for event functions:
避免加入空事件
即使是空的 MonoBehaviours 也会占用资源,因此我们应该删除空的 Update 及 LateUpdate 方法。
如果你想用这些方法进行测试,请使用预处理指令(preprocessor directives):
#if UNITY_EDITOR
void Update()
{
}
#endif
如此一来,在编辑器中的 Update 测试便不会对构建版本造成不良的性能影响。
删去 Debug Log 语句
Log 声明(尤其是在Update、LateUpdate及FixedUpdate中)会拖慢性能,因此我们需要在构建之前禁用 Log 语句。
你可以用预处理指令编写一条 Conditional 属性来轻松禁用 Debug Log。比如下方这种的自定义类:
Conditional 属性:
public static class Logging
{
[System.Diagnostics.Conditional("ENABLE_LOG")]
static public void Log(object message)
{
UnityEngine.Debug.Log(message);
}
}
用自定义类生成 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 数据结构指南来选择正确的结构。
MSDN 数据结构指南:
避免在运行时添加组件
在运行时调用 AddComponent 会占用一定的运行成本,Unity 必须检查组件是否有重复或依赖项。
当组件已经配置完成,Instantiating a Prefab(实例化预制件)一般来说性能更强。
缓存 GameObjects 和组件
调用 GameObject.Find、GameObject.GetComponent 和 Camera.main(2020.2以下的版本)会产生较大的运行负担,因此这些方法不适合在 Update 中调用,而应在 Start 中调用并缓存。
下方例子展示了一种低效率的 GetComponent 多次调用:
void Update()
{
Renderer myRenderer = GetComponent<Renderer>();
ExampleFunction(myRenderer);
}
private Renderer myRenderer;
void Start()
{
myRenderer = GetComponent<Renderer>();
}
void Update()
{
ExampleFunction(myRenderer);
}
对象池(Object Pool)
Instantiate(实例化)和 Destroy(销毁)方法会产生需要垃圾回收数据、引发垃圾回收(GC)的处理高峰,且其运行较为缓慢。与其经常性地实例化和销毁 GameObjects(如射出的子弹),不如使用对象池将对象预先储存,再重复地使用和回收。
https://en.wikipedia.org/wiki/Object_pool_pattern
在这个例子中,ObjectPool创建了20个PlayerLaser实例供重复使用
PlayerLaser对象池目前尚未激活,正等待玩家射击
这一来你就可以减少托管内存分配的次数、防止产生垃圾回收的问题。
使用 ScriptableObjects(可编程对象)
固定不变的值或配置信息可以存储在 ScriptableObject 中,不一定得储存于 MonoBehaviour。ScriptableObject 可由整个项目访问,一次设置便可应用于项目全局,但它并不能直接关联到 GameObject 上。
我们可在 ScriptableObject 中用字段来存储值或设定,然后在 MonoBehaviours 中引用该对象。
下方的 ScriptableObject 字段可有效防止多次 MonoBehaviour 实例化产生的数据重复。
请参考 ScriptableObjects 文档了解如何使用。
后续我们将再发表两篇文章:一篇讨论 UI Physics,另一篇讨论音频和资源、项目配置和图形。敬请期待。
长按关注
第一时间了解Unity引擎动向,学习最新开发技巧