翻译:随梦、
在游戏中对象池是比较常见的做法,通过不摧毁和重建GameObjects以达到重用的效果,节省宝贵的CPU周期,很容易找到相关的免费脚本和教程,甚至Unity提供了在线的教程,他们的展示是一个很好的介绍,使我不用介绍基础的东西,在这篇文章里,我将分享我的想法的实现和如何改进它。
Unity Live Training
Unity在线培训对象池是由Mike Geig进行的—Unity Live Training demo,我个人不了解Mike,但总的来说我认为如果他在Unity网站上发出的一些内容,作为专业的他必须满足一些最低的标准,我看了看他,发现他也是一个作家和大学讲师,这对我来说足够好了!如果你还没有观看这个视频,以此感觉这样做的自由—对这个主题它提供了一个很好的介绍,这个视频49分钟,没有任何地方复制了代码,如果你宁愿跳过它或者只是看看代码,下面我提供一个通用的副本供参考。
他的示例可以归结为三个基础脚本:
1.第一个脚本在一段时间后回收一颗子弹。
2.第二个脚本急速发射产生子弹(来自对象池的重用)
3.一个脚本管理对象池本身。
[C#] 纯文本查看 复制代码
using UnityEngine; using System.Collections; public class BulletDestroyScript : MonoBehaviour { void OnEnable () { Invoke("Destroy", 2f); } void Destroy () { gameObject.SetActive(false); } void OnDisable () { CancelInvoke("Destroy"); } }
[C#] 纯文本查看 复制代码
using UnityEngine; using System.Collections; public class BulletFireScript : MonoBehaviour { public float fireTime = 0.05f; void Start () { InvokeRepeating("Fire", fireTime, fireTime); } void Fire () { GameObject obj = ObjectPoolerScript.current.GetPooledObject(); if (obj == null) return; // Position the bullet obj.SetActive(true); } }
[C#] 纯文本查看 复制代码
using UnityEngine; using System.Collections; using System.Collections.Generic; public class ObjectPoolerScript : MonoBehaviour { public static ObjectPoolerScript current; public GameObject pooledObject; public int pooledAmount = 20; public bool willGrow = true; List<GameObject> pooledObjects; void Awake () { current = this; } void Start () { pooledObjects = new List<GameObject>(); for (int i = 0; i < pooledAmount; ++i) { GameObject obj = (GameObject)Instantiate(pooledObject); obj.SetActive(false); pooledObjects.Add(obj); } } public GameObject GetPooledObject () { for (int i = 0; i < pooledObjects.Count; ++i) { if (!pooledObjects.activeInHierarchy) { return pooledObjects; } } if (willGrow) { GameObject obj = (GameObject)Instantiate(pooledObject); pooledObjects.Add(obj); return obj; } return null; } }
BulletFireScript(子弹发射)和BulletDestroyScript(子弹回收)脚本并不重要,—它们只是使用池系统的一个例子,ObjectPoolerScript是个重要的脚本,想要强调的是有一些地方需要改进。
第一个改进方面是可重用性,脚本本身为了重用将自己划分为自己的组件,但以目前的形式,只能在不同的项目中重用,然而,我想说一个更重要的,它在同一个项目不可重用,原因:该脚本只持有一个预制,如果你想在池里有不同类型的子弹,能源或敌人等等,你将需要为每个脚本使用一个,注意,你不能简单的添加该组件和分配不同的预设,因为有些类实现了单例模式,无论静态类叫做“current”的引用脚本是最后一个Awake,另一个实例是不会被很好的查找,或者用来相互区分。
我喜欢这个ObjectPoolerScript(对象池)系统,我喜欢它在需要的时候可以扩展,一个小的特点要求我们说明添加多少,指定最大计数在某种情况下可能有所帮助。
下一个更改的区域是关于基于GameObject是否活跃在Scene中的Hierarchy如何区分其是“pooled(池对象)”和“not pooled(不是池对象)”,这个问题会有多个原因:
1.这个对象在展示给用户之前不是活跃的,也不知道哪个用户会将它激活,这意味着池可能错误地提供同一对象的多个用户。
2.因为池会检查在Hierarchy中活跃的,禁用任何父对象将标记池对象为可重用的—这可能是一个意向不到的结果。
3.必须检查整个父物体的层次结构来判断一个对象是否可用,比用一个布尔值慢得多。
这个实现从没有在我们的池子物体中检查是否可用,例如,如果你有一个对象池,然后父对象被销毁,你不能做到池对象不会被销毁,池管理器在下次检查到被破坏的索引对象是将崩溃,在一个系统中,你从一个池中拿出对象和将他们添加到池中,应该检查对象司否为null,因此增加一定程度的安全性。
我自己实现的池是基于一个通用的使用队列,它自然的从自己的集合添加和删除对象,这个特殊的问题是Mike错误的主意,他说,从一个集合中删除和插入对象是“很昂贵的(消耗大)”,甚至直接的回答一个关于使用两个不同的列表,这样你不需要搜索一个满足条件的的对象返回的问题,他的回答基本上是“搜索比管理两个不同的列表更有效率”。
我最初的想法是,“为什么?”,他实现的搜索系统取决于对象很快就找到了一个有效且可重用的对象,介于O(1)和O(n)之间,我们只能说平均是O(n/2),无论系统的大小队列的入队和出队方法都是O(1),即使使用两个列表管理,只要容量不需要改变你可以添加在O(1),你可以从O(1)时间列表里RemoveAt。
提示:
O(1)和O(n)是Big O notation的例子,,只是一个书呆子的方式表明一种算法需要多长时间来执行输入,O(1)意味着持续时间要快的唯一方法是不做任何事,O(n)意味着线性时间—系统越大越慢的过程。
我的下一个想法是无论其能力即使从队列添加和删除都要保持速度,它并不意味着它是快的,也许Mike知道我不知道的东西—我认为它可能是一个笑的搜索基础池,就像他的例子,实际上可能更快的添加和删除对象返回到对象集合,所以我决定对其进行测试,我创建了一个新的项目并添加一个脚本创建了两个不同类型的池:一个池保持一个固定的对象集合,根据需要寻找一个有效的匹配,第二个池维护一个队列可以添加和删除池的对象时不行也要搜索,最大的问题在于搜索或修改集合最终将变得更加昂贵。
在Mike的示例中,为了没有任何差距的开火能力,一个宇宙飞船需要约41个子弹,因为Mike还指出,池中可以被多个人物(敌人等)重用,要求一个池子有100个子弹似乎有点不合理,因此,我创建的测试是基于100个子弹的池,和循环1000次,每次循环得到和“使用”池中的所有对象,然后将他们返回到池中,我使用一个System.Diagnostics,秒表测量所需的时间,并将每个结果记录到控制台。
两个测试证明他们可以执行的很快,然而,测试的结果证明了我的说法——在集合实际添加和珊处对象,搜索列表比队列系统慢大约四倍。
1.搜索池完成用了128ms
2.队列池完成用了31ms
很多人都想测试这个代码来验证我是否公平公正,否则,基于这些测试结果,基于他的警告,我认为没有理由无视自己的实现。
[C#] 纯文本查看 复制代码
using UnityEngine; using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; public class PoolMe { public bool isPooled; } public abstract class BasePool { public abstract PoolMe GetPooledObject (); public abstract void ReturnPooledObject (PoolMe obj); } public class SearchPool : BasePool { List<PoolMe> pool; public SearchPool (int count) { pool = new List<PoolMe>(count); for (int i = 0; i < count; ++i) { PoolMe p = new PoolMe(); p.isPooled = true; pool.Add( p ); } } public override PoolMe GetPooledObject () { for (int i = 0; i < pool.Count; ++i) { if (pool.isPooled) { pool.isPooled = false; return pool; } } return null; } public override void ReturnPooledObject (PoolMe obj) { obj.isPooled = true; } } public class QueuePool : BasePool { Queue<PoolMe> pool; public QueuePool (int count) { pool = new Queue<PoolMe>(count); for (int i = 0; i < count; ++i) ReturnPooledObject(new PoolMe()); } public override PoolMe GetPooledObject () { if (pool.Count > 0) { PoolMe retValue = pool.Dequeue(); retValue.isPooled = false; return retValue; } return null; } public override void ReturnPooledObject (PoolMe obj) { obj.isPooled = true; pool.Enqueue(obj); } } public class PoolingComparisonDemo : MonoBehaviour { const int objCount = 100; const int testCount = 1000; IEnumerator Start () { TestPool(new SearchPool(objCount)); yield return new WaitForSeconds(1); TestPool(new QueuePool(objCount)); } void TestPool (BasePool pool) { List<PoolMe> activeObjects = new List<PoolMe>( objCount ); Stopwatch watch = new Stopwatch(); watch.Start(); // Perform a repeating test of getting pooled objects and putting them back for (int i = 0; i < testCount; ++i) { // Get and "use" all items in the pool for (int j = 0; j < objCount; ++j) activeObjects.Add(pool.GetPooledObject()); // Put all items back in the pool for (int j = objCount - 1; j >= 0; --j) { pool.ReturnPooledObject(activeObjects[j]); activeObjects.RemoveAt(j); } } watch.Stop(); UnityEngine.Debug.Log( string.Format("Completed {0} in {1} ms", pool.GetType().Name, watch.Elapsed.Milliseconds) ); } }
我的实现
你可能也注意到,我的poolable对象包含一个键,那是那是因为我想要一个可以为多个不同的对象重用的系统,而不需要为每一个新池创建管理器。
我的控制器,简而言之,有一个字典,地图从一个字符串键PoolData类,包含如下信息,如使用预制实例化新对象,对象在内存中保持最大数量,以及队列半身用于存储可重用的对象,使用它,你第一次调用AddEntry当指定键映射到什么预制后,并通知有多少个对象同时在内存中,有一个理想的方法就是要知道一个对象在游戏中需要多少,因为你可以在初始种群数和最大数量使用不同的值,你能完全控制池是否能增长,如果有,是多少。
我是用一个静态的公开方法,这意味着你引用一个实例不需要池管理器,你只需要类本身的引用,静态方法和属性略高与实例方法和属性,然而,你在继承和复写的功能上失去了一些灵活性,请选你需要最合适的模式。
尽管我使用静态方法,我仍然选择创建一个私有的单例实例,我用这个GameObject,主要有两个原因,你可能会不关心:
1.管理——父对象池的管理器,在编辑器的Hierarchy窗口我可以隐藏可见性,在开发过程中非常好!
2.保存——我的池对象管理器可以适应场景的变化,以及任何集合项目结构也能够生存,如果你不想要这个特性,在任何脚本在Destroy时要添加和删除条目,否则,如果你在多个场景中重用对象,或者与另一个场景之间来回切换,然后这些后续加载时间不一样长。
[C#] 纯文本查看 复制代码
using UnityEngine; using System.Collections; using System.Collections.Generic; public class PoolData { public GameObject prefab; public int maxCount; public Queue<Poolable> pool; } public class GameObjectPoolController : MonoBehaviour { #region Fields / Properties static GameObjectPoolController Instance { get { if (instance == null) CreateSharedInstance(); return instance; } } static GameObjectPoolController instance; static Dictionary<string, PoolData> pools = new Dictionary<string, PoolData>(); #endregion #region MonoBehaviour void Awake () { if (instance != null && instance != this) Destroy(this); else instance = this; } #endregion #region Public public static void SetMaxCount (string key, int maxCount) { if (!pools.ContainsKey(key)) return; PoolData data = pools[key]; data.maxCount = maxCount; } public static bool AddEntry (string key, GameObject prefab, int prepopulate, int maxCount) { if (pools.ContainsKey(key)) return false; PoolData data = new PoolData(); data.prefab = prefab; data.maxCount = maxCount; data.pool = new Queue<Poolable>(prepopulate); pools.Add(key, data); for (int i = 0; i < prepopulate; ++i) Enqueue( CreateInstance(key, prefab) ); return true; } public static void ClearEntry (string key) { if (!pools.ContainsKey(key)) return; PoolData data = pools[key]; while (data.pool.Count > 0) { Poolable obj = data.pool.Dequeue(); GameObject.Destroy(obj.gameObject); } pools.Remove(key); } public static void Enqueue (Poolable sender) { if (sender == null || sender.isPooled || !pools.ContainsKey(sender.key)) return; PoolData data = pools[sender.key]; if (data.pool.Count >= data.maxCount) { GameObject.Destroy(sender.gameObject); return; } data.pool.Enqueue(sender); sender.isPooled = true; sender.transform.SetParent(Instance.transform); sender.gameObject.SetActive(false); } public static Poolable Dequeue (string key) { if (!pools.ContainsKey(key)) return null; PoolData data = pools[key]; if (data.pool.Count == 0) return CreateInstance(key, data.prefab); Poolable obj = data.pool.Dequeue(); obj.isPooled = false; return obj; } #endregion #region Private static void CreateSharedInstance () { GameObject obj = new GameObject("GameObject Pool Controller"); DontDestroyOnLoad(obj); instance = obj.AddComponent<GameObjectPoolController>(); } static Poolable CreateInstance (string key, GameObject prefab) { GameObject instance = Instantiate(prefab) as GameObject; Poolable p = instance.AddComponent<Poolable>(); p.key = key; return p; } #endregion }
Demo
我创建了一个小示例来测试池管理器,以确保所有事情是我们所预期的,我创建了两个场景(一定要将它们添加到Build Settings中),每个示例脚本附加到一个对象(场景摄像机),我改了一个场景的背景,让我们可以容易区分场景的变化,我也用的OnGUI所以不需要另外设置这个脚本,我只是创建一个球体来做脚本预制的参考。
做一个简单的说明,我在项目中不使用OnGUI,我将使用Unity新的UI系统代替,然而,为了快速掩饰,OnGUI可以很好,因为所有的设置可以提供一个脚本,而新的UI需要设置很多Canvas,Panel,Button,通过脚本的Linking events等等。
[C#] 纯文本查看 复制代码
using UnityEngine; using System.Collections; using System.Collections.Generic; public class Demo : MonoBehaviour { const string PoolKey = "Demo.Prefab"; [SerializeField] GameObject prefab; List<Poolable> instances = new List<Poolable>(); void Start () { if (GameObjectPoolController.AddEntry(PoolKey, prefab, 10, 15)) Debug.Log("Pre-populating pool"); else Debug.Log("Pool already configured"); } void OnGUI () { if (GUI.Button(new Rect(10, 10, 100, 30), "Scene 1")) ChangeLevel(0); if (GUI.Button(new Rect(10, 50, 100, 30), "Scene 2")) ChangeLevel(1); if (GUI.Button(new Rect(10, 90, 100, 30), "Dequeue")) { Poolable obj = GameObjectPoolController.Dequeue(PoolKey); float x = UnityEngine.Random.Range(-10, 10); float y = UnityEngine.Random.Range(0, 5); float z = UnityEngine.Random.Range(0, 10); obj.transform.localPosition = new Vector3(x, y, z); obj.gameObject.SetActive(true); instances.Add(obj); } if (GUI.Button(new Rect(10, 130, 100, 30), "Enqueue")) { if (instances.Count > 0) { Poolable obj = instances[0]; instances.RemoveAt(0); GameObjectPoolController.Enqueue(obj); } } } void ChangeLevel (int level) { ReleaseInstances(); Application.LoadLevel(level); } void ReleaseInstances () { for (int i = instances.Count - 1; i >= 0; --i) GameObjectPoolController.Enqueue(instances); instances.Clear(); } }
总结
在这节课中,我们讨论的主题是对象池,以及如何编写一个定制的池管理器,我分享我对对象池的想法提供到Unity’s Live Training 支出我觉得需要改的地方,对声称高效的方法进行了测试,最后,我提供自己的实现,它应该更灵活,安全,可重用和有效的。