スポンサーリンク
最適化・パフォーマンス

【Unity】オブジェクトプール(Object Pool)の実装方法|Instantiateを減らしてゲームを高速化する方法

最適化・パフォーマンス

Unityでゲームを作っていると、弾や敵、エフェクトなどを何度も生成・削除する場面がありますよね。最初は問題なく動いていても、ゲームが複雑になるにつれて「一瞬カクつく」「大量に出現すると重くなる」と感じることもあるのではないでしょうか。

そんなときによく使われるのがオブジェクトプール(Object Pool)という仕組みです。ただ、「Instantiateとの違いがよく分からない」「Unity 6ではどう実装すればいいの?」と迷ってしまう方も少なくありません。

この記事では、Unity 6で利用できるUnityEngine.Pool.ObjectPool<T>を使った実装方法を中心に、どんな場面で使うべきなのか、実装時に気を付けたいポイントまで順番に解説します。読み終える頃には、自分のゲームでオブジェクトプールを導入すべきか判断でき、実装まで進められるようになります。


ObjectPoolを使うべき場面

オブジェクトプールは便利な仕組みですが、すべてのPrefabに導入すればよいというわけではありません。

まずは、自分のゲームで本当に必要かどうかを判断しましょう。判断基準はとてもシンプルで、「同じオブジェクトを短時間に何度も生成・削除するかどうか」です。

ObjectPoolを使ったほうがよいケース

次のようなオブジェクトは、オブジェクトプールとの相性がとても良いです。

  • プレイヤーや敵が連続して発射する弾
  • 敵キャラクターの出現・消滅を繰り返すゲーム
  • 爆発・ヒット・魔法などのエフェクト
  • 一定時間ごとに何度も生成されるオブジェクト

例えばシューティングゲームでは、1秒間に何十発もの弾を生成することがあります。そのたびにInstantiate()Destroy()を繰り返していると、CPU負荷やガベージコレクション(GC)の発生につながり、ゲームがカクつく原因になることがあります。

このようなケースでは、一度生成したオブジェクトを使い回すオブジェクトプールの効果を実感しやすいでしょう。

ObjectPoolを使わなくてもよいケース

一方で、次のようなオブジェクトは無理にプール化する必要はありません。

  • タイトル画面や設定画面などのUI
  • ゲーム中に1〜2回しか表示しない演出
  • シーン開始時に一度だけ生成するPrefab

これらは生成回数が少ないため、オブジェクトプールを導入しても体感できるほどの効果が得られないことが多く、管理するコードだけが増えてしまう可能性があります。

「生成回数が少ないものはそのままInstantiate、多いものだけObjectPoolにする」という考え方がおすすめです。

もし現在Instantiate()の基本的な使い方があいまいな場合は、先に基礎を確認しておくとObjectPoolとの違いも理解しやすくなります。




Instantiateが重くなる原因

Instantiate()が重くなりやすい理由は、オブジェクトをその場で新しく作る処理だからです。さらに、使い終わったあとにDestroy()で削除すると、生成と破棄を何度も繰り返すことになります。

数個だけなら大きな問題になりにくいですが、弾や敵のように何度も出すオブジェクトでは、処理が積み重なってカクつきの原因になることがあります。

生成と破棄を繰り返すと負荷が増える

例えば、弾を撃つたびに次のような処理をしているとします。

Instantiate(bulletPrefab, firePoint.position, firePoint.rotation);

そして、弾が壁に当たったり一定時間が経ったりしたら、次のように削除します。

Destroy(gameObject);

この流れは分かりやすい反面、弾を撃つ回数が増えるほど、生成と破棄の処理も増えていきます。特に、1秒間に何十個もPrefabを作るようなゲームでは、CPU負荷やメモリ管理の負担が目立ちやすくなります。

「弾を撃った瞬間だけ一瞬止まる」「敵がまとめて出るタイミングでカクつく」といった場合は、Instantiate()Destroy()の呼び出し回数を確認してみるとよいでしょう。

ObjectPoolは生成回数を減らす

オブジェクトプールでは、使い終わったオブジェクトを完全に削除せず、いったん非表示にして保管します。そして次に必要になったとき、保管していたオブジェクトを再び表示して使います。

流れは次のようなイメージです。

  • 最初に必要な数だけ作っておく
  • 使うときに表示する
  • 使い終わったら非表示にして戻す
  • 次に使うときは新しく作らず再利用する

つまり、Instantiate()Destroy()を毎回行うのではなく、Get()で取り出して、Release()で戻す形に変えるわけです。

ただし、カクつきの原因が必ず生成処理とは限りません。物理演算、エフェクト、Update内の重い処理などが原因の場合もあります。まずは「どのタイミングで重くなるか」を見ながら、頻繁に生成・削除しているPrefabがあるか確認してみてください。




Unity 6でObjectPoolを実装する

ここからは、Unity 6で使えるUnityEngine.Pool.ObjectPool<T>を使って、弾を再利用する例を作っていきます。

まずは「弾を新しく作る」「弾を取り出す」「弾を戻す」「不要な弾を削除する」という4つの処理を、ObjectPoolに登録します。少し長く見えますが、役割を分けて見るとかなりシンプルです。

ObjectPoolを作成する

弾を管理するスクリプト例です。空のGameObjectに付けて、bulletPrefabfirePointをInspectorから設定してください。

using UnityEngine;
using UnityEngine.Pool;

public class BulletPoolShooter : MonoBehaviour
{
    [SerializeField] private Bullet bulletPrefab;
    [SerializeField] private Transform firePoint;

    private ObjectPool<Bullet> bulletPool;

    private void Awake()
    {
        bulletPool = new ObjectPool<Bullet>(
            createFunc: CreateBullet,
            actionOnGet: OnGetBullet,
            actionOnRelease: OnReleaseBullet,
            actionOnDestroy: OnDestroyBullet,
            collectionCheck: true,
            defaultCapacity: 20,
            maxSize: 50
        );
    }

    private Bullet CreateBullet()
    {
        Bullet bullet = Instantiate(bulletPrefab);
        bullet.SetPool(bulletPool);
        return bullet;
    }

    private void OnGetBullet(Bullet bullet)
    {
        bullet.gameObject.SetActive(true);
        bullet.transform.position = firePoint.position;
        bullet.transform.rotation = firePoint.rotation;
        bullet.Fire(firePoint.forward);
    }

    private void OnReleaseBullet(Bullet bullet)
    {
        bullet.gameObject.SetActive(false);
    }

    private void OnDestroyBullet(Bullet bullet)
    {
        Destroy(bullet.gameObject);
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            bulletPool.Get();
        }
    }
}

この例では、スペースキーを押すたびにbulletPool.Get()で弾を取り出します。プールの中に使える弾があれば再利用し、足りなければCreateBullet()で新しく作られます。

弾側でReleaseを呼ぶ

次に、弾側のスクリプトです。一定時間が経ったら、Destroy()ではなくRelease()でプールに戻します。

using UnityEngine;
using UnityEngine.Pool;

public class Bullet : MonoBehaviour
{
    [SerializeField] private float speed = 10f;
    [SerializeField] private float lifeTime = 2f;

    private ObjectPool<Bullet> pool;
    private Rigidbody rb;
    private float timer;

    private void Awake()
    {
        rb = GetComponent<Rigidbody>();
    }

    public void SetPool(ObjectPool<Bullet> bulletPool)
    {
        pool = bulletPool;
    }

    public void Fire(Vector3 direction)
    {
        timer = 0f;
        rb.velocity = Vector3.zero;
        rb.angularVelocity = Vector3.zero;
        rb.velocity = direction * speed;
    }

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

        if (timer >= lifeTime)
        {
            pool.Release(this);
        }
    }

    private void OnCollisionEnter(Collision collision)
    {
        pool.Release(this);
    }
}

ここで大事なのは、弾を消したいタイミングでDestroy(gameObject)を書かないことです。プールに戻す場合は、pool.Release(this)を使います。

GetとReleaseに置き換える

オブジェクトプールを導入するときは、今あるコードをいきなり全部変える必要はありません。まずは、生成回数が多いPrefabを1種類だけ選んで置き換えるのがおすすめです。

今までの処理ObjectPool使用時
Instantiate()で生成Get()で取得
Destroy()で削除Release()で返却
毎回作り直す非表示にして再利用する

判断に迷ったら、コード内でInstantiate()Destroy()が何度も呼ばれているPrefabを探してみてください。弾、敵、エフェクトのように短時間で増えるものから置き換えると、効果を確認しやすいです。




再利用前に状態をリセットする

ObjectPoolでよくある失敗が、前回使った状態が残ったまま再利用してしまうことです。

オブジェクトプールは、オブジェクトを削除せずに使い回します。そのため、位置や速度、HPなどは自動で初期状態に戻りません。前回の情報が残っていると、「弾が変な方向に飛ぶ」「敵のHPが減ったまま出てくる」といった不具合につながります。

前回の状態が残ると不具合になる

再利用するオブジェクトでは、次のような値が残りやすいです。

  • 位置や回転
  • Rigidbodyの速度
  • HPや状態異常
  • Animatorの再生状態
  • Particle Systemの再生状態
  • 前回のターゲット情報

例えば弾なら、前回の速度が残っていると、取得した瞬間に意図しない方向へ動くことがあります。敵なら、前回倒されたときのHPや行動状態が残り、出現直後から倒れた状態になることもあります。

判断基準としては、再利用した直後に「位置・速度・HP・見た目・動き」がおかしい場合は、まずリセット漏れを疑うと分かりやすいです。

取得時に初期化する

状態のリセットは、プールから取り出すタイミングで行うと管理しやすいです。先ほどの弾の例なら、actionOnGetFire()の中で、位置・回転・速度を初期化します。

private void OnGetBullet(Bullet bullet)
{
    bullet.gameObject.SetActive(true);
    bullet.transform.position = firePoint.position;
    bullet.transform.rotation = firePoint.rotation;
    bullet.Fire(firePoint.forward);
}

弾側では、発射前に速度をいったんゼロにしてから、新しい方向へ飛ばしています。

public void Fire(Vector3 direction)
{
    timer = 0f;
    rb.velocity = Vector3.zero;
    rb.angularVelocity = Vector3.zero;
    rb.velocity = direction * speed;
}

敵をプールする場合は、HPや行動状態を戻す処理を用意しておくと安心です。

public void ResetEnemy()
{
    hp = maxHp;
    target = null;
    isDead = false;
    transform.localScale = Vector3.one;
}

ObjectPoolは「再利用する仕組み」なので、再利用前に初期状態へ戻す処理までセットで考えるのが大切です。コードが動いているのに挙動だけおかしいときは、生成処理ではなくリセット処理を見直してみてください。




プールサイズを決める

ObjectPoolを使うときは、defaultCapacitymaxSizeの設定も大切です。ここを適当に大きくしすぎるとメモリを使いすぎますし、小さすぎるとプールが足りず、結局新しく生成される回数が増えてしまいます。

最初は難しく考えすぎず、「画面上に同時に存在する最大数+少し余裕」を目安にすると決めやすいです。

小さすぎると追加生成が発生する

例えば、弾が最大で30発ほど画面に残るゲームなのに、プールの初期数を10にしていると、足りない分は追加で生成されます。

追加生成自体が悪いわけではありませんが、毎回のように不足しているなら、オブジェクトプールの効果が弱くなってしまいます。

defaultCapacity: 20,
maxSize: 50

この例なら、最初の目安として20個分の容量を用意し、最大50個までプールに保持できます。弾幕のように一時的に数が増えるゲームでは、最大数に少し余裕を持たせると扱いやすくなります。

大きすぎるとメモリを使いすぎる

逆に、ほとんど使わないオブジェクトまで大量にプールしておくと、使っていない間もメモリを占有します。

例えば、同時に10体しか出ない敵に対して、最初から200体分のプールを想定するのは少し大きすぎるかもしれません。とくにモバイル向けゲームでは、余分なメモリ使用が動作の重さにつながることがあります。

判断に迷ったら、次のように考えると決めやすいです。

対象サイズの目安
画面上に同時に存在する最大弾数+少し余裕
同時に出現する最大数+予備数
エフェクト同時に再生される最大数+予備数

最初から完璧な数値を決める必要はありません。まずは想定される最大同時数を入れて、実際に動かしながら調整していくのが現実的です。

最大同時数を基準にする

プールサイズを決めるときは、「合計で何回生成するか」ではなく、同時に何個存在するかを見ます。

例えば、ゲーム全体で弾を1000発撃つとしても、画面上に同時に残る弾が30発程度なら、最初から1000個分のプールを作る必要はありません。

まずは次のように確認してみてください。

  • 弾は一度に何発まで画面に残るか
  • 敵は最大で何体まで同時に出るか
  • エフェクトは同時に何個まで再生されるか
  • 不足したときに一時的な追加生成を許容できるか

この基準で決めておくと、メモリを使いすぎず、必要な場面ではしっかり再利用できるバランスを取りやすくなります。

ObjectPoolはゲーム最適化の基本テクニックの一つです。

さらにUnity全体を体系的に学びたい方は、Unity 6対応の解説書を1冊持っておくと理解が深まります。

Unityの教科書 Unity 6完全対応版
✅ Amazonでチェックする✅ 楽天でチェックする




まとめ

ObjectPoolは、弾・敵・エフェクトのように同じPrefabを何度も生成する場面で役立つ仕組みです。まずは、ゲーム内でInstantiate()Destroy()を頻繁に使っているPrefabを1つ選び、Get()Release()に置き換えてみましょう。

導入するときは、状態リセットとプールサイズの調整も忘れないようにしてください。最後にProfilerで効果を確認できれば、自分のゲームに合った最適化として安心して使いやすくなります。

GCやCPU負荷があまり下がらない場合は、ObjectPool以外の最適化も必要かもしれません。GCの仕組みや確認方法を詳しく知りたい場合は、こちらの記事も参考になります。


よくある質問(FAQ)

Q
ObjectPoolは初心者でも使うべきですか?
A

弾や敵、エフェクトを何度も生成するゲームなら、初心者のうちから使ってみる価値があります。最初からすべてをObjectPool化する必要はありません。まずは、弾Prefabなど分かりやすいものを1種類だけ選び、Instantiate()Destroy()Get()Release()に置き換えるところから始めると扱いやすいです。

Q
SetActiveを使えばObjectPoolになりますか?
A

SetActive(false)で非表示にするだけでは、ObjectPoolとしては少し足りません。ObjectPoolでは、非表示にしたオブジェクトを管理し、必要なときに取り出して、使い終わったら戻す流れまで作ります。

つまり、SetActive()はObjectPoolの一部として使う処理です。再利用するためには、Get()で取得し、Release()で返却する仕組みもセットで考えると分かりやすいです。

※当サイトはアフィリエイト広告を利用しています。リンクを経由して商品を購入された場合、当サイトに報酬が発生することがあります。

※本記事に記載しているAmazon商品情報(価格、在庫状況、割引、配送条件など)は、執筆時点のAmazon.co.jp上の情報に基づいています。
最新の価格・在庫・配送条件などの詳細は、Amazonの商品ページをご確認ください。

スポンサーリンク