一、对象池概念
对象池模式并不是游戏开发独有的设计模式,它的设计思路与其他开发中的数据库连接池、线程池的思路等是一样的。
其核心思想是,使用完不直接删除,而是将其放回池子里,需要用的时候再取出来。
对象池模式的出现主要优化两点:
1、防止对象被频繁的创建和删除,从而内存抖动、频繁GC(垃圾回收)
2、对象初始化成本较高
但是因为传统软件开发的对象通常都是轻中里量级的, 分配/释放对象的开销可以忽略不计,所以所以在传统的软件开发中朴素的对象池应用还是比较少的。一般都是为了②上优化,例如数据库连接池、线程池,这样就很好解决了重用,同时还能解决连接数问题等。
但是在游戏开发的过程中,由于很多游戏的游戏对象创建和删除也很频繁,同时游戏对象包含了非常多的对象,所以即使是朴素的对象池技术也有了比较多的应用场景。
二、对象池操作
下面用文字的方式简介对象池的基本操作
借用: 通俗点讲就是从池中获取物体,如果是第一次获取物体要初始化池。 如果池中没有想要的物体了,则创建一个该对象
归还: 通俗点讲就是物品用完了原本是要删除的,但是应用了对象池之后则是把物体归还到池内,前提是池中数量是不大于预设的最大数量的(防止太多内存炸了),如果池中数量已经大于了预设的最大数量,则直接删除
预热: 就是预加载一定数量的对象,我个人认为这是对象池中比较精髓的部分之一。如果不做预热的,那么第一次创建对象的时候还是直接涉及初始化问题。一个很容易懂道理是玩家宁愿在加载界面多等1秒,也不会愿意在游戏中卡顿0.1秒,特别是竞技类的游戏,玩家会想砸电脑的(笑)。所以我觉得如果不做预热的对象池优化只做了一半。
缩小: 差不多就像是预热反着来,上面在归还的时候说如果大于了设定的数量阈值就不返回池中而是直接删除,但实际上删除也有可能会带来时间成本,所以我们可以先不删除,在每次游戏中途的过关之类的加载界面的时候再删除缩小内存池。如果怕在加载界面之前内存爆了的话可以多设置一个必须删除的阈值,其作用跟上面归还时写的一样。(该功能我在我的DEMO当中没有做)
重置: 每个新拿出来的物体应该和新创建的一样时“崭新”的,不能明显带有上次使用过的状态,因此再每次物体出池的时候要对可能存在后效性的地方重置。在unity中则是在物体的OnEnable()中写物体手动初始化的内容,包括清空刚体的力等等,OnEnable()和Start()的区别就是Start()只在物体第一次启用的第一帧运行,OnEnable会在每次物体重新启用的时候运行。
三、具体实验
下面是我自己做的一个小DEMO小实验
因为只是一个小demo实验,所以写的很不健壮我也在注释中表达了,所以如果是关于完整性、健全性的问题就不要过多吐槽了。但是如果我的代码里犯了原则性问题导致还能有大优化的地方请大家指出学习。
下面是ObjectPool的脚本,函数的设计是为了让他们看起来更像unity原生的Instantiate和Destroy。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ObjectPool : MonoBehaviour
{
//自身单例
public static ObjectPool me;
//池的存储
//TODO 此处value使用自定义封装类型而不是单纯的queue更健全
private Dictionary<string, Queue<GameObject>> pool;
//每个池中最大数量
//TODO 应该每个池设置每个池单独的数量
private int maxCount = int.MaxValue;
public int MaxCount
{
get { return maxCount; }
set
{
maxCount = Mathf.Clamp(value, 0, int.MaxValue);
}
}
//初始化
void Awake()
{
me = this;
pool = new Dictionary<string, Queue<GameObject>>();
}
/// <summary>
/// 从池中获取物体
/// </summary>
/// <param name="go">需要取得的物体</param>
/// <param name="position"></param>
/// <param name="rotation"></param>
/// <returns></returns>
public GameObject GetObject(GameObject go,Vector3 position,Quaternion rotation)
{
//如果未初始化过 初始化池
if(!pool.ContainsKey(go.name))
{
pool.Add(go.name, new Queue<GameObject>());
}
//如果池空了就创建新物体
if(pool[go.name].Count == 0)
{
GameObject newObject = Instantiate(go, position, rotation);
newObject.name = go.name;/*
确认名字一样,防止系统加一个(clone),或序号累加之类的
实际上为了更健全可以给每一个物体加一个key,防止对象的name一样但实际上不同
*/
return newObject;
}
//从池中获取物体
GameObject nextObject=pool[go.name].Dequeue();
nextObject.SetActive(true);//要先启动再设置属性,否则可能会被OnEnable重置
nextObject.transform.position = position;
nextObject.transform.rotation = rotation;
return nextObject;
}
/// <summary>
/// 把物体放回池里
/// </summary>
/// <param name="go">需要放回队列的物品</param>
/// <param name="t">延迟执行的时间</param>
/// TODO 应该做个检查put的gameobject的池有没有创建过池
public void PutObject(GameObject go,float t)
{
if (pool[go.name].Count >= MaxCount)
Destroy(go,t);
else
StartCoroutine(ExecutePut(go,t));
}
private IEnumerator ExecutePut(GameObject go, float t)
{
yield return new WaitForSeconds(t);
go.SetActive(false);
pool[go.name].Enqueue(go);
}
/// <summary>
/// 物体预热/预加载
/// </summary>
/// <param name="go">需要预热的物体</param>
/// <param name="number">需要预热的数量</param>
/// TODO 既然有预热用空间换时间 应该要做一个清理用时间换空间的功能
public void Preload(GameObject go,int number)
{
if (!pool.ContainsKey(go.name))
{
pool.Add(go.name, new Queue<GameObject>());
}
for (int i = 0; i < number; i++)
{
GameObject newObject = Instantiate(go);
newObject.name = go.name;//确认名字一样,防止系统加一个(clone),或序号累加之类的
newObject.SetActive(false);
pool[go.name].Enqueue(newObject);
}
}
}
然后是测试脚本GameManager
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Profiling;
public class GameManager : MonoBehaviour
{
public GameObject testObject;
// Start is called before the first frame update
void Start()
{
//预热
ObjectPool.me.Preload(testObject, 500);
}
//无对象池测试
public void TestOfNotOP()
{
StartCoroutine(CreateOfNotOP());
}
private IEnumerator CreateOfNotOP()
{
//统计500帧所用时间
float t = 0.0f;
//每一帧生成一个对象,定时2秒后自动消除
for (int i = 0; i < 500; i++)
{
int x = Random.Range(-30, 30);
int y = Random.Range(-30, 30);
int z = Random.Range(-30, 30);
GameObject newObject=Instantiate(testObject, new Vector3(x, y, z),Quaternion.identity);
Destroy(newObject, 2.0f);
yield return null;
t += Time.deltaTime;
}
Debug.Log("无对象池500帧使用秒数:"+t);
}
//使用对象池测试
public void TestOfOP()
{
StartCoroutine(CreateOfOP());
}
private IEnumerator CreateOfOP()
{
//统计500帧所用时间
float t = 0.0f;
//每一帧生成一个对象,定时2秒后自动消除
for (int i = 0; i < 500; i++)
{
int x = Random.Range(-30, 30);
int y = Random.Range(-30, 30);
int z = Random.Range(-30, 30);
GameObject newObject = ObjectPool.me.GetObject(testObject, new Vector3(x, y, z), Quaternion.identity);
ObjectPool.me.PutObject(newObject, 2.0f);
yield return null;
t += Time.deltaTime;
}
Debug.Log("使用对象池500帧使用秒数:"+t);
}
}
一个Canvas上弄了两个按钮,然后弄了个空的GameObject用来当GameManager,绑定了上了ObjectPool(对象池的脚本)和GameManager(测试脚本),让两个按钮的OnClick事件分别监听TestOfNotOP()和TestOfOP()
最后用资源商店里下载的一个粒子特效来测试(之后补上详细测试视频)
可以看到,如果不使用对象池的话播放500帧(每帧生成一个粒子特效)大概是在9.8-9.9秒左右,而应用了对象池之后播放500帧大概是在9.4-9.5秒左右。还是有优化的结果的,如果是旧版本的unity可能优化的会更多,这点很久以前雨松大佬也吐槽过 http://www.xuanyusong.com/archives/2925 现在的unity粒子特效初始化应该是优化过了的。
总的来说就是,所有的优化技术,优化成本和结果一定要衡量。