【Unity 教程】对象池



对象池在游戏中相当常用。通过对GameObject的反复利用,而不是摧毁之后再重建,能节约宝贵的CPU资源。关于对象池的免费脚本和教程比比皆是,甚至unity官方也在实战训练课程中讲过。他们的描述介绍虽然很精彩,但我不准备直接使用它。在这篇文章中,我将分享对Unity官方实现的看法,以及如何去改进它。

Unity Live Training

对象池的实战训练由Mike Geig 提供,点这里—UnityLive Training demo。我个人不了解Mike,但一般来说我认为,如果一个人在Unity网站上整理内容,作为一个专业人士,他必须满足一些最低水平的标准。我搜索了下,发现他是作家同时也是一名大学讲师。对我来说足够了!如果你还没看过这视频,那就去看吧—视频里对于这次的主题的介绍很棒。视频时长49分钟,然而其中并没有明显的让你拷贝他代码的地方,所以如果你只是为了代码而跳过视频,我在下面提供了一个示例仅供参考。他的demo可以归纳为以下三个基础脚本:
  • 一段时间后回收一颗子弹的脚本
  • 迅速地发射子弹(从对象池中重用)的脚本
  • 管理对象池的脚本

using UnityEngine;
using System.Collections;
  
public class BulletDestroyScript : MonoBehaviour 
{
    void OnEnable ()
    {
        Invoke("Destroy", 2f);
    }
  
    void Destroy ()
    {
        gameObject.SetActive(false);
    }
  
    void OnDisable ()
    {
        CancelInvoke("Destroy");
    }
}


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);
    }
}

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;
    }
}

子弹的发射及回收脚本在这里其实并不那么重要——这只是如何使用“对象池系统”的一个例子。
ObjectPoolerScript 脚本非常重要,有几个改进的地方我想强调一下。

第一个是可重用性。脚本本身就包含在其组件中以便重复使用,但目前看来只能在不同项目间重用。然而我想说更重要的是,它在同一个项目中,是不可重用的。这就是为什么该脚本只引用了一个预制件。如果你想在池中保存不同类型的子弹,道具以及敌人等等,你需要在每一个物体上面放一个新脚本。请注意你不能简单地多次添加该组件并分配不同的预制件,因为有些类使用了单例设计模式。最后调用Awake的脚本拥有静态类的引用“current”,。没有很好的方法找到其它实例或者将它们区分开来。

我很喜欢这个可以预填充对象池的系统,我也喜欢它能在需要时扩充。我们可以添加一个小功能,就是说它允许生成多少。某些情况下指定一个最大值会比较好。

下一个改进是关于一个物体如何被认为“已入池”或“未入池”是基于它的GameObject是否为激活状态(active)。造成这个疑问的原因有很多,例如:




  • 对象在分配给用户时并不处于激活状态,所以也没办法知道用户何时会激活这个物体。这就意味着对象池很可能错误地将同一个物体分配给多个用户。
  • 因为对象池正在检查层次面板中的激活状态,禁用任何父物体都将导致已入池对象被标记为可重用—这可能会导致不可预期的后果。
  • 必须检查整个层次结构来判断一个物体是否可用—要比在某些位置保存bool值慢得多。



这个方法从不检查它已入池物体的有效性。例如,你有一个对象池,用户使用一个对象并把它作为另一个物体的父物体,然后父物体被销毁,你不可能从销毁中保存已入池对象。池管理器将在它下一次检查被摧毁物体索引的时候崩溃。在一个系统中,把一个物体从一个池中拿出再放进去,池可以选择不添加空(null)物体,因而从一定程度上保证安全。

我自己的池化通过一个队列来实现,它会自然地从它自己的集合中添加或移除对象。


这个特殊的问题是由Mike提出的坏主意。他表示从集合中移除和插入对象“实在是太昂贵了”甚至回答说使用两个不同的list,这样就不需要寻找返回一个可接受的对象。他的回答大致上是在说,“查找比管理两个不同的list更有效率”。

我第一个反应是“啊哈?!”,他实现的查找系统在某处的时间复杂度介于O(1) 与 O(n)之间,这取决于多快能找到一个有效的重用对象,我们只能说平均复杂度将会是O(n/2)。不管系统多大,队列中的入列和出列方法都是O(1)的时间复杂度。即使你使用两个List去管理,只要不改变长度Add 操作就是O(1)的复杂度,你还能以O(1)时间复杂度将它从list的最后移除。

小贴士:

O(1) 和O(n) 是大O符号表达式,用来表示算法在接收输入后的执行时间(即算法时间复杂度)。O(1) 表示恒定时间——变快的唯一方法就是什么也不做。O(n) 表示线性时间——系统越大,过程越慢。

我的下一个想法是,不管它的容量有多大,即使从队列中增删一些对象,依然能保持它的速度,但这并不一定意味着快。也许Mike知道一些我不知道的东西—我想很有可能像他的例子一样的基于对象池的小型搜索实际上要比在对象集合中移除、再加入快。所以我决定亲自彻底检验一下。我新建了一个工程,并加入了一个创建了两种类型对象池的脚本:一个池保存一个固定的对象集合,并且根据需要寻找一个有效的匹配。第二个池保存一个可以添加删除不需要搜索的可入池物体的队列。最大的问题在于,搜索或者修改集合最终都会更加耗时。

在Mike的demo中,为了使得在发射能力上没有任何差距,一个宇宙飞船需要大约41个在对象池中的子弹。因为Mike也提出,池可以为其他角色重用(敌人等等),而池中存放100个子弹也没什么不合理的。因此,我创建了100个子弹,并循环1000次,每次循环都得到并“使用”所有池中对象,再将它们返回池中。我使用System.Diagnostics.Stopwatch来测算每次测试所需的时间,并将结果输出到控制台。两个测试都能很快地执行,但是,测试结果对我有利——必须搜索list要比队列系统中在对象的集合中实际地增加移除对象慢了大约4倍的时间。




  • SearchPool 完成:128 ms
  • QueuePool 完成: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<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) );
    }
}


我的实现

不是根据对象是否激活来决定该对象是否可以入池,我决定另外添加一个带有bool值的组件来帮我指明。比起一个基于GameObject激活标志作为入池判断的系统来说,这更灵活也更安全。

using UnityEngine;
using System.Collections;
  
public class Poolable : MonoBehaviour 
{
    public string key;
    public bool isPooled;
}


?

你也许已经注意到了在我Poolable里有一个key。这是因为我想让系统能重用于多种不同的对象,而不用为每一个对象另外创建新的池管理器。我的控制器,简单来说,有一个从字符串 key 映射到PoolData类的字典,它包含了以下信息:用于实例化新对象的预制,内存中保存的最大对象数量,以及用于存储可重用对象的队列。要使用它,你首先要调用AddEntry方法,在其中指定key与prefab的映射,并且告诉它预先创建多少(如果有)对象以及要存入内存的最大对象数量。在理想情况下,你会知道在游戏中平均需要多少对象。因为你可以在原始群体和最大计数中使用不同的值。你对池的是否扩充,扩充多少,有着完全控制权。

我使用的这个方法是静态的——这就意味着你并不需要一个实例引用来使用池管理器,你只需引用该类本身,静态方法比实例方法和属性要稍微快一点。但同时你也失去了一些灵活性比如继承和重载某些功能性函数。选择最适合你需求的模式。


即使我是用了静态方法,我仍然选择去创建一个单例实例,我使用这个GameObject有以下两个原因,有兴趣的可以关注一下:




  • 组织结构:通过使池化物体成为池管理器的子物体,我能在编辑器的层次面板(Hierarchy)中折叠它们,这样开发真是棒极了!
  • 保存物体:我的池管理器能保存场景改动,还可以保存其层次中的已入池对象。如果你不想要这功能,只需在销毁已添加实体的脚本同时销毁该实体即可。否则,如果你在多个场景中重用物体,或者在一个场景中来回反复地改变,那么这些场景的后续加载时间将会不一样长。
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

我创建了一个小demo来测试池管理器,并验证一切都按照我期望的那样运作。我创建了两个场景(确保在build setting里加入了它们),分别在一个对象(场景相机)上关联了我的Demo脚本。我改变了其中一个场景的背景,以便明显区分场景的改变。我也使用了OnGUI,所以无需另外设置该脚本。我只是简单地创建了一个Sphere作为脚本中的预制。

这个demo在屏幕上显示了四个按钮,前两个可以切换场景来确认池管理器及其保存的已入池对象。后两个按钮可以在池中添加或删除对象。请注意,如果你只在原始容量内出入对象池,那么不需创建新对象。如果你从队列中出去的数量比初始的数量多,那将会新建对象来填满空缺,但是在池中只保存指定最大数量的对象。举个例子,如果指定初始数量为10,最大数量为15,然后出列20个,将会另外新建10个对象,但是在入列时,只有其中的5个对象会被储存在池中,另外5个将会被摧毁。

我不会在实际项目中使用OnGUI—而是使用ugui来代替。但是,为了快速演示的目的,OnGUI非常方便,因为全部的设置都可以在一个脚本中完成,然而ugui却需要设置各种如Canvas,Panels,Button,以及通过接口连接事件等等。
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实战训练课程中对对象池demo的想法并指出了我觉得需要提升的地方。我挑战了查找对象池要比添加和删除池中对象更高效的说法,并且我给出了两个测试及其测试结果评估。最后我展示了我自己的实现方法,一种更灵活,更安全,重用性及效率更高的方法。



原文作者:Jonathan Parham
原文链接: https://theliquidfire.wordpress.com/2015/07/06/object-pooling/

转载:http://www.unitymanual.com/thread-41351-1-1.html



  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值