【Unity】お手軽?なオブジェクトプール 【デザインパターン】

f:id:seiroise:20160606030443j:plain

備忘録第二弾ということで「オブジェクトプール」についてまとめておきます。

"お手軽?"とかは、ただの主観なのでもっと使いやすいオブジェクトプールがあったら教えてください。

オブジェクトプール?

まず「オブジェクトプールって何?」ってやつです。
オブジェクトプールは簡単に言えば「プログラムの実行中に作ると重たいから先にいっぱい作っておこう!」っていう仕組みです。

ゲーム以外のプログラムでも使われているものなので割といろんな人になじみ深いものなのかもしれないです。
聞いたところによると有名なHTTPサーバである「Apache」の内部でも使われているみたいです。

オブジェクトプールの効能

気になるオブジェクトプールの効能ですが、とりあえず下の図を見てください。

f:id:seiroise:20160606032602j:plain:w500

それぞれ1000個のGameObjectを生成した結果をProfilerで確認したものです。
1番上がおなじみのInstantiateで普通に生成した場合
2番目がオブジェクトプールで擬似的に生成した場合
3番目はおまけです

注目してほしいのは「Time ms」の項目で
普通にInstantiateした場合はInstantiateとActivate合わせて大体61.21ms
オブジェクトプールの方がActivateのみなので大体8.75ms
なんとオブジェクトプールを使うと速度が約7倍早くなってます!
驚異的ですねぇ~

しかしオブジェクトプールは速度と引き換えにバグを抱えやすくなる要因でもあるので、
きちんと管理していかなければなりません。

デザインパターンなの?

サブタイトルにデザインパターンとか書いてあるのは下の書籍でパターンとして紹介されていたからです。

この本はかなりおすすめです。一押しです。
多摩センターの丸善に一冊しか置かれてないのが不思議なぐらいです。

デザインパターンについて多少知識があった方がいいと思いますが、
作者の語り口やユーモアがいたるところに散りばめてあるので読むだけでもかなり面白いです。
あと裏表紙の著者とわんこが面白いです。

もう手遅れだけど、3年ぐらい前のプログラミング覚えたての時に欲しかったです。(いやほんとに…

前書きが長くなりましたが、次からオブジェクトプールを作っていきます。

作る前に仕様の確認

作ると言っていたのは嘘だったようです。
まずは仕様を確認しましょう。

  1. 管理はGameObjectのActiveのオンオフで
  2. 使わなくなったときはActiveをオフにするだけ
  3. 生成したオブジェクトは子にしてActiveがオフの状態にしてお
  4. 足りなくなったときは追加できるようにする
  5. ジェネリックで作っておいて用途に応じて継承する

とりあえずこんな感じです。
1と2はUnityで既にGameObjectという仕組みができてるのでそれをそのまんま使います。
とくに2は重要で元々の機能を使うことでオブジェクトプール用の基底クラスを作ったりしなくてよくなるのでお手軽さを上げてます。

ではではほんとに作っていきます。

ほんとに作る

まずはクラスの定義から

public class GenericObjectPool<T> : MonoBehaviour where T : Component {
  //クラスの中身
}

仕様の5番で基底はジェネリックにしとくようにしたのでこんな感じ。
一応whereで制限を掛けときます。

次にメンバたち

[Header("Option")]
[SerializeField]
private T obj;             //プールするオブジェクト
[SerializeField, Range(0, 1024)]
private int objNum = 128;    //プールするオブジェクトの数
[Header("Add Option")]
[SerializeField, Range(0, 1024)]
private int addObjNum = 128; //追加するオブジェクトの数

//内部パラメータ
private T[] pool;              //実際のプール
private bool awaked = false; //初期化フラグ

それぞれコメントに書いてある通りですね。

お次は生成(追加)部分

/// <summary>
/// プールにオブジェクトを追加
/// </summary>
private int AddPoolObj(T poolObj, int poolObjNum) {
    //新しいプールを作成
    T[] newPool;
    int count = 0;
    if(pool == null) {
        newPool = new T[poolObjNum];
    } else {
        newPool = new T[pool.Length + poolObjNum];
        //移し替え
        foreach (T elem in pool) {
            newPool[count] = elem;
            count++;
        }
    }
    //新しいのを追加
    int temp = count;  //countの値を一旦保持
    T obj;
    for (int i = 0; i < poolObjNum; i++, count++) {
        obj = Instantiate<T>(poolObj);
        obj.gameObject.SetActive(false);
        obj.name = poolObj.name;
        obj.transform.SetParent(transform);
        newPool[count] = obj;
    }
    //プール入れ替え
    pool = newPool;
    return temp;
}

一応追加と初期の生成も兼ねてるので、
プールがなかったら作って、プールがある場合は移し替えてます。

とあるところで配列の探索はforeachの方が高速やで!という記事(C#におけるforeach文とfor文による添え字アクセスのパフォーマンス検証 | ftvlog)を見かけたので、配列はなるべくforeachを使うようにしてます。

肝心の取り出し部分
単品

/// <summary>
/// オブジェクトの取り出し
/// </summary>
public T Pop() {
    //初期化確認
    if(!awaked) Awake();
    //使用可能オブジェクトの検索
    foreach(T elem in pool) {
        if(!elem.gameObject.activeInHierarchy) {
            elem.gameObject.SetActive(true);
            return elem;
        }
    }
    //足りない場合
    if(addObjNum > 0) {
        //追加して最初の要素を返す
        return pool[AddPoolObj(obj, addObjNum)];
    }
    return null;
}

複数

/// <summary>
/// 複数のオブジェクトの取り出し
/// </summary>
public T[] Pop(int popNum) {
    //初期化確認
    if (!awaked) Awake();
    //使用可能オブジェクトの検索
    T[] objs = new T[popNum];
    int popCount = 0;
    for(int i = 0; i < pool.Length; i++) {
        if(!pool[i].gameObject.activeInHierarchy) {
            pool[i].gameObject.SetActive(true);
            objs[popCount] = pool[i];
            popCount++;
            if(popCount >= popNum) break;
        }
        //足りない場合
        if((addObjNum > 0) && (i == pool.Length - 1)) {
            if(popCount < popNum) {
                //追加する
                AddPoolObj(obj, addObjNum);
            }
        }
    }
    return objs;
}

それぞれ毎回取得O(n)の計算量が掛かってしまっているのが若干痛いところですね。
O(1)にするためには「取り出し」と「しまい」があれば大丈夫なのですが今回オブジェクトプールが管理するのは「取り出し」だけなのでこんな感じになってます。
どちらとも管理しなくてもO(1)にする方法があったような気がするんだけど、忘れてしまったのでそれはまた思い出してからで。

とりあえず実装はこんな感じです。

次は継承先を作ります。恐らく一番使われるであろうTransform用のオブジェクトプールです。

/// <summary>
/// Transform用のオブジェクトプール
/// </summary>
public class TransformObjectPool : GenericObjectPool<Transform>{

    /// <summary>
    /// オブジェクトの取り出し
    /// </summary>
    public Transform Pop(Vector3 position) {
        Transform obj = Pop();
        if(obj == null) return null;
        obj.transform.position = position;
        return obj;
    }
    /// <summary>
    /// オブジェクトの取り出し
    /// </summary>
    public Transform Pop(Vector3 position, Quaternion rotation) {
        Transform obj = Pop();
        if (obj == null) return null;
        obj.transform.position = position;
        obj.transform.rotation = rotation;
        return obj;
    }
    /// <summary>
    /// 複数のオブジェクトの取り出し
    /// </summary>
    public Transform[] Pop(int popNum, Vector3 position) {
        Transform[] objs = Pop(popNum);
        foreach(Transform elem in objs) {
            if(elem == null) break;
            elem.position = position;
        }
        return objs;
    }
}

一旦継承させておくことでそのクラスにあった取り出したときの初期化が出来るようになります。

使うときはこんな感じで

//プールを取ってくる
TransformObjectPool pool = FindObjectOfType<TransformObjectPool>();
//(0, 0, 0)に生成
pool.Pop(Vector3.zero);

というわけでオブジェクトプールについての備忘録でしたー