Unity 性能优化
Unity Profiler
Unity Profiler 是 Unity 引擎自带的性能分析工具,能够在运行时为大量的 Unity3D 子系统生成使用情况和统计报告,可以帮助开发者快速找到游戏的性能瓶颈。
一般情况下,有两种使用 Profiler 工具的方法,分别是指令注入和基准分析。
指令注入(Instrumentation)通过观察方法的调用行为,内存的分配来观察应用程序的内部工作情况。
基准分析(Benchmarking)涉及对应用程序执行表面级别的检测,在游戏运行于目标硬件期间收集一些基本数据,执行测试场景。进行基准分析的过程中,开发人员感兴趣的重要指标通常是渲染帧率(FPS),总体内存消耗和 CPU 活动的行为方式(寻找活动中较大的峰值),有时还有 CPU 或 GPU 的温度。
性能分析的最佳方法
性能优化的目标是使用基准分析来观察应用程序,寻找问题的实例,然后使用指令注入工具在代码中寻找问题根源的线索。
解决问题之前应先列一份任务清单以帮助开发人员专注于这个问题,并确保不会浪费时间来尝试实现任何可能的优化,而这些优化对于主要的性能瓶颈没有影响。
Unity 查找性能问题的通用步骤:
- 验证目标脚本是否存在于场景中
- 验证脚本在场景中出现的次数是否正确
- 验证事件的正确顺序
- 最小化正在进行的代码更改
- 最小化内部影响
- 最小化外部影响
脚本策略
使用最快的GetComponent方法
在Unity目前的版本中有三种获取组件的方法,分别是 GetComponent<T>
、GetComponent(typeof(T))
和 GetComponent(string)
。第一个方法比第二个方法快一点,第三个方法显著慢于其他两个方法。
移除空的回调定义
Monobehaviour 组件在场景中第一次实例化时,引擎会将任何定义好的回调添加到一个函数指针列表中,并在需要的时刻调用这个列表。但这里需要注意的是,即使回调是空的,Unity也会将该回调添加到列表中,这将会在代码被调用时产生一定的额外开销,将浪费少量的CPU资源。
可以使用以下正则表达式来搜索出代码中空的回调。
1# 这里使用 Update 方法举例
2void\\s*Update\\s*?\\(\\s*?\\)\\s*?\\n*?\\(\\n*?\\s*?\\)
优化组件引用
如果可以,尽量避免反复去获取相同的组件引用。
例如以下方法,每次执行时都要重新获得五个组件的引用,这对于CPU而言不是很友好。如果这个方法是在 Update 里调用的,对于性能的影响还要更加严重。
更好的方法是在Awake中获取这些组件的引用并保存在类中,TakeDamage方法直接调用这些引用。
1void TakeDamage()
2{
3 var rb = GetComponent<RigidBody>();
4 var collider = GetComponent<Collider>();
5 var ai = GetComponent<AIController>();
6 var anim = GetComponent<Animator>();
7
8 if (GetComponent<Health>().health < 0)
9 {
10 rb.enabled = false;
11 collider.enabled = false;
12 ai.enabled = false;
13 anim.SetTrigger("death");
14 }
15}
共享,而不是每个组件都单独获取数据
让多个对象共享某些计算的结果可以节省性能开销。例如读入的 Json 数据,或者是为一组 AI 计算行动路径。
如果每次执行一个消耗较大的操作时,从多个位置调用它总是得到相同的输出,那么重构就是明智的。
Update, Coroutines 和 InvokeRepeating
在写代码时,新人很容易犯的错误就是在Update回调中以超出需要的频率重复调用某段代码。
举例来说,有一个 AI 寻路处理方法,它的功能是找出它要移动的目的地,当我们将它放入Update回调中时,它可能会占用过度的帧率预算。
1private void Update()
2{
3 ProcessAI();
4}
如果我们想要提高游戏的性能,可以尝试减少该方法的调用频率,如果效果并没有因为调用的次数减少而变得不可接受的差,那么这部分的性能我们就节省了下来。
1private float m_AirProcessDelay = 0.2f;
2private float m_Timer = 0.01f;
3
4private void Update()
5{
6 m_Timer += Time.deltaTime;
7 if (m_Timer > m_AirProcessDelay)
8 {
9 ProcessAI();
10 m_timer -= m_AirProcessDelay;
11 }
12}
当然,我们也可以采用协程的方式编写该代码,这样可以让我们更简单的延迟调用。
使用协程的主要好处是,它的调用频率完全由开发控制,在时间没到之前它将一直处于空闲状态,从而减少对大多数帧的性能影响。
但协程与标准的方法调用相比,启用协程会带来额外的开销成本(大约是标准的方法调用的三倍),而且还会需要额外的内存来存储方法的执行状态。
因此如果使用协程,需要确保降低频率的好处大于此成本。
Note:
一旦初始化,协程的运行独立于 MonoBehaviour 组件中 Update回调的出发,不管组件是否禁用,都将继续调用协程。因此如果执行大量的GameObject构建和析构操作,协程可能会显得很笨拙。
当包含了协程的 GameObject 变为不活动时,在该 GameObject 身上运行的协程将全部终止。
如果协程的调用可以被简化为一个 while 循环,则可以考虑使用 InvokeRepeating
代替,它的建立更加简单,并且开销更小。
Note:
InvokeRepeating 和协程之间的一个重要区别是,InvokeRepeating 完全独立于 MonoBehaviour 和 GameObject 的状态,关闭组件或者让 GameObject 失活都无法停止 InvokeRepeating 。如果想要停止 InvokeRepeating ,有两种方法。
- 调用 CancelInvoke:它将停止由给定的 MonoBehaviour 发起的所有 InvokeRepeating
- 销毁 MonoBehaviour 或者其 GameObject。
优化空引用检查
GameObject 和 MonoBehaviour 是特殊的对象,它同时存在于 托管代码 与 本机代码 中,数据会在这两个内存空间中移动,每次移动都将导致额外的 CPU 开销和可能的额外内存分配。
这种效果通常被称为跨越本机-托管的桥接,发生时有时候会触发 GC,许多方法都会意外地触发这种额外的开销,对 GameObject 的空引用检查就是其中之一。
可以使用 System.Object.ReferenceEqual
方法避免这种情况,它的功能与常见的判空相同,同时运行速度是原本的两倍。
1if (gameObject != null)
2{
3}
4
5if (!System.Object.ReferenceEqual(gameObject, null))
6{
7}
避免从 GameObject 中检索字符串属性
从对象中检索字符串属性本应不增加内存成本,但是从 GameObject 中检索字符串属性会触发跨越本机-托管的桥接。
GameObject 的 name 属性和 tag 属性都受此影响,因此最好只在性能无关紧要的地方使用它们。
如果非要使用,对于 tag 属性而言,最好使用 CompareTag 方法。这是 GameObject 提供的比较方法,它可以避免跨越本机-托管的桥接。
直接使用字符串 比较 tag 属性 1000w 次需要 2000ms,并需要 400ms 进行垃圾回收,总共是 2400ms。
使用 CompareTag 比较 1000w 次仅需要 1000ms,并且不需要进行垃圾回收。
Tip:向 CompareTag 传递字符串文本时不会导致运行时的内存分配,因为硬编码的字符串将在应用程序初始化时被分配,运行时只是引用它们。
使用合适的数据结构
软件开发中的一个常见的性能问题是简单地为了便利而使用不适当的数据结构来解决问题。最常用的两种数据结构是 List 和 Dictionary。
如果希望遍历一组对象,最好使用列表,因为他实际上是一个 Array,数据之间在内存中紧闭排布,因此迭代导致的缓存丢失最小。
如果两个对象相互关联且希望快速获取、插入或删除这些关联,最好使用字典。
有时候,数据结构通常需要同时处理两种情况,快速找出哪个对象映射到另一个对象,同时还能遍历组。在这种情况下我们可以同时使用 List 和 Dictionary,虽然这将导致消耗额外的内存,但是这种开销相对于迭代 Dictionary 来说是更容易接受的。
避免在运行时修改 Transform 的父节点
Unity中,共享父元素的 Transform 按顺序存储在预先分配的内存缓冲区的内存中,并在 Hierarchy 窗口中根据父元素下面的深度进行排序。
这种数据结构允许在整个组中进行更快的迭代,这对于物理和动画等多个子系统非常有用。
但是缺点是,如果将一个 GameObject 的父对象指定为另一个对象,父对象必须将新的子对象放入预先分配的内存缓冲区中,并根据新的深度对所有这些 Transform 进行排序。如果缓冲区没有足够的空间,还必须扩展缓冲区,以便以深度优先的顺序容纳新的子对象及其所有的子对象。如果 GameObject 的结构比较复杂,那么该操作需要耗费一些时间来完成。
当然,也可以提前给 Transform 分配一个更大的缓冲区,这样就可以避免在运行时扩展缓冲区,减少不必要的内存分配。
避免在运行时使用 Find 和 SendMessage 方法
GameObject.Find 方法和 SendMessage 方法的开销非常大,应该不惜一切代价避免在运行时使用。
依赖使用这两种方法通常是非常糟糕的设计,意味着缺乏使用 Unity 和 C# 的经验,或者仅仅是由于原型开发期间的懒惰。
虽然在开发中我们常说不要提前优化,但由于这两个方法的性能损耗实在太大,以至于需要打破这个原则。
我们可以采用以下几种方式来替代这两个方法:
- 将引用提前分配给预先存在的对象
- 静态类
- 单例组件
- 全局消息传递系统
将引用提前分配给预先存在的对象
方法一(将引用提前分配给预先存在的对象)在软件开发中有许多争议,因为它破坏了封装性,使得任何标记为私有的字段都公共字段一样处理。
在某些情况下,例如你在代码里将一个私有变量标记为 SerializeField,然后在 Inspector 面板为这个私有变量赋值,最后在代码中删除 SerializeField 标记。然后在运行时,你会发现在 Inspector 面板时所赋的值仍然存在。
这种无法被定位的赋值会导致一些极难定位的问题,更不要说由于在 Inspector 面板中填写数值导致在涉及多人协作时的 meta 文件冲突了。
从我个人角度上来说,我会尽量避免在 Inspector 面板设定数值。在代码中中,或者是在 Excel, CSV, Json, Text 等文本配置文件中修改值是一个更安全,也更容易排查问题的方式。
静态类
任何的全局管理器类在软件工程领域都是不受欢迎的,部分原因是因为“管理器”的名称很模糊,没有说明它应该做什么,但更主要的原因是很难调试。
对管理器的更改可以在运行期间的任何时间和任何地点发生,并且它自身还维护着其他系统所依赖的状态信息,同时许多类都可能包含对它的直接方法调用或间接方法调用(不排除有些方法中使用了字符串硬编码进行Invoke),如果未来想要修改它,则需要对系统中几乎每一个类进行修改。
当然,即使有着这些缺陷,静态类仍然是迄今为止最容易理解和实现的解决方案。
单例组件
静态类很难和与 Unity 相关的功能交互,不能直接利用 MonoBehaviour 的特性,比如事件回调、协程、分层设计和预制块。
一个常见的解决方案是实现一个类似于单例的组件,它提供静态方法来授予全局的访问权,在任何给定的时间只允许 MonoBehaviour 的一个实例存在。
1public class MonoSingleton : MonoBehaviour
2{
3 private static MonoSingleton mInstance;
4 private Dictionary<string, Coroutine> mActiveCoroutines = new();
5
6 public static MonoSingleton Instance
7 {
8 get
9 {
10 if (mInstance == null)
11 {
12 var objs = FindObjectsByType<MonoSingleton>(FindObjectsSortMode.None);
13 if (objs.Length > 0)
14 {
15 mInstance = objs[0];
16 }
17
18 if (mInstance == null)
19 {
20 var singletonObject = new GameObject();
21 mInstance = singletonObject.AddComponent<MonoSingleton>();
22 singletonObject.name = "MonoSingleton";
23 }
24 }
25
26 return mInstance;
27 }
28 }
29
30 private void Awake()
31 {
32 if (mInstance == null)
33 {
34 mInstance = this;
35 DontDestroyOnLoad(gameObject);
36 }
37 else if (mInstance != this)
38 {
39 Destroy(gameObject);
40 }
41 }
42}
全局消息传递系统
解决对象间通信的最后一种建议采用的方法是实现一个全局的消息传递系统,任何对象都可以访问该系统,并将消息通过该系统发送给任何可能对监听特定类型的消息感兴趣的对象。
禁用未使用的脚本和对象
在构建大型的、开放的游戏时,如果每个游戏对象的脚本都要实时被调用,那么对性能造成的影响将是毁灭性的。许多的游戏对象都在玩家的视野之外,对于玩家的游玩没有任何的影响,反而会拖累游戏性能,因此在不需要的时候,暂时将它们关掉或许是更好的选择。
通过可见性禁用对象
为游戏对象的脚本上添加 OnBecameVisible
和 OnBecameInvisible
的回调。
Note: 可见性回调必须与渲染管线通信,因此 GameObject 必须附加一个可渲染的组件,也必须保证希望接受可见性回调的组件也与可渲染对象连接在同一个 GameObject 上。
通过距离禁用对象
简单的实现是使用一个协程定期检查 玩家与当前游戏物体的距离。
使用距离的平方而不是距离
CPU 比较擅长浮点数相乘而不擅长计算平方根,而每次使用 magnitude
属性或者 Distance
方法要求 Vector3 计算距离时,其实都是在进行平方根运算,这会消耗大量的 CPU 资源。
更好的方案是使用距离的平方来判断游戏对象之间的距离,Vector3 也提供了 sqrMagnitude
属性,该值便是距离的平方。
1// 使用距离
2float distance = (transform.position - other.tranform.position).Distance();
3
4if (distance < targetDistance)
5{
6}
7
8// 使用距离的平方
9float distance = (transform.position - other.tranform.position).sqrMagnitude;
10
11if (distance < (targetDistance * targetDistance))
12{
13}
Note:
两个方法的结果几乎相同,只是由于浮点数本身的特性,可能会损失小数点很多位之后的一点点精度。当我们不需要如此精确的精度时,损失一点点的精度换取更高的性能是可以接受的。
最小化反序列化行为
Unity 的序列化系统主要用于场景、预制件、ScriptableObjects 和各类资产类型中。这些资产往往以文本或者二进制的形式保存在磁盘中。
然而,在运行时从磁盘读取和反序列化数据相对而言是一个非常慢的过程,因此所有的反序列化活动都会产生显著的性能成本。
这种反序列化在调用 Resources.Load
方法时发生,虽然一旦数据加载到内存中后,以后重新加载相同的引用速度会快得多,但是第一次访问时是必须从磁盘加载的。
我们可以通过以下四种方式来解决这个问题:
- 拆分序列化对象:例如将一个 UI 面板拆分为多个小部件,这些小部件只在点击的时候才加载。这样就减少了加载基础 UI 面板时的体积。
- 异步加载序列化对象。
- 在内存中保存之前加载的序列化对象。
- 将公共数据存入相同的序列化对象。
创建自定义的 Update 层
平衡CPU使用率
当有许多的 MonoBehaviour 脚本在开始场景一起被初始化,一起启动了协程,每隔 500ms 处理一次 AI 任务时,它们极有可能在同一帧触发,导致 CPU 的使用率在一段时间内出现一个巨大的峰值。
解决方案有如下三种:
- 每次计时器过期或协程触发时,生成一个随机的等待时间。
- 将协程的初始化分散到各个帧中,这样每帧中只会启动少量的协程初始化。
- 将调用更新的职责传递给某个 God 类,该类对每帧的调用数量进行限制。
当然,需要警惕的是,剧烈的设计变更可能带来许多危险和意想不到的副作用。
重写 Update
优化的一个好的方法是根本不使用 Update,或者更准确地说,只使用一次。
当 Unity 调用如 Update 的函数回调时,它都要经过跨越本机-托管的桥接,这会造成一定的性能损耗,如果场景中的游戏对象较多,那么耗费在这个过程中的损耗将是一个相当惊人的数字。执行一千个单独的 Update 回调的成本比执行一个 Update 的回调调用一千个常规方法的成本高。
因此,让一个 God 类的 MonoBehaviour 使用它自己的 Update 回调来调用自定义组件使用的自定义更新样式来更新系统,可以最小化 Update 需要跨越桥接的频率。
界面优化
拆分画布
画布组件的主要任务是管理在层级窗口中绘制 UI 元素的网格,并发出渲染这些元素所需的 Draw Call。另一个重要的作用是将网格合并进行批处理以降低 Draw Call 数。
当画布或其子对象发生变动时,画布需要为所有的 UI 对象重新生成网格,然后重新发送 Draw Call,这被称为画布污染(更改 UI 元素的颜色不会污染画布)。
UI 元素的改变导致 CPU 使用率大幅上升,通常是由于在单个画布中构建了过多的 UI 元素,这将导致 UI 元素更改时画布需要重新生成的网格过多。
这种情况一般都可以使用更多的画布来解决,将 UI 拆分为多个画布,将工作的负载分开,简化单个画布所需的任务。
动静结合
在拆分 UI 时,可以按照静态、偶尔动态、连续动态三个标准分离 UI 元素。
- 静态:永远也不会改变的 UI 元素,例如背景图片。
- 偶尔动态:只在做出响应时更改,例如 UI 按钮。
- 连续动态:元素会定期更新,例如滚动的轮播图。
移除 Raycast Target
UI 元素具有 Raycast Target 选项,允许元素通过单击、触摸和其他用户的行为进行交互。当用户行为事件发生时,GraphicsRaycast 组件将执行像素到边界框检查以确定与之交互的是哪个元素。禁用不需要交互的元素的 Raycast Target 选项,就可以减少 GraphicsRaycast 所需要检查的数量,从而提高性能。
不要使用 Animator
Unity 的 Animator 组件与 UGUI 的搭配完全就是灾难,当使用 Animator 制作 UI 动画时,每一帧 Animator 都会改变 UI 元素的属性导致画布网格需要重新绘制,这将带来巨大的开销。
因此在开发中应该避免使用 Animator。
正确的隐藏 UI 元素
对于 UI 元素,即使将 alpha 值设置为 0 ,也依然会被画布提交到 Draw Call,正确的做法应该是更改 UI 元素的 isActive
属性。或者是使用 CanvasGroup 组件来控制其下所有元素的 alpha 值,当 CanvasGroup 的 alpha 值被设置为 0时,将自动清除其子对象,不会发出任何的 Draw Call。
使用空的 UI Text 元素进行全屏交互
在强制玩家必须处理弹出窗口才能进入下一步时,通常会激活一个很大的、透明的可交互元素覆盖整个屏幕避免玩家能够点击弹窗后面的 UI 元素。
这通常会使用 UI Image 来完成,但在弹窗不需要背景图的时候,其实可以使用一个没有定义字体、也没有填充文本的 Text 元素用于屏蔽交互。它不需要生成任何的渲染信息,性能开销相较于将 Image 的透明度调低的方案来说是非常低的。
艺术资源优化
音频文件优化
产生音频瓶颈的原因多种多样,过度压缩、过多的音频操作、过多的活动音频组件、低效的内存存储方式和访问速度都是导致内存和 CPU 性能低下的原因。
加载音频文件
音频文件的加载方式分为 Preload Audio Data
, Load In Background
, LoadType
Preload Audio Data
: 音频文件在场景加载时完全载入内存,播放时无需额外加载。播放时零延迟,适合短小且频繁使用的音效(如UI点击、爆炸声等)。但是占用内存较高,若大量音频预加载可能导致内存压力,适合小型音效、必须即时响应的音频。Load In Background
: 音频文件在后台异步加载,不阻塞主线程。减少场景加载卡顿,适合较大音频文件(如背景音乐)。但播放时若未加载完成可能出现延迟,适合非即时性的大文件(如过场音乐、环境音效)。LoadType
: 该选项定义了将什么类型的数据拉入内存,以及一次拉入多少数据。- Decompress On Load : 加载时解压为PCM格式,播放时CPU开销低,但内存占用极高(适合高频播放的小文件)。
- Compressed In Memory : 保持压缩状态,播放时实时解压,内存占用低但CPU开销较高(适合大文件低频播放)。
- Streaming : 从磁盘流式读取,内存占用最小,但需要稳定的I/O性能(适合超长音频,如背景音乐)。
压缩格式选择
Unity 支持三种压缩格式,分别是 PCM
, ADPCM
, Compressed
PCM
:提供高品质但牺牲文件大小的压缩,最适合使用在很短的音效上。ADPCM
: 适用于大量音效上如脚步爆破和武器,它比 PCM 小 3.5 倍但CPU使用率远低于Vorbis / MP3Compressed (Vorbis/MP3)
: 比PCM小但是品质比PCM低,比ADPCM消耗更多CPU。但大多数情况下我们还是应该使用这种格式,这个选择还多了个 Quality 可以调节质量改变文件大小 (Quality测试1和100对内存影响并不大)
音频性能增强
- 最小化活动音源数量 : 由于每个播放的音频源消耗特定的 CPU,因此禁用场景中冗余的音频源可以节省 CPU 周期。因此能够找到的大多数音频管理插件的实现了某种类型的音频限制功能(通常称为音频池)。当然,如果涉及环境音效,我们仍然需要将它们放在场景中的特定位置,以利用对数音量效果,这使其具有伪 3D 效果。
- 为 3D 声音启用强制单声道 : 如果不需要立体声,可以在立体音频文件上启用 Force to Mono 设置可以将两个音频通道的数据混合到一个声道,节省 50% 的磁盘占用和内存空间占用。
- 重新采样到较低的频率 : 将导入的音频文件重新采样到较低的频率可以减小文件和运行时内存占用。将 Sample Rate 设置为 Override Sample Rate 即可手动调整。22050Hz 是人类语音和古典乐的一个常见采样率。
- 为每个文件赋予最合适的压缩格式
- 注意流媒体 : Streaming 加载类型的优点是运行时的内存成本低,因为运行时给其分配了一个小的缓冲区,文件像数据队列一样连续地被推入内存。但是需要考虑 IO 速度,而且如果一次传输多个文件可能会在磁盘上造成大量缓存丢失。
纹理文件
游戏开发中经常会混淆纹理(Texture)和精灵(Sprite)的概念。
纹理只是简单的图像文件,是一个颜色数据的大列表,用于告诉插值程序,图像的每一个像素应该是什么颜色。
精灵是网格的 2D 等价物,它定义了图像在游戏场景中的出现方式和位置,通常只是一个四边形,用于渲染面向当前相机的平面。
精灵表是一个大纹理文件内大量独立图像的集合,通常用于存储 2D 角色动画。
网格和精灵都使用纹理,将图像渲染到它们的表面。它们通常由引擎外部的美术工具制作(例如 PS 或 Krita),然后作为游戏素材被导入到游戏项目中。运行时这些文件被加载进内存,推给 GPU 显存,并在给定的 Draw Call 期间由着色器渲染到目标网格或者精灵上。
纹理性能增强
- 减小纹理文件的大小 : 纹理文件越大, GPU 内存带宽消耗越大,越容易产生渲染瓶颈。
- 谨慎使用 Mipmap : 如果玩家看不到细节,那么渲染远处的小对象就没有意义,这种情况下使用 Mipmap 是可以的。然而 Mipmap 会生成多张低分辨率的副本并与原始纹理一起被打包,导致纹理文件将比原始文件大 33%,这将消耗额外的存储空间和 GPU 带宽。
- 从外部进行分辨率的压缩管理 : 为了方便团队协作,Unity 会自动解析 PSD 和 TIFF 等文件生成项目可使用的纹理文件,但是 Unity 自动生成的纹理文件有时候并不如其他工具做得好,因此可以使用外部工具生成出所需的美术资源,再将其导入 Unity 中,可以节省空间。
- 调整 Anisotropic Filtering 等级 : 这个功能的开销很大,如果场景中有些纹理肯定不会从倾斜角度看到,例如远处的背景对象或者 UI 元素,那么可以将该功能关闭,节省运行时开销。
- 考虑使用图集 : 这是将许多较小的、独立的纹理合并到一个较大的纹理文件中,实现最小化材质数量,从而最小化所使用的 Draw Call 数量的技术。如果程序的瓶颈在 CPU,那么通过减少 Draw Call 将有效降低 CPU 负载从而提高帧率。当然,由于推送到 GPU 的数据是一样的,因此图集技术并不能减小 GPU 内存带宽。
- 调整非方形纹理的压缩率 : 纹理文件通常以 正方形、2 的 n 次幂的格式保存。或者是长方形纹理,其宽高仍然是 2 的 n 次幂。当然,并不推荐使用长方形纹理,因为一些 GPU 只能处理正常性纹理格式,Unity 将耗费额外的资源将纹理拓展为正方形,导致纹理的采样速度可能比方形纹理更慢。
加速物理引擎
物理引擎的内部工作情况
Unity 的物理引擎分为两种,用于 3D 物理的 NVIDIA 的 PhysX 和用于 2D 物理的 开源项目 Box2D。
最大时间步长
物理引擎通常是在时间按固定值前进的假设下运行的,无论是 3D 引擎还是 2D 引擎,每个迭代称为时间步长。
如果距离上一次调用经过了足够的时间,则物理引擎固定更新的处理将调用在场景中所有激活的 MonoBehaviour 的 FixedUpdate
回调,否则将跳过更新。
FixedUpdate
回调适用于任何期望独立于帧率的游戏行为(例如 AI)。
如果自上次固定更新以来已经过了很长时间,则物理引擎将在本次更新时连续调用多次 FixedUpdate
, 如果当前前几次更新的内容过多,导致时间已经到了下次需要更新的时候,那么物理引擎将进行新一轮的更新。
因此,当物理活动较多时,有时候可能会出现物理引擎固定更新的时间比给定的时间片还要长。这将导致物理引擎永远无法追上最新进度,最后陷入死亡螺旋。
为了防止死亡螺旋的出现,游戏引擎通常提供了最大时间步长作为限制,如果当前一批的固定更新处理的时间太长,则放弃进一步的处理,直到下一次渲染更新完成。
物理更新和运行时变化
因为物理引捕获和处理更改的时间是固定的,因此如果将物理相关的处理放入 Update
, 回调,或者是WaitForSeconds
, WaitForEndOfFrams
等协程中,往往导致意想不到的物理行为。
静态碰撞器和动态碰撞器
动态碰撞器意味着 GameObject 包含 Collider 组件和 Rigidbody 组件。通过将 Rigidbody 组件添加到 Collider 所附加的相同对象上,物体会基于牛顿运动定律做出反应。
静态碰撞器就是没有附加 Rigidbody,只有 Collider 的 GameObject。当动态碰撞器撞上静态碰撞器时,就像是撞上了一个无限质量的墙面,因此非常适合作为全局屏障和其他不能移动的障碍物。
物理引擎会自动将两种碰撞器分为两种不同的数据类型,简化未来的处理任务。
碰撞检测
Unity 的碰撞检测分为三个等级,Discrete
,Continuous
和 ContinuousDynamic
,可以在 Rigidbody 组件的 Collision Detection 属性中设置。
Discrete
: 仅在固定物理帧(FixedUpdate)检查碰撞。如果物体速度过快(如子弹、高速移动物体),可能会直接穿过其他碰撞体(穿模)。性能最好,适用于大多数常规物理模拟,但不适用于高速移动的物体。- Continuous : 对当前物体进行连续碰撞检测(CCD, Continuous Collision Detection),防止它穿过其他物体。其他物体仍使用
Discrete
检测,因此其他高速物体仍可能穿过它。比ContinuousDynamic
的性能更好,但仅能防止当前物体穿模。 ContinuousDynamic
: 对当前物体和所有Continuous
,ContinuousDynamic
物体进行连续碰撞检测。确保高速物体之间不会互相穿透。它最精确,适用于高速运动的物体。但是性能消耗最大,不适合大量物体同时使用。
碰撞矩阵
物理引擎有一个碰撞矩阵,可以在碰撞矩阵中调整不同物体层级之间的碰撞。这样可以节省状态检测阶段的物理处理,并允许不同层级之间的对象彼此移动而不发生任何碰撞。
Unity 中可以在 Edit -> Project Settings -> Physics/Physics2D -> Layer Collision Matrix
中进行设置。
对于整个项目来说总共只能有 32 个层,因为物理引擎使用 32 位位掩码来确定层间冲突。
Rigidbody 的激活与休眠
现代物理引擎会在物体静止时将其状态从激活转为休眠,当一个物体休眠后,每一轮的更新中处理器都只会花少量时间进行检测附近物体是否与之发生了碰撞,直到其被外力或碰撞事件唤醒。
用于检测简直状态的测量值各个引擎都有所不同,Unity 的阈值可以在 Edit -> Project Settings -> Physics -> Sleep Threshold
中进行修改。
这里需要注意的是,如果阈值太低,则物体很难进入休眠状态,消耗大量资源。如果阈值太高,则缓慢移动的物体可能会忽然停止。
射线和对象投射
在遇到子弹碰撞和爆炸伤害判断的时候,我们可以使用物理射线和 Physics.OverlapSphere
等方法获取目标列表,而不需要到处使用碰撞体,这样能够减少性能开销。
物理性能优化
场景设置
应该尽可能地使游戏世界中所有物理物体的缩放接近 $(1,1,1)$,因为 Unity 假设试图模拟的游戏发生在地球表面,默认的重力值是 $-9.81$ 以匹配地球重力。如果所有物体都放大了五倍,则重力就会减弱五倍,反之亦然。
保持所有对象在世界空间的位置接近 $(0,0,0)$ 将具有更好的浮点数精度,提高模拟的一致性。在游戏开发中常使用的几个技巧是秘密将玩家传送回世界的中心,或者固定它们的位置。
当然,大部分游戏都没有浮点不精确的风险,因为大多数游戏的关卡往往最多持续半个小时,但如果玩家的流程足够长,同时需要处理超大的场景或异步加载场景,则当玩家走了数万步后就可能注意到一些奇怪的物理现象。
适当使用静态碰撞器
静态碰撞器和动态碰撞器在物理引擎中是由两个数据结构单独存储,如果在运行时将新对象引入静态碰撞器的数据结构,那么存储静态碰撞器的数据结构就需要重新生成,这将导致显著的 CPU 峰值。
此外,移动、旋转、缩放静态碰撞器也将触发数据结构重新生成,因此需要尽量避免。
如果希望碰撞器在不与其他物体发生物理碰撞时移动,可以为其添加 Rigidbody,并勾选 Kinematic,可以实现类似于静态碰撞器的效果。
优化碰撞矩阵
通过关闭不同层级之间的物理碰撞,物理引擎可以使一个层简单地忽略其他层物体的碰撞,减少了每次固定更新必须检查的边界物体的数量,以及在应用程序的生命周期中需要处理的碰撞数量。
首选离散检测
离散检测的消耗相当低,因为只传送一次对象并在附近的对象之间执行一次重叠检查。
连续碰撞检测的消耗比离散碰撞检测方法高出一个数量级,而连续动态碰撞检测的消耗比连续碰撞检测又高出一个数量级。
修改固定更新频率
在某些情况下,离散碰撞检测的效果不够好,或者整个游戏中包含着大量的小的物理对象,而离散碰撞检测无法捕获足够的碰撞来保持产品质量,但是使用连续碰撞检测的开销又过于高昂,这种时候可以在设置里修改物理引擎的固定更新频率。
最小化射线投射和边界体积检查
物理射线的方法都非常有用,但是它们相较于其他方法而言开销过大,因此应该避免在 Update 或是协程中定期调用这些方法,而是将其放在一些关键的事件中进行调用。
避免复杂的网格碰撞器
碰撞器的性能消耗从小到大的排列为球体、胶囊体、立方体、凸形网格碰撞器、凹形网格碰撞器。因此在构建物体的碰撞器的时候,使用更简单的基本体,或者是使用简化的网格碰撞器是更好的实践方案。
避免复杂的物理组件
某些特殊的物理碰撞器组件(例如 TerrainCollider, Cloth 和 WheelCollider)在某些情况下比所有基础碰撞器甚至网格碰撞器的消耗都要高上几个数量级,因此在项目中应该尽量避免。
使物理对象休眠
物理引擎的休眠特性会给开发人员带来一种错觉,即在游戏中增加一倍的刚体数量,其消耗的性能只会增加一倍。然而实际上,碰撞频率和活动物体的总累积时间更有可能以指数形式增加而不是线性形式。
休眠的物体还有可能产生岛屿效应的危险。当大量刚体相互接触,并随着系统动能的降低逐渐休眠变形成岛屿,然而由于它们之间依然相互接触,因此这些对象一旦被唤醒就会产生链式反应,唤醒周围所有的刚体,大量对象重新进入物理模拟将产生较大的 CPU 峰值。
确定何时使用物理
提高特性性能的最明显的方法是尽量避免使用它。对于游戏中所有可移动的物体,应该花点时间确认是否有必要使用物理引擎。
掌握内存管理
内存管理性能增强
垃圾回收策略
一种常见的策略是在游戏进行到合适的时机时手动触发垃圾回收。当然,最好的垃圾回收策略是避免垃圾回收。
使用 Resources.Load
会将资源数据存储在内存中,如果我们希望控制游戏的内存占用,就需要去清除当前游戏中没有用到的资源。Unity 虽然提供了 Resources.UnloadUnusedAssets
方法,但是它需要遍历所有的资源并检查其是否被项目引用。
更好的方法是使用 Resources.UnlockAsset
方法,一次卸载一个指定的资源。该方法速度更快,且不需要遍历资源列表。
字符串连接
字符串是不可变的引用类型,每一次的字符串修改都将导致新的 string 数据在堆上被创建。
在以下的测试代码中,不考虑编译器自动优化代码的情况下,使用 +
进行字符串合并将导致三次 string 的重新创建。
1string testText = "Hello" + "World" + "!" + "!";
使用 String.Format
与 StringBuilder
方法是更好字符串处理的方案。
装箱与拆箱
将值类型(如 int
、struct
)转换为 object
或接口类型(如 System.ValueType
、IComparable
)时,CLR 会在堆上分配内存,并将值类型数据复制到堆中。
在 C# 中,ArrayList
内部使用 object[]
存储数据,所有值类型存入时都会触发装箱(即使存储 int
也会转为 object
)。
在没有特殊需求的情况下,使用 List<T>
泛型数组是一个更好的方案,因为List<T>
是 .NET 2.0 引入的泛型集合,其底层使用 T[]
数组存储,而非 object[]
,因此在存储数据时不会触发装箱。
数据布局的重要性
为了增加 CPU 的缓存命中,有时候我们需要对代码中的数据结构进行优化。在缓存命中率上,结构体数组(Struct[]
)优于类数组(Class[]
)。同时,数据结构中的热数据(如位置、速度)应紧密排列,提供缓存命中率。
只要一个 Struct 中存储有一个引用类型的数据,那么 GC 就将关注 Struct 整个对象,以及它所有的成员数据和间接引用的对象,当 GC 触发时,需要移动之前验证对象的所有字段,这样造成相当大的性能损耗。
而如果我们将不同类型的数据分散到不同数组中,那么 GC 就可以跳过大量数据。
1// 使用 Struct 存储数据
2public struct TestStruct
3{
4 public int testInt;
5 public float testFloat;
6 public bool testBool;
7 public string testString;
8}
9
10var testStructArr = new TestStruct[1000]
11
12// 使用数组存储数据
13int[] testInt = new int[1000];
14float[] testFloat = new float[1000];
15bool[] testBool = new bool[1000];
16string[] testString = new string[1000];
对字典键使用 InstanceID
字典可以快速找出某个对象的映射,在游戏开发中,常见的一个做法是将 MonoBehaviour 或 ScriptableObject 引用作为字典的键,但这样会导致一个问题,当访问字典元素时需要调用 UnityEngine.Object
中继承的方法,这使得元素的比较和映射的获取相对较慢。
一个好的解决方案是使用 Object.GetInstanceID
进行改进,它返回一个整数,用于表示该对象的唯一标示值,在整个程序的生命周期中该值不会发生变化,也不会在两个对象之间重用。使用该方法比直接引用对象作为字典的键要快上两到三倍。
LINQ
在 项目使用中,LINQ 在使用上经常需要构建临时集合(例如在使用 Where 与 Select 等方法时),这将导致 GC 更容易被触发。
如果数据的查询发生在 Update,或者会被 Update 频繁调用的方法中,则可能造成严重的性能损耗。虽然新版的 Mono 优化了相关的实现,但是基本开销仍在,因此在性能敏感的函数中需要尽量避免 LINQ 的使用。
对象池
对象池是通过避免释放和重新分配来最小化和建立对内存使用的控制的一种极好的方案。
Reference
《Unity 游戏优化》