ゲームプログラムの基本パターン「Object Pool」の実装方法について

こんにちは。プログラマーの尾関です。
今回は、Game Programming Patterns から 「Object Pool」 の実装方法を紹介したいと思います。

Game Programming Patterns > Object Pool

■Object Pool とは

Object Pool とは、あらかじめオブジェクトを生成して格納しておきます。この時点のオブジェクトは未使用状態です。

そしてオブジェクトが必要になったら Object Pool から取り出して使います。

使用済みとなったオブジェクトのみ、更新や描画処理を行います。未使用のオブジェクトは何もしません。

さらに新しいオブジェクトが必要になったら、上から未使用のオブジェクトを探して取り出します。

不要になった Object は ObjectPool に戻します。(実装方法としては Object が保持している “使用済みフラグ” を OFF にすることが多いです)

この仕組みはあらゆるゲームで有用なものです。
アクションゲームやシューティングゲームなど、大量のオブジェクトを扱うゲームではインスタンス生成が高速化に行えるメリットがあり、非リアルタイムのRPGでも、演出用のオブジェクトを Object Pool で管理してもよいです。

ちなみに最近のゲームエンジンでよく見かける「使うときにオブジェクトを New する」方式は、 “あらかじめ生成する” のではないので Object Pool と異なります。

▼Object Pool のメリット

Object Pool のメリットとしては以下のことが考えられます。

  • メモリのピーク値の固定化
  • 処理速度の改善
  • オブジェクトの最大数を制限することで、ワーストケースを想定しやすい

利用するオブジェクトを「最初にすべて生成する」ことでメモリのピーク値が固定化できます。またアクションゲームのようにオブジェクトの生成と破棄を頻繁に繰り返す場合、高速化の恩恵を得られることが多いです。例えば、ファイルロードやモデル・テクスチャのセットアップといった重たい処理がオブジェクトごとに必要である場合、Object Pool ではあらかじめ必要なぶんだけ作っておくので、生成と破棄のコストを減らすことができます。(ただし最初の確保時に時間がかかることがあります)

特にメモリについて、SwitchやPS4といった最近の開発環境ではメモリが枯渇することはあまりないですが、3DSくらいまではメモリが不足することが多く、 Object Pool で固定化することでメモリをギリギリまで使うようにしていました。

最初に生成可能な最大数を決めてしまうことで、最大数ぶんのオブジェクトを表示した場合の処理速度が計測しやすいのもメリットです。

▼Object Pool のデメリット

Object Pool のデメリットは以下の通りです。

  • 自分で生成と破棄を管理しなければならない
  • オブジェクトの生成可能な最大数が決まってしまう
  • 未使用のメモリが使われないままになってしまう可能性がある

これらはメリットの裏返しです。

メモリ管理を自分で行うのが Object Pool なので、最初にまとめて生成したオブジェクトは責任をもって破棄する必要があります。

また、利用可能な最大数を決めるということは、動的に最大数を増やせないということでもあります。例えば「最大数を超えて敵を出したい」といった突発的な要望や「エフェクトの最大数がどれだけ出るかわからない」といった最大値が読めない、という状況に対処しづらい欠点があります。このような要望が出やすいゲームデザインの場合は、Object Pool は使わない方がよいかもしれませんね。

■C++での実装例

最後に C++ での実装例です。

デザインパターンでいう、Factory に似た構成です。

  • Object: 基本となるオブジェクト。これを継承して 敵やパーティクルなどを実装する
  • ObjectPool: Object をプール(集約)するクラス。これを継承して敵やパーティクルの管理クラスを実装する

続けてコードの実装例です。

#include <stdio.h>

// ■基本オブジェクト.
class Object {
public:
  bool  exists = false;  // 生存フラグ.
  int   timer  = 0;      // 汎用タイマー.
  float x      = 0.f;    // 座標(X).
  float vx     = 0.f;    // 移動量(X).

public:
  Object() {}
  virtual ~Object() {}

  // 生成.
  virtual void Create() {
    exists = false; // 生成時は無効.
    // 他に処理が必要であればオーバーライドする.
  }
  virtual void Init()   {} // 初期化.

  virtual void Update() {} // 更新.
  virtual void Draw()   {} // 描画.
};

// ■オブジェクト管理.
template <class T>
class ObjectPool {
protected:
  T*  _pMembers = nullptr;
  int _size     = 0;

public:
  ObjectPool() {}
  virtual ~ObjectPool() {}

  // 生成.
  void Create(int size) {
    _pMembers = new T[size];
    _size = size;
  }

  // 破棄.
  void Destroy() {
    delete[] _pMembers;
  }

  // 未使用のオブジェクトを取得する.
  T* Recycle() {
    for(int i = 0; i < _size; i++) {
      if(_pMembers[i].exists == false) {
        _pMembers[i].exists = true; // 生存フラグを立てる.
        return &_pMembers[i]; // 未使用なので再利用可能.
      }
    }

    // 見つからなかった.
    return nullptr;
  }

  // 更新.
  void Update() {
    for(int i = 0; i < _size; i++) {
      if(_pMembers[i].exists) {
        _pMembers[i].Update(); // 生存しているオブジェクトのみ更新.
      }
    }
  }

  // 描画.
  void Draw() {
    for(int i = 0; i < _size; i++) {
      if(_pMembers[i].exists) {
        _pMembers[i].Draw(); // 生存しているオブジェクトのみ描画.
      }
    }
  }

  // 生存数をカウントする.
  int Count() {
    int cnt = 0;
    for(int i = 0; i < _size; i++) {
      if(_pMembers[i].exists) {
        cnt++; // 生存している.
      }
    }
    return cnt;
  }
};

// ■敵の実装
class Enemy : public Object {
public:
  Enemy() {}
  virtual ~Enemy() {}

  // 初期化.
  void Init(float x, float vx) {
    this->x  = x;  this->vx  = vx;
    timer = 0;
  }

  virtual void Update() {
    x += vx; // 移動.
    timer++; // タイマー更新.
    if(x > 100.f) {
      printf("ゴール到着\n");
      exists = false; // 100を超えたらおしまい.
    }
  }

  virtual void Draw() {
    printf("timer = %d | x = %3.2f\n", timer, x);
  }
};

class EnemyPool : public ObjectPool<Enemy> {
};

// ■実行例.
void main() {
  // オブジェクト管理生成.
  EnemyPool pool;
  pool.Create(32); // ひとまず32個作っておく.

  // 敵を生成.
  Enemy* pEnemy = pool.Recycle(); // 取り出し.
  pEnemy->Init(0, 10); // 0から10の速さで進む.
  Enemy* pEnemy2 = pool.Recycle(); // 取り出し.
  pEnemy2->Init(50, 12.5f); // 50から12.5の速さで進む.

  // 更新処理.
  while(pool.Count() > 0) {
    pool.Update();
    pool.Draw();
  }

  // 終了処理.
  pool.Destroy();
}

Object::exists が「使用済み」「未使用」の状態を管理するフラグとなります。ObjectPool::Recycle() を呼び出すことで未使用の Object を返し、exists フラグを立てます。未使用にしたい場合は exists フラグを false にすることで未使用状態となります。

あと、以前にPyxel で書いた記事は、Pythonでの Object Pool を実装した例となりますね。

Python での実装方法が気になる方は、こちらを確認してもよいかもしれません。

■参考にした記事

Game Programming Patterns > Object Pool

日本語で読みたい場合は、翻訳された本が販売されています。他にも役に立つゲームプログラムのパターンがありおススメです!

以上、尾関でした。

Follow me!