【Unity】大量のオブジェクトの管理を軽量化!オブジェクトプールの使い方

デザインパターン

Unityでゲーム開発をしていると、特にアクションゲームやシューティングゲームでは、大量のオブジェクトをリアルタイムで生成・破棄する機会が多くあります。

しかし、GameObjectのInstantiate()関数やDestroy()関数は比較的重い処理のため、頻繁にオブジェクトを生成・破棄すると、パフォーマンスの低下やガベージコレクションを引き起こしてしまい、ゲームの動作が不安定になる可能性があります。

そこで、大量のオブジェクトの管理を軽量化する手法の一つとして「オブジェクトプール」という手法があります。
ということで今回は「オブジェクトプール」とはどんなものなのか、どう実装するのかについてお話していこうと思います。

スポンサーリンク

オブジェクトプールとは?

オブジェクトプールは、生成したオブジェクトをプールに保持しておき、必要に応じてプールからオブジェクトを取り出し再利用するデザインパターンです。

使用が終わったオブジェクトはプールに戻し、再び必要になった際に再利用するため、オブジェクトの生成(Instantiate)と破棄(Destroy)のコストを削減し、ゲームのパフォーマンスを向上させることができます。

そのため、とくにアクションゲームやシューティングゲームなどのように大量のオブジェクトを扱うゲームにおいては処理の軽量化としてかなり有効な手段となります。
UnityにおいてはInstantiateでオブジェクトを作成し、破棄する際にDestroyするのではなく、SetActiveによってアクティブを切り替えて管理することが多いです。

ObjectPool<T0>クラス

オブジェクトプールは自作のクラスで実装することも可能ですが、
Unity2021.1からUnity公式でUnityEngine.Pool.IObjectPool<T0>の実装クラスとしてUnityEngine.Pool.ObjectPool<T0>クラスがあります。

公式ドキュメントはこちらです。

Unity - Scripting API: ObjectPool<T0>

ObjectPool<T0>クラスはジェネリッククラスで、T0の部分はクラスであれば何でも大丈夫です。
つまり、ObjectPool<int>でもObjectPool<GameObject>でも使えます。

ObjectPool<T0>クラスでは内部的にStack<T>クラスでオブジェクトを管理します。

プロパティ

CountActiveプールによって作成され、現在使用中で返却されていないオブジェクトの数
CountAllプールによって作成されたオブジェクト全ての数
CountInactiveプールによって作成され、現在使用可能なオブジェクトの数

コンストラクタの引数

Func<T> createFuncプールが空の時に新しくインスタンスを生成するときに実行する関数。ほとんどの場合はnew T();になる。
Action<T> actionOnGetインスタンスがプールから取得される際に呼び出される処理。
Action<T> actionOnReleaseインスタンスがプールに返されるときに呼び出される処理。
Action<T> actionOnDestroyインスタンスをプールに返そうとした時にプール容量が上限に達しており、プールに返すことが出来なかった時に呼び出される処理。
bool collectionCheck同じインスタンスがプールにあるときに更にプールに返そうとした時に例外をスローするかどうか。エディタ上でのみ実行される。
int defaultCapacityスタックの初期容量。
int maxSizeプールの最大サイズ。

パブリックメソッド

Clearプールされているすべてのアイテムを削除する。プール内の項目ごとにactionOnDestroyが呼び出される。
Disposeプールされているすべてのアイテムを削除する。プール内の項目ごとにactionOnDestroyが呼び出される。
Getプールからインスタンスを取得する。プールが空の場合は、新しいインスタンスが作成される。
Releaseインスタンスをプールに戻す。

実装例

それではここからは、実際にObjectPool<T0>クラスを使用してオブジェクトプールの実装を試してみます。
実装する内容は「オブジェクトプールとは?」の項目でお見せした動画と同じものです。

オブジェクトプールで管理するオブジェクトのクラスを作成

出現から2秒で非アクティブになるだけのオブジェクト

EnemyObjectクラス

using System;
using UnityEngine;

public class EnemyObject : MonoBehaviour
{
    private Action _onDisable;  // 非アクティブ化するためのコールバック
    private float _elapsedTime;  // 初期化されてからの経過時間

    public void Initialize(Action onDisable)
    {
        _onDisable = onDisable;
        _elapsedTime = 0;
    }

    private void Update()
    {
        _elapsedTime += Time.deltaTime;

        if (_elapsedTime >= 2)
        {
            _onDisable?.Invoke();
            gameObject.SetActive(false);
        }
    }
}

今回は試しに管理するオブジェクトのクラスとして「EnemyObject」クラスを作成します。

オブジェクトプールの挙動を確認したいだけなので、
初期化され表示されてから2秒たったらコールバックを実行して非アクティブ化する
といった簡素なものになっています。

private Action _onDisable;

重要なポイントとしてはこのAction型の_onDisableです。
後程登場するオブジェクトプールの管理クラス側から、非アクティブ時に発火するコールバックを渡すことで、オブジェクトプール側に自身の返却を通知します。

オブジェクトプールの管理クラスを作成

EnemyObjectPoolクラス

using UnityEngine;
using UnityEngine.Pool;

public class EnemyObjectPool : MonoBehaviour
{
    // アクセスしやすいようにシングルトン化
    private static EnemyObjectPool _instance;
    public static EnemyObjectPool Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = FindObjectOfType<EnemyObjectPool>();
            }

            return _instance;
        }
    }

    [SerializeField] private EnemyObject _enemyPrefab;  // オブジェクトプールで管理するオブジェクト
    private ObjectPool<EnemyObject> _enemyPool;  // オブジェクトプール本体

    private void Start()
    {
        _enemyPool = new ObjectPool<EnemyObject>(
            createFunc: () => OnCreateObject(),
            actionOnGet: (obj) => OnGetObject(obj),
            actionOnRelease: (obj) => OnReleaseObject(obj),
            actionOnDestroy: (obj) => OnDestroyObject(obj),
            collectionCheck: true,
            defaultCapacity: 3,
            maxSize: 10
        );
    }

    // プールからオブジェクトを取得する
    public EnemyObject GetEnemy()
    {
        return _enemyPool.Get();
    }

    // プールの中身を空にする
    public void ClearEnemy()
    {
        _enemyPool.Clear();
    }

    // プールに入れるインスタンスを新しく生成する際に行う処理
    private EnemyObject OnCreateObject()
    {
        return Instantiate(_enemyPrefab, transform);
    }

    // プールからインスタンスを取得した際に行う処理
    private void OnGetObject(EnemyObject enemyObject)
    {
        enemyObject.transform.position = Random.insideUnitSphere * 5;
        enemyObject.Initialize(() => _enemyPool.Release(enemyObject));
        enemyObject.gameObject.SetActive(true);
    }

    // プールにインスタンスを返却した際に行う処理
    private void OnReleaseObject(EnemyObject enemyObject)
    {
        Debug.Log("Release");  // EnemyObject側で非アクティブにするのでログ出力のみ。ここで非アクティブにするパターンもある。
    }

    // プールから削除される際に行う処理
    private void OnDestroyObject(EnemyObject enemyObject)
    {
        Destroy(enemyObject.gameObject);
    }
}

基本的にオブジェクトプールを管理するクラスのインスタンスは1つだけなので、こちらのクラスはシングルトン化してアクセスしやすくしています。

_enemyPool = new ObjectPool<EnemyObject>(
    createFunc: () => OnCreateObject(),
    actionOnGet: (obj) => OnGetObject(obj),
    actionOnRelease: (obj) => OnReleaseObject(obj),
    actionOnDestroy: (obj) => OnDestroyObject(obj),
    collectionCheck: true,
    defaultCapacity: 3,
    maxSize: 10
);

こちらの部分で実際にオブジェクトプールを作成しています。
各引数の詳細についてはコンストラクタの引数をご覧ください。

今回の例ではそれぞれメソッドを作っていますが、ラムダ式

_enemyPool = new ObjectPool<EnemyObject>(
    createFunc: () =>
    {
        return Instantiate(_enemyPrefab, transform);
    },
    actionOnGet: (obj) =>
    {
        obj.transform.position = Random.insideUnitSphere * 5;
        obj.Initialize(() => _enemyPool.Release(obj));
        obj.gameObject.SetActive(true);
    },
    actionOnRelease: (obj) => 
    {
        Debug.Log("Release");
    },
    actionOnDestroy: (obj) => 
    {
        Destroy(obj.gameObject);
    },
    collectionCheck: true,
    defaultCapacity: 3,
    maxSize: 10
);

このように書いても大丈夫です。

重要な部分は

enemyObject.Initialize(() => _enemyPool.Release(enemyObject));

の部分です。

enemyObjectに対して、自分自身をオブジェクトプールに返却する処理をコールバックとして渡しています。
こうすることでオブジェクト自身で非アクティブ化のタイミングを管理し、非アクティブになったタイミングをオブジェクトプール側で把握しなくてもオブジェクトプールにインスタンスを返却することが出来ます。

オブジェクトプールを操作するボタン用のクラスを作成

ObjectPoolButtonクラス

using UnityEngine;

public class ObjectPoolButton : MonoBehaviour
{
    public void Create()
    {
        EnemyObjectPool.Instance.GetEnemy();
    }

    public void Clear()
    {
        EnemyObjectPool.Instance.ClearEnemy();
    }
}

オブジェクトプールを操作するボタンでは、
オブジェクトプールからインスタンスを取得するメソッド
オブジェクトプールを空にするメソッド
のみ定義しています。

シーン上に各オブジェクトを準備

  • 適当なプレハブアセットに「EnemyObject」クラスをアタッチ
  • シーン上にEmptyオブジェクトで「EnemyObjectPool」を作成し、「EnemyObjectPool」クラスをアタッチ
  • Canvasを作成し、その配下に「Create」ボタンと「Clear」ボタンを作成
  • 「Create」ボタンと「Clear」ボタンにそれぞれ「ObjectPoolButton」クラスをアタッチして、各メソッドが実行されるように設定

これで準備が完了です。

挙動確認

プールが足りない時のObjectPool.Get()の挙動

初期状態でCreateボタンを押した際、最初はプールが空でインスタンスが足りていないためcreateFuncが実行され、その後actionOnGetによる初期化が実行されています
そのため、2秒後にオブジェクトが非アクティブになるタイミングでactionOnReleaseが実行されていますね。

プールが足りているときのObjectPool.Get()の挙動

非アクティブなオブジェクトがある状態(プールにインスタンスがある状態)でCreateボタンを押した際はcreateFuncは実行されず、そのままactionOnGetによる初期化が実行されているのが分かります。

上限を超えた際のObjectPool.Release()の挙動

今回の例ではmaxSizeを10に設定しているため、actionOnReleaseが実行された数が10を超えたところでactionOnDestroyによってオブジェクトが破棄されているのが分かります。

createFuncとactionOnGetは10を超えても実行されている点については注意が必要です。
あくまでもプールに返そうとした時に上限を超えていたらactionOnDestroyが実行されるということですね。

ObjectPool.Clear()の挙動

最後にClearボタンを押した際にはその時点で非アクティブなオブジェクトに対してactionOnDestroyが実行されているのが分かります。
これはプールされているオブジェクトを削除するメソッドのためですね。

ObjectPoolクラスの基本認識として、
createFuncによって作成されたオブジェクトがプールされているオブジェクトという訳ではなく、ObjectPool.Release()によって渡されたオブジェクトのみがプールされているオブジェクトという認識をはっきりと持った方が良さそうです。

オブジェクトプールの注意点

オブジェクトの初期化とリセット

オブジェクトプールはインスタンスを使いまわす仕組みのため、初期化やリセット処理をしっかりと行わないと使いまわした際に前の状態が残ったままになってしまいます

特にコンストラクタで初期化を行っている場合は、その初期化は使いまわした際には行われないため注意が必要です。

オブジェクトの破棄とメモリリーク

オブジェクトプールを使用していると、生成したオブジェクトを非アクティブのまま残してしまいがちになってしまうことがあります。

不要なオブジェクトが増え続けた結果、メモリリークを引き起こす原因にもなってしまうため、不要になったオブジェクトは適切に都度Destroy()で破棄しましょう。

SetActive()への過信

SetActive()はInstantiate()やDestroy()に比べると処理は軽いです。
そのため、オブジェクトプールを使っているから大丈夫!と思ってSetActive()しすぎていると、思いのほか処理が重くなってしまい、最悪ゲームが落ちてしまう、ということもありえます。

SetActive()自体もそこそこ処理負荷は高いんですね。

ある程度軽減されてるとはいえ、オブジェクトのアクティブ化/非アクティブ化は工夫して1フレーム内で回し過ぎないようにするなどの対策はしましょう。

どのくらい負荷がかかっているのかについての検証はぜひこちらをご覧ください。

まとめ

  • 大量のオブジェクトの管理を軽量化する手法として、「オブジェクトプール」がある
  • Unity2021.1からUnity公式でObjectPool<T0>クラスがあり、比較的簡単にオブジェクトプールを実装可能
  • オブジェクトプールを使う際の注意点として
    初期化やリセットをしっかりと行わないと状態が残り続けてしまう
    ・都度適切にオブジェクトを破棄しないとメモリリークの要因になってしまう
    SetActive()もそこそこ処理は重い
    という点がある
デザインパターン基礎知識
スポンサーリンク
フーシャ

主にUnityを触ってるクライアントエンジニア。
学部の情報工学科卒業後、
スマホ向けゲームの開発/運営会社に新卒で入社して現在5年目の社会人です。

フーシャをフォローする
フーシャをフォローする

コメント

タイトルとURLをコピーしました