【为什么使用对象池】
游戏制作避免不了做游戏优化,让游戏达到60分容易,但从60分到90分就是一个漫长的优化路程,因此提前接触到优化的知识在游戏开发设计的时候就能规避很多坑点(说是接触而不是开展优化是因为项目初期功能不明确,做太多优化的功能其实是没有意义的)。
例如游戏中会经常用到很多个相同类型的物体,比如子弹特效,比如一个UI的背包。如果每次要用的时候都去创建用完就删除掉,会造成频繁的资源回收(GC),部分游戏玩着玩着就卡顿就源于此。
对象池是一种设计模式,是一种游戏经常用到的脚本类型,了解对象池对游戏性能优化非常有帮助。
【对象池初识】
对象池的核心思想是:预先初始化一组可重用的实体,而不是按需销毁然后重建
就像做游戏一样的,我们先做一个对象池原型的实现具体功能。
比如说一个界面:
界面的Item是随着游戏进程增加而增加的。
我们利用对象池的核心思想就是“当我们第一次实例化好多个Item物体后,下一次打开如果界面更新就没必要再创建第二次重复的资源物体了”,因此如果这个界面关闭的时候应该把资源临时存放在一个“池”里面。
像这样:
/*资源保存在“池”中,外部表现无非是Item物体修改了父物体,然后关闭激活,这是非常简单的操作*/
当再打开界面的时候,因为池中有资源,所以就跳过了读取资源实例化的步骤(资源加载和实例化是最消耗性能的)。
/*这里有必要强调的是,我们没必要单独去做第一次对象池的实例化,我们只需要知道,这个物体是重复的,所以这个物体我们都在对象池中去取。而对象池也只关注,当别人调用了我的创建实例函数,我必须返回给它一个创建好的物体,而池子里面有没有资源让对象池自己判断,如果没有则创建,如果有则直接拿出来给它,这种设计方法叫做空池触发*/
以下是我们做空池触发的对象池脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class ObjectsPool : MonoBehaviour {
[SerializeField]
private GameObject _prefab;
private Queue<GameObject> _pooledInstanceQueue = new Queue<GameObject>();
public GameObject GetInstance()
{
if (_pooledInstanceQueue.Count>0)
{
GameObject instanceToReuse = _pooledInstanceQueue.Dequeue();
instanceToReuse.SetActive(true);
return instanceToReuse;
}
return Instantiate(_prefab);
}
public void ReturnInstance(GameObject gameObjectToPool)
{
_pooledInstanceQueue.Enqueue(gameObjectToPool);
gameObjectToPool.SetActive(false);
gameObjectToPool.transform.SetParent(gameObject.transform);
}
}
脚本解析:
_prefab:我们要加载和实例化的资源对象
_pooledInstanceQueue :对象池存储的实质,利用队列思想来存取物体
GetInstance():得到对象函数,内部判断当前队列数量是否为0(是否空池),如果空池则创建资源,否则从池子中取得对象返回。取的对象后,对象池不会在对该对象处理,因此是移除了队列。
ReturnInstance():返回对象函数,对象池有进有出,当外部功能用完资源后,通过该函数重新让资源入池。这里处理了让对象重新进入队列,同时关闭物体激活和设置父物体。
然后我们做一个测试脚本:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class TestScript : MonoBehaviour {
public GameObject Tran_Content;
public ObjectsPool mPool;
private List<GameObject> itemList = new List<GameObject>();
public void OnEnable()
{
for (int i = 0; i < 8; i++)
{
var obj = mPool.GetInstance();
obj.transform.SetParent(Tran_Content.transform);
itemList.Add(obj);
}
}
public void OnDisable()
{
for (int i = 0; i < itemList.Count; i++)
{
mPool.ReturnInstance(itemList[i]);
}
}
}
这个测试脚本有一个问题,以下的内容会看到。
当前效果:
【一些基础规范】
做完基础原型后,先普及一些概念。很多知识点都来自http://www.infoq.com/cn/news/2015/07/ClojureWerkz,这里我提取一些方便大家理解。
两种基本的对象池回收模式
“借用(borrowing)”和引用计数。前者更清晰,而后者则意味着要实现自动回收。
借用模式:和我们刚才做的UI功能相似,将对象从对象池中借用出来,对象不在和对象池有任何关系,之后由消费者返回对象池。借用和返回都由消费者来实现。
引用计数模式:引用计数用于同时有多个消费者访问已分配对象的情况,只有当所有的消费者都释放了对象引用时,对象才可以被回收。这个模式可以用Unity的内存池举例,Unity内存存放了游戏资源,但是有部分资源如果没有被当前游戏的功能块引用,则会在某段时间自动清理掉该部分内存。而判断是否被引用的方法是通过给每一个内存资源加一个引用计数,当没有对象用到该资源时(计数为0)即开始释放资源, Unity中的 Resources.UnloadUnusedAssets()接口可以主动调用释放无用的资源。
分配触发方式:
空池触发:任何时候,只要池空了,就分配对象。这是一种最简单的方式。
水位线:空池触发的缺点是,某次对象请求会因为执行对象分配而中断。为了避免这种情况,可以使用水位线触发。当从池中请求新对象时,检查池中可用对象的数量。如果可用对象小于某个阈值,就触发分配过程。
Lease/Return速度:大多数时候,水位线触发已经足够,但有时候可能会需要更高的精度。在这种情况下,可以使用lease和return速度。例如,如果池中有100个对象,每秒有20个对象被取走,但只有10个对象返回,那么9秒后池就空了。开发者可以使用这种信息,提前做好对象分配计划。
常常有游戏在加载进度条时,给对象池注入水位线,比如提前存入10个模型的资源,在加载进入战斗的时候就可以流畅的全部加载出来。这种触发方式就像缓存作用,可以把游戏中用到的资源提前缓存准备好,避免游戏运行中动态加载。
避免问题的规范:
引用混乱:对象在系统中某个地方注册了,但没有返回到池中。
过早回收:消费者已经决定将对象返还给对象池,但仍然持有它的引用,并试图执行写或读操作,这时会出现这种情况。
隐式回收:当使用引用计数时可能会出现这种情况。
大小错误:这种情况在使用字节缓冲区和数组时非常常见:对象应该有不同的大小,而且是以定制的方式构造,但返回对象池后却作为通用对象重用。
重复下单:这是引用泄露的一个变种,存在多路复用时特别容易发生:一个对象被分配到多个地方,但其中一个地方释放了该对象。
就地修改:对象不可变是最好的,但如果不具备那样做的条件,就可能在读取对象内容时遇到内容被修改的问题。
缩小对象池:当池中有大量的未使用对象时,要缩小对象池。
对象重新初始化:确保每次从池中取得的对象不含有上次使用时留下的脏字段。
我们刚才的UI测试脚本就犯了回收的错误。 我们将对象池的对象临时存储在了itemList 中,方便界面关闭的时候将对象回收。但是我们回收完毕对象后并没有将itemList 清理。这样就会造成对象池中的对象在外部仍然能够获取到,这样没法安全的清理对象池回收内存,同时如果我们加入界面数据后,对象的不正确存储会造成功能问题。
【进阶功能】
刚才我们所做的对象池只能存储一种对象,现在我们要扩展功能,让对象池能存储多种对象。
思路:
将Queue<GameObject>转换成Dictionary<string, Queue<GameObject>>处理做为存储对象功能,同时我们需要让对象池能识别不同的对象,因此加入Dictionary<GameObject, string>类型的变量存储物体的Tag。
代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class NewObjectPool : MonoBehaviour {
private GameObject CachePanel;
private Dictionary<string, Queue<GameObject>> m_Pool = new Dictionary<string, Queue<GameObject>>();
private Dictionary<GameObject, string> m_GoTag = new Dictionary<GameObject, string>();
/// <summary>
/// 清空缓存池,释放所有引用
/// </summary>
public void ClearCachePool()
{
m_Pool.Clear();
m_GoTag.Clear();
}
/// <summary>
/// 回收GameObject
/// </summary>
public void ReturnCacheGameObejct(GameObject go)
{
if (CachePanel == null)
{
CachePanel = new GameObject();
CachePanel.name = "CachePanel";
GameObject.DontDestroyOnLoad(CachePanel);
}
if (go == null)
{
return;
}
go.transform.parent = CachePanel.transform;
go.SetActive(false);
if (m_GoTag.ContainsKey(go))
{
string tag = m_GoTag[go];
RemoveOutMark(go);
if (!m_Pool.ContainsKey(tag))
{
m_Pool[tag] = new Queue<GameObject>();
}
m_Pool[tag].Enqueue(go);
}
}
/// <summary>
/// 请求GameObject
/// </summary>
public GameObject RequestCacheGameObejct(GameObject prefab)
{
string tag = prefab.GetInstanceID().ToString();
GameObject go = GetFromPool(tag);
if (go == null)
{
go = GameObject.Instantiate<GameObject>(prefab);
go.name = prefab.name + Time.time;
}
MarkAsOut(go, tag);
return go;
}
private GameObject GetFromPool(string tag)
{
if (m_Pool.ContainsKey(tag) && m_Pool[tag].Count > 0)
{
GameObject obj = m_Pool[tag].Dequeue();
obj.SetActive(true);
return obj;
}
else
{
return null;
}
}
private void MarkAsOut(GameObject go, string tag)
{
m_GoTag.Add(go, tag);
}
private void RemoveOutMark(GameObject go)
{
if (m_GoTag.ContainsKey(go))
{
m_GoTag.Remove(go);
}
else
{
Debug.LogError("remove out mark error, gameObject has not been marked");
}
}
}
脚本解析:
m_GoTag :相比第一版对象池我们增加了这个变量,这里利用对象的InstanceID是唯一的,让InstanceID作为标记。
RequestCacheGameObejct(GameObject prefab):这里增加传入prefab,因为对象池需要能存储多个对象,对象池通过外部传入的资源对象来判断。函数内部增加对取出的物体标记的功能。
MarkAsOut(GameObject go, string tag)和RemoveOutMark(GameObject go):将取出的资源添加标记,避免返回的资源不是从对象池创建的资源,返回对象池后资源去除标记。
ReturnCacheGameObejct(GameObject go):增加判定返回对象的功能。
【总结】
对象池算是游戏优化必定会用到的设计模式,网上对对象池有很多的资源,但是针对游戏行业的比较少,要么太过复杂,要么太过偏门。其实总的来说对象池并不难,是一个花一点时间就能掌握的技巧。当你使用对象池来做功能优化的时候,你会开始逐渐脱离引擎写代码,这是程序进阶的必经之路。
游戏开发技术交流群:610475807
公众号:皮皮关
暂无关于此日志的评论。