0%

【学习记录】—— Unity部分性能优化

实习笔记——Unity 性能优化

本文内容是对以下两个官方教程视频的笔记整理,内容广泛但不深入,可以看作一个目录框架,并且视频发布于 2020.7,时至今日,Unity 经过多次版本更新,有些方法可能已经过时或失效

[Unity 活动]-Unite Now - (中文字幕)性能优化技巧(上)

[Unity 活动]-Unite Now - (中文字幕)性能优化技巧(下)

1、分析工具 (Profiling tools )

关于分析工具,记住一定要在目标设备上进行分析,在工作设备上的分析结果可能会和目标设备上的实际情况有所出入。

【Unity Editor Profiler】

性能分析器(Profiler)是 Unity 内部集成的一款性能优化工具,可以检查脚本代码,查看运行过程中资源使用情况,边开发边了解资源分配的情况,还可以比较不同平台上的性能。但是在运行过程中可能会增加一些性能消耗,降低程序运行速度。

在 Edit->Preference->Analysis->Profiler 进行基本设置
2.png

在 Window->Analysis->Profiler 中打开
1.png

【Memory Profiler 】

功能:

  • 进一步分析应用程序的内存使用状况
  • 比较不同时间的快照,以便找出内存泄漏
  • 查看内存配置碎片化的问题

详情可见 Memory Profiler 内存分析器使用方法

【Frame Debugger】

是时比较常用的工具,可以逐帧分析 Draw Call 等渲染步骤的细节
在 Window->Analysis->Frame Debugger 中打开

3.png

以上是 Unity 提供的工具,除此之外还有一些其他厂商提供的工具

4.png

2、栈(stack)与堆(heap)

【Stack】
栈是内存中存储函数和值类型的地方,当我们调用一个函数时,会将该函数体与参数 push 到堆栈中,如果该函数中调用了其他函数,就会继续将那个函数 push 到栈中,知道函数执行完毕后,才会将其 pop 出去,因此我们在看 Debug 信息的时候,就会发现 Log 里面能够做到一层层的方法回溯,方便我们查看整体的调用过程,这也就是栈回溯。
在这里不会遇到碎片化或者垃圾收集的问题,但是如果调用了太多的函数,一直 push 进栈却没有 pop,最后会导致栈溢出

【Heap】
堆是内存中另一个区域,比栈大。我们将所有的类别、实体和对象存放在这。通常我们每创建一个新的对象,会在堆中找到下一个足够存放的空位置将其存储。但是当我们销毁对象后,内存空间不会马上释放出来,而是标记成未使用,之后交由垃圾收集器去释放这部分内存空间。
对象实例化和摧毁的过程其实很慢,所以我们要尽可能的避免在堆中配置内存的行为

3、垃圾收集器 (Garbage Collection)

是用于清理先前配置的内存的机制,它的工作是对所有处于堆上的对象或内存遍历一次,找到需要被释放的东西

每一次 GC,都会遍历堆积上所有的对象,找到需要释放的东西,也就是没有被引用的对象,然后将其释放。但是有时候我们的一些错误引用,导致一些我们希望释放掉的对象没有被 GC 掉,那么就会造成内存泄漏。

选择正确的数据结构

获取多个游戏对象实体,我们使用特定的数据结构来呈现数据和对象

1
2
3
4
5
GameObject[] m_NetworkedObjects;
or
Dictionary<int, GameObject> m_NetworkedObjects;
or
List<>() m_NetworkedObjects;

在阵列或串列结构中使用索引的成本很低,但是增加或移除对象时,将比在字典结构中进行更昂贵,所以要根据需求使用的合适的数据结构

4、对象池 (Object pooling)

为什么需要对象池?

在游戏中,创建和销毁对象是十分常见的操作,通常我们是使用游戏对象的实例化 Instantiate() 和摧毁 Destroy() 来实现的,但是如果太过频繁的执行这个行为,每次垃圾收集器执行时其负载就会增加,因为在很多时间点上都会有大量的已摧毁物体存在,这会造成 CPU 负载峰值并导致堆碎片化

原理

在真正需要对象之前,先把它们实例化好,然后将其摧毁再创建新的对象,我们要做的是回收对象,将其藏在某处,恢复对象的初始参数,在用到的时候再把它们放到相应的位置。
注意点:

  • 对象池的大小(要满足需求)
  • 一开始要产生多少数量的对象池在池中

可以使用单例模式来设计对象池

5、脚本化对象 (Scriptable Objects)

(1)基本介绍

Scriptable Objects 是用来存储数据的一个资源文件,有着资源文件的特性,可以用来存储数据。

如果项目中使用了 prefab,并在其内的 MonoBehaviour 中存放固定数据,如下

1
2
3
4
5
public class AB : MonoBehaviour
{
public float a;
public float b;
}

有一个叫 AB 的 MonoBehaviour 对象,里面有 a,b 两个变量,如果我有很多这样的对象,并且 a,b 在每个游戏对象里都通用的话,就能用它们来做游戏参数设置,每次实例化这个 prefab 时,其组件中的这些数据就会重复一份,相当于让这两个浮点数重复出现在有此脚本的 AB 对象上,但是它们的数值都一样,这就造成了不必要的重复。
当遇到这种情况时,就可以改用 Scriptable Objects

1
2
3
4
5
6
7
8
9
10
public class ABConfig : ScriptableObject
{
public float a;
public float b;
}

public class AB : MonoBehaviour
{
public ABConfig m_ABConfig;
}

此时让我们的 prefab 去参考它,就只需要耗费一组这样数据的内存,每个 AB 对象都会指向同一个 Scriptable Object 对象以做参数设置之用,即使我们有几千个 AB 对象,也只需要花费两个浮点数的内存。

注:

MonoBehaviour 是以组件形式挂在 GameObject 上的,而 ScriptableObject 则以 Assets 资源的形式存在的

6、变量或属性 (Variables or properties)

为了封装的安全性,在写程序时让对象属性通过 getter/setter 产生,所以对象属性基本上是方法调用,调用太多次时,花费在栈中时间就会增加。如果发生在频繁执行的循环中,就需要考虑对其优化。
【优化建议】

可以利用 #if 这类的前置指令来处理

1
2
3
4
5
6
#if DEVELOPMENT_BUILD
private int health;
public int Health { get { return health; } }
#else
public int Health;
#endif

如果还在开在开发中,或者程序在编辑器中运行,那就都采用对象属性访问的方式。

#else 代表如果在设备上执行已发布的构建版本,那就把它当普通的变量来使用

7、Resources 文件夹

代码中的资源管理通常会使用 Resources 文件夹以及下面两种方法

1
2
Resources.Load(...);
Resources.UnloadUnusedAssets();

这些方法有常见的坑:

  • Resources 文件夹里的所有资源都会和游戏一起打包,即使并没有用到
  • Resources 中的资源数量会直接影响游戏的启动时间

【优化建议】

不要直接使用 Resources 文件夹,而是改用 Addressable 资源系统,以更有效率的方式管理资源的载入和卸载

8、移除空的事件函数

例如:

1
2
3
4
5
6
7
8
9
10
11
12
void Start()
{

}
void Update()
{

}
void Awake()
{

}

即使空的函数,有时也会带来微性能消耗,应该将其删除

9、避免在 Start() 和 Awake() 中写入负载很重的初始化逻辑

如果在 Start() 和 Awake() 中写入了负载很重的初始化逻辑,游戏的启动画面或者载入画面将需要花更长的时间渲染,用户将会看到长时间的黑屏,因为你必须等每个游戏对象都完成 Start 和 Awake 执行

Unity 会在第一个 Awake() 和 Start() 方法执行后渲染第一个画面

可以先简单呈现一个东西,然后再开始其他对象初始化的步骤

10、Hash the value instead

如果我们从代码指定参数或是指定材质和着色器的属性,例如

1
2
3
4
5
animator.SetTrigger("Jump");

material.SetTexture("_MainMap", selectedTexture);

shader.SetGlobalColor("_MainColor", selectedColor);

我们常用包含属性名称的字符串当参数去调用对应的方法,对于程序员来说方便且好看,然而在 Unity 内部根本不用这些字符串,而会把它们杂凑成一个对应属性代号的整数,如果我们把这些函数写在 Update()等频繁调用的函数中,Unity 实际上会反复进行杂凑运算,造成不必要的性能消耗。

【优化建议】

一开始算出杂凑值,然后直接使用可以传入整数代号的重载方法

1
2
3
4
5
6
7
8
9
int parameterId = Animator.StringToHash("Jump");
animator.SetTrigger(parameterID);

int propertyId = Shader.PropertyToID("_MainMap");
material.SetTexture(propertyId, selectedTexture);

int propertyId = Shader.PropertyToID("_MainColor");
shader.SetGlobalColor(propertyId, selectedColor);

广泛使用可以省下很多处理器的时间

11、减少层级架构的复杂性

6.png

处于某些原因,我们的场景中可能有很深的嵌套结构,当我们对有许多子物体的父物体进行平移、旋转、缩放等位置改变时,即使它的子对象在转换前后看不出有什么变化,我们还是造成了不必要的转换运算。较深的层级结构会让垃圾收集器花更多时间在层级结构之间遍历。
【优化建议】
尽量避免很深的层级结构,将那些真正需要坐标转换的对象从不需要坐标转换的对象中分离出来,这也可以加快垃圾收集器的处理时间

12、Accelerometer Frequency (没用过这玩意,不是很懂)

这个功能定义 Unity 从设备读取加速度仪信息的频率,在不需要这个功能的项目中可以关闭,即使有用到也尽量开到最低。

13、移动游戏对象

Unity 中有很多移动对象的方法,如下

1
2
3
4
5
void Update()
{
transform.Translate(...);
}

如果对象需要使用碰撞判定,我们则会加上刚体组件与碰撞体,通过坐标转换移动有刚体组件的对象时,会造成 PhysX 物理引擎整体重新计算,对于复杂的场景成本很高。

【优化建议】

如果要移动一个有刚体组件的对象,使用 Rigidbody 类提供的方法,例如:

1
2
3
4
5
void FixedUpdate()
{
rigidBody.MovePosition();
}

14、GameObject.AddComponent(…)

当我们在运行期间将对象实例化时,常用增加组件来定义对象的行为

1
2
3
4
5
6
7
GameObject newBarrel = Instantiate(m_Template);

newBarrel.AddComponent(typeof(XXXX));
newBarrel.AddComponent(typeof(XXXX));
newBarrel.AddComponent(typeof(XXXX));
newBarrel.AddComponent(typeof(XXXX));

实例化一个 Barrel 游戏对象时,如果要增加一些组件,就一直要调用 AddComponent() 函数,在运行期间调用该函数效率很低。当我们在运行期间增加组件时,Unity 会做如下事情:

1、检查组件有无 DisallowMultipleComponent 的设置,如果有,还要去检查是否有同类组件加入
2、检查是否存在 RequireComponent 设置,若存在,就代表这个组件需要别的组件同步加入,然后必须加入那些组件,然后再重复一遍上述的检查
3、还需要调用所有被加入的 MonoBehaviour 的 Awake 方法

上述步骤都发生在堆(heap)上 ,会增加垃圾收集器的处理时间,影响性能

【优化建议】

尽可能避免在执行期间加入组件

15、为参照建立缓存 (Cache your references)

找到场景中的游戏对象是很常见的需求,对象可能是运行阶段或初始化阶段产生的。

1
2
GameObject.Find(...);
GameObject.GetComponent(...);

GameObject.Find() 一类的方法需要 Unity 遍历所有内存中的游戏对象以及组件,在复杂场景中相当低效。
GameObject.GetComponent() 会查询所有附加到游戏对象上的组件,增加运行阶段的成本

【优化建议】

为刚找到的对象参照建立缓存

1
2
3
4
5
void Update()
{
GameObject go = GameObject.Find();
go.Something();
}

也就是只调用一次 Find,充分利用结果,缓存最好在 Start 方法内建立

1
2
3
4
5
6
7
8
9
10
11
12
GameObject go;

void Start()
{
go = GameObject.Find();
}

void Update()
{
go.Something();
}

当找到那个对象后,整个游戏就只使用缓存的版本

16、纹理导入的设置

目的: 尽可能减少文件大小并保证视觉效果,即在磁盘、空间以及视觉效果之间取得平衡

  • 根据平台的不同,将纹理大小上限设为该平台的最小值
  • 确定纹理大小是 2 的幂次方,因为某些压缩格式可能无法支持非 2 的幂次方的压缩,具体可见纹理压缩,同时尽可能将多张纹理合并为大图。
  • 对于背景纹理或者其他不透明的纹理,可以将其 Alpha Channel 移除
  • 如果不需要从代码访问纹理的底层数据,可以将 Read/Write Enabled 选项取消
  • 如果较低的 16bit 的颜色格式就已经足够,则不需要使用 32bit 格式
  • 对于相机的 Z 值改变不会有任何变化的纹理(例如 UI 等),可以将 Mipmaps 关闭

17、mesh 网格的导入设置

若 3D 资源导入不当,可能会造成文件占用过大、执行期内存使用过高等后果,在保证视觉效果的情况下尽可能提高压缩程度

  • Read/Write Enabled 选项如果被开启,Unity 将会存储两份 Mesh,导致执行期内存用量变为两倍
  • 如果该 3D 资源没有使用动画,可以将 Rigs 关闭,例如场景中的房子等
  • 对于法线向量和切线向量,如果该模型的材质(具体可以是材质所用的 Shader)没有使用它们,也可以将其关闭。

18、图形部分

  • 降低 DrawCall(大坑)
  • 尽量减少不必要的阴影,一个带有 MeshRender 组件的物体会自动开启阴影效果,如果有无该阴影对场景影响不大,可以将其关闭

19、某些组件的 RayCaster 选项

该组件是用来处理输入事件的,例如触控或者鼠标点击到游戏对象,有时对于那些应该不能互动(也就是点了没反应)也附带了 RayCaster 组件。因此,当每次鼠标点击或者触控时,系统就需要遍历所有可能接受输入事件的 UI 元素,这就会有许多“点击位置或触摸位置是否落在矩形当中的”的检查,来判断点击或触摸该对象是否应该做出反应。在 UI 相当复杂的情况下,这个运算的成本就会很高。所以应该确保只有那些具备可互动功能的组件开启 RayCaster,以减少 CPU 运行时间和不必要的评估。

20、全屏 UI

有时对主画面进行展示时,会对其他 UI 元素或者集合对象进行遮蔽,此时虽然我们并没有看到场景中的 3D 对象,但是 CPU 和 GPU 还是会有运行成本,为了减少 CPU 和 GPU 的运行消耗,可以做出如下优化:

  • 将渲染 3D 场景的摄像机关掉。对于被完全遮住的部分,直接关闭渲染该部分的摄像机。建议关闭 canvas 组件而不是游戏对象本身,这样在下次重新出现时,能减少运行处理时间。
  • 隐藏被遮挡掉的其他 UI
  • 尽可能降低帧率,如果当前 UI 是静态的,或者动画帧率较低,就没必要再把帧率维持在 60fps