ゲーム開発チーム「丸ダイス」の開発ブログです! 公式サイトはこちら

【Unity】シンプルかつ軽量なオブジェクトプールの実装

UnityのInstantiateが遅い。皆さん言ってますね。実際、モバイルとかでエフェクトや弾をガンガン出していると目に見えて遅くなっていきます。 というわけで、「使い終わったオブジェクトは貯めておいて(あるいは最初にまとめて生成して)、必要になったら取り出す」という「オブジェクトプール」の方法が良く取られます。

実装の方法はいろいろあるようですが、軽く調べたところ今回実装した方法はスマートかつ無駄なオーバーヘッドも少ない感じがしたので、上げておこうかなという次第です。

Githubに公開しておきました(被使用・使用側の実装、サンプルシーン) github.com

実装

using UnityEngine;
using System.Collections.Generic;

public abstract class PoolObj<T> : MonoBehaviour {
    private static GameObject mOriginal;
    private static Stack<T> mObjPool = new Stack<T>();

    public static void SetOriginal(GameObject origin) {
        mOriginal = origin;
    }

    public static T Create()
    {
        T obj;
        if (mObjPool.Count > 0)
        {
            obj = Pop();
        }
        else
        {
            var go = Instantiate<GameObject>(mOriginal);
            obj = go.GetComponent<T>();
        }
        (obj as PoolObj<T>).Init();
        return obj;
    }

    private static T Pop()
    {
        var ret = mObjPool.Pop();
        return ret;
    }

    public static void Pool(T obj)
    {
        (obj as PoolObj<T>).Sleep();
        mObjPool.Push(obj);
    }
    
    public static void Clear()
    {
        mObjPool.Clear();
    }

    public abstract void Init();
    public abstract void Sleep();
}

これだけです。こいつをPoolObj.csなどとしてプロジェクトに放り込めばスクリプト単体で動作します。

使用時の最小コード - Hoge オブジェクトをプールする

  • プールされるオブジェクトは Init(); Sleep(); で初期化時、休眠時の処理を実装する(しなければコンパイルエラーになる)
//Hoge.cs プールされるオブジェクト
using UnityEngine;

public class Hoge : PoolObj<Hoge> {
    public override void Init()
    {
           //gameObject.SetActive(true);
           //初期化時の処理を実装
    }
    public override void Sleep()
    {
           //gameObject.SetActive(false);
           //休眠時の処理を実装
    }
}
//Hogeを使用する側 (呼び出し位置は全て任意. SetOriginal() が 最初のCreate(); より先なら良い)
    public GameObject originalPrefab; // Hoge.cs が付いている
...
    Hoge.SetOriginal(originalPrefab); //元となるプレハブを指定
...
    Hoge hoge = Hoge.Create(); //インスタンスを取得
...
    Hoge.Pool(hoge); //インスタンスを休眠

所感

どうも、プールする親玉もMonoBehaviourにしてListで管理するみたいな実装が多い印象を受けたのですが、今回の実装のようにすれば任意のコードから操作出来ますし、Pool(); は自分で呼んでも他の誰かから呼ばれてもOKです。 (そもそも、オブジェクトの管理者にUpdate(); などMonoBehaviourの機能は必要ありません)

初期化時・休眠時の処理についても、よくある実装では gameObject.SetActive(); を使うことが多い印象です。確かに一番典型的な例ではこれで構わないでしょうね。

が、巨大なオブジェクトでは SetActive(); はInstantiate(); ほどでないにせよ遅いことが分かっていますし、後のBulletでの使用例のように、変数初期が必要な場合もあります。

つまり、オブジェクトごとに最適な休眠状態は違うが、オブジェクト指向に則った実装では、そのオブジェクトの使用者はそれを意識する必要がない。 ということです。

こんな理由で上のような実装になりました。注意点としては

  • シーンをまたいだ際にClearしないと、nullがプールされた状態のままになる

といったところでしょうか。最後にサンプルソースを。(Githubと同じです)

使用例 - クリックされた位置に Bullet を発射するシンプルなシーン

サンプルコード

//Bullet.cs プールされるオブジェクト

using UnityEngine;

public class Bullet : PoolObj<Bullet> {

    int mCount = 0;
    private Vector3 mVelocity;
    // Update is called once per frame
    void Update () {
        if( mCount > 120)
        {
            Bullet.Pool(this);
        }
        transform.Translate(mVelocity);
        mCount++;
    }
    public void Shoot(Vector3 pos, Vector3 velocity) {
        mVelocity = velocity;
        transform.position = pos;
    }


    public override void Init()
    {
        mCount = 0;
        gameObject.SetActive(true);
    }

    public override void Sleep()
    {
        gameObject.SetActive(false);
    }
}
//Game.cs 使用者

using UnityEngine;

public class Game : MonoBehaviour {

    public GameObject mBulletPrefab;

    void Awake()
    {
        Bullet.SetOriginal(mBulletPrefab);
    }

    void Update()
    {
        // ボタンが押されたらその方向に弾を撃つ
        if (Input.GetMouseButtonDown(0))
        {
            Bullet bullet = Bullet.Create();

            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            float speed = 1;
            bullet.Shoot(ray.origin, ray.direction * speed);
        }
    }
}