优化是游戏开发中很重要的一部分,涉及的方面主要有 CPU,GPU,内存,网络等。

在这篇博客中,主要会写关于内存相关的优化,Unity 是如何管理内存的,以及怎样才能使游戏更高效地运行。

在深入到 Unity 之前,我们先来看一下内存是如何工作的。

操作系统会在物理内存的基础上,创建一个虚拟内存,用于程序运行。在这个抽象中,会有一些虚拟地址空间(VAS)

在内存优化上,主要考虑两部分,也就是栈(Stack)和堆(Heap)。在理解栈和堆之前,首先要理解在 Unity 中,数据的类型,可分为值类型,和引用类型。对于值类型的数值,会直接存储在栈上,并且是可以直接复制的。例如,int, float, bool, struct, Color Vector3 等。

而引用类型的数据,则是存储在堆上,但是,指向这个数据的指针,是存储在栈上的。

栈(Stack)

  • 存储值类型
  • 存储引用类型的指针
  • 由 CPU 自动管理,无须程序员关心。在函数调用时,参数将自动申请内存放入栈中,当函数结束时,这些参数又会从栈取出,释放内存,这一切操作都是自动的。
  • 栈比堆的速度快
  • 栈的容量有限,如果超过上限,则会出现栈溢出。
  • 栈的运行方式是 Last in first out。也就是后进先出,或者说先进后出。

堆(Heap)

  • 可以存储引用类型和值类型的数据
  • 由程序员手动管理
  • 内存空间不一定是连续的
  • 比栈速度慢
  • 可以扩展空间大小

接下来,我们来理解什么是 “垃圾”,以及什么是 “垃圾回收”。

垃圾

存储在栈上的一个指向堆数据的指针结束时(释放时),堆上的数据没有用了,对于这样的对象,可以理解为 “垃圾”。

垃圾回收

为了解决垃圾内存的问题,.Net 有对应的垃圾回收系统

在初始化一个新的进程时,语言运行时会为该进程保留一个连续的地址空间区域。这个保留的地址空间称为托管堆。托管堆维持指向堆中下一个对象的地址指针。

从托管堆中分配内存,比非托管堆中分配内存要快。

垃圾回收系统会创建一个包含从根可访问的所有对相的图。当一个对象不在这个图中,也就是无法从根节点访问时,垃圾回收系统会将这部分内存视为垃圾,并在合适的时机释放为其分配的内存。

Unity 的内存管理系统

Unity 的内存管理分为三部分。

  • 托管内存

托管内存使用托管堆以及垃圾回收系统自动分配和释放内存,而提供这个功能的是 Mono 或 IL2CPP。在 Unity 中我们大部分时候使用的都是托管内存,所以 New 对象时,都不考虑释放,垃圾回收系统会帮我们处理掉。

  • C# 非托管内存

C#非托管内存层使你可以在编写C#代码的方便下访问本机内存层以微调存储器分配。

您可以在Unity Core API中使用Unity.CollectionsNamespace(包括NativeArray),以及Unity Collections软件包中的数据结构来访问C#非托管的内存。如果您使用Unity的 Job System 或 Brust,则必须使用C#非托管内存。

  • 原生内存

Unity用来运行引擎的c++内存。在大多数情况下,Unity用户是无法访问这些内存的,但是如果你想微调应用程序的某些方面的性能,了解这些内存是很有用的。

虽然上面的内容听起来很复杂,但其实确实不太容易理解。

托管堆由语言运行时自动管理,内存申请,内存释放,下面这个图可以方便理解。

当有一个对象结束时,垃圾回收系统会将这部分内存释放,但是不会重新排列内存。

当有更大的内存申请时,堆管理系统可能会执行下面两个操作之一

  1. 运行垃圾回收,释放掉该释放的内存,看看有没有满足新内存申请大小的空间
  2. 如果第一步失败了,那就扩展堆的大小

编码技巧

  • 使用 for 代替 foreach
  • 尽可能地重用 List 之类的数据结构,在重用之前调用 Clear()
  • 尽量避免 ()=> 这种匿名函数
  • 减少装箱拆箱操作(值类型转为引用,引用转为转为值类型)
    int value = 100;
    object obj = value;
    int num = (int)obj;
    
  • 对于字符串操作,可以使用 StringBuilder
  • 尽可能地缓存对象
  • 使用对象池来优化频繁创建和销毁的物品,例如游戏中的子弹

Unity 技巧

  • 使用 Addressable System 来加载和卸载对象
  • 压缩 Texture,图的大小尽量为2的次方,使用图集。
  • 没用的 Shader 功能去掉

技术交流,欢迎添加我的微信:ifloop