はじめに
こんにちは!株式会社ルーデル25卒の猪俣です。
本記事では、Unityにおける「オブジェクトプール」という技術についてご紹介します。
私がレアゾンホールディングスの新卒エンジニア研修のうち、ゲーム研修で学んだ内容を基に作成しています。
ゲーム研修について
ゲーム研修では、下記の内容の講義を受けました。
- 環境構築
- Unity基礎
- インゲーム
- ビルド
- アウトゲーム
- ゲームの改造
本記事はインゲームの中でも、オブジェクトプールに着目します。
オブジェクトプールとは?
今回、記事のテーマとして挙げさせていただいた「オブジェクトプール」とは、オブジェクトをあらかじめ生成してプール(保管)しておき、必要な時に取り出して使い回す仕組みのことです。
シューティングゲームの弾や、アクションゲームで大量に出現する敵など、たくさんのGameObjectを量産して使用したい!
そんな時におすすめなのがオブジェクトプールです!!
オブジェクトプールのここがすごい!
ゲームを開発している中で、こんな状況になったことはありませんか?
敵を沢山出したいぞ!!
まずは敵のGameObjectをInstantiate()で沢山生成して、、、
倒せたらDestroy()で破棄して、、
その処理、ちょっと待った!!
大量のInstantiate() , Destroy()の繰り返しは、ゲームのパフォーマンスが悪くなってしまいます!😱
この二つの関数を何度も呼び出すと、そのたびにメモリを確保し、使い終わった後には掃除をする、という処理が裏で頻繁に走ります。
この「確保」と「掃除」のコストが積み重なることで、ゲーム全体の動作が重くなってしまうのです。
そこで頼りになるのがオブジェクトプール!!オブジェクトプールは、 Instantiate() と Destroy() の呼び出し回数を最小限に抑え、パフォーマンスを軽量化できてしまうテクニックです!同じ種類のオブジェクトを頻繁に生成・破棄する時は特に有効になります!
オブジェクトプールの仕組み
では、オブジェクトプールがどのようにしてパフォーマンスを良くしているか説明します。
-
ゲーム開始時などに、あらかじめ一定数のオブジェクトを生成し、非アクティブな状態(画面に見えない状態)で保管する
-
オブジェクトが必要になったら、プールから一つ取り出し、アクティブな状態にして使用する(
Instantiate()の代わり) -
使い終わったら再び非アクティブな状態にしてプールに戻す(
Destroy()の代わり)
このサイクルで、無駄なメモリの確保や解放処理を減らすことができるのです!
オブジェクトプールを実装してみよう!
では、オブジェクトプールを実装していきましょう!
4つのパートと「おまけ」に分けて実装していきます。
最終的には、一定時間ごとに敵が現れ、クリックすると消える(プールに戻る) という動作を完成させます。
- Part1:プールの「器」を作ろう
- Part2:プールから敵を取り出せるようにしよう
- Part3:使った敵をプールに戻せるようにしよう
- Part4:プールが空になっても対応できるようにしよう
- おまけ:敵を自動で出現させてみよう
事前準備:敵プレハブの作成
⚠️「Box Collider 2D」のアタッチもしましょう!
簡単な敵プレハブの用意の手順(ここをクリックで展開)
まず、事前準備として敵プレハブの作成をお願いします。
以下、簡単に手順の説明を記載します。
1. 敵として表示したい画像(スプライト)を用意し、Projectビューにインポート
2. 1の画像をヒエラルキーウィンドウにドラッグ&ドロップし、シーン上にGameObjectとして配置
【最重要】
3. シーンに配置した敵オブジェクトを選択し、Inspectorウィンドウで「Add Component」をクリック。
検索窓に「Box Collider 2D」と入力し、コンポーネントを追加。
(キャラクターの形が円形ならCircle Collider 2Dでも構いません)
4. 3の設定が完了したGameObjectを、ヒエラルキーからProjectビューにドラッグ&ドロップすると、プレハブが完成。
5. 作成できたら、シーン上に残っている元のGameObjectは不要なので削除しましょう。
これで、クリックに反応できる敵のプレハブが完成しました!
オブジェクトプールの実装を始めましょう!
[参考画像]今回、私が用意した敵のプレハブはこちらです!
Part1:プールの「器」を作ろう
まずは、オブジェクトプールの機能を管理する空のGameObjectをシーンに用意します。
GameObjectの名前は「EnemyPoolManager」など、分かりやすいものに設定しましょう。
EnemyPoolManager.csファイルを作成
次に、このオブジェクトにアタッチするためのスクリプト、EnemyPoolManager.csを作成します。
EnemyPoolManager.csを、シーンに作成したEnemyPoolManagerオブジェクトにアタッチしてください。
ゲームが始まったときに、敵をあらかじめ生成し、待機させる、言い換えるとオブジェクトプールの本体のEnemyPoolManager.cs の中身を書いていきます。
必要な変数を3つ用意します。
_enemyPrefab, _initialPoolSize, _enemyPool を作りましょう。
なお、_enemyPoolは、作った敵をしまっておくための箱で、データ構造はQueueを使用します。
StackやListでも作れますが、Queueの「先に入れたものから先に出す(FIFO)」という性質により、オブジェクトの再利用を公平に行えるため、今回はQueueを選択しました。
using System.Collections.Generic;
using UnityEngine;
public class EnemyPoolManager : MonoBehaviour
{
// プーリング対象となる敵のプレハブ
[SerializeField] private GameObject _enemyPrefab;
// 最初にプールしておくオブジェクトの数。Inspectorから変更できるよう、[SerializeField]にしておくと便利です。
[SerializeField] private int _initialPoolSize = 100;
// プールされたオブジェクトを格納するキュー
private readonly Queue<GameObject> _enemyPool = new Queue<GameObject>();
private void Awake()
{
// プールを初期化する
InitializePool();
}
// プールを指定されたサイズで初期化します。
private void InitializePool()
{
// _initialPoolSize の数だけ、敵を作ってプールに入れていく。
for (int i = 0; i < _initialPoolSize; i++)
{
var newEnemy = CreateNewEnemy();
// 作った敵をキューに追加。
_enemyPool.Enqueue(newEnemy);
}
}
// 実際に敵を1体作る処理です。
private GameObject CreateNewEnemy()
{
// Instantiateで敵を生成。
var newEnemy = Instantiate(_enemyPrefab);
// 作った敵は非表示にして、プールされている状態にする。
newEnemy.SetActive(false);
return newEnemy;
}
}
コードが書けたら、Unityエディタで EnemyPoolManager オブジェクトのインスペクターを開きましょう。Enemy Prefab の欄に
Projectビューから事前に用意していた 敵のプレハブ をドラッグ&ドロップしてアタッチしてください。
これで器の完成です!!Part2にいきましょう!
Part2:プールから敵を取り出せるようにしよう
1. 敵をプールから取り出す命令 GetEnemy() を作る
次に、EnemyPoolManager.cs に、プールから敵を取り出すための GetEnemy() メソッドを追加します。この命令は、他のスクリプトから呼ばれることを想定して public にしておきます。
// Part1のコードに続けて追記
// プールから敵オブジェクトを1体取得する命令
public GameObject GetEnemy()
{
// プールに待機中のオブジェクトがあれば、それを取り出す
GameObject enemyToGet = _enemyPool.Dequeue();
// 敵を見えるようにする
enemyToGet.SetActive(true);
return enemyToGet;
}
まずは取り出す機能だけをシンプルに実装します!
2. 動作確認用にEnemySpawner.csを作る
実装したGetEnemy()が正しく機能するか、テスト用のスクリプトを作って確認してみましょう。
中身はSpaceキーで敵を生成するシンプルなものにします。
シーンに、空のGameObjectをEnemySpawner という名前で作成しましょう。そしてEnemySpawner.csを作成し、EnemySpawnerにアタッチします。
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
[SerializeField] private EnemyPoolManager _poolManager;
void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
var enemy = _poolManager.GetEnemy();
// 敵が重ならないよう、出現位置をランダムにする
// ※値はカメラに映る範囲で適宜調整してください
float randomX = Random.Range(-4f, 4f);
float randomY = Random.Range(-4f, 4f);
enemy.transform.position = new Vector2(randomX, randomY);
}
}
}
Unityエディタで EnemySpawner オブジェクトのインスペクターを開きましょう。_poolManager の欄に、シーンから EnemyPoolManager (EnemyPoolManager.csがアタッチされているオブジェクト)をドラッグ&ドロップしてアタッチすれば完了です。
ゲームを再生し、Spaceキーを連打してみましょう。敵が画面にどんどん現れれば成功です!
[参考画像]Spaceキーを押すごとに、わらわら発生する敵👾

Part3:使った敵をプールに戻せるようにしよう
敵が「使用済み」になったことを判定し、プールに戻す処理を作りましょう。
今回は、簡単に 「敵キャラクターがクリックされたら、使用済みとみなしてプールに戻す」 という仕様にします。
1. 敵をプールに戻す命令 ReturnEnemy() を作る
まず EnemyPoolManager.cs に、敵をプールに戻す ReturnEnemy() メソッドを追加します。
// Part2のコードに続けて追記
// 使用済みの敵オブジェクトをプールに戻す命令
public void ReturnEnemy(GameObject enemy)
{
// 敵を再び非表示にする
enemy.SetActive(false);
// キューの最後尾に戻す
_enemyPool.Enqueue(enemy);
}
2. 敵がクリックされたことを検知する Enemy.cs を作る
次に、敵自身がクリックされたことを知るための新しいスクリプト Enemy.cs を作成し、敵のプレハブにアタッチしておきましょう。
using UnityEngine;
public class Enemy : MonoBehaviour
{
// 自身を管理してくれているプールの情報を入れておくための変数
public EnemyPoolManager PoolManager { get; set; }
// オブジェクトがクリックされた時に自動で呼ばれるメソッド
private void OnMouseDown()
{
// 自分自身(のGameObject)をプールに戻すようお願いする
if (PoolManager != null)
{
PoolManager.ReturnEnemy(gameObject);
}
}
}
3. 取り出す敵に情報を渡す
Enemy.cs には、自分(敵自身)がどのプールに戻ればいいかを知るために EnemyPoolManager の情報が必要です。
そこで、GetEnemy() メソッドを少し修正して、敵を取り出す際にこの情報を渡してあげます。
// Part2で作成した GetEnemy() を修正
public GameObject GetEnemy()
{
// プールに待機中のオブジェクトがあれば、それを取り出す
GameObject enemyToGet = _enemyPool.Dequeue();
enemyToGet.SetActive(true);
// ★ここから追記★
// 取り出した敵にアタッチされている Enemy.cs を取得
var enemyComponent = enemyToGet.GetComponent<Enemy>();
// Enemy.cs に、自分自身(このEnemyPoolManager)の情報を渡す
enemyComponent.PoolManager = this;
// ★ここまで追記★
return enemyToGet;
}
これで、画面に登場した敵をクリックすると、Enemy.cs の OnMouseDown() が呼ばれ、EnemyPoolManager の ReturnEnemy() を通じてプールに戻る、という流れが完成しました。
ゲームを再生して確認してみましょう!
Spaceキーで敵を出現させる → その敵をクリック
をすると、ヒエラルキービューで、敵がプールから順番に出る(Active)、クリックするとプールに戻る(非Active)といったサイクルが確認できると思います!
【確認】
クリックしても敵が消えない場合は、以下の点を確認しましょう!1.敵のプレハブに Box Collider 2D などのColliderがアタッチされているか
2.敵のプレハブに Enemy.cs がアタッチされているか
Part4:プールが空になっても対応できるようにしよう
最初に100体の敵を用意したけど、101体目が必要になったらどうするの?
そんな時もあるでしょう!
今のままだと、最初に用意した100体の敵をすべて画面に出し切ってしまうと、GetEnemy() で Dequeue() しようとした際に誰もいなくてエラーになってしまいます。
そこで、GetEnemy() をもっと賢くし、イレギュラーにも対応しましょう!
プールが空っぽだった場合は、その場で新しい敵を作って貸し出すように処理を変更します。
// Part3で作成した GetEnemy() を書き換え
public GameObject GetEnemy()
{
GameObject enemyToGet;
// もしプールの中に1体でも敵が残っていたら…
if (_enemyPool.Count > 0)
{
enemyToGet = _enemyPool.Dequeue();
}
// もし1体も残っていなかったら…
else
{
// 緊急で新しい敵を生成!
// パフォーマンスのために、普段はここを通らない方が望ましい
Debug.LogWarning("プールが空です。新しい敵を動的に生成します。Initial Pool Sizeを増やすことを検討してください。");
enemyToGet = CreateNewEnemy();
}
enemyToGet.SetActive(true);
var enemyComponent = enemyToGet.GetComponent<Enemy>();
enemyComponent.PoolManager = this;
return enemyToGet;
}
これで、想定以上の数の敵が必要になった場合でも、エラーで止まることなくゲームを続けることができます。
ただ、Debug.LogWarningにもあるように、ここで動的に生成が大量に発生すると、結局処理が重くなってしまいます。
オブジェクトプールの意味がなくなってしまうので、あまりにもプールが空になる場合はInitial Pool Sizeの調整しましょう!
オブジェクトプールを有効活用には、Initial Pool Sizeの適切な設定がポイントになります!
これでオブジェクトプールの完成です!!!
おまけ:敵を自動で出現させてみよう
最後におまけです!これまではテストのためにSpaceキーで手動で敵を出現させていましたが、ゲームらしく自動で出現するように改造してみましょう。
EnemySpawner.csを以下のように書き換えます。
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
// InspectorからEnemyPoolManagerをアタッチするための変数
[SerializeField] private EnemyPoolManager _poolManager;
// 敵を出現させる間隔(秒)
[SerializeField] private float _spawnInterval = 0.5f;
void Start()
{
// _spawnIntervalごとに SpawnEnemy メソッドを繰り返し呼び出す
InvokeRepeating(nameof(SpawnEnemy), 1f, _spawnInterval);
}
private void SpawnEnemy()
{
// プールから敵を1体もらう
var enemy = _poolManager.GetEnemy();
// もしプールから敵が取得できなかった場合は何もしない
if (enemy == null) return;
// 敵の出現位置をランダムに設定
float randomX = Random.Range(-4f, 4f);
float randomY = Random.Range(-4f, 4f);
enemy.transform.position = new Vector3(randomX, randomY, 0);
}
}
ゲームを再生すると、敵が次々と出現し、クリックすると消える(プールに戻る)様子が確認できるはずです!
付録:完成コード一覧
EnemyPoolManager.cs(最終形)
using System.Collections.Generic;
using UnityEngine;
public class EnemyPoolManager : MonoBehaviour
{
// プーリング対象となる敵のプレハブ
[SerializeField] private GameObject _enemyPrefab;
// 最初にプールしておくオブジェクトの数。Inspectorから変更できるよう、[SerializeField]にしておくと便利です。
[SerializeField] private int _initialPoolSize = 100;
// プールされたオブジェクトを格納するキュー
private readonly Queue<GameObject> _enemyPool = new Queue<GameObject>();
// AwakeはStartよりも先に一度だけ呼ばれるライフサイクルメソッド。
private void Awake()
{
// プールを初期化する
InitializePool();
}
// プールを指定されたサイズで初期化します。
private void InitializePool()
{
// _initialPoolSize の数だけ、敵を作ってプールに入れていく。
for (int i = 0; i < _initialPoolSize; i++)
{
var newEnemy = CreateNewEnemy();
// 作った敵をキューに追加。
_enemyPool.Enqueue(newEnemy);
}
}
// 実際に敵を1体作る処理です。
private GameObject CreateNewEnemy()
{
// Instantiateで敵を生成。
var newEnemy = Instantiate(_enemyPrefab);
// 作った敵は非表示にして、プールされている状態にする。
newEnemy.SetActive(false);
return newEnemy;
}
// プールから敵オブジェクトを1体取得する命令
public GameObject GetEnemy()
{
GameObject enemyToGet;
// もしプールの中に1体でも敵が残っていたら…
if (_enemyPool.Count > 0)
{
enemyToGet = _enemyPool.Dequeue();
}
// もし1体も残っていなかったら…
else
{
// 緊急で新しい敵を生成!
// パフォーマンスのために、普段はここを通らない方が望ましい
Debug.LogWarning("プールが空です。新しい敵を動的に生成します。Initial Pool Sizeを増やすことを検討してください。");
enemyToGet = CreateNewEnemy();
}
enemyToGet.SetActive(true);
var enemyComponent = enemyToGet.GetComponent<Enemy>();
enemyComponent.PoolManager = this;
return enemyToGet;
}
// 使用済みの敵オブジェクトをプールに戻す命令
public void ReturnEnemy(GameObject enemy)
{
// 敵を再び非表示にする
enemy.SetActive(false);
// キューの最後尾に戻す
_enemyPool.Enqueue(enemy);
}
}
Enemy.cs(最終形)
using UnityEngine;
public class Enemy : MonoBehaviour
{
// 自身を管理してくれているプールの情報を入れておくための変数
public EnemyPoolManager PoolManager { get; set; }
// オブジェクトがクリックされた時に自動で呼ばれるメソッド
private void OnMouseDown()
{
// 自分自身(のGameObject)をプールに戻すようお願いする
if (PoolManager != null)
{
PoolManager.ReturnEnemy(gameObject);
}
}
}
EnemySpawner.cs(おまけ適用後)
using UnityEngine;
public class EnemySpawner : MonoBehaviour
{
// InspectorからEnemyPoolManagerをアタッチするための変数
[SerializeField] private EnemyPoolManager _poolManager;
// 敵を出現させる間隔(秒)
[SerializeField] private float _spawnInterval = 0.5f;
void Start()
{
// _spawnIntervalごとに SpawnEnemy メソッドを繰り返し呼び出す
InvokeRepeating(nameof(SpawnEnemy), 1f, _spawnInterval);
}
private void SpawnEnemy()
{
// プールから敵を1体もらう
var enemy = _poolManager.GetEnemy();
// もしプールから敵が取得できなかった場合は何もしない
if (enemy == null) return;
// 敵の出現位置をランダムに設定
float randomX = Random.Range(-4f, 4f);
float randomY = Random.Range(-4f, 4f);
enemy.transform.position = new Vector3(randomX, randomY, 0);
}
}
まとめ
今回の研修を通して学んだ、Unityにおけるパフォーマンス改善の基本テクニック「オブジェクトプール」について解説しました。
-
Instantiate()/Destroy()の多用はパフォーマンス低下の原因になる - オブジェクトプールは、オブジェクトを使い回すことで負荷を軽減する仕組み
-
Queueを使うと、先入れ先出しで公平にオブジェクトを再利用できる - プールが空になった場合も想定しておくと、より堅牢なシステムになる
この仕組みは、大量に生成・破棄されるあらゆるものに応用できます。
ぜひ、ご自身のゲーム開発に活かしてみてください。
最後までお読みいただき、ありがとうございました。
▼採用情報
レアゾン・ホールディングスは、「世界一の企業へ」というビジョンを掲げ、「新しい"当たり前"を作り続ける」というミッションを推進しています。
現在、エンジニア採用を積極的に行っておりますので、ご興味をお持ちいただけましたら、ぜひ下記リンクからご応募ください。