游戏源码回收-优化中国联通游戏性能:顶级 Unity 工程师的性能分析、内存和代码架构技巧

我们的 Accelerate Solution 团队对 Unity 引擎的源代码有深入的了解,可以帮助客户充分利用该引擎。 团队的日常工作包括对客户项目进行深入讨论,寻找在速度、稳定性和效率方面需要优化的地方。 这次,我们邀请了Unity最资深的软件工程师团队来分享一些中国联通游戏优化方面的专业知识。 作者:托马斯·克罗-雅各布森

Accelerate Solution 团队分享了许多很棒的技巧,很难在一篇博文中涵盖所有这些技巧。 因此,我们决定将这如山的知识编译成一本完整的电子书(可在此处下载)和一个博客文章系列,重点介绍其中 75 种可行的方法。

在本系列的第一篇文章中,我们将重点讨论如何使用性能分析、内存优化和代码架构来提高游戏性能。 在接下来的几周内,我们将发布另外两篇文章:一篇讨论 UI 物理,另一篇讨论音频和资源、项目配置和图形。

如果您想查看全文,请在此处下载免费电子书。

话不多说,直接说吧!

性能分析

优化的第一步是通过性能分析收集性能数据,这也是移动优化的第一步。

我们希望尽早且经常地在目标设备上执行分析。

Unity Profiler 提供有关应用程序的关键性能信息,因此是优化不可或缺的一部分。 尽早对项目进行绩效分析,不要等到销售前。 追踪每一个故障或性能峰值。 清楚地了解自己的项目绩效可以帮助您更轻松地发现新问题。

在 Unity 编辑器中进行分析可以明确游戏不同系统的相对性能,而在运行设备上进行分析可以为您提供更精确的性能洞察。 经常分析目标设备上的开发版本。 分析并优化最高和最低配置设备的性能。

除了Unity Profiler之外,您还可以使用iOS和Android原生工具进一步测试引擎在平台上的性能。

例如,适用于 iOS 的 Xcode 和 Instruments,

以及 Android 上的 Android Studio 和 Android Profiler。

某些硬件附带额外的分析工具(例如 Arm Mobile Studio、IntelVTune 和 Snapdragon Profiler)。 有关详细信息,请参阅分析使用 Unity 制作的应用程序教程。

有针对性的优化

如果游戏存在性能问题,请勿自行推测或猜测原因,请务必使用 Unity Profiler 和特定于平台的工具来查明卡顿问题的根源。

但是,并非此处提到的所有优化都适用于您的应用程序。 适用于某个项目的方法可能不适用于您的项目。 找到真正的绩效困境,并将精力集中在真正有效的地方。

了解 Unity Profiler 的工作原理

Unity Profiler 可帮助您检查运行时的卡顿或关闭情况,让您更好地了解特定帧或时间点发生的情况。 该工具默认启用CPU和内存检测轨道,您还可以根据需要启用其他分析模块,包括渲染器、音频和化学(例如严重依赖数学模拟的游戏或音频游戏)

或者使用 Unity Profiler 来测试应用程序的性能和资源分配。

检查开发版本以完善目标设备的应用程序,并检查自动连接分析器或自动关联分析器以缩短其启动时间。

选择需要分析的目标平台。 按“录制”按钮录制应用程序运行几秒钟(默认情况下为 300 帧)。 打开Unity > Preferences > Analysis > Profiler > Frame Count界面更改录制帧率,最大录制帧率可以降低到2000帧。 当然,较长的录制帧率会让Unity编辑器占用更多的CPU资源和显存,但在某些情况下它发挥着非常重要的作用。

该分析器使用标记框架,可以分析除以ProfileMarkers的代码运行时间(例如MonoBehaviour的Start或Update方法,或者特定的API调用)。 使用深度分析时游戏源码回收,Unity 可以分析每个函数调用的开始和结束,准确显示导致应用程序性能较差的代码部分。

您可以使用时间轴视图来明确应用程序最依赖于 CPU 还是 GPU。

在分析游戏时,我们建议分析性能峰值和每帧成本。 当分析分辨率太高的应用程序时,分析和优化每帧运行成本高昂的代码会更有效。 高峰时,首先分析复杂运算(如数学、AI、动画)和垃圾数据收集。

单击窗口中的某个帧,然后使用时间轴或层次结构视图进行分析:

时间轴可以显示特定帧持续时间的可视化图表,帮助您可视化各种活动以及不同线程之间的关系。 您可以使用此选项来了解项目主要依赖于CPU还是GPU。

层次结构将显示分组的 ProfileMarkers 并以微秒为单位对样本进行排序(Time ms“花费的总时间”和 Self ms“自执行持续时间”)。 还可以统计该帧上函数的调用次数以及内存清理(GC Alloc)的次数。

层次结构视图允许按持续时间对 ProfileMarkers 进行排序。

可以在此处找到完整的 Unity Profiler 概述。 新人还可以​​观看这个 Unity Profiling 简介教程。

请注意,在优化任何项目之前,请务必保存 Profiler .data 文件,以便您可以在进行更改后比较优化前后的情况。 剖析、优化和比较、清除和重复等等以提高性能。

分析仪

该工具可以汇总多帧Profiler数据,用户可以选择这些问题较大的帧。 如果想了解项目修改后Profiler中相应的变化,可以使用Compare视图分别加载并比较两个数据集,完成测试和优化。 配置文件分析器可在 Unity 包管理器中下载。

Profiler分析器可以很好地补充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 依赖性

当CPU消耗或GPU消耗超过帧预算时,Profiler可以发出警告,它会弹出以下带有Gfx前缀的标志:

Gfx.WaitForCommands 标志表示渲染线程正在等待主线程完成,这可能会影响性能。

而Gfx.WaitForPresent表示主线程正在等待GPU提交渲染帧。

记忆分析

Unity 对用户生成的代码和脚本使用自动内存管理。 值类型局部变量等大数据分配到显存堆栈,大数据和持久存储数据分配到托管显存。

垃圾收集器会定期识别并删除未使用的托管视频内存,这是一个手动过程,可能会导致游戏在检测堆上的对象时冻结或变慢。

这里,优化显存是指关注托管显存的分配和删除时机,尽量减少显存垃圾回收的影响。 有关详细信息,请参阅了解托管堆。

在 Memory Profiler 中记录、查看和比较帧数据。

内存分析器

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

单击树形图视图中的变量可跟踪其在视频内存中本机对象上的状态。 在这里您可以找到因纹理过大或资源重复加载而导致的常见视频内存消耗问题。

请在此处了解如何使用 Unity 的内存分析器来优化视频内存使用。 您还可以查看官方内存分析器文档。

减少内存垃圾收集 (GC) 对性能的影响

Unity使用Boehm-Demers-Weiser垃圾收集器,它会终止主线程代码,并在垃圾收集工作完成后恢复它。

请注意,一些过多的托管内存分配会导致 GC 能耗激增:

字符串:在 C# 中,字符串是引用类型,而不是值类型。 我们需要减少不必要的字符串创建或修改操作,尽量避免解析JSON、XML等字符串组成的数据文件,将数据存储在ScriptableObjects中,或者以MessagePack或Protobuf等格式保存。 如果需要在运行时创建字符串,请使用 StringBuilder 类。

Unity函数调用:一些函数涉及托管视频内存分配。 我们需要缓存链表引用以避免循环期间链表的内存分配,并尽量使用这些不会导致垃圾回收的函数。 例如,使用 GameObject.CompareTag 而不是使用 GameObject.tag 手动比较字符串(因为返回新字符串会形成垃圾数据)。

Boxing(装箱):避免在引用类型变量处传入值类型变量,因为这样做会导致系统创建临时对象并在幕后将值类型转换为对象类型(如 int i = 123; object o = i ) ,从而形成了垃圾收集的需要。 尝试使用正确的类型覆盖来传入所需的值类型。 泛型还可以用于类型覆盖。

协程:虽然yield不会导致垃圾回收,但创建一个新的WaitForSeconds对象会导致垃圾回收。 我们可以缓存并重用W​​aitForSeconds对象,而不必在yield中再次创建它。

LINQ和Regular Expressions(正则表达式):这两种方式在后台数据打包时会形成垃圾收集。 如果需要追求性能,尽量避免使用LINQ和正则表达式,而是使用for循环和列表来创建链表。

垃圾收集的定期处理

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

在了解自动内存管理中了解如何正确使用此功能。

分散垃圾收集与增量垃圾收集(Incremental GC)

增量垃圾收集不会长时间中断程序的运行,而是将总负载分散到多个帧上,形成分段收集过程。 如果垃圾数据收集对性能影响较大,可以尝试启用该选项,以增加GC的处理峰值。 您可以使用Profile Analyzer来检查该功能的实际效果。

使用增量垃圾收集来增加 GC 处理峰值。

编程和代码架构

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

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

Profiler 将在整个引擎运行过程中显示自定义脚本、设置和图表。

在此处了解 PlayerLoop 和脚本生命周期。

您可以使用以下方法和技巧来优化您的脚本。

深入理解Unity PlayerLoop

我们需要掌握Unity帧循环的执行顺序。 每个Unity脚本都会按照预定的顺序运行wind函数,这需要我们了解Awake、Start、Update等运行周期相关函数之间的区别。

请参阅脚本生命周期流程图中函数的执行顺序。

减少每帧的代码量

有很多代码不需要每一帧都运行,这些不必要的逻辑可以在Update、LateUpdate和FixedUpdate中删除。 这些风暴函数可以节省每帧必须更新的代码。 任何不需要每帧更新的逻辑都不需要倒入其中。 只有当相关事物发生变化时才需要执行这些逻辑。

如果必须使用 Update,请考虑每 n 帧运行一次代码。 这种定义运行时的方式也是将复杂工作负载分解为更小部分的常用技术。 在下面的示例中,ExamplentFunction 将每三帧运行一次。

私有 int 间隔 = 3;void Update(){if (Time.frameCount % 间隔 == 0){ExampleExpectiveFunction();}}

避免向 Start/Awake 添加复杂的逻辑

当第一个场景加载时,每个对象都会调用以下函数:

苏醒

开启

开始

在应用程序完成第一帧渲染之前,我们需要避免在此函数中运行复杂的逻辑。 否则,您的应用程序将需要非常长的时间才能加载。

在事件函数的执行顺序中了解有关加载第一个场景的更多信息。

避免加入空浪

即使是空的MonoBehaviours也会占用资源,所以我们应该删除空的Update和LateUpdate技术。

如果您想以这种方式进行测试,请使用预处理器指令:

#if UNITY_EDITORvoid Update(){}#endif

这样,编辑器中的更新测试不会对重构版本造成不利的性能影响。

删除Debug Log语句

Log语句(尤其是Update、LateUpdate和FixedUpdate中)会降低性能,因此我们需要在创建之前禁用Log语句。

您可以通过使用预处理器指令编辑条件属性来轻松禁用调试日志。 例如,如下所示的自定义类:

公共静态类日志记录{[System.Diagnostics.Conditional("ENABLE_LOG")]静态公共无效日志(对象消息){UnityEngine.Debug.Log(消息);}}

添加自定义预处理指令可以实现脚本分段。

使用自定义类生成Log信息时,只需在Player Settings中禁用ENABLE_LOG预处理指令,所有Log语句就会立即消失。

使用哈希值,避免字符串

底层 Unity 代码不使用字符串来访问 Animator、Material 和 Shader 属性。 出于效率原因,所有属性名称都被哈希为属性 ID,用作实际属性名称。

在Animator、Material或Shader上使用Set或Get方法时,我们可以使用整数值而不是字符串。 后者也需要哈希一次,这不像整数值那么简单。

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

选择正确的数据结构

由于数据结构每帧可能迭代数千次,因此其结构对性能影响很大。 如果你不知道数据集合应该用List、Array还是Dictionary来表示,可以参考C#的MSDN数据结构手册来选择正确的结构。

避免在运行时添加组件

在运行时调用AddComponent会花费一定的运行成本,并且Unity必须检查组件是否有重复或依赖关系。

当组件已配置时,实例化预制件通常会提高性能。

缓存游戏对象和组件

调用GameObject.Find、GameObject.GetComponent、Camera.main(2020.2以下版本)会造成较大的运行负载,所以该方法不适合在Update中调用,而应该在Start中调用并缓存。

以下示例显示了多次调用的低效 GetComponent:

void Update(){渲染器 myRenderer = GetComponent();ExampleFunction(myRenderer);}

事实上,GetComponent的结果会被缓存,因此只需要调用一次。 缓存的结果可以在Update中完全重用,而无需再次调用GetComponent。

私有渲染器 myRenderer;void Start(){myRenderer = GetComponent();}void Update(){ExampleFunction(myRenderer);}

对象池

Instantiate(实例化)和Destroy(销毁)方法会造成需要垃圾收集数据的处理高峰,触发垃圾收集(GC),运行更加顺畅。 与其频繁地实例化和销毁GameObjects(比如发射炮弹),不如使用对象池提前存储对象,然后反复使用和回收。

在本例中,ObjectPool 创建 20 个 PlayerLaser 实例以供重用。

在游戏中的特定点(例如显示菜单屏幕时)创建可重用实例,以减少 CPU 处理峰值的影响,然后使用集合生成“对象池”。 游戏过程中游戏源码回收,实例可以在需要时启用/禁用,并在使用后返回到池中,而无需销毁。

PlayerLaser对象池还没有激活,等待玩家射箭。

通过这种方式,您可以减少托管视频内存分配的数量并防止垃圾收集问题。

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

使用 ScriptableObjects(可编程对象)

固定值或配置信息可以存储在ScriptableObject中,不一定存储在MonoBehaviour中。 ScriptableObject可以被整个项目访问,并且可以将单个设置应用于全局项目,但不能直接与GameObject关联。

我们可以使用数组来存储ScriptableObject中的值或设置,然后在MonoBehaviours中引用该对象。

用作“库存”的 ScriptableObject 可以保存多个游戏对象的设置。

下面的ScriptableObject数组可以有效避免多个MonoBehaviour实例化导致的数据重复。

在 ScriptableObjects 简介教程中了解如何使用 ScriptableObjects。 您还可以参考此处的文档。

下载完整的性能优化方法

在本系列的下一篇文章中,我们将仔细研究图形和 GPU 优化。 如果您想了解所有方法和技巧,可以在此处下载完整的电子书。

下载电子书

如果您想了解有关集成支持服务的更多信息,或者想直接从 Unity 工程师和专家那里获得建议、指导和最佳实践,您可以在此处了解 Unity 成功计划。

收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

悟空资源网 游戏源码 游戏源码回收-优化中国联通游戏性能:顶级 Unity 工程师的性能分析、内存和代码架构技巧 https://www.wkzy.net/game/189282.html

常见问题

相关文章

官方客服团队

为您解决烦忧 - 24小时在线 专业服务