Unityを触り始めた頃によくあるのが、弾やエフェクトをすべて Update の中で Instantiate と Destroy しまくる実装です。動いてはくれますが、GC(ガーベジコレクション)が頻繁に走ってフレーム落ちしたり、プロファイラを見ると Instantiate / Destroy のスパイクが目立ってきたりします。
弾や敵、エフェクトのように「たくさん生成されて、すぐ消える」オブジェクトは、毎回破棄せずに「一度作って非表示にしておき、必要になったら再利用する」ほうが圧倒的に効率的です。これを実現するのが「オブジェクトプール」です。
この記事では、Unity6 / C#で使える汎用的な「ObjectPool」コンポーネントを実装して、弾などを再利用できるようにしていきます。コンポーネント化しておくことで、プレイヤーの弾、敵の弾、エフェクトなどに簡単に使い回せるようになります。
【Unity】Instantiate地獄から脱出!「ObjectPool」コンポーネント
フルコード:ObjectPool.cs
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// シンプルなオブジェクトプールコンポーネント。
/// 弾やエフェクトなど、頻繁に生成・破棄されるオブジェクトを
/// あらかじめプールしておき、非アクティブ化して再利用します。
///
/// ・プレハブ1種類につき、このコンポーネント1つを用意する想定
/// ・GetObject() で使用可能なオブジェクトを取得
/// ・返却時は SetActive(false) しておくだけでもOK
/// </summary>
public class ObjectPool : MonoBehaviour
{
[Header("プール対象プレハブ")]
[SerializeField]
private GameObject prefab;
[Header("初期プール数")]
[SerializeField]
[Min(0)]
private int initialPoolSize = 10;
[Header("足りなくなったときに自動で増やすか")]
[SerializeField]
private bool expandable = true;
/// <summary>
/// プールしているオブジェクトのリスト
/// 使用中・未使用を問わず保持します。
/// </summary>
private readonly List<GameObject> pool = new List<GameObject>();
/// <summary>
/// 初期化済みかどうか
/// Awakeで初期化しますが、念のため遅延初期化にも対応
/// </summary>
private bool initialized = false;
private void Awake()
{
InitializePool();
}
/// <summary>
/// プールを初期化します。
/// すでに初期化済みの場合は何もしません。
/// </summary>
public void InitializePool()
{
if (initialized)
{
return;
}
if (prefab == null)
{
Debug.LogError($"[ObjectPool] Prefab が設定されていません: {name}", this);
return;
}
// 初期プール分を生成して非アクティブ化
for (int i = 0; i < initialPoolSize; i++)
{
CreateNewObject();
}
initialized = true;
}
/// <summary>
/// プールから使用可能なオブジェクトを取得します。
/// 見つからない場合、expandable が true なら新規生成します。
/// </summary>
/// <returns>利用可能な GameObject。取得できなければ null。</returns>
public GameObject GetObject()
{
if (!initialized)
{
InitializePool();
}
// 非アクティブなオブジェクトを探す
for (int i = 0; i < pool.Count; i++)
{
if (!pool[i].activeInHierarchy)
{
// 再利用するときは位置や回転を呼び出し側で設定しましょう
return pool[i];
}
}
// 見つからない場合は必要に応じて増やす
if (expandable)
{
return CreateNewObject();
}
// これ以上増やさない設定の場合は null を返す
Debug.LogWarning($"[ObjectPool] 利用可能なオブジェクトがありません (expandable = false): {name}", this);
return null;
}
/// <summary>
/// オブジェクトを明示的にプールへ返却するための補助メソッド。
/// 実際には SetActive(false) するだけでもプール対象になりますが、
/// 明示的に返却処理を呼びたい場合に使います。
/// </summary>
/// <param name="obj">プールへ戻したいオブジェクト</param>
public void ReturnObject(GameObject obj)
{
if (obj == null)
{
return;
}
// 念のため、プール管理下のオブジェクトか確認
if (!pool.Contains(obj))
{
Debug.LogWarning("[ObjectPool] このオブジェクトはこのプールで管理されていません: " + obj.name, this);
return;
}
obj.SetActive(false);
}
/// <summary>
/// プール内のすべてのオブジェクトを非アクティブ化します。
/// シーン切り替え時やリスタート時などに便利です。
/// </summary>
public void DeactivateAll()
{
foreach (var obj in pool)
{
if (obj != null && obj.activeSelf)
{
obj.SetActive(false);
}
}
}
/// <summary>
/// 現在のプール総数を返します。
/// </summary>
public int Count => pool.Count;
/// <summary>
/// 実際に新しいオブジェクトを生成し、プールに追加します。
/// </summary>
/// <returns>生成された GameObject</returns>
private GameObject CreateNewObject()
{
// 親をこのプールの Transform にしておくと階層管理が楽になります
GameObject obj = Instantiate(prefab, transform);
obj.SetActive(false);
pool.Add(obj);
return obj;
}
}
弾を撃つ側のシンプルな使用例:BulletShooter.cs
上の ObjectPool を使ってプレイヤーの弾を撃つ例です。
using UnityEngine;
/// <summary>
/// ObjectPool を使って弾を発射するサンプルコンポーネント。
/// Spaceキーで弾を発射する単純な例です。
/// 実際のゲームでは新InputSystemやUIボタンなどに置き換えてください。
/// </summary>
public class BulletShooter : MonoBehaviour
{
[Header("利用するオブジェクトプール")]
[SerializeField]
private ObjectPool bulletPool;
[Header("弾の発射位置")]
[SerializeField]
private Transform muzzleTransform;
[Header("弾の初速")]
[SerializeField]
private float bulletSpeed = 10f;
private void Update()
{
// デモ用に Space キーで発射
if (Input.GetKeyDown(KeyCode.Space))
{
Shoot();
}
}
private void Shoot()
{
if (bulletPool == null)
{
Debug.LogError("[BulletShooter] bulletPool が設定されていません。", this);
return;
}
// プールから弾オブジェクトを取得
GameObject bullet = bulletPool.GetObject();
if (bullet == null)
{
// expand=false で枯渇した場合など
return;
}
// 位置と回転を発射口に合わせる
bullet.transform.position = muzzleTransform.position;
bullet.transform.rotation = muzzleTransform.rotation;
// 有効化してシーン上に出す
bullet.SetActive(true);
// 弾に Rigidbody が付いていれば前方向に力を加える
Rigidbody rb = bullet.GetComponent<Rigidbody>();
if (rb != null)
{
rb.velocity = muzzleTransform.forward * bulletSpeed;
}
}
}
弾側の例:一定時間で自動的にプールへ戻る Bullet.cs
using UnityEngine;
/// <summary>
/// 弾オブジェクト側の挙動サンプル。
/// ・一定時間経過したら自動で非アクティブ化
/// ・何かに当たったら非アクティブ化
/// 非アクティブになれば ObjectPool から再利用されます。
/// </summary>
[RequireComponent(typeof(Collider))]
public class Bullet : MonoBehaviour
{
[Header("生存時間(秒)")]
[SerializeField]
private float lifeTime = 3f;
private float timer;
private void OnEnable()
{
// 再利用時にも必ず呼ばれるので、タイマーをリセット
timer = 0f;
}
private void Update()
{
timer += Time.deltaTime;
if (timer >= lifeTime)
{
// プールへ返却(=非アクティブ化)
gameObject.SetActive(false);
}
}
private void OnTriggerEnter(Collider other)
{
// 何かに当たったら即座に非アクティブ化
// 実際にはタグ判定やダメージ処理などをここに追加していきます。
gameObject.SetActive(false);
}
}
使い方の手順
-
プレハブの準備
弾として使いたい GameObject(例: 球体 + Rigidbody + Collider)を作成し、
上のBulletコンポーネントをアタッチしてプレハブ化しておきます。
例:- PlayerBullet プレハブ
- EnemyBullet プレハブ
- 小さな爆発エフェクトのプレハブ
-
ObjectPool コンポーネントをシーンに配置
空の GameObject を作成し、名前をPlayerBulletPoolなどにします。
そこに上記のObjectPoolコンポーネントをアタッチし、- Prefab に PlayerBullet プレハブを指定
- Initial Pool Size に 30 など(想定最大同時弾数)を設定
- 弾が足りない場合に自動で増やしたいなら Expandable にチェック
これでシーン開始時に弾のインスタンスがまとめて生成され、非アクティブ状態で待機します。
-
発射側に BulletShooter を設定
プレイヤーキャラの GameObject にBulletShooterをアタッチします。
インスペクタで- Bullet Pool に PlayerBulletPool をドラッグ&ドロップ
- Muzzle Transform に銃口となる子オブジェクトを指定
- Bullet Speed に好みの速度を設定
再生して Space キーを押すと、ObjectPool から弾が取り出されて発射されます。
-
他の用途への流用例
同じ手順で、以下のようなオブジェクトにも簡単に流用できます。- 敵の弾:EnemyBullet プレハブ用の ObjectPool を別に用意し、敵AIの攻撃スクリプトから利用する。
- ヒットエフェクト:エフェクトプレハブをプールし、弾が当たった位置に再配置して SetActive(true) にする。
- 動く床の足場:スクロールステージで流れてくる足場をプールして再利用し、ステージ端で非アクティブ化して戻す。
どれも「一度作って非アクティブにしておき、必要になったら再利用する」というパターンで共通です。
メリットと応用
1. Instantiate / Destroy のスパイクを抑えられる
弾やエフェクトを毎フレーム Instantiate していると、生成コストとGC負荷でフレームレートが不安定になりがちです。ObjectPool で「最初にまとめて生成しておく」ことで、ゲーム中のコストを SetActive(true/false) にほぼ限定でき、かなり安定します。
2. プレハブ管理がシンプルになる
プレハブ1種類につき ObjectPool を1つ置いておけば、「この弾はこのプールから出す」と明確に分けられます。
・プレイヤー弾プール
・敵弾プール
・エフェクトプール
といった構成にしておくと、レベルデザイナーがシーン上でプールの個数や初期数を調整するだけでバランス調整ができるので、コードを触らずにチューニングしやすくなります。
3. コンポーネント指向で責務を分離できる
「弾を発射する処理(BulletShooter)」と「弾を再利用する仕組み(ObjectPool)」を分けておくことで、どちらかを差し替えたり、他のオブジェクトに流用したりしやすくなります。
巨大な「GameManager」スクリプトに弾の生成・破棄ロジックを書きがちな人は、こういった小さなコンポーネントに分割していくと、プロジェクトがかなり見通しやすくなります。
4. 応用:事前ウォームアップや上限制御
初期プール数をステージ開始時に多めに作っておく「ウォームアップ」や、「絶対にこれ以上は増やさない」上限制御なども簡単に組み込めます。
例えば、ステージ開始時に一定数だけアクティブ化しておくような処理も追加できます。
改造案:プール内のオブジェクトをランダムにアクティブ化する
例えば、ステージ開始時にランダムな位置でエフェクトをいくつか光らせたい、などの用途向けに、ObjectPool にこんなメソッドを追加しても面白いです。
// ObjectPool クラス内に追加できる改造メソッドの一例
/// <summary>
/// プール内からランダムに指定数だけオブジェクトを有効化し、
/// 位置をランダムな範囲に配置するサンプル。
/// レベルデザイン時の「仮配置」などに使えます。
/// </summary>
public void ActivateRandomObjects(int count, Vector3 center, Vector3 range)
{
if (!initialized)
{
InitializePool();
}
// 利用可能な非アクティブオブジェクトのリストを作る
List<GameObject> inactiveList = new List<GameObject>();
foreach (var obj in pool)
{
if (!obj.activeInHierarchy)
{
inactiveList.Add(obj);
}
}
// 要求数より少なければあるだけ使う
int spawnCount = Mathf.Min(count, inactiveList.Count);
for (int i = 0; i < spawnCount; i++)
{
GameObject obj = inactiveList[i];
// ランダムな位置に配置
Vector3 randomOffset = new Vector3(
Random.Range(-range.x, range.x),
Random.Range(-range.y, range.y),
Random.Range(-range.z, range.z)
);
obj.transform.position = center + randomOffset;
obj.transform.rotation = Quaternion.identity;
obj.SetActive(true);
}
}
このように、小さな責務ごとにコンポーネントを分けておくと、後から「ちょっとした改造」を追加しやすくなります。ObjectPool を一度仕込んでおけば、弾・敵・エフェクト・動く床など、いろいろな要素のパフォーマンスと管理性をぐっと上げられます。
