【译文】对象池

2015年07月23日 12:58 1 点赞 0 评论 更新于 2025-11-21 18:43

翻译:随梦 原文链接: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 的示例主要包含三个基础脚本:

  1. 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 pooledObjects;

void Awake () { current = this; }

void Start () { pooledObjects = new List(); 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[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 pool;

public SearchPool (int count) { pool = new List(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[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 pool;

public QueuePool (int count) { pool = new Queue(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 activeObjects = new List( 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) ); } }


### 关于 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 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(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(); }

static Poolable CreateInstance (string key, GameObject prefab) { GameObject instance = Instantiate(prefab) as GameObject; Poolable p = instance.AddComponent(); p.key = key; return p; }

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 instances = new 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 在线培训中的对象池示例,并分析了现有实现存在的问题。通过性能测试比较了队列和搜索方式在对象池管理中的优劣。最后,提供了我自己实现的对象池系统,该系统具有更高的灵活性、安全性、可重用性和效率,希望能为您在游戏开发中使用对象池提供参考。

作者信息

洞悉

洞悉

共发布了 3994 篇文章