【Unity】最適化に必須!オブジェクトプーリングでInstantiateとDestroyを撲滅する

作成: 2025-12-07最終更新: 2026-07-10

弾を連射するたびにカクつく——原因はInstantiateとDestroyの繰り返しです。オブジェクトを使い回すプーリングの仕組みを、自前のシンプル実装とUnity公式のObjectPool<T>(UnityEngine.Pool)の両方で解説します。

弾を連射すると、撃つたびに一瞬カクつく。敵のラッシュやエフェクトの多い場面でフレームレートが落ちる。Profiler を見るとInstantiateGC.Collectが上位に——。

Instantiate()(生成)とDestroy()(破棄)は、メモリの確保・解放を伴う重い処理です。しかも破棄されたオブジェクトはガベージになり、GCスパイク まで誘発します。1発ずつなら問題なくても、毎秒何十回と繰り返せば確実にカクつきます。

解決策が オブジェクトプーリング(Object Pooling)——「一度作ったものは捨てずに使い回す」テクニックです。

オブジェクトプーリングのイメージ。レンタルショップのカウンターで弾を貸し出し、使い終わった弾が返却スロープから棚へ戻っていく循環

この記事でわかること

  • Instantiate/Destroyなぜ重いのか
  • プーリングの3ステップ——事前生成・取得・返却
  • 自前のシンプル実装(Queue版)で仕組みを理解する
  • Unity公式のObjectPool<T>(UnityEngine.Pool・Unity 2021+)の使い方
  • 実践:ヒットエフェクトを自動返却つきのプールで回す

Sponsored

オブジェクトプーリングとは

考え方はレンタルショップと同じです。商品(オブジェクト)は毎回製造(Instantiate)して廃棄(Destroy)するのではなく、棚に置いておいて貸し出し、使い終わったら返してもらう——このサイクルを回します。

プーリングサイクルの図。事前生成(弾を棚にストック)→取得(借りてアクティブ化)→使用(発射)→返却(非アクティブ化して棚へ戻す)が円環になっている
  1. 事前生成: ゲーム開始時など負荷が問題にならないタイミングで、必要数(例: 弾30個)をInstantiateし、全部SetActive(false)で眠らせて「プール」に保管する。
  2. 取得: 弾が必要になったら、Instantiateする代わりにプールから1つ取り出し、SetActive(true)にして位置・向きをセットし直して使う。
  3. 返却: 弾が当たった・画面外に出たら、Destroyする代わりにSetActive(false)でプールに戻す。

これでゲームプレイ中のInstantiate/Destroyゼロになります。負荷の面で比べると、違いは一目瞭然です。

毎回Instantiate/Destroyする方式とプーリングの比較。左は生成と破棄を繰り返して負荷グラフがスパイクだらけ、右はプールの循環で負荷グラフが平坦

生成破棄を繰り返す左側は、CPUスパイクに加えてガベージを撒き散らし、あとから GCスパイク という時限爆弾も仕掛けます。プーリングの右側は、最初にまとめて払うコスト以外はほぼフラットです。

自前で実装して仕組みを理解する

まず、仕組みがそのまま見える最小限の実装です。Queueで非アクティブなオブジェクトを管理します。

// ObjectPool.cs
using System.Collections.Generic;
using UnityEngine;

public class ObjectPool : MonoBehaviour
{
    public GameObject objectToPool; // プールする対象のPrefab(弾など)
    public int amountToPool = 30;   // 最初に生成しておく数

    private Queue<GameObject> pooledObjects;

    void Start()
    {
        pooledObjects = new Queue<GameObject>();

        for (int i = 0; i < amountToPool; i++)
        {
            GameObject obj = Instantiate(objectToPool);
            obj.SetActive(false);
            pooledObjects.Enqueue(obj);
        }
    }

    public GameObject GetPooledObject()
    {
        if (pooledObjects.Count > 0)
        {
            GameObject obj = pooledObjects.Dequeue();
            obj.SetActive(true);
            return obj;
        }
        // 棚が空なら追加生成してプールを拡張する(負荷はかかるが弾切れよりまし)
        return Instantiate(objectToPool);
    }

    public void ReturnObjectToPool(GameObject obj)
    {
        obj.SetActive(false);
        pooledObjects.Enqueue(obj);
    }
}

使う側は、Instantiateと書いていた場所をGetPooledObject()に、Destroyと書いていた場所をReturnObjectToPool()に置き換えるだけです。

// PlayerShooter.cs(抜粋)
void Fire()
{
    GameObject bullet = bulletPool.GetPooledObject();
    bullet.transform.SetPositionAndRotation(firePoint.position, firePoint.rotation);
    bullet.GetComponent<Bullet>().SetPool(bulletPool); // 弾が自分で帰れるように
}

// Bullet.cs(抜粋): 当たったら自分でプールに帰る
void OnCollisionEnter(Collision collision)
{
    myPool.ReturnObjectToPool(gameObject);
}
Sponsored

公式のObjectPool<T>を使う

Unity 2021以降には、公式のプーリングAPI UnityEngine.Pool が標準搭載されています。自前実装で書いた処理に加えて、上限管理・二重返却チェック・破棄時の後始末まで揃っているため、実戦ではこちらが第一候補です。

コンストラクタの引数が多く見えますが、役割を整理するとレンタルショップの運営マニュアルそのものです。

ObjectPool<T>の構成図。createFunc=新しく作る方法、actionOnGet=貸出時にすること、actionOnRelease=返却時にすること、maxSize=棚の上限、の4項目
// BulletSpawner.cs
using UnityEngine;
using UnityEngine.Pool;

public class BulletSpawner : MonoBehaviour
{
    public Bullet bulletPrefab;
    private ObjectPool<Bullet> pool;

    void Awake()
    {
        pool = new ObjectPool<Bullet>(
            createFunc: () => Instantiate(bulletPrefab),          // 棚が空のときの生成方法
            actionOnGet: bullet => bullet.gameObject.SetActive(true),   // 貸し出し時
            actionOnRelease: bullet => bullet.gameObject.SetActive(false), // 返却時
            actionOnDestroy: bullet => Destroy(bullet.gameObject), // 上限超過分の処分
            defaultCapacity: 30,
            maxSize: 100 // これ以上は返却されてもDestroyされる
        );
    }

    public void Fire(Vector3 position, Quaternion rotation)
    {
        Bullet bullet = pool.Get(); // 取得
        bullet.transform.SetPositionAndRotation(position, rotation);
        bullet.Init(returnAction: () => pool.Release(bullet)); // 返却手段を渡す
    }
}

弾側は、寿命が尽きたら渡された返却手段を呼ぶだけです。

// Bullet.cs
using System;
using UnityEngine;

public class Bullet : MonoBehaviour
{
    private Action returnToPool;

    public void Init(Action returnAction)
    {
        returnToPool = returnAction;
    }

    void OnCollisionEnter(Collision collision)
    {
        returnToPool?.Invoke(); // Destroyの代わりにプールへ帰る
    }
}

maxSizeを超えた分は返却時に破棄されるため、「一瞬だけ大量に必要だった」あとにプールが膨れ上がったまま、という事態を防げます。

導入の判断基準は1行です。 「同じPrefabのInstantiateが毎秒何度も走るなら、プールにする」。弾・敵・ヒットエフェクト・ダメージ数字がその代表です。

実践:ヒットエフェクトをプールで回す

弾の次にプール化の効果が大きいのが ヒットエフェクト です。剣戟アクションの斬撃ヒット、弾幕シューティングの着弾の火花、ローグライトで敵を巻き込む範囲攻撃——ジャンルを問わず、エフェクトは「戦闘中ずっと、大量に、短命で」生成されるプーリングの本命です。

ただし弾と違って、エフェクトには「当たったら返却」のようなわかりやすい返却タイミングがありません。そこで 「再生が終わったら自分で棚に帰る」 仕組みを組み込みます。鍵になるのはParticleSystemStop Action です。

ヒットエフェクトのプール運用の流��れ。攻撃がヒットしてエフェクト再生→再生終了でOnParticleSystemStoppedが呼ばれる→自動でプールの棚へ返却される

まず、エフェクトのPrefab側でParticleSystemのMainモジュールにある Stop ActionCallback に設定します。これで再生が終わった瞬間にOnParticleSystemStopped()が呼ばれるようになります。

// HitEffect.cs(エフェクトPrefabに付ける)
using System;
using UnityEngine;

public class HitEffect : MonoBehaviour
{
    private Action returnToPool;

    public void Init(Action returnAction)
    {
        returnToPool = returnAction;
    }

    // Stop ActionをCallbackにしておくと、再生終了時に呼ばれる
    void OnParticleSystemStopped()
    {
        returnToPool?.Invoke();
    }
}
// EffectPool.cs(シーンに1つ置く)
using UnityEngine;
using UnityEngine.Pool;

public class EffectPool : MonoBehaviour
{
    public HitEffect effectPrefab;
    private ObjectPool<HitEffect> pool;

    void Awake()
    {
        pool = new ObjectPool<HitEffect>(
            createFunc: () =>
            {
                HitEffect effect = Instantiate(effectPrefab);
                effect.Init(() => pool.Release(effect)); // 帰り道は生成時に1回だけ教える
                return effect;
            },
            actionOnGet: effect => effect.gameObject.SetActive(true),
            actionOnRelease: effect => effect.gameObject.SetActive(false),
            actionOnDestroy: effect => Destroy(effect.gameObject),
            defaultCapacity: 20,
            maxSize: 50
        );
    }

    // 攻撃がヒットした場所で呼ぶだけ
    public void PlayAt(Vector3 position)
    {
        HitEffect effect = pool.Get();
        effect.transform.position = position;
        effect.GetComponent<ParticleSystem>().Play();
    }
}

使う側は、敵にダメージを与えた処理の中でeffectPool.PlayAt(hitPoint)と1行呼ぶだけです。エフェクトは再生し終わると勝手にプールへ帰るので、呼びっぱなしで管理不要——乱戦で1秒間に何十発ヒットしても、Instantiateは最初の数回しか走りません。

ポイントは2つです。 「返却タイミングをオブジェクト自身に持たせる」(Stop Action + OnParticleSystemStopped)と、 「帰り道(pool.Release)はcreateFuncで生成時に1回だけ渡す」。この形はダメージ数字のポップアップや足音の土煙にもそのまま流用できます。エフェクト自体の作り方は パーティクルシステム入門 を、プール化の効果測定は Profilerの使い方 を参照してください。

おまけ:先に知っておくと良いこと

  • 使い回しの初期化忘れに注意: プールから出てきたオブジェクトは前回の状態を引きずっています。Rigidbodyの速度、体力、トレイルの残像などは、OnEnable()か取得直後にリセットしてください。「2発目の弾があらぬ方向に飛ぶ」バグの定番原因です。
プールの使い回しで前回の状態が残る注意図。左は返却されたままの弾で速度・向きが前回のまま(警告)、右は取り出すときに位置・速度・状態を初期化してから使う(正解)
  • TrailRendererの残像: 使い回すと前回の位置から線がつながって見えます。返却時か取得時にtrailRenderer.Clear()を呼びます。
  • 二重返却に注意: 同じオブジェクトを2回Releaseすると、公式ObjectPool<T>はエラーで教えてくれます(コンストラクタのcollectionCheck引数・デフォルトtrue)。自前実装ではQueueに同じものが2つ入り、あとで同じ弾が2箇所から発射される怪現象になります。
  • コレクション用のプールもある: UnityEngine.PoolにはListPool<T>DictionaryPool<K,V>もあり、「一時的なListが毎フレーム欲しい」場面の GC対策 に使えます。

まとめ

  • Instantiate/Destroyの繰り返しは二大パフォーマンスキラー。CPU負荷とGCスパイクの両方を引き起こす。
  • プーリングは 事前生成→取得→返却 の3ステップで、生成破棄をゼロにする。
  • Unity 2021以降は公式のObjectPool<T>が第一候補。上限管理と二重返却チェックが最初から揃っている。
  • 返却タイミングが曖昧なエフェクトは、 Stop Action + OnParticleSystemStopped で「自分で帰る」形にする。
  • プールから出てきたオブジェクトは 前回の状態を引きずる。取得直後のリセットを習慣にする。
  • 対象の見極めは 「同じPrefabのInstantiateが毎秒何度も走るか」

特にモバイルでは、プーリングは「推奨」ではなく「必須」のテクニックです。あなたのプロジェクトで一番InstantiateされているPrefabはどれですか? それがプール化の最初の候補です。