Unity 2021から利用できるUnity標準のオブジェクトプールについて

先日Unity Weeklyの仕込みで記事を漁っていたら、たまたまUnity標準のObjectPool実装が2021.1以降利用できることを知ったのでつぶやいてみたら、思った以上に反響がありました。せっかくなので簡単に触ってみたので記事を書きました。

検証には2021.2.0a6を用いました。

アルファ版なためこの記事で取り扱っている内容についても今後変更される可能性がある点に注意していただければと思います。

そもそもオブジェクトプールがなぜ必要なのか

銃の弾丸やパーティクルなど、ゲーム中で頻繁に登場と退場を繰り返す要素を大量に扱いたい場合に、その要素を愚直に、都度オブジェクトの生成と破棄を繰り返すような実装をすると、 Garbage Collectionの呼び出しによりスパイクが発生したり、またメモリのフラグメントによりゲームのパフォーマンスの低下を招き、ゲーム体験を低下させる可能性があります。

そのような問題を解決する手法の1つとして、オブジェクトの生成自体は初期化中にまとめて行い、そのオブジェクトを使い回すオブジェクトプールという手法がよく利用されます。

オブジェクトプールはUnity標準で用意されていないため、各々独自で実装していました。実装方法やサードパーティ製のツールについては下記のようなものがあります。

UnityEngine.Poolの登場

オブジェクトプールはパフォーマンスを最適化をしていく上でよく利用されるテクニックなので、要望も多かったのか2021.1以降ではUnityEngine.Poolという名前空間下でプール関係のクラスが実装されたようです。

ObjectPoolを使ってみる

まずは手を動かしながらObjectPoolの動作を追っていきます。

こちらに乗っているサンプル実装を一部改変しつつ動作を検証します。コード全文はそれぞれ下記で確認できます。

サンプルに登場するコンポーネントはそれぞれ下記を行います。

  • ObjectPoolExampleはオブジェクトプールの管理と、オブジェクトプールからパーティクルを取得しパーティクルを再生する。再生するパーティクルにはReturnToPoolをアタッチする。
  • ReturnToPoolは自身が保持するパーティクルの終了を検知して、そのタイミングでパーティクルをオブジェクトプールに返却する。

下記にエディタでサンプルを再生する様子を示します。

このように再生後のパーティクルが使い回されていることが確認できます。(パーティクルがピンクなのはテクスチャを貼り付けてないからです...)

それではこのサンプルのコードを追っていきます。

ObjectPoolの生成

まずObjectPoolの初期化は下記のように行います。

_pool = new ObjectPool<ParticleSystem>(
    OnCreatePoolObject,   // createFunc
    OnTakeFromPool,       // actionOnGet
    OnReturnedToPool,     // actionOnRelease
    OnDestroyPoolObject,  // actionOnDestroy
    true,                 // collectionCheck
    DefaultCapacity,      // defaultCapacity
    MaxSize               // maxSize
);

Unity - Scripting API: Pool.ObjectPool_1.ObjectPool

コンストラクタの型パラメータはプールするオブジェクトの型を指定します。(上記の場合はParticleSystemを指定しています。)

コンストラクタは引数が多いので順に説明していきます。まずはじめの4つの引数はオブジェクトプールでオブジェクトを扱う際にそれぞれのフェーズで呼び出されるコールバックメソッドを指定します。 引数順にそれぞれオブジェクト生成時(createFunc)、取得時(actionOnGet)、解放時(actionOnRelease)、破棄時(actionOnDestroy)に呼び出されます。

  1. プールから非アクティブな利用可能なオブジェクトを検索する。createFuncを用いてオブジェクトの生成を行う。
    • サンプルではOnCreatePoolObjectを指定しています
  2. オブジェクトをプールから貸出状態に変更する。このときactionOnGetが呼び出される。(2.は、1.で生成したか、プールから取得したかは関わらずに実施される)
    • サンプルではOnTakeFromPoolを指定しています
  3. オブジェクトが不要になったらプールに返却する。このときactionOnReleaseが呼び出される。
    • サンプルではOnReturnedToPoolを指定しています
  4. このときプールが最大量を超えていた場合(詳細は後述)、actionOnDestroyが呼び出されてオブジェクトが破棄される。
    • サンプルではOnDestroyPoolObjectを指定しています
  5. プールを破棄する場合、プールされたオブジェクトもセットで、すべてのオブジェクトに対してactionOnDestroyを呼び出して破棄される。

defaultCapacityはプールで利用するコレクションの初期容量です。maxSizeと合わせるで大きく問題にはならなそうです。

collectionCheckRelease時の二重解放のチェックを行うかどうか、maxSizeはプールオブジェクトの最大保持数です。こちらは後述します。

それではサンプルでの実装を追っていきます。

OnCreatePoolObject

プールで利用するオブジェクトを生成するときに呼び出されます。戻り値として生成したオブジェクトを返却します。

ParticleSystem OnCreatePoolObject()
{
    // プールするパーティクルシステムの作成
    // _nextIdはオブジェクト名をユニークにするために
    // インクリメンタルなIDを保持している
    var go = new GameObject($"Pooled Particle System: {_nextId++}");
    var ps = go.AddComponent<ParticleSystem>();
    // パーティクルの終了挙動をエミッター停止 & エミッションのクリアとする
    ps.Stop(true, ParticleSystemStopBehavior.StopEmittingAndClear);

    // パーティクルを1秒のワンショット再生とする
    // (ので約1秒後にパーティクルは停止する)
    var main = ps.main;
    main.duration = 1f;
    main.startLifetime = 1f;
    main.loop = false;

    // パーティクルが終了したらプールに返却するための
    // 挙動を実装したコンポーネントをアタッチ
    var returnToPool = go.AddComponent<ReturnToPool>();
    returnToPool.Pool = Pool;

    return ps;
}

パーティクルシステム用のゲームオブジェクトを生成してそのオブジェクトにParticleSystemコンポーネントをアタッチし、設定を行った後に戻り値として返却しています。

サンプルでは簡単のために、パーティクルシステムをワンショット再生として設定しています(ループ再生を行わない場合duration期間後に再生が終了する)。 また、再生終了後に自動的にプールに戻すために、ReturnToPool(後述)コンポーネントをアタッチし、戻り先のプールとして自身の保持しているオブジェクトプールの参照を渡します。

OnTakeFromPool

プールからオブジェクトを取り出すときに呼び出されます。取り出す際になにかオブジェクトに対して共通の処理を行うことができるので、オブジェクトに対してセットアップ処理などを差し込むなどで利用できます。

void OnTakeFromPool(ParticleSystem ps)
{
    // プールからパーティクルシステムを借りるときに
    // そのオブジェクトのアクティブをONにする
    ps.gameObject.SetActive(true);
}

サンプルでは上記のようにゲームオブジェクトのアクティブをONにしています。(プールに入っているときにはアクティブをOFFにする実装のため。)

OnReturnedToPool

プールにオブジェクトを返却するときに呼び出されます。こちらもOnTakeFromPoolと同様に解放時にオブジェクトに対して共通の処理を行うことができます。オブジェクトのリセット処理などを差し込むなどで利用できます。

void OnReturnedToPool(ParticleSystem ps)
{
   // 逆にプールにパーティクルシステムを返却するときに
   // そのオブジェクトのアクティブをOFFにする
   ps.gameObject.SetActive(false);
}

サンプルではゲームオブジェクトのアクティブをOFFにしています。

OnDestroyPoolObject

プールのオブジェクトを破棄するために呼び出されます。基本的にOnCreatePoolObjectで生成したものを破棄する処理を書きます。

void OnDestroyPoolObject(ParticleSystem ps)
{
    // プールされたパーティクルの削除が要求されているので、
    // オブジェクトを破棄する。
    //
    // OnCreatePoolObjectでオブジェクトを生成しているので
    // ここで破棄する責務があるという解釈
    Destroy(ps.gameObject);
}

サンプルではゲームオブジェクトそのものをGameObject.Destroyで破棄しています。このゲームオブジェクトにアタッチしたコンポーネントはGameObject.Destroy中にまとめて破棄されるのでコンポーネントごとには破棄を書いていません。

オブジェクトの取得

プールからオブジェクトを取得するにはGetを利用します。

Unity - Scripting API: Pool.ObjectPool_1.Get

// OnGUI中

// ボタンを押したらパーティクルを再生する
if (GUILayout.Button("Create Particles"))
{
    // プールからいくつかパーティクルを取得して再生する。
    // パーティクルはReturnToPoolコンポーネントにより
    // 再生終了後に自動的にプールへ返却される
    var amount = Random.Range(1, 10);
    for (var i = 0; i < amount; ++i)
    {
        // プールからオブジェクトを取得
        var ps = Pool.Get();
        // 適当な位置に移動させて
        ps.transform.position = Random.insideUnitSphere * 10f;
        // パーティクルを再生
        ps.Play();
    }
}

サンプルではvar ps = Pool.Get();によりプールからオブジェクトを取得しています。このとき未貸し出しの利用可能なオブジェクトがプールに存在しない場合、プールによりオブジェクトの生成が行われます。

ちなみにオブジェクトの取得にはpublic PooledObject<T> Get(out T v);というPooledObject構造体を返却するGetメソッドも存在します。PooledObjectSystem.IDisposableを実装していて、Disposeメソッド内でオブジェクトの返却が呼び出される実装になっています。そのためusing statementと組み合わせるとスコープを抜けたら自動でオブジェクトをプールに返却できます(参考: C#のUsing Statementと、C# 8.0で導入されたUsing Declarationについて | Yucchiy's Note)。

// StringBuilderのプール
static ObjectPool<StringBuilder> stringBuilderPool
    = new ObjectPool<StringBuilder>(
    () => new StringBuilder(),
    (sb) => sb.Clear());

void Update()
{
    // StringBuilderをプールから借りる
    using (stringBuilderPool.Get(out var stringBuilder))
    {
        stringBuilder.AppendLine("Some text");
        Debug.Log(stringBuilder.ToString());
    }

    // ここではプールから借りたStringBuilderが
    // 自動的に返却される
}

上記はこちらの公式ドキュメントから引用した実装で、StringBuilderをプールから取得することで、毎フレームStringBuilderをアロケーションすることを防いでいます。

オブジェクトの解放

プールから借りたオブジェクトを解放するにはReleaseを利用します。

Unity - Scripting API: Pool.ObjectPool_1.Release

// ReturnToPoolの実装から
void OnParticleSystemStopped()
{
    // パーティクルシステムが停止したときにここが呼び出される

    // プールから借りていたパーティクルを解放(返却)する
    Pool.Release(Particle);
}

上記はReturnToPoolのオブジェクト解放箇所を抜粋したものになります。 ParticleSystemmain.stopActionParticleSystemStopAction.Callbackを指定すると、再生終了時にOnParticleSystemStoppedが呼び出されるようになります。

サンプルではパーティクルシステム再生完了後にオブジェクトを返却したいため、OnParticleSystemStopped内でReleaseを呼び出しています。

Releaseを呼び出したときにプールの最大数(コンストラクタのmaxSize)を超えていた場合、プールによってオブジェクトが破棄されます。このときOnDestroyPoolObjectを介して破棄が実施されます。

1点注意としてコンストラクタでcollectionChecktrueを指定すると、すでに返却されたオブジェクトに対してReleaseを呼び出した場合に例外を吐きます。

// ReturnToPoolの実装から
void OnParticleSystemStopped()
{
    // パーティクルシステムが停止したときにここが呼び出される

    // プールから借りていたパーティクルを解放(返却)する
    Pool.Release(Particle);
    // 二度解放すると例外を吐く
    Pool.Release(Particle);
    // InvalidOperationException:
    // Trying to release an object that has already been released to the pool.
}

二重解放は場合によっては危険な処理の可能性があるためでしょうか。もし例外を吐きたくない場合はcollectionCheckfalseにします。 ただし、当然ですがOnReturnedToPoolは都度呼び出されます。

プールの破棄

プールを破棄したい場合はClearを呼び出します。

if (GUILayout.Button("Clear Pool"))
{
    // プールを破棄する
    Pool.Clear();
}

プール内に存在するオブジェクトはそれぞれに対してOnDestroyPoolObjectが呼び出されて破棄されます。

Count Property

プール内のアクティブなオブジェクト数、非アクティブなオブジェクト数、トータルオブジェクト数を知りたい場合は、それぞれプロパティが用意されています。

GUILayout.Label($"All = {Pool.CountAll}, Inactive = {Pool.CountInactive}, Active = {Pool.CountActive}");

この中でもCountInactiveが特に利用頻度が多いかもしれません。

IObjectPoolとObjectPool、ListPool

紹介したObjectPool以外にもLinkedPoolというクラスが存在します。どちらのクラスもIObjectPoolを実装しています。

違いはプールの内部のデータ構造です。ObjectPoolはスタックで、ListPoolは連結リストでプールが実装されています。

データ構造の違いによりGetReleaseでの参照や要素の追加の速度に影響がでそうです(未検証ですすいません。)。

ただしどちらもIObjectPoolを実装しているので、参照はIObjectPoolで持つような実装にしておけば、実装を変更することなく、内部のデータ構造を入れ替えることが可能です。

CollectionPool

オブジェクトプール以外にも、配列や辞書、ハッシュをプールするためのAPIも合わせて実装されました。

利用シーンとして平均や合計を計算するための一時キャッシュなど、一時的にリストを用いてなにか計算したいケースがある場合などです。

void Update()
{
    var vec = new List<int>();

    // vecを用いてなにか計算する(たとえば平均など)
}

ただし、例えば上記のようにUpdateで毎フレーム利用したい場合に毎フレームリストを確保するとアロケーションが毎フレーム発生して、GCによるパフォーマンス低下の原因となります。

そこでプロパティとして事前にリストを確保してそれを使い回す実装を行うことで、この問題を回避することができます。

private List<int> _vec = null;

void Start()
{

    _vec = new List<int>();
}

void Update()
{
    // _vecを用いて計算する
}

わざわざ一時的な計算のためにプロパティを持つと可読性が悪くなったりするため、このような場合にプールを使うとアロケーションなしでリストが利用できるので便利かもしれません。

void Update()
{
    using (CollectionPool<List<int>, int>.Get(out var vec))
    {
        // プールから借りたリストを利用して計算する

    }

    // ここではvecがプールに解放される
}

上記ではCollectionPoolを用いましたが、利用するコレクションが決まっていればDictionaryPoolHashSetPoolListPoolをそれぞれ用いるのが良さそうです。

GenericPool

コレクションではなく通常のクラスのインスタンスをプールしたい場合は、GenericPoolが利用できます。

Unity - Scripting API: GenericPool

using UnityEngine;
using UnityEngine.Pool;

public class GenericPoolExample
{
    class MyClass
    {
        public int someValue;
        public string someString;
    }

    void Start()
    {
        // プールから取得
        var instance = GenericPool<MyClass>.Get();

        // instanceを用いてなにか処理

        // オブジェクトの解放
        GenericPool<MyClass>.Release(instance);
    }
}

似たような実装にUnsafeGenericPoolがありますが、違いとして二重解放時に例外を吐くかどうかです。 具体的にはObjectPoolで紹介したcollectionCheckで型が違い、trueの場合の挙動を利用したい場合は場合はGenericPoolを利用しう、falseの挙動を利用したい場合はUnsafeGenericPoolを利用すると良いでしょう。

気になった点

ここまででObjectPoolの利用方法について簡単に説明しましたが、触っていていくつか気になった点を挙げておきます。

  • プールされているオブジェクトが足りないときにはじめてオブジェクトをインスタンス化する挙動
    • ゲームでは初期化でまとめてオブジェクトをインスタンス化してプールして、Getでは原則アロケーションしない実装のほうが好ましい。
    • 初期化フェーズで必要個数Getして、すぐにReleaseすることで事前プールは可能なので問題ないか。
    • オブジェクトが足りない場合にインスタンス化したいのをやめたい場合は、取得前にCountInactiveを確認する実装に倒せば良さそう。
      • そもそもオブジェクトが足りない場合は仕様を見直したほうがよさそう。
  • maxSizeを超えたオブジェクト生成を行った場合に、アクティブな要素数とCountActiveが合わないケースがある
    • バグ?
    • サンプルで Create Particlesボタンを叩きまくると再現する。
  • スレッドセーフではない点
    • 基本問題なさそうだが...、そういう処理を書く場合はGetRelease時にロックが必要かも?
    • Taskとか使うと場合によっては考慮が必要?

まとめ

2021.1から利用できるUnity標準のオブジェクトプールについて紹介しました。