Unity游戏内存优化

Unity 游戏内存优化

Unity 内存按照分配方式分为:Native Memory(原生内存)和 Managed Memory(托管内存)。Native Memory 并不会被系统自动管理,需要我们手动去释放。而 Managed Memory 的内存管理是自动的,会通过 GC 来释放。

优化 Managed Memory

Managed Memory 简介

Unity 中,Mono 虚拟机的内存池,我们的内存以 Block 的形式管理,当一个 Block 连续 6 次 GC 没 有被访问到,这块内存会被返回给系统,条件苛刻,比较难触发。

一般来说,Managed Memory 可以分为栈 (Stack) 和堆 (Heap)。栈的内存大小较小,是用于存储值类型和函数。堆的内存更大,用于存储所有的引用类型。栈的内存大小通常在游戏运行时已经进行分配好了,不会在游戏过程中动态变化,通常只需要注意嵌套的函数不能太多,导致 StackOverflow。堆的内存大小是可变化的,通常我们每创建一个新的对象,会在堆积中找到下一个足够存放的空位置,将其存储。但是当我们销毁对象后,内存空间不会马上释放出来,而是标记成未使用,之后垃圾收集器会释放这部分空间。对象实例化和摧毁的过程很慢。如果需要的内存比之前已经配置好的还多,在放不下的情况下,堆积会膨胀,并且每次都增长两倍,且不会再缩回去,过大的堆积就会影响到我们游戏的性能。此外,当一些占用内存小的对象被释放后,会导致内存变得断断续续,一些大的内存放不下,就会导致内存空间的浪费。

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

具体优化措施

避免在 Update 和其他频繁调用的函数中分配内存

尽量避免在每帧中创建新对象、数组、委托或使用 string 类的操作(如拼接)。

使用对象池

在游戏程序中,创建和销毁对象事很常见的操作,通常会通过 Instantiate 和 Destroy 方法来实现,如果频繁的进行这些操作,GC 的时候会导致负载很重,因为会有大量的已摧毁对象的存在,不仅会造成 CPU 的负载峰值,还可能导致堆积碎片化。因此我们可以使用对象池来处理这类问题。

使用对象池时需要注意,要决定对象池的大小,以及一开始要产生多少数量的对象在池中。因为如果你需要的对象数量多过池中现有的,就必须将对象池变大,扩的太大可能造成浪费,扩的小可能又造成频繁的添加。

Destroy 与 null

Destroy 用于显式地销毁游戏对象,而将引用设置为 null 仅仅是移除了对对象的引用。

  • 当你想从游戏中完全移除一个对象时,应该使用 Destroy。这适用于不再需要的游戏对象,比如被玩家摧毁的敌人或使用完毕的临时效果。
  • 如果你只是想释放一个引用,并且不关心对象是否仍然存在于场景中,可以将引用设置为 null。这在处理临时引用时有用,但请记住,这不会影响对象的生命周期。
  • 在内存管理方面,合理使用 Destroynull 可以帮助避免内存泄漏和其他资源管理问题。

Class 和 Struct

  • Class 是引用类型,实例化涉及动态内存分配,内存管理由垃圾回收器(GC)处理,可能导致 GC 压力增加。一般适用于比较复杂的或者较大的数据结构,或者需要对象的生命周期跨越多个方法调用。
  • Struct 是值类型,直接存储在栈上,结构体的创建和销毁通常比类更快,因为它们通常在栈上分配。结构体不受垃圾回收器管理,所以不会对 GC 造成压力。适用于小型、简单的数据结构,尤其是当这些数据需要频繁创建和销毁时。

如何选择

  • 数据大小和复杂性:对于较大或更复杂的对象,类是更好的选择。对于小型、不可变的数据结构,结构体可能更合适。
  • 生命周期和共享:如果你需要在多个对象间共享或引用同一个实例,使用类。如果你需要的是数据的副本而非引用,那么结构体更合适。
  • 性能考虑:频繁创建和销毁大量小型对象时,结构体可能因为较小的 GC 压力而更高效。

减少装箱拆箱操作

装箱和拆箱是值类型和引用类型之间转换的过程,这两个过程在内存和性能上有一定的开销。 例如 LINQ 和常量表达式以装箱的方式实现,String.Format() 也常常会产生装箱操作等。

闭包和匿名函数

实现方式

  • 匿名函数:这是没有名称的函数,通常用于简短的操作,如 LINQ 查询或事件处理器。
  • 闭包:是指那些捕获了外部作用域中一个或多个变量的匿名函数。闭包“记住”了它被创建时的环境。

内存占用

  • 当编译器遇到闭包或匿名函数时,它会生成一个或多个类来支持这些结构。这些类存储了闭包捕获的变量和匿名函数的代码。
  • 这意味着每当你使用闭包或匿名函数时,实际上都会创建一个新的对象实例。这些对象需要在堆上分配内存,并由垃圾回收器管理。

如何理解

  • 灵活性与成本:使用闭包和匿名函数提供了极大的编码灵活性和表达力,但这是以增加内存使用为代价的。
  • 适度使用:在性能关键的代码路径中,特别是在频繁调用的循环或高频更新方法(如 Unity 的 Update 方法)中,应谨慎使用闭包和匿名函数。
  • 性能分析:如果你的应用遇到性能问题,使用性能分析工具来检查是否有大量闭包和匿名函数的创建,这可能是优化的一个方向。

协程

协程属于闭包和匿名函数的特例。游戏开始启动一个协程直到游戏结束才释放是是错误的做法。因为协程只要没被释放,里面的所有变量,即使是局部变量(包括值类型),也都会在内存里。建议用的时候才生产一个协程,不用的时候就丢掉(StopCoroutine)。

单例

慎用单例,且不要什么都往里放,因为里面的变量会一直占用内存。

Scriptable Objects

Scriptable Objects 是一种特殊的数据容器,它们用于存储大量数据,但不需要附加到游戏对象(GameObjects)上。与附加到游戏对象的传统组件(如 MonoBehaviour)不同,Scriptable Objects 不需要一个游戏对象即可存在,它们提供了一种更轻量级的、不依赖于场景的方式来处理数据和行为。

  • 特性和用途
  1. 数据存储Scriptable Objects 非常适合用于存储不频繁变化的数据,如配置文件、游戏设置、预设等。
  2. 内存效率:由于它们不需要附加到游戏对象上,Scriptable Objects 可以更高效地管理内存,尤其是在处理大量数据时。
  3. 共享和重用数据:可以在不同的游戏对象或系统之间共享和重用 Scriptable Objects,这有助于减少重复和提高数据一致性。
  4. 独立于场景Scriptable Objects 不依赖于特定的场景,因此可以在多个场景和项目之间共享。

属性与变量

属性相对于变量有封装性、可扩展性、数据绑定支持、接口实现等优点。但是在调用时和函数一样会在栈上分配内存。一般情况下不需要考虑,除非循环嵌套。

缓存一些哈希值

在我们想要在运行时修改动画或者材质的时候,可以使用下面方法来实现

animator.SetTrigger("Idle");
material.SetColor("Color", Color.white);

这类方法往往也可以通过索引来作为参数,使用字符串只是能显示的更加直观,但是当我们传递字符串时,程序内部会进行一些处理,频繁调用的话可能就会造成性能的消耗。因此我们可以先找到对应的索引,并将其缓存起来,供后续使用,如下:

int idleHash = Animator.StringToHash("Idle");
animator.SetTrigger(idleHash);
int colorId = Shader.PropertyToID("Color");
material.SetColor(colorId, Color.white);

整体而言,意义不是特别大,除非每帧调用。

缓存引用对象

例如我们常常会在游戏运行的时候去查找一些对象,GameObject.Find 与其他所有关联的方法,需要遍历所有内存中的游戏对象以及组件,因此在复杂场景中,效率会很低。GameObject.GetComponent,会查询所有附加到 GameObject 上的组件,组件越多,GetComponent 的成本就越高。若使用的是 GetComponentInChildren,随着查询变复杂,成本会更高。

因此不要多次查询相同的对象或组件,而且查询一次后将其缓存起来,方便后续的使用。

优化 Native Memory

Native Memory 简介

Allocator 与 Memory Lable

Unity 在里面重载了 C++ 的所有分配内存的操作符,例如 alloc,new 等。每个操作符在被使用的时候要求有一个额外的参数就是 Memory Lable,Profilter 中查看 Memory Detailed 里的 Name 很多就是 Memory Label。它指的就是当前的这一块内存内存要分配到哪个类型池里。

GetRuntimeMemory

Unity 在底层会用 Allocator,使用重载过的分配符分配内存的时候,会根据 Memory Lable 分配到不同的 Allocator 池里面。每个 Allocator 池,单独做自己的跟踪。当我们要在 Runtime 去 Get 一个 Memory Lable 下面池的时候,可以从对应的 Allocator 中取,可以从中知道有什么东西,有多少兆。

NewAsRoot

前面提到的 Allocator 的生成是使用 NewAsRoot,生成一个所谓的 Memory Island,它下面会有很多的子内存。例如一个 Shader,当我们加载一个 shader 进内存的时候,首先会生成一个 shader 的 Root,也就是 Memory Island。然后 Shader 底下的数据,例如 Subshader,Pass,Properties 等,会作为该 Root 底下的成员,依次的分配。所以我们最后统计 Runtime 的内存时,统计这些 Root 即可。

会及时返还给系统

因为是 C++ 的,所以当我们去 delete 或 free 一个内存的时候,会立刻返回给系统

具体优化措施

Scene

导致 Native Memory 增长的原因,最常见的就是 Scene。因为是 c++ 引擎,所有的实体最终都会反映在 c++ 上,而不会反映在托管堆上。所以当我们构建一个 GameObject 的时候,实际上在 Unity 的底层会构建一个或多个 object 来存储这一个 GameObject 的信息(Component 信息等)。所以当一个 Scene 里面有过多的 GameObject 存在的时候,Native Memory 就会显著的上升,甚至可能导致内存溢出

注:当我们发现 Native Memory 大量上升时,可以先着重检查我们的 Scene。

各类资源的压缩与配置

Audio

音频资源可以开启强制单声道,设置合适的压缩格式,降低源文件的比特率。 DSP Buffer:DSP Buffer,是指一个声音的缓冲,当一个声音要播放的时候,需要向 CPU 去发送指令。如果声音的数据量非常的小,会造成频繁的向 CPU 发指令,造成 IO 压力。在 Unity 的 FMOD 声音引擎里面,一般会有一个 Buffer,当 Buffer 填充满了才会去向 CPU 发送一次播放声音的指令。

Texture

没有特殊需求就关闭Read/Write、​Mip MapsUpload Buffer:在 Unity 的 Quality 里设置如图,和声音的 Buffer 类似,填满后向 GPU push 一次。 对于不透明的纹理,​​关闭alpha配置。 纹理的大小尽量为 2 的幂次方。 Android 设备运行平台要求支持 OpenGL ES 3.0 的使用 ETC2,RGB 压缩为 RGB Compressed ETC2 4bits,RGBA 压缩为 RGBA Compressed ETC2 8bits。需要兼容 OpenGL ES 2.0 的使用 ETC,RGB 压缩为 RGB Compressed ETC 4bits,RGBA 压缩为 RGBA 16bits。(压缩大小不能接受的情况下,压缩为 2 张 RGB Compressed ETC 4bits) IOS 设备运行平台要求支持 OpenGL ES 3.0 的使用 ASTC,RGB 压缩为 RGB CompressedASTC 6x6 block,RGBA 压缩为 RGBA Compressed ASTC 4x4 block。对于法线贴图的压缩精度较高可以选择 RGB CompressedASTC 5x5 block。需要兼容 OpenGLES 2.0 的使用 PVRTC,RGB 压缩为 PVRTC 4bits,RGBA 压缩为 RGBA 16bits。(压缩大小不能接受的情况下,压缩为 2 张 RGB Compressed PVRTC 4bits)

Mesh

Read/Write:同 Texture,若开启,Unity 会存储两份 Mesh,导致运行时的内存用量变成两倍。 Compression:Mesh Compression 是使用压缩算法,将 Mesh 数据进行压缩,结果是会减少占用硬盘的空间,但是在 Runtime 的时候会被解压为原始精度的数据,因此内存占用并不会减少。需要注意的是有些版本开了,实际解压之后内存占用大小会更严重。 如果没有使用动画,请关闭 Rig,例如房子,石头这些。 如果没有用到 Blendshapes,也关闭。 如果 Material 没有用到法向量和切线信息,关闭可以减少额外信息。

Code Size

代码也是占内存的,需要加载进内存执行。模板泛型的滥用,会影响到 Code Size 以及打包速度(IL2CPP 编译速度,单一一个 cpp 文件编译的话没办法并行的)。例如一个模板函数有四五个不同的泛型参数(float,int,double 等),最后展开一个 cpp 文件可能会很大。因为实际上 c++ 编译的时候我们用的所有的 Class,所有的 Template 最终都会被展开成静态类型。因此当模板函数有很多排列组合时,最后编译会得到所有的排列组合代码,导致文件很大。

Resource

Resource 文件夹里的内容被打进包的时候会做一个红黑树(R-B Tree)用做索引,即检索资源到底在什么位置。所以 Resource 越大,红黑树越大,它不可卸载,并在刚刚加载游戏的时候就会被一直加在内存里,极大的拖慢游戏的启动时间,因为红黑树没有分析和加载完,游戏是不会启动的,并造成持续的内存压力。所以建议不要使用 Resource,使用 AssetBundle。

按需加载和卸载资源

不需要的资源及时卸载,多利用 profiler 查看是否还有没有卸载的图集资源。

其余优化方向(非内存)

GameObject 的层次结构

某些情况下,场景中的物体可能有很深的嵌套结构,当我们对父节点的 GameObject 进行坐标转换时,就会产生 OnTransformChanged 事件,这消息会传递给该 GameObject 下所有子对象,即使这些对象没有任何渲染组件(也就是我们看不见任何变化),造成一些不必要的转换运算,包括平移,旋转和缩放。同时,尽量减少堆 transform 的移动等操作。

此外,较深的结构也会导致在 GC 时,花费更多的时间在层级结构间遍历。

避免在 Awake 和 Start 中添加大量的逻辑

这对游戏启动很重要,Unity 会在 Awake 和 Start 方法执行后渲染第一个画面,某些情况可能会导致启动画面或是载入画面需要花更长的时间渲染,因为你必须等每个游戏对象都完成 Awake 和 Start 的执行。

删除空的 Unity 事件

Monobehaviour 中的 Start,Update 这些方法即使是空的,也会带来些微的性能消耗,因此若为空,就删除它们。

避免添加组件

在运行时调用 AddComponent 其实很没效率,尤其在一帧中多次启用这类调用。

当我们添加一个组件的时候,Unity 会做下列操作:

  • 先看组件有没有 DisallowMultipleComponent 的设置,如果有,就要去检查是否有同类型的组件已加入
  • 然后检查 RequireComponent 设置是否存在,如果设置了,就代表这个组件需要别的组件同步加入(重复做添加组件的操作)
  • 最后调用所有被加入的 MonoBehaviour 的 Awake 方法

上述这些步骤都发生在堆积上,所以可能会影响性能和增加 GC 的处理时间。