Unity開発におけるゲームの快適な動作を妨げる最大の敵の一つが、「ガベージコレクション(GC)によるスパイク(一瞬のカクつき)」です。特にシューティングゲームの弾丸、アクションゲームのヒットエフェクト、あるいはRPGで頻繁に出現するダメージ数字のUIなど、数多く作ってすぐに消えるオブジェクトに対して InstantiateDestroy をそのまま使用すると、ヒープメモリが瞬く間に断片化し、GCが走るたびにフレームレートが低下してプレイヤーの操作を阻害してしまいます。

本記事では、頻繁な生成・破棄がパフォーマンスを悪化させるメカニズムと、その解決策として必須となる「オブジェクトプール(ObjectPool)パターン」の正しい実装フロー、そしてUnity 2021.1以降で標準搭載された UnityEngine.Pool の効果的な活用方法について解説します。

1. 根本原因:Instantiate と Destroy が重い理由とメモリ断片化

Unityにおいて、InstantiateDestroy は非常に強力で扱いやすいAPIですが、内部的におこなわれている処理は極めて複雑で、多大な処理コストが発生します。

Instantiate/Destroyのオーバーヘッドとオブジェクトプールによる再利用メカニズムの比較図

図:生成・破棄によるGCスパイク発生と、オブジェクトプールによるメモリ安定化の仕組み

生成と破棄がパフォーマンスに悪影響を及ぼす主な要因は以下の3点です:

  • シリアライズデータ展開のCPU負荷: Instantiate を呼ぶと、Unityは対象のPrefabアセット(ディスクまたはメモリ上)からシリアライズされたデータを読み込み、それをメモリ上に新規展開してGameObjectや各種コンポーネントを実体化させます。この処理はメインスレッドで同期的におこなわれるため、大量に実行すると顕著なフレーム遅延の原因になります。
  • Transform階層(親子関係)の再構築: 新規に生成されたオブジェクトがシーンツリーに参加する際、および破棄される際、Unityは内部のシーン構造(Transformの階層リストなど)の配列を再計算・再アラインします。子オブジェクトが多いほど、この再計算処理(Transform hierarchy update)のオーバーヘッドが指数関数的に増大します。
  • GC Allocの発生とヒープメモリの断片化(フラグメンテーション): Instantiate を行うと、Mono/IL2CPPのヒープ領域にメモリが割り当てられます。一方、Destroy を呼んでも、そのオブジェクトが確保していたメモリは即座にOSやUnityに返却されるわけではありません。ガベージコレクタ(GC)が次に起動してスキャンを完了するまでメモリ上に「ゴミ(Garbage)」として残留し、GC走行時にスレッドをブロック(Stop The World)させ、カクつき(スパイク)を発生させます。さらに、サイズの異なるメモリの確保・解放が繰り返されると、メモリ領域が虫食い状になる「断片化」が起こり、実際にはメモリが空いているのに連続した領域が確保できず、ヒープ全体のサイズが勝手に拡張されてアプリのメモリ使用量が増大し続けます。

2. 解決策:UnityEngine.Pool を使用した標準的なオブジェクトプールの構築

これらの問題を解決するために、事前に一定数のオブジェクトを生成して「プール(一時保管場所)」に確保しておき、必要なときにそこから借りてアクティブ化し、不要になったら非アクティブ化してプールへ返却する「オブジェクトプール」を導入します。

Unity 2021.1以降では、汎用的なオブジェクトプールを提供する UnityEngine.Pool.ObjectPool が標準で搭載されており、自分でプールクラスをゼロから書く必要がなくなりました。

実装例①:プールで管理されるオブジェクト(例:弾丸クラス)の実装

まず、プールから取り出される側のオブジェクト(弾丸)を実装します。このオブジェクトは、「自分がどのプールに属しているか」を知っており、ライフサイクルが終わった(敵に当たった、または一定時間経過した)際に、自身でプールに返却できるように設計します。

using UnityEngine;
using UnityEngine.Pool;

public class Bullet : MonoBehaviour
{
    private IObjectPool<Bullet> originPool;
    [SerializeField] private float speed = 20f;
    [SerializeField] private float lifeTime = 2f;
    private float timer;

    // プールへの参照をセットする初期化関数
    public void SetPool(IObjectPool<Bullet> pool)
    {
        originPool = pool;
    }

    private void OnEnable()
    {
        timer = 0f;
    }

    private void Update()
    {
        transform.Translate(Vector3.forward * speed * Time.deltaTime);

        timer += Time.deltaTime;
        if (timer >= lifeTime)
        {
            ReleaseToPool();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        // 何かに衝突したらプールへ返却
        ReleaseToPool();
    }

    private void ReleaseToPool()
    {
        if (originPool != null)
        {
            // プールへ返却(非アクティブ化)
            originPool.Release(this);
        }
        else
        {
            // プールがない場合のフォールバック
            Destroy(gameObject);
        }
    }
}

実装例②:プールマネージャー(生成・管理する側)の実装

次に、ObjectPool をラップし、弾丸のインスタンス作成・プールからの取得・返却・オブジェクト破棄のイベントハンドラを定義するマネージャーを作成します。

using UnityEngine;
using UnityEngine.Pool;

public class BulletSpawner : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private int defaultCapacity = 20;
    [SerializeField] private int maxPoolSize = 100;

    private IObjectPool<Bullet> bulletPool;

    private void Awake()
    {
        // UnityEngine.Pool.ObjectPoolを初期化
        bulletPool = new ObjectPool<Bullet>(
            createFunc: CreateBulletInstance,          // プール内にオブジェクトが足りない時に新規生成する関数
            actionOnGet: OnTakeBulletFromPool,        // プールからオブジェクトを取り出した際の処理
            actionOnRelease: OnReturnBulletToPool,    // プールへオブジェクトを返却した際の処理
            actionOnDestroy: OnDestroyPoolObject,     // プールサイズ上限を超えて不要になったオブジェクトを破棄する処理
            collectionCheck: true,                     // 既にプールに返却済みのオブジェクトを二重返却しようとした際に警告を出すチェック(デバッグ用)
            defaultCapacity: defaultCapacity,          // 初期キャパシティ
            maxSize: maxPoolSize                       // プールで保持する最大数
        );
    }

    // 新規生成コールバック
    private Bullet CreateBulletInstance()
    {
        Bullet instance = Instantiate(bulletPrefab);
        instance.SetPool(bulletPool); // 弾自身にプール参照を渡す
        return instance;
    }

    // 取得時コールバック
    private void OnTakeBulletFromPool(Bullet bullet)
    {
        bullet.gameObject.SetActive(true); // アクティブ化
    }

    // 返却時コールバック
    private void OnReturnBulletToPool(Bullet bullet)
    {
        bullet.gameObject.SetActive(false); // 非アクティブ化
    }

    // 破棄時コールバック
    private void OnDestroyPoolObject(Bullet bullet)
    {
        Destroy(bullet.gameObject); // メモリ上限オーバー時の実体破棄
    }

    // 外部から弾を発射するメソッド
    public void FireBullet(Vector3 position, Quaternion rotation)
    {
        // プールからインスタンスを取得(足りなければ内部でCreateBulletInstanceが走る)
        Bullet bullet = bulletPool.Get();
        
        // 位置と回転を設定
        bullet.transform.position = position;
        bullet.transform.rotation = rotation;
    }
}

3. オブジェクトプールで絶対に避けるべきバッドプラクティス

オブジェクトプールを導入しても、誤った実装をしているとCPU負荷が減らなかったり、むしろ新たなバグを誘発したりします。以下の項目に注意してください。

1. 取得・返却時に毎回親子関係を変更(SetParent)する

シーンビューを整理するために、プールされた非アクティブなオブジェクトを「Pool_Root」などの親オブジェクトの下に入れ、取得時に親から切り離すという実装をよく見かけます。しかし、transform.SetParent の実行はUnity内部でTransformツリーの再計算を伴うため、想像以上に高負荷です。何百発もの弾で毎回これをおこなうと、オブジェクトプールを導入した意味が半減します。
対策: 親階層は生成時に一度だけ設定するか、基本的に親子関係を作らずルートに置いたままアクティブ・非アクティブのみを制御するようにしてください。

2. プールに戻したオブジェクトの「初期化漏れ」によるバグ

オブジェクトプールは状態をリセットせずに使い回すため、返却前の状態(速度、タイマー、HP、エフェクトの再生状況など)が引き継がれ、不自然な挙動を引き起こす原因になります。
対策: プールから取得した際(あるいは OnEnable 時)に、必ずすべての移動速度、現在HP、物理挙動(Rigidbodyの速度リセットなど)、タイマー等のステートを初期値にリセットするコードを実行してください。

3. 二重返却(Double Release)と不正アクセス

一度プールに返却して非アクティブ化されたオブジェクトに対して、別のスクリプトが参照を持ち続けており、後からそのステートを書き換えたり、再度プールに返却(二重返却)してしまうと、プール内のリスト構造が破損してゲームが停止したり、意図しない場所でオブジェクトがアクティブ化する致命的なバグの原因になります。
対策: Unity標準の ObjectPool は、コンストラクタの collectionCheck 引数を true に設定することで、二重返却時に例外(InvalidOperationException)を投げてくれるため、開発中は必ずこれを有効にしてデバッグしてください。また、返却されたオブジェクトに対する不要な参照は、速やかに null を代入してクリーンに保ちます。

4. 高度な最適化:ListやStringBuilder、カスタムクラスのプール化

ゲームオブジェクトだけでなく、C#の ListDictionary、文字列連結に使う StringBuilder などのメモリ確保もGCスパイクの原因になります。Unity 2021.1以降には、これら一般的なC#コレクション用のプールも標準提供されています。

using UnityEngine;
using UnityEngine.Pool;
using System.Collections.Generic;

public class InventorySystem : MonoBehaviour
{
    public void UpdateInventoryUI()
    {
        // UnityEngine.Pool.ListPoolを使って、ヒープ割り当てなしの一時リストを取得
        using (var pooledList = ListPool<string>.Get(out List<string> tempList))
        {
            // tempListに対する処理(GC Allocなしでヒープ確保を回避)
            tempList.Add("Sword");
            tempList.Add("Shield");

            foreach (var item in tempList)
            {
                Debug.Log(item);
            }
        } // usingスコープを抜けると、自動的にリストの中身がクリアされてプールへ返却される
    }
}

毎フレームやイベントトリガーごとに一時リストを new List() して使い捨てるような設計がある場合は、この ListPoolDictionaryPool に置き換えることで、コード全体のクオリティを高めつつGCスパイクの芽を事前に摘むことができます。

5. 比較まとめ:通常処理とオブジェクトプール適用のメリット

比較項目 通常のInstantiate / Destroy オブジェクトプール(UnityEngine.Pool)
CPU処理時間(スパイク) 高負荷(シリアライズ展開・Transformツリー再構築が毎回実行される) 低負荷(アクティブ・非アクティブのトグルと初期化処理のみ)
GC Alloc(ゴミメモリ確保) 大量に発生(GCスパイクの直接原因) ゼロ(初回生成時およびプール拡張時のみ発生)
メモリ断片化と使用量 激しい(メモリ領域がバラバラになりヒープが肥大化しやすい) 最小限(一定のメモリ領域が固定確保され安定動作する)
実装の手間 極めて簡単(1行で記述可能) 中程度(プールの設定と返却管理のロジック構築が必要)

ゲーム開発の初期フェーズでは InstantiateDestroy で素早くプロトタイプを作り、オブジェクトが頻出する箇所が明確になった段階で UnityEngine.Pool.ObjectPool を組み込むことで、実装効率と動作パフォーマンスを高いレベルで両立させることができます。ぜひプロジェクトに導入してみてください。