【ゲーム制作】オブジェクトプールパターンとは?|弾を使い回してカクつきをなくす

ゲーム制作

今回は、ゲーム制作でよく出てくる「オブジェクトプールパターン」について解説していきます。

「弾を撃つたびにカクつく…」
「new / delete を毎フレームやってるけど大丈夫?」
「オブジェクトプールって、要するに何?」

こんな疑問はありませんか?

シューティングの弾やエフェクトのように、大量に生まれてすぐ消えるオブジェクトを毎回 new していると、それ自体が重さの原因になります。オブジェクトプールは、作らずに使い回すという発想でこれを解決します。

この記事を読み終えると、あなたはオブジェクトプールの意味・実装方法・ゲームでの使いどころ・初心者がハマりやすいミスをしっかり理解できると思いますので、ぜひ最後まで読んでいただけると嬉しいです。

オブジェクトプールパターンとは?

オブジェクトプールパターンとは、オブジェクトを最初にまとめて作っておき、必要になったら「使用中」の札を付けて貸し出し、不要になったら返してもらって使い回すパターンです。

レンタルショップをイメージすると分かりやすいです。客が来るたびに商品を新しく製造するのではなく、棚にある在庫を貸して、返ってきたらまた棚に戻す。これだけです。

ゲームでは「作らない・消さない・フラグを切り替えるだけ」と覚えると本質を外しません。デザインパターン全体はデザインパターンをゲーム開発で活用する方法の記事もどうぞ。

なぜオブジェクトプールが必要?

理由は、newdelete が思ったより高いからです。よくある弾の実装を見てください。

// 悪い例:撃つたびに作って、消えるたびに壊す
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

注目してほしいのは、newdelete も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 していた実装をプールに置き換えたら、撃った瞬間のカクつきがきれいに消えました。ここまでは狙いどおりです。

ところが今度は、前に撃った弾の速度を引きずって、変な方向に飛ぶ弾が出るという怪現象が発生しました。

原因は初期化漏れSpawnxyactive_ は設定していたのに、加速度の変数だけ書き忘れていたのです。new なら毎回まっさらな状態から始まりますが、使い回すプールでは前回の値が残ります。ここがプール最大の落とし穴でした。

もう一つ。Spawn が満杯で nullptr を返すのをチェックし忘れて落としたこともあります。プールは有限なので、借りられないことがあるのを忘れてはいけません。

オブジェクトプール使用時のよくある失敗例と対処法

①Spawnでの初期化漏れ

体験談のとおり、前回の値が残りますSpawnではメンバを「全部」設定するのを徹底しましょう。メンバを増やしたときに Spawn も直す、が鉄則です。

②満杯時のnullptrを見ない

プールは有限です。Spawn の戻り値を必ず確認してください。満杯なら「撃たない」で問題ないことがほとんどです(nullptrの記事もどうぞ)。

③返却した弾のポインタを持ち続ける

返却済みの弾は次の発射で別の弾として復活します。古いポインタを持っていると、まったく無関係な弾を操作してしまいます。返したら忘れるのが原則です。

注意点

  • プールは有限。満杯のときの挙動を決めておく
  • Spawn全メンバを初期化する(使い回しの宿命)
  • 数個しか出ないオブジェクトに無理に使わない

まとめ

  • オブジェクトプールは作らずに使い回すパターン
  • 消す代わりにフラグを折って棚に戻すだけ
  • new/delete のコストと断片化を避けられる
  • 最大の罠は初期化漏れ。前回の値が残る

弾やエフェクトのように「大量に出て、すぐ消える」ものがあるなら、プールは効果がはっきり出る最適化です。まずは弾から置き換えてみてください。

関連記事