【译文】对象池
翻译:随梦 原文链接:http://www.taidous.com/thread-24982-1-1.html
引言
在游戏开发中,对象池是一种常见的优化技术。它通过避免频繁地销毁和重建 GameObjects 来实现对象的重用,从而节省宝贵的 CPU 周期。网络上很容易找到相关的免费脚本和教程,甚至 Unity 也提供了在线教程。这些教程对对象池做了很好的介绍,因此本文将不再赘述基础内容,而是分享我对对象池实现的想法以及如何对其进行改进。
Unity 在线培训
Unity 在线培训中的对象池课程由 Mike Geig 主讲,相关演示可参考 Unity Live Training demo。虽然我个人对 Mike Geig 不太了解,但鉴于他在 Unity 网站上发布的内容,作为专业人士,其内容应该满足一定的标准。我了解到他还是一位作家和大学讲师,这让我对他的课程更有信心。
如果您还没有观看这个 49 分钟的视频,建议您花时间观看,它能为您提供关于对象池主题的良好介绍,且视频中没有复制代码。若您想直接查看代码,下面为您提供通用副本以供参考。
基础脚本示例
Mike Geig 的示例主要包含三个基础脚本:
- BulletDestroyScript:用于在一段时间后回收一颗子弹。
using UnityEngine; using System.Collections;
public class BulletDestroyScript : MonoBehaviour { void OnEnable () { Invoke("Destroy", 2f); }
void Destroy () { gameObject.SetActive(false); }
void OnDisable () { CancelInvoke("Destroy"); } }
2. **BulletFireScript**:用于急速发射子弹(从对象池中重用)。
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); } }
3. **ObjectPoolerScript**:用于管理对象池本身。
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
void Awake () { current = this; }
void Start ()
{
pooledObjects = new List
public GameObject GetPooledObject () { for (int i = 0; i < pooledObjects.Count; ++i) { if (!pooledObjects[i].activeInHierarchy) { return pooledObjects[i]; } }
if (willGrow) { GameObject obj = (GameObject)Instantiate(pooledObject); pooledObjects.Add(obj); return obj; }
return null; } }
需要说明的是,`BulletFireScript`(子弹发射)和 `BulletDestroyScript`(子弹回收)脚本只是使用池系统的示例,并非核心。而 `ObjectPoolerScript` 是关键脚本,但存在一些需要改进的地方。
## 现有脚本的改进方向
### 可重用性问题
当前脚本在可重用性方面存在局限。虽然脚本本身为了重用进行了组件划分,但目前形式下,它仅能在不同项目中重用,在同一项目中重用存在困难。原因在于该脚本只持有一个预制体,如果想在对象池中管理不同类型的子弹、能源或敌人等,就需要为每种类型单独创建一个脚本。此外,由于部分类实现了单例模式,静态类 `current` 引用的脚本是最后一个执行 `Awake` 方法的,其他实例难以被正确查找和区分。
不过,该对象池系统也有优点,它在需要时可以扩展,并且可以指定最大计数,这在某些情况下可能会有所帮助。
### 基于 Hierarchy 状态区分对象的问题
现有实现基于 GameObject 是否活跃在 Scene 的 Hierarchy 中来区分其是否为“池对象”,这会引发多个问题:
1. **对象激活状态不确定**:对象在展示给用户之前可能处于非活跃状态,且不清楚哪个用户会将其激活,这可能导致对象池错误地将同一对象提供给多个用户。
2. **父对象禁用影响**:由于对象池会检查 Hierarchy 中的活跃状态,禁用任何父对象都会将池对象标记为可重用,这可能产生意外结果。
3. **性能问题**:判断一个对象是否可用需要检查整个父物体的层次结构,这比使用一个布尔值进行判断要慢得多。
此外,现有实现没有对池中的对象是否可用进行充分检查。例如,当父对象被销毁时,池对象也会被销毁,但池管理器在下次检查到被破坏的索引对象时会崩溃。在从对象池中获取和添加对象的系统中,应该检查对象是否为 null,以增加系统的安全性。
### 队列与搜索性能比较
Mike Geig 的实现中,从集合中删除和插入对象被认为是“昂贵的”操作。他认为搜索比管理两个不同的列表更有效率,因为搜索系统能在 O(1) 到 O(n) 之间找到有效且可重用的对象,平均为 O(n/2)。
然而,我自己实现的对象池基于通用队列,队列的入队和出队操作时间复杂度为 O(1)。即使使用两个列表进行管理,只要容量不需要改变,添加和删除操作也可以在 O(1) 时间内完成。
为了验证这一点,我进行了测试。创建了一个新项目并添加脚本,实现了两种不同类型的池:一种是保持固定对象集合,根据需要寻找有效匹配的搜索池;另一种是维护队列,在添加和删除对象时也需要搜索的队列池。测试基于 100 个子弹的池,循环 1000 次,每次循环获取并“使用”池中的所有对象,然后将它们返回到池中。使用 `System.Diagnostics.Stopwatch` 测量所需时间,并将结果记录到控制台。
测试结果表明,搜索池完成操作耗时 128ms,而队列池仅耗时 31ms,队列系统的性能明显优于搜索系统。
以下是测试代码:
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
public SearchPool (int count)
{
pool = new List
public override PoolMe GetPooledObject () { for (int i = 0; i < pool.Count; ++i) { if (pool[i].isPooled) { pool[i].isPooled = false; return pool[i]; } } return null; }
public override void ReturnPooledObject (PoolMe obj) { obj.isPooled = true; } }
public class QueuePool : BasePool
{
Queue
public QueuePool (int count)
{
pool = new Queue
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
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) ); } }
### 关于 Big O notation
O(1) 和 O(n) 是 Big O notation 的示例,它是一种用于表示算法执行时间与输入规模关系的方法。O(1) 表示算法的执行时间是常数,即无论输入规模如何变化,执行时间都保持不变;O(n) 表示算法的执行时间与输入规模呈线性关系,输入规模越大,执行时间越长。
## 我的实现
我的对象池实现中,可池化对象包含一个键,目的是构建一个可以为多个不同对象重用的系统,而无需为每个新对象池创建管理器。
### 控制器设计
我的控制器使用一个字典,将字符串键映射到 `PoolData` 类。`PoolData` 类包含用于实例化新对象的预制体、对象在内存中保持的最大数量以及用于存储可重用对象的队列。
使用时,首次调用 `AddEntry` 方法指定键与预制体的映射关系,并通知同时在内存中保留的对象数量。可以根据游戏中对象的实际需求,在初始种群数和最大数量中使用不同的值,从而完全控制对象池是否能增长以及增长的数量。
我使用了静态公开方法,这样无需引用池管理器的实例,只需引用类本身。静态方法和属性在性能上略优于实例方法和属性,但在继承和复写功能方面会失去一些灵活性,您可以根据实际需求选择合适的模式。
尽管使用了静态方法,我仍然创建了一个私有的单例实例。使用这个 `GameObject` 主要有两个原因:
1. **管理方便**:作为父对象池的管理器,可以在编辑器的 Hierarchy 窗口中隐藏其可见性,这在开发过程中非常实用。
2. **场景适应性**:池对象管理器可以适应场景的变化,集合项目结构也能在场景切换时保持不变。如果不需要这个特性,需要在脚本销毁时添加和删除条目,否则在多个场景中重用对象或在场景之间来回切换时,后续加载时间可能会不一致。
以下是相关代码:
using UnityEngine; using System.Collections; using System.Collections.Generic;
public class PoolData
{
public GameObject prefab;
public int maxCount;
public Queue
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
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
static Poolable CreateInstance (string key, GameObject prefab)
{
GameObject instance = Instantiate(prefab) as GameObject;
Poolable p = instance.AddComponent
endregion
}
### 示例演示
为了测试池管理器的功能,我创建了一个小示例。创建了两个场景,并将示例脚本附加到场景摄像机上。为了便于区分场景变化,我修改了其中一个场景的背景。同时,使用 `OnGUI` 方法进行简单的界面展示,避免了使用 Unity 新 UI 系统时需要设置大量 Canvas、Panel、Button 以及链接事件等繁琐操作。
以下是示例代码:
using UnityEngine; using System.Collections; using System.Collections.Generic;
public class Demo : MonoBehaviour
{
const string PoolKey = "Demo.Prefab";
[SerializeField] GameObject prefab;
List
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[i]); instances.Clear(); } }
## 总结
本文围绕对象池展开讨论,介绍了 Unity 在线培训中的对象池示例,并分析了现有实现存在的问题。通过性能测试比较了队列和搜索方式在对象池管理中的优劣。最后,提供了我自己实现的对象池系统,该系统具有更高的灵活性、安全性、可重用性和效率,希望能为您在游戏开发中使用对象池提供参考。