Unity GC优化

Unity GC优化

Unity内存管理

内存管理层级

  1. Managed Memory:一个受控的内存层次,使用受管理的堆和垃圾回收器来自动分配内存
  2. C# Unmanaged Memory:可以通过使用Unity Collections namespace中的数据结构来手动管理内存,不试用垃圾回收器
  3. Native Memory:Unity引擎自身试用的C++内存,一般不涉及优化。

==GC是在Managed Memory这个层级,所谓的GC优化,主要也是对堆上对象的分配和释放做优化==。

单次GC过程

查看所有引用类型对象,将它们所指向的内存标记为“活跃”,未被标记的内存空间将被释放,用于后续的内存分配。

根据GC执行的过程可以了解到,==堆上分配的引用类型对象越多,单次GC的开销越大==。

为什么需要进行GC优化

当触发GC时,帧率会短暂地下降,这是因为GC执行过程中,其他程序需要等待GC完成之后才能继续工作。

而且,由于在这个层次中,内存是被自动管理的,在没有主动接管GC的情况下,GC发生的时机是不受控也不可预测的。

简单点来说,==如果不进行GC优化,游戏可能会卡,而且不知道什么时候卡==。

如果==单次GC的执行时间长==,那么==游戏会卡得很厉害==。 如果==GC的频率很高==,那么==游戏会经常卡,动不动就卡==。

游戏卡顿会让玩家感觉体验很差,所以我们一定要进行GC优化来避免游戏卡顿。

GC优化方向

  1. 减少GC的运行时间 -> 降低游戏卡顿的严重程度
  2. 减少GC的频率 -> 降低游戏卡顿的频率
  3. 让GC发生在非敏感时间段 -> 避免在关键时候游戏卡了

前2个方向,其实指向同一个事情,那就是==尽量减少在堆上的分配==。

非敏感期手动GC

前一个关卡结束了,新关卡正在加载中的时候,属于非敏感期。 玩家角色正在激烈地与Boss战斗的时候,属于敏感期。

GC如果发生在非敏感期,即便出现帧率降低,对玩家的影响也会降低到几乎没有。 手动调用System.GC.Collect()做全量GC,这次GC是阻塞的 手动调用GarbageCollection.CollectIncremental 做Incremental GC GarbageCollector.Mode (Enabled; Disabled; Manual) Enabled自动GC;Disabled不执行任何GC,手动也不行;Manual支持手动GC

字符串优化

为什么要专门讨论优化字符串? 因为C#中的字符串是引用类型,且很常用,如果没有优化的意识,会徒增很多性能开销。 我们可能无意识地创建许多字符串,因为C#中字符串是不可修改的,每当我们使用连接符拼接两个字符串时,实际上创建了新的字符串。

  1. 缓存 如果要多次使用一个字符串,缓存它。
  2. 减少字符串拼接的使用 用多个文本控件来代替不必要的字符串维护。比如有一个TextMeshPro需要显示的text = “Time” + timer.ToString(),那么可以用一个TextMeshPro来显示Time,另一个来显示timer.ToString()
  3. 如果必须在运行时构建字符串,使用StringBuilder,如果可能,可以使用ZString三方库
  4. 在编译生产环境包时,删除所有Debug.Log()

微软字符串操作最佳实践:[[https://learn.microsoft.com/en-us/dotnet/standard/base-types/best-practices-strings?redirectedfrom=MSDN]] Unity官方最佳实践:[[https://docs.unity3d.com/cn/current/Manual/BestPracticeUnderstandingPerformanceInUnity5.html]]

ZString由Cygames的子公司Cysharp开发,其开发的Unitask和ZString均以Zero Allocation作为特色。https://github.com/Cysharp/ZString

所有的ZString方法仅为最终字符串分配内存,而且ZString可以支持访问内部缓存,所以如果需要输出字符串的组件提供了无字符串api(比如Unity TextMeshPro SetCharArray),那么可以做到字面意义上的零分配。

根据ZString的Github README,其通过9项特性实现了零分配

  1. 将StringBuilder转化为结构体来避免为Builder本身分配内存
  2. 从ThreadStatic或ArrayPool借用写入缓冲区
  3. 所有append方法都支持泛型,写入缓冲区而不是拼接value.ToString()
  4. T1~T16 AppendFormat(AppendFormat<T1,…,T16>(string format, T1 arg1, …, T16 arg16)来避免Struct开箱装箱
  5. T1~T16 Concat(Concat<T1,...,T16>(T1 arg1, ..., T16 arg16))来避免Struct开箱装箱
  6. ZString.Format/Concat/Join来代替String.Format/Concat/Join
  7. 直接支持UTF16和UTF8
  8. 可以访问内部缓存来避免为最终字符串分配内存
  9. 可以与Unity TextMeshPro集成以实现零分配

协程优化

在Unity中,协程是通过返回IEnumerator类型的函数实现的,这会涉及到临时对象分配。

当我们在调用StartCoroutine(MyCoroutine())时,Unity在堆上创建一个迭代器对象(协程的状态机),用于保存协程的执行状态,包括局部变量和当前的执行位置。 缓存复用协程实例

if (cachedCoroutine == null) cachedCoroutine = MyCoroutine(); StartCoroutine(cachedCoroutine);

如果在协程中,使用了yield return new WaitForSeconds(1),由于WaitForSeconds是类,每次使用时都会在堆上分配一个新的对象。其他yield类型也会进行类似分配。 缓存复用WaitForSeconds

private readonly WaitForSeconds cachedWait = new WaitForSeconds(1f); 
IEnumerator MyCoroutine() { yield return cachedWait; }

如果协程中使用了闭包(比如捕获了外部变量),也会导致额外的对象分配。闭包会创建一个新的类实例来保存这些变量。如果协程需要使用一些外部变量,可以将这些变量作为参数传递给它,而不是直接在闭包中引用外部变量。

IEnumerator MyCoroutine(float delay) { yield return new WaitForSeconds(delay);}

避免在Update中高频调用协程 使用UniTask,来替代Unity协程 https://github.com/Cysharp/UniTask

装箱

避免所有将值类型赋值给object类型的场合 使用泛型的List<T>来代替非泛型的ArrayList 避免将值类型传递给object参数

函数调用

RaycastRaycastNonAlloc Physics.Raycast(origin, direction, out hit, maxDistance) 如果 out 参数是一个引用类型,并且在方法内部创建了新的对象,则会导致内存分配。

void CreateObject(out MyClass obj) 
{ 
	obj = new MyClass(); // 这里会导致内存分配 
} 

MyClass myObject; 
CreateObject(out myObject); // 会导致内存分配

使用RaycastNonAlloc则不会在函数内部产生内存分配

RaycastHit[] hits = new RaycastHit[10]; // 预分配数组 
int hitCount = Physics.RaycastNonAlloc(origin, direction, hits, maxDistance);
使用 Hugo 构建
主题 StackJimmy 设计