Unity对象池详解

对象池模式是一种设计模式,通常用于管理和重用对象以提高性能和资源利用率。这模式非常适合在游戏开发中,特别是在处理大量相似对象实例的情况下。对象池模式的主要思想是在需要对象时从一个预先创建的对象池中获取对象,而不是在需要时创建新的对象实例。这可以减少内存分配和垃圾回收的开销,提高性能。今天我们就来详细介绍一下 Unity 中的对象池。

本篇文章使用的示例来自于B站阿严老师,将会结合提供的例子对对象池进行详细的解释。B站主页指路https://space.bilibili.com/27164588,Github项目指路https://github.com/AtCloudStudio/UnityObjectPoolTutorial

什么是对象池

通常,在需要用到某个对象的时候,我们会分配一小块内存空间,new一个对象出来。示例:Object object = new Object();,在这个对象完成它的任务之后,我们将这个对象销毁,释放掉它占用的内存。
在Unity引擎中,这个过程就是我们需要一个对象时,通过实例化来生成,不需要这个对象时,通过 Destroy 进行销毁,Unity 引擎的垃圾回收系统会自动为我们完成 GC 的工作。

1
2
3
4
//实例化对象
GameObject gameObject = Instantiate(prefab);
//销毁游戏对象
Destroy(gameObject);

但这样做有一个明显的问题,当游戏中需要频繁地创建和销毁游戏对象时,GC 就会消耗较多的运行性能,耗费大量的 CPU 时间。例如对一个横板射击游戏,我们如果频繁地同时生成成百上千个子弹,再频繁地销毁,那么游戏就会变得十分卡顿,甚至崩溃。

而对象池就是一种用于在重复创建和销毁游戏对象时,减少资源和内存开销常用的性能优化技术。对象池的原理很简单,在程序运行的初始阶段,先取出一块完整的内存空间,用来预先创建一组游戏对象,让它们预备好随时被启用;当需要一个对象时,我们不再new一个新对象,而是从池中获取一个空闲的对象,对其进行配置和激活;当我们不再需要这个对象时,对象池并不销毁对象,而是将对象设置为不活跃状态,回收到池中,下次需要的时候再次从池中拿出来。这种重用机制减少了内存分配和垃圾回收的开销,提高了游戏性能和资源利用。

为什么使用对象池

使用对象池的主要原因是优化游戏性能和资源管理。

  • 减少内存分配和垃圾回收:在游戏中频繁创建和销毁对象会导致内存分配和垃圾回收,这些操作会占用宝贵的CPU时间并引起性能下降。对象池通过重用已创建的对象,减少了这些开销。

  • 提高性能:频繁的对象实例化和销毁可能会导致游戏的帧率下降,特别是在移动设备等资源有限的平台上。对象池可以减轻这种性能问题,使游戏更流畅。

  • 避免资源加载和卸载:对象池在游戏启动时一次性加载对象,而不是在需要时加载。这可以减少加载和卸载资源的次数,从而加快游戏启动时间和场景切换。

  • 平滑游戏体验:使用对象池可以避免对象创建时的瞬间性能峰值,使游戏体验更平滑,不会有明显的卡顿。

  • 快速对象重用:对象池允许您快速获取已创建并配置好的对象,节省了初始化和配置对象的时间,特别是在需要频繁生成相似对象的情况下。

  • 资源节约:通过重复使用对象,可以降低游戏的内存占用,从而允许游戏在资源受限的环境中运行。

Unity ObjectPool详解

命名空间

1
using UnityEngine.Pool;

构造方法

1
2
3
4
5
6
7
public ObjectPool(Func<T> createFunc, 
Action<T> actionOnGet = null,
Action<T> actionOnRelease = null,
Action<T> actionOnDestroy = null,
bool collectionCheck = true,
int defaultCapacity = 10,
int maxSize = 10000);

参数列表解释

  • Func<T> createFunc
    这是一个委托,用于自定义在需要新对象时如何创建对象的方法。需填入一个带泛型T类型返回值的方法,返回一个类型为 T 的新对象。例如,可以传递一个 lambda 表达式,或者引用一个创建新对象的方法。
  • Action <T> actionOnGet
    这是一个委托,定义在从池中获取对象(出池)时应执行的操作。它通常用于初始化对象的状态,例如设置位置、旋转等。可以将其留空(null)以不执行任何操作。
  • Action <T> actionOnRelease
    这是一个委托,定义在将对象放回到池中(进池)时应执行的操作。它通常用于重置对象的状态,以备下次重用。可以将其留空(null)以不执行任何操作。
  • Action <T> actionOnDestroy
    这是一个委托,定义在销毁对象时应执行的操作,比如在 actionOnDestroy 中通过 Destroy() 方法将因为池满而不能入池的对象直接删除,可以将其留空(null)以不执行任何操作。
  • bool collectionCheck
    设定是否开启对象回收检测功能,设为真则在进池前检查对象是否已经存在池中。
  • int defaultCapacity
    这是对象池的默认初始容量,即在对象池创建时池的大小。
  • int maxSize
    这是对象池的最大容量。如果对象池中的对象数达到最大容量时,无法再把新对象存入对象池。注意,当对象池中的对象个数不足以满足 Get() 函数所需要的对象个数时,对象时就会调用第一个参数定义的函数,来创建出新的可用对象;因此 maxSize 这个参数并不能阻止对象池创建出新的对象,仅仅是阻止它存入更多的对象,这个参数防止栈所占用的内存无限地增长。

属性

  • int CountAll
    只读属性,初始值为0。当前启用中的对象与存储在对象池中的对象的个数总和,为 CountActive 属性与 CountInactive 属性之和,每调用一次 createFunc 委托该值就会 + 1。
  • int CountActive
    只读属性,初始值为0。对象池创建的,但现在正在启用中,还没有返回对象池的对象个数,每调用一次 Release() 方法就会 - 1,Get() 方法 + 1。
  • int CountInactive
    只读属性,初始值为0。存储在栈中的等待被启用的对象个数,也是实际会占用对象池容量的对象,每调用一次 Release() 方法就会 + 1,Get() 方法 - 1,最大值为池的最大容量。

常用方法

  • T Get()
    该方法用于出池,它会尝试从对象池中获取一个对象。如果对象池为空(没有可用的对象),它将调用 createFunc 来创建一个新对象,然后将其返回。在需要对象时调用,而不必关心对象是否存在或者已被销毁。
  • void Release(T element)
    这个方法用于进池,即将对象返回到对象池中。它接受一个对象 element,如果池未达到最大容量,它将把该对象放回池中以供后续重用。如果池已满,它将触发 actionOnDestroy 事件(如果定义了的话),通常用于销毁对象或执行其他清理操作。
  • void Clear()
    这个方法用于清空整个对象池,即将池中的所有对象都销毁或释放。这对于在场景切换或游戏状态改变时进行对象池的清理非常有用。

运用对象池的宝石生成器

这是一个运用了 Unity 对象池来进行管理的宝石生成器 demo ,我们将通过这个 demo,来加深对对象池应用的理解。demo 运行画面如图所示,场景某个位置有一个生成五颜六色宝石的生成器,当宝石落到地面后一段时间,宝石会消失。
demo画面

宝石预制体脚本

宝石落地时,触发 OnTriggerEnter(),落地后开始计时,到达设置的宝石消失时间时,执行 deactivateAction 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using UnityEngine;

public class Gem : MonoBehaviour
{
[SerializeField] float lifeTimeAfterLanding = 2f;
bool hasLanded;
float deactivateTimer;
System.Action<Gem> deactivateAction;

void Update()
{
if (!hasLanded) return;

deactivateTimer += Time.deltaTime;
if (deactivateTimer >= lifeTimeAfterLanding)
{
deactivateTimer = 0f;
deactivateAction.Invoke(this);
}
}

void OnTriggerEnter(Collider other)
{
hasLanded = true;
}

void OnDisable()
{
hasLanded = false;
}

public void SetDeactivateAction(System.Action<Gem> deactivateAction)
{
this.deactivateAction = deactivateAction;
}
}

没有使用对象池的普通版本

使用一个数组,存储不同的宝石预制体。设置一个发射时间和发射数量。在 Update() 中计时,每经过一段时间等于发射时间时,执行一个 Spawn() 发射宝石。
Spawn() 函数中做的事情就是创建指定数量个宝石对象,宝石种类随机,设置一个球体内的随机空间坐标作为宝石的产生坐标,并将 Destory() 作为委托的处理函数,传给gem对象,作为销毁宝石时执行的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
using UnityEngine;

public class GemSpawnerNormalVersion : MonoBehaviour
{
[SerializeField] Gem[] gemPrefabs;
[SerializeField] int spawnAmount = 50;
[SerializeField] float spawnInterval = 0.5f;

float spawnTimer;

void Update()
{
spawnTimer += Time.deltaTime;
if (spawnTimer >= spawnInterval)
{
spawnTimer = 0f;
Spawn();
}
}

void Spawn()
{
for (int i = 0; i < spawnAmount; i++)
{
var randomIndex = Random.Range(0, gemPrefabs.Length);
var prefab = gemPrefabs[randomIndex];
var gem = Instantiate(prefab, transform);

gem.transform.position = transform.position + Random.insideUnitSphere * 2f;
gem.SetDeactivateAction(delegate { Destroy(gem.gameObject); });
}
}
}

在 Hierarchy 面板,我们可以看到创建的宝石数量越来越多,并且如果宝石发射的数量值设的较大,间隔时间较短,销毁较慢,画面还会越来越卡顿。

使用对象池的优化版本

创建单一宝石对象池

首先,我们先从创建一个单一预制体的 GemPool 做起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using UnityEngine;
using UnityEngine.Pool;

public class GemPool : MonoBehaviour
{
[SerializeField] Gem prefab;
[SerializeField] int defalutSize = 100;
[SerializeField] int maxSize = 200;
ObjectPool<Gem> pool; //一个基于Stack的对象池系统
void Awake()
{
//创建对象池
pool = new ObjectPool<Gem>(
OnCreatePoolItem,
OnGetPoolItem,
OnReleasePoolItem,
OnDestroyPoolItem,
true,
defalutSize,
maxSize);
}

void Update()
{
var gem = pool.Get(); //每帧创建一个宝石
gem.transform.position = transform.position + Random.insideUnitSphere * 2f;
}

private Gem OnCreatePoolItem()
{
var gem = Instantiate(prefab, transform);
//宝石创建之后,调用设定禁用函数,在委托处理函数中,调用对象池的Release函数而不是直接销毁
gem.SetDeactivateAction(delegate { pool.Release(gem); });
return gem;
}

private void OnGetPoolItem(Gem obj)
{
//激活宝石
obj.gameObject.SetActive(true);
}

private void OnReleasePoolItem(Gem obj)
{
//隐藏宝石
obj.gameObject.SetActive(false);
}

private void OnDestroyPoolItem(Gem obj)
{
//销毁宝石
//注意,当对象池运行时,宝石对象的状态会不断在激活和隐藏中切换,不会主动销毁,除非我们主动清空对象池
//或者对象池中的宝石数量已经达到最大值,才会对试图返回对象池的对象进行销毁
Destroy(obj.gameObject);
}
}

如图所示,我们可以得到一个单一宝石的对象池,在 hierachy 面板可以看到,宝石对象在启用与禁用之间来回切换状态,并且游戏对象的数目逐渐趋于稳定。
单一宝石对象池
我们使用 Ctrl + 7 快捷键打开 Profiler 分析器查看,可以发现除了一开始宝石实例化时产生了一些 GC,当前帧的垃圾回收与内存重新分配的值是0,说明对象池稳定运行之后,不会再实例化新的宝石预制体出来,确实没有再产生额外的内存分配和垃圾回收消耗。
GC分析

创建泛型类的基础对象池

我们根据单一对象池脚本进行优化,写一个泛型类的基础对象池,不仅能用于 Gem 类,也能用于其他类,并且将 GemPool 类进行优化,继承 BasePool。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
using UnityEngine;
using UnityEngine.Pool;

public class BasePool<T> : MonoBehaviour where T : Component
{
[SerializeField] T prefab;
[SerializeField] int defaultSize = 100;
[SerializeField] int maxSize = 500;

ObjectPool<T> pool;

public int ActiveCount => pool.CountActive;
public int InactiveCount => pool.CountInactive;
public int TotalCount => pool.CountAll;

protected void Initialize(bool collectionCheck = true) =>
pool = new ObjectPool<T>(OnCreatePoolItem, OnGetPoolItem, OnReleasePoolItem, OnDestroyPoolItem, collectionCheck, defaultSize, maxSize);

protected virtual T OnCreatePoolItem() => Instantiate(prefab, transform);
protected virtual void OnGetPoolItem(T obj) => obj.gameObject.SetActive(true);
protected virtual void OnReleasePoolItem(T obj) => obj.gameObject.SetActive(false);
protected virtual void OnDestroyPoolItem(T obj) => Destroy(obj.gameObject);

public T Get() => pool.Get();
public void Release(T obj) => pool.Release(obj);
public void Clear() => pool.Clear();
}

当某个类需要用到对象池时,我们只需要继承 BasePool 类,然后在 Awake() 函数中调用 Initialize 初始化方法,就可以使用对象池系统了。对象池初始化时的这几个委托处理函数,添加了 protected virtual 修饰符,这时继承这个类的子类就可以对这些函数进行重写了。Get(), Release(T obj), Clear() 方法使用了 unity 对象池本身对应的方法,子类可以直接调用这几个方法来使用这个对象池系统。
修改后的 GemPool 类示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using UnityEngine;

public class GemPool : BasePool<Gem>
{
void Awake()
{
Initialize();
}

void Update()
{
Get();
}
protected override Gem OnCreatePoolItem()
{
var gem = base.OnCreatePoolItem();
gem.SetDeactivateAction(delegate { Release(gem); });
return gem;
}

protected override void OnGetPoolItem(Gem gem)
{
base.OnGetPoolItem(gem);
gem.transform.position = transform.position + Random.insideUnitSphere * 2f;
}
}

此时,得到的示例效果和之前没有使用 BasePool 时相同:
BasePool

实现多种类宝石对象池管理

我们可以为每一种预制体构建一个宝石对象池,同样用数组来存储宝石预制体。每种类型的宝石我们都创建一个空对象,作为该类宝石的父物体,将生成的宝石放在对应的父对象下,并且在这个父对象上添加 GemPool 脚本。但因为这些脚本是运行时实时添加的,所以它的预制体变量是空的,因此,我们还需要在 GemPool 中提供一个 SetPrefab 方法,用来把宝石预制体数组里的预制体赋值给 GemPool 类。

宝石对象池管理类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
using System.Collections.Generic;
using UnityEngine;

public class GemSpawnerPoolVersion : MonoBehaviour
{
[SerializeField] Gem[] gemPrefabs;
[SerializeField] int spawnAmount = 50;
[SerializeField] float spawnInterval = 0.5f;

float spawnTimer;

List<GemPool> gemPools = new List<GemPool>();

void Start()
{
foreach (var prefab in gemPrefabs)
{
var poolHolder = new GameObject($"Pool: {prefab.name}");

poolHolder.transform.parent = transform;
poolHolder.transform.position = transform.position;
poolHolder.SetActive(false); //由于对象池是在Awake中初始化的,还没有prefab,所以先禁用

var pool = poolHolder.AddComponent<GemPool>();

pool.SetPrefab(prefab);
poolHolder.SetActive(true); //设置完prefab再启用
gemPools.Add(pool);
}
}

void Update()
{
spawnTimer += Time.deltaTime;

if (spawnTimer >= spawnInterval)
{
spawnTimer = 0f;
Spawn();
}
}

void Spawn()
{
for (int i = 0; i < spawnAmount; i++)
{
//随机选择一种宝石,然后从该宝石对应的对象池中取出宝石
var randomIndex = Random.Range(0, gemPrefabs.Length);
var pool = gemPools[randomIndex];
pool.Get();
}
}
}

宝石对象池类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
using UnityEngine;

public class GemPool : BasePool<Gem>
{
void Awake()
{
Initialize();
}

protected override Gem OnCreatePoolItem()
{
var gem = base.OnCreatePoolItem();
gem.SetDeactivateAction(delegate { Release(gem); });

return gem;
}

protected override void OnGetPoolItem(Gem gem)
{
base.OnGetPoolItem(gem);
gem.transform.position = transform.position + Random.insideUnitSphere * 2f;
}

public void SetPrefab(Gem prefab)
{
this.prefab = prefab;
}
}

为了对比普通版本和对象池版本的性能差异,我们要保持和普通版本相同的数值配置。
普通版本(没有使用对象池)可以看到随着时间的推行,帧率逐渐下降,最终维持在一个较低值,甚至可能崩溃,并且可以看到持续产生了大量的 GC,游戏画面卡顿。
普通版本
而使用了对象池的版本可以看到随着时间的推行,帧率逐渐稳定,并且除了一开始创建实例时,之后基本不会再产生 GC (除非超过对象池最大值,有对象被创建和销毁,偶尔会产生 GC),游戏运行流畅。
对象池版本

注意:Unity 自带的对象池也有一个问题,就是没有预创建对象,一开始对象池是空的,一边运行一边才往对象池中加入对象,这个问题可以通过预先自创建对象来解决。

总结

总之,对象池可以大幅提高性能。在游戏中频繁创建和销毁对象会导致内存分配和垃圾收集的压力,降低帧率,使游戏不流畅。使用对象池,可以避免这种性能瓶颈,因为对象已经被创建,只需激活和停用,减少了开销,提高了游戏的响应速度。其次,对象池有助于内存优化。对象池管理对象的生命周期,确保对象的内存分配只发生一次,避免了内存碎片化和泄漏问题。这意味着游戏更加稳定,减少了因内存问题引起的崩溃风险。
对象池适用于各种游戏场景,无论是在敌人生成、子弹发射、粒子效果还是UI元素中,对象池都可以发挥作用。它提供了一种通用的方法,适用于各种游戏元素的管理和重复使用。
最重要的是,对象池是相对容易实施和维护的。现代游戏引擎如Unity提供了丰富的工具和资源来支持对象池的创建和管理。一旦设置,对象池通常需要很少的维护,但可以显著改善游戏性能。
综上所述,对象池是一项强大的技术,可以提高游戏的性能、降低内存开销、确保游戏的流畅性,适用于各种游戏场景,并且易于实施和维护。它是现代游戏开发中不可或缺的工具,有助于提供更出色的游戏体验。