今回は、ゲーム制作でよく出てくる「オブジェクトプールパターン」について解説していきます。
「弾を撃つたびにカクつく…」
「new / delete を毎フレームやってるけど大丈夫?」
「オブジェクトプールって、要するに何?」
こんな疑問はありませんか?
シューティングの弾やエフェクトのように、大量に生まれてすぐ消えるオブジェクトを毎回 new していると、それ自体が重さの原因になります。オブジェクトプールは、作らずに使い回すという発想でこれを解決します。
この記事を読み終えると、あなたはオブジェクトプールの意味・実装方法・ゲームでの使いどころ・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。
オブジェクトプールパターンとは?
オブジェクトプールパターンとは、オブジェクトを最初にまとめて作っておき、必要になったら「使用中」の札を付けて貸し出し、不要になったら返してもらって使い回すパターンです。
レンタルショップをイメージすると分かりやすいです。客が来るたびに商品を新しく製造するのではなく、棚にある在庫を貸して、返ってきたらまた棚に戻す。これだけです。
ゲームでは「作らない・消さない・フラグを切り替えるだけ」と覚えると本質を外しません。デザインパターン全体はデザインパターンをゲーム開発で活用する方法の記事もどうぞ。
なぜオブジェクトプールが必要?
理由は、new と delete が思ったより高いからです。よくある弾の実装を見てください。
// 悪い例:撃つたびに作って、消えるたびに壊す
if (input.IsShot()) {
bullets.push_back(new Bullet(x, y)); // 毎回メモリ確保
}
for (auto it = bullets.begin(); it != bullets.end(); ) {
if ((*it)->IsDead()) {
delete *it; // 毎回メモリ解放
it = bullets.erase(it);
} else {
++it;
}
}これの何が問題かというと、次の3つです。
- 確保・解放そのものが遅い。毎フレーム何十回もやれば効いてくる
- メモリが断片化する。空きはあるのに確保できない状態に近づく
- メモリ上にバラバラに散るので、走査でキャッシュが効かない
特に「弾を大量に撃った瞬間だけカクつく」という症状は、これが原因のことが多いです。
オブジェクトプールの実装
まず、貸し出される側に「使用中かどうか」の札を持たせます。
#include <iostream>
#include <vector>
class Bullet {
public:
// 使い回すので、コンストラクタではなくここで初期化する
void Spawn(float x, float y, float vy) {
x_ = x; y_ = y; vy_ = vy;
active_ = true;
}
void Update() {
if (!active_) return;
y_ += vy_;
if (y_ < 0.0f) active_ = false; // 画面外に出たら返却
}
bool IsActive() const { return active_; }
private:
float x_ = 0.0f, y_ = 0.0f, vy_ = 0.0f;
bool active_ = false; // これが「使用中の札」
};次がプール本体です。最初にまとめて作っておき、空いているものを探して貸すだけです。
class BulletPool {
public:
explicit BulletPool(size_t size) : bullets_(size) {} // 最初に全部作る
// 空いている弾を1つ借りる
Bullet* Spawn(float x, float y, float vy) {
for (auto& b : bullets_) {
if (!b.IsActive()) { // 空いているものを見つけたら
b.Spawn(x, y, vy); // 使い回す
return &b;
}
}
return nullptr; // 全部使用中(=これ以上は撃たせない)
}
void UpdateAll() {
for (auto& b : bullets_) b.Update();
}
int ActiveCount() const {
int n = 0;
for (const auto& b : bullets_) if (b.IsActive()) ++n;
return n;
}
private:
std::vector<Bullet> bullets_; // 連続メモリに並ぶのでキャッシュも効く
};
int main() {
BulletPool pool(100); // 弾は最大100発
pool.Spawn(320.0f, 400.0f, -8.0f);
pool.Spawn(300.0f, 400.0f, -8.0f);
std::cout << "使用中: " << pool.ActiveCount() << "n";
for (int i = 0; i < 60; ++i) pool.UpdateAll(); // 画面外に出る
std::cout << "使用中: " << pool.ActiveCount() << "n";
return 0;
}使用中: 2
使用中: 0
注目してほしいのは、new も delete も1回も出てこないことです。弾が「消えた」のは active_ が false になっただけ。実体は棚に戻っただけで、次の発射で再利用されます。
しかも std::vector<Bullet> なのでメモリが連続しています。毎フレームの全走査でキャッシュが効くというオマケ付きです(STLコンテナ徹底比較の記事もどうぞ)。
探索を速くする工夫
上の実装は「空きを頭から探す」ので、プールが大きいと探索が O(n) になります。気になる場合は、空いている番号をあらかじめ持っておくと O(1) にできます。
class BulletPool {
public:
explicit BulletPool(size_t size) : bullets_(size) {
free_.reserve(size);
// 空き番号を積んでおく
for (size_t i = size; i > 0; --i) free_.push_back(i - 1);
}
Bullet* Spawn(float x, float y, float vy) {
if (free_.empty()) return nullptr; // 満杯
size_t i = free_.back();
free_.pop_back(); // 空きリストから取り出す
bullets_[i].Spawn(x, y, vy);
return &bullets_[i];
}
void Release(size_t i) { free_.push_back(i); } // 返却
private:
std::vector<Bullet> bullets_;
std::vector<size_t> free_; // 空いている番号
};ただし、まずは頭から探す単純版で十分です。数百個程度なら差は出ません。
【重要】私が実際にオブジェクトプールで困った体験談
個人開発の弾幕シューティングで、弾を new していた実装をプールに置き換えたら、撃った瞬間のカクつきがきれいに消えました。ここまでは狙いどおりです。
ところが今度は、前に撃った弾の速度を引きずって、変な方向に飛ぶ弾が出るという怪現象が発生しました。
原因は初期化漏れ。Spawn で x、y、active_ は設定していたのに、加速度の変数だけ書き忘れていたのです。new なら毎回まっさらな状態から始まりますが、使い回すプールでは前回の値が残ります。ここがプール最大の落とし穴でした。
もう一つ。Spawn が満杯で nullptr を返すのをチェックし忘れて落としたこともあります。プールは有限なので、借りられないことがあるのを忘れてはいけません。
オブジェクトプール使用時のよくある失敗例と対処法
①Spawnでの初期化漏れ
体験談のとおり、前回の値が残ります。Spawnではメンバを「全部」設定するのを徹底しましょう。メンバを増やしたときに Spawn も直す、が鉄則です。
②満杯時のnullptrを見ない
プールは有限です。Spawn の戻り値を必ず確認してください。満杯なら「撃たない」で問題ないことがほとんどです(nullptrの記事もどうぞ)。
③返却した弾のポインタを持ち続ける
返却済みの弾は次の発射で別の弾として復活します。古いポインタを持っていると、まったく無関係な弾を操作してしまいます。返したら忘れるのが原則です。
注意点
- プールは有限。満杯のときの挙動を決めておく
Spawnで全メンバを初期化する(使い回しの宿命)- 数個しか出ないオブジェクトに無理に使わない
まとめ
- オブジェクトプールは作らずに使い回すパターン
- 消す代わりにフラグを折って棚に戻すだけ
new/deleteのコストと断片化を避けられる- 最大の罠は初期化漏れ。前回の値が残る
弾やエフェクトのように「大量に出て、すぐ消える」ものがあるなら、プールは効果がはっきり出る最適化です。まずは弾から置き換えてみてください。


