弾を連射すると、撃つたびに一瞬カクつく。敵のラッシュやエフェクトの多い場面でフレームレートが落ちる。Profiler を見るとInstantiateとGC.Collectが上位に——。
Instantiate()(生成)とDestroy()(破棄)は、メモリの確保・解放を伴う重い処理です。しかも破棄されたオブジェクトはガベージになり、GCスパイク まで誘発します。1発ずつなら問題なくても、毎秒何十回と繰り返せば確実にカクつきます。
解決策が オブジェクトプーリング(Object Pooling)——「一度作ったものは捨てずに使い回す」テクニックです。
この記事でわかること
Instantiate/Destroyがなぜ重いのか- プーリングの3ステップ——事前生成・取得・返却
- 自前のシンプル実装(Queue版)で仕組みを理解する
- Unity公式の
ObjectPool<T>(UnityEngine.Pool・Unity 2021+)の使い方- 実践:ヒットエフェクトを自動返却つきのプールで回す
動作確認環境: Unity 6 (6000.x)
オブジェクトプーリングとは
考え方はレンタルショップと同じです。商品(オブジェクト)は毎回製造(Instantiate)して廃棄(Destroy)するのではなく、棚に置いておいて貸し出し、使い終わったら返してもらう——このサイクルを回します。

- 事前生成: ゲーム開始時など負荷が問題にならないタイミングで、必要数(例: 弾30個)を
Instantiateし、全部SetActive(false)で眠らせて「プール」に保管する。 - 取得: 弾が必要になったら、
Instantiateする代わりにプールから1つ取り出し、SetActive(true)にして位置・向きをセットし直して使う。 - 返却: 弾が当たった・画面外に出たら、
Destroyする代わりにSetActive(false)でプ ールに戻す。
これでゲームプレイ中の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);
}
公式のObjectPool<T>を使う
Unity 2021以降には、公式のプーリングAPI UnityEngine.Pool が標準搭載されています。自前実装で書いた処理に加えて、上限管理・二重返却チェック・破棄時の後始末まで揃っているため、実戦ではこちらが第一候補です。
コンストラクタの引数が多く見えますが、役割を整理するとレンタルショップの運営マニュアルそのものです。

// 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: () =>
{
Bullet bullet = Instantiate(bulletPrefab); // 棚が空のときの生成方法
bullet.Init(() => pool.Release(bullet)); // 帰り道は生成時に1回だけ教える
return bullet;
},
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);
}
}
defaultCapacityは先に作っておく数ではないので、最初のGet()のときに初めてInstantiateが走ります。弾側は、寿命が尽きたら渡された返却手段を呼ぶだけです。
// 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が毎秒何度も走るなら、プールにする」。弾・敵・ヒットエフェクト・ダメージ数字がその代表です。
実践:ヒットエフェクトをプールで回す
弾の次にプール化の効果が大きいのが ヒットエフェクト です。剣戟アクションの斬撃ヒット、弾幕シューティングの着弾の火花、ローグライトで敵を巻き込む範囲攻撃——ジャンルを問わず、エフェクトは「戦闘中ずっと、大量に、短命で」生成されるプーリングの本命です。
ただし弾と違って、エフェクトには「当たったら返却」のようなわかりやすい返却タイミングがありません。そこで 「再生が終わったら自分で棚に帰る」 仕組みを組み込みます。鍵になるのはParticleSystemの Stop Action です。

まず、エフェクトのPrefab側でParticleSystemのMainモジュールにある Stop Action を Callback に設定します。これで再生が終わった瞬間にOnParticleSystemStopped()が呼ばれるようになります。あわせて Looping と Play On Awake はオフにします。Loopingがオンのままだと再生が終わらないので、いつまでも呼ばれません。
// 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はどれですか? それがプール化の最初の候補です。