自前ゲームエンジンにおける古くて大事なこと:ゲームオブジェクト型エンジン

C言語で種類ごとに管理していたゲームオブジェクトを、C++のクラス・継承・仮想関数・ポリモーフィズムによって一括管理する、昔ながらのゲームオブジェクト型エンジンを解説します。古典的な設計だからこそ、ゲームループや寿命管理、ObjectManagerなど、コンシューマータイトルや自前ゲームエンジン開発を目指すなら避けて通れない基礎が詰まっています。

Share
自前ゲームエンジンにおける古くて大事なこと:ゲームオブジェクト型エンジン

はじめに


UnityやUnreal Engineなどの出来合いのゲームエンジンを使えば、エンジンそのものの仕組みを深く知らなくてもゲームを作ることはできます。

しかし、ゲームプログラマーとしてコンシューマーゲームやAAAタイトル、その土台になるゲームエンジンの開発を目指すのであれば、それだけでは十分とは言えません。C++で大規模なプログラムを組み、DirectXを使って描画やリソースを扱い、必要であればメモリやハードウェアに近いところまで理解していく力が必要になります。

この記事では、そうした分野を目指す学生にとって、C++とDirectXを使ってゲームの土台を組めることは避けて通れない、という立場で話を進めます。

今回扱うのは、GameObjectを基底クラスとして、プレイヤー、敵、弾、アイテムなどを共通の仕組みで管理する昔ながらのゲームオブジェクト型エンジンです。

これは最新の設計ではありません。現在ではコンポーネント指向やECS、データ指向設計など、さらに発展した方法も使われています。

しかし、古いからといって知らなくてよいわけではありません。ゲームループ、クラス、継承、仮想関数、オーバーライド、ポリモーフィズム、生成と削除、オブジェクトの寿命管理といった、ゲームプログラミングの基本事項がこの中に詰まっています。

古いけれど、ゲームプログラマーを目指すなら避けて通れない。

この記事では、C言語でゲームを作る場合の基本形から出発し、何がつらくなり、C++のクラス、継承、仮想関数によってそれをどう整理し、ゲームオブジェクト型エンジンへ発展させていくのかを順番に見ていきます。


1.ゲームは「初期化・更新・描画」で動いている

ゲームプログラムの基本的な処理は、大きく次の3つに分けられます。

  1. 初期化:ゲームに必要なデータを準備する
  2. 更新:キャラクターの位置や状態を変化させる
  3. 描画:現在の状態を画面へ表示する

初期化はゲームやステージを開始するときに一度行い、その後は更新と描画を何度も繰り返します。

図 ゲームループ

この繰り返しがゲームループです。60fpsのゲームであれば、基本的には更新と描画を1秒間に約60回繰り返します。

実際には入力、衝突判定、物理演算、AI、サウンドなどもありますが、それらも大きく見れば初期化・更新・描画のどこかへ組み込まれています。

図 ゲームループとオブジェクト

2.C言語で作っていくと

構造体と関数を組み合わせて作る

C言語でプレイヤーのオブジェクト(自機とか、マ◯オ)を作る場合、まずデータを構造体にまとめます。

struct Player
{
    float x;
    float y;
    int hp;
};

しかし、構造体はデータを保存する箱です。Player構造体が自分で移動したり、自分を描画したりすることはありません。

そこで、構造体を操作する関数を別に作ります。

void Player_Initialize(struct Player* player);
void Player_Update(struct Player* player);
void Player_Draw(const struct Player* player);

ゲーム側では、構造体と関数を組み合わせて使います。

struct Player player;

Player_Initialize(&player);

while (Game_IsRunning())
{
    Player_Update(&player);
    Player_Draw(&player);
}

C言語では、次のものが別々に存在しています。

Playerのデータ    → Player構造体
Playerの初期化    → Player_Initialize関数
Playerの更新      → Player_Update関数
Playerの描画      → Player_Draw関数

人間から見れば、すべて「プレイヤーに関係するもの」です。しかし、C言語の仕組みとして、構造体と関数が一つのオブジェクトに結び付いているわけではありません。(普段C++で書いてるとこう見えてしまいます。Cで書いてるとそりゃそう作るだろって思うんだけどね。)

図 C言語のデータと、データ処理ロジック

3.オブジェクトが増えると、データと処理ロジックも増える

ゲームに敵を追加するなら、敵用の構造体と関数が必要です。

struct Enemy
{
    float x;
    float y;
    int hp;
};

void Enemy_Initialize(struct Enemy* enemy);
void Enemy_Update(struct Enemy* enemy);
void Enemy_Draw(const struct Enemy* enemy);

弾を追加するなら、弾用の構造体と関数も必要です。

struct Bullet
{
    float x;
    float y;
    float speed;
};

void Bullet_Initialize(struct Bullet* bullet);
void Bullet_Update(struct Bullet* bullet);
void Bullet_Draw(const struct Bullet* bullet);

さらに、敵や弾は複数登場します。そのため、種類ごとの配列と個数も管理しなければなりません。

struct Player player;
struct Enemy enemies[100];
struct Bullet bullets[1000];

int enemyCount;
int bulletCount;

更新処理も、種類ごとに書きます。

Player_Update(&player);

for (int i = 0; i < enemyCount; i++)
{
    Enemy_Update(&enemies[i]);
}

for (int i = 0; i < bulletCount; i++)
{
    Bullet_Update(&bullets[i]);
}

描画も同じです。

Player_Draw(&player);

for (int i = 0; i < enemyCount; i++)
{
    Enemy_Draw(&enemies[i]);
}

for (int i = 0; i < bulletCount; i++)
{
    Bullet_Draw(&bullets[i]);
}

ボス、アイテム、エフェクト、NPC、扉、宝箱、動く床を追加するたびに、次の一式が増えていきます。

構造体
初期化関数
更新関数
描画関数
配列
現在の個数
種類ごとの繰り返し処理

小さなゲームなら、この方法でも問題ありません。しかし、ゲームが大きくなるほど、物量を増やして対応する構造になっていきます。

図 データと処理ロジックの増加

4.本当の問題は、コードが長くなることではない

この問題の本質は、単にコード量が増えることではありません。

Player、Enemy、Bulletなどを種類ごとに別々に管理しているため、初期化・更新・描画といった共通の処理も、それぞれに対して個別に書かなければなりません。

新しくDragonを追加したら、Dragonの構造体と関数を作るだけでは終わりません。ゲーム全体の初期化、更新、描画へ、Dragon用の処理を書き足す必要があります。

また、ゲーム全体の更新方法や描画の流れを変更したい場合も、種類ごとに書かれた管理処理へ、それぞれ変更を反映しなければなりません。

つまり、ゲームに登場するものが増えるほど管理する側の処理も一緒に増え、ゲーム内容を追加したり共通の処理を変更したりするたびに、ゲーム全体の管理処理まで修正することになります。

C言語では、特殊な仕組みを自分で作らない限り、このように種類ごとにデータと処理を管理するしかありません。

C++ではこの辺のことをうまく解決することが出来ます。(と、言われています笑)


5.C++のクラスの役割

C言語では、キャラクターのデータを構造体に入れ、そのデータを操作する関数を別に用意していました。

C++のクラスでは、こうしたデータと、そのデータを扱う処理を一つの単位としてまとめることができます。(ある程度C++わかっている前提で話します)

class Player
{
private:
    float x_ = 0.0f;
    float y_ = 0.0f;
    int hp_ = 100;
    bool isDead_ = false;

public:
    void Initialize();
    void Update();
    void Draw() const;

    void Damage(int damage);
    bool IsDead() const;
};

C言語では別々に存在していたPlayerのデータと処理が、Playerクラスという一つの単位にまとまりました。(これだけでも価値がありそうだよね!)

Player
 ├─ x座標
 ├─ y座標
 ├─ HP
 ├─ Initialize
 ├─ Update
 └─ Draw

呼び出す側も、次のようになります。
「誰」の「何」を処理するよ。というのが構造的にも、見た目的にもわかりやすくなります。

Player player;

player.Initialize();

while (Game_IsRunning())
{
    player.Update();
    player.Draw();
}

クラスにまとめる利点は、関連するデータと関数を同じ場所へ置けることだけではありません。そのデータを、どのような手順で変更するかもクラスに任せられます。

例えば、プレイヤーが敵と接触したとき、当たり判定を行った側がHPを直接減らすのではなく、Playerへダメージ処理を依頼します。

if (IsHitPlayerAndEnemy())
{
    player.Damage(20);
}

ここで呼び出す側が伝えているのは、「プレイヤーが20ダメージを受けた」という情報だけです。(hp=とか, isDead=とかをプレイヤーの外部から書かない)

HPを減らし、0未満にならないように補正し、必要であれば死亡状態へ変更する処理は、Playerクラス自身が担当します。

void Player::Damage(int damage)
{
    hp_ -= damage;

    if (hp_ <= 0)
    {
        hp_ = 0;
        isDead_ = true;
    }
}

bool Player::IsDead() const
{
    return isDead_;
}

このようにすると、敵との接触、弾への命中、ステージギミックなど、ダメージを受ける原因が増えても、呼び出す側は同じようにDamage()を呼ぶだけです。HPを0未満にしない処理や、どの条件で死亡状態になるのかを、それぞれの場所へ書く必要はありません。

外側の処理がプレイヤーの死亡状態を知りたい場合は、IsDead()を使ってPlayerへ問い合わせます。

また、hp_isDead_のアクセス指定をprivateにしておけば、外部から状態を直接書き換えられなくなります。C言語では、同じスコープ内の変数であれば自由に書き換え可能でしたが、その状態は多数のオブジェクトや似たようなプロフィールのオブジェクト(同じ構造体由来で、名前の違う似たような関数で似たような内容を処理する状態)では、引数の受け渡しミスや、代入ミスが何時起こっても不思議ではないです。(Cでプログラムを少し本気で作ってみればすぐその状態が理解できるよ!)
C++では、そのような状態を言語の仕組み上(public, private指定)でガードできます。

player.hp_ -= 20;       // privateなので書き換えられない
player.isDead_ = true;  // privateなので書き換えられない

privateにすること自体が目的なのではありません。Playerが持つデータを、どのようなルールで変更するかを指定できるところに旨味があります。「」が「どんな動作をしたら」変数が変更されるか、をpublicメンバ関数として外部に見せることができます。

6.クラスにまとめただけでは、一括管理できない

クラスによってデータと処理は整理できました。しかし、PlayerEnemyBulletが別々の型であることに変わりはありません。

Player player;
std::vector<Enemy> enemies;
std::vector<Bullet> bullets;

更新も、まだ種類ごとに必要です。

player.Update();

for (Enemy& enemy : enemies)
{
    enemy.Update();
}

for (Bullet& bullet : bullets)
{
    bullet.Update();
}

C言語より整理しやすくなりましたが、更新や描画を種類ごとに個別に呼び出している点は変わっていません。新しい種類を追加すれば、その種類のための更新・描画処理を管理側へ書き足す必要があります。

そこで、種類が違っても同じ方法で初期化・更新・描画できるように、ゲームに登場するものが従う共通のルールを作ります。


7.すべてをGameObjectとして扱う

プレイヤーと敵では、持っているデータも動きも異なります。しかし、ゲームエンジンから見ると、次の点は共通しています。

  • ゲームへ登場するときに初期化する
  • 毎フレーム更新する
  • 毎フレーム描画する
  • 必要がなくなったら削除する

そこで、共通ルールとしてGameObjectクラスを作ります。

class GameObject
{
private:
    bool destroyRequested_ = false;

public:
    virtual ~GameObject() = default;

    virtual void Initialize() = 0;
    virtual void Update() = 0;
    virtual void Draw() const = 0;

    void Destroy() { destroyRequested_ = true; }
    bool IsDestroyRequested() const { return destroyRequested_; }
};

GameObjectは具体的なキャラクターではありません。

「ゲーム内に存在するものは、初期化・更新・描画ができる」という、エンジンとゲームオブジェクトの間の約束です。

PlayerEnemyは、このルールを継承します。

class Player : public GameObject
{
public:
    void Initialize() override;
    void Update() override;
    void Draw() const override;
};

class Enemy : public GameObject
{
public:
    void Initialize() override;
    void Update() override;
    void Draw() const override;
};

それぞれの処理内容は違いますが、外側から見れば、どちらもGameObjectです。

なぜコンストラクタだけで初期化しないのか

C++にはコンストラクタがあるため、「Initialize()は不要ではないか」と思うかもしれません。

コンストラクタは、そのオブジェクトが成立するために必ず必要な初期値を設定する場所です。一方、ゲームエンジンでは、モデルや画像の読み込み、他オブジェクトの検索、エンジン機能への登録などを、オブジェクト生成後に行いたいことがあります。

そのため自前エンジンでは、コンストラクタとInitialize()を役割分担させる設計がよく使われます。ただし、何でもInitialize()へ押し込むのではなく、コンストラクタだけで安全な状態になるように作ることが大切です。基本的にメンバ変数の初期化は、コンストラクタに任せる設計がよいでしょう。


8.異なるクラスを同じ配列へ入れる

PlayerEnemyBulletでは、GameObjectから受け継いだUpdate()を、それぞれのクラスに必要な処理へオーバーライド(親クラスの処理の上書き)します。

Update()の実装部分だけを抜き出すと、例えば次のようになります。

void Player::Update()
{
    // プレイヤーの更新処理
}

void Enemy::Update()
{
    // 敵の更新処理
}

void Bullet::Update()
{
    // 弾の更新処理
}

このように、それぞれが同じUpdate()という名前の関数を持ちながら、中身は自分専用の処理にできます。Initialize()Draw()も同じように、それぞれのクラスでオーバーライドします。

そして、すべてがGameObjectを継承しているため、GameObject*として同じ配列へ入れられます。

std::vector<GameObject*> objects;

objects.push_back(new Player());
objects.push_back(new Enemy());
objects.push_back(new Bullet());

ここでは型の関係を見やすくするために生ポインタを使っています。オブジェクトを誰が所有し、どこで破棄するかはエンジンの設計によって異なるため、ここでは一括管理の仕組みに話を絞ります。

配列の型は、すべてGameObject*です。しかし、実際に指しているものは異なります。

GameObject* → Player
GameObject* → Enemy
GameObject* → Bullet

この状態で、すべてのオブジェクトに同じようにUpdate()を呼び出します。

for (GameObject* object : objects)
{
    object->Update();
}

objectの型はすべてGameObject*ですが、Update()は仮想関数として定義され、それぞれのクラスでオーバーライドされています。そのため、呼び出されたときには実際のオブジェクトに対応したUpdate()が実行されます。

実体がPlayer  → Player::Update()
実体がEnemy   → Enemy::Update()
実体がBullet  → Bullet::Update()

このコードには、PlayerEnemyBulletという名前を使った呼び分け処理はありません。それでも、オーバーライドした関数が実体に応じて選ばれます。

このように、同じGameObject*として扱い、同じUpdate()を呼び出しているにもかかわらず、実体に応じて異なる処理が実行される仕組みを、**ポリモーフィズム(多態性)**と呼びます。

よくあるオブジェクト指向の説明では、「同じ命令で違う処理が呼ばれる」と説明されます。しかし、ゲームエンジンで本当に重要なのは、その先です。

エンジン側が、Player用、Enemy用、Bullet用と種類ごとの処理を書き分けなくても、一括して処理できる。

これが、ゲームオブジェクト型エンジンにおけるポリモーフィズムの実践的な価値です。

少しマニアックな話:virtualの裏側

多くのC++コンパイラでは、仮想関数を**仮想関数テーブル(vtable)**と呼ばれる関数一覧表で実現します。オブジェクトは自分の型に対応する表を参照し、object->Update()を呼ばれたときに、実体に合った関数を選びます。

C++規格がvtableという実装を必須にしているわけではありませんが、一般的な処理系ではこれに近い方法が使われます。そのため仮想関数呼び出しは完全に無料ではありません。

ただし、ここで重要なのは数命令の削減ではありません。PlayerEnemyBulletごとに更新処理を書き分けていた管理コードを、GameObjectへの共通の呼び出しへまとめられることです。

一方で、大量の頂点やパーティクルまで一個ずつ仮想関数で処理すればよい、という話ではありません。仮想関数はゲームオブジェクトを共通の方法で管理するために使い、重い大量処理では別の方法を選ぶ、という使い分けが重要になります。


9.新しい敵を追加しても、エンジン側は変えない

新しくDragonを追加するとします。

class Dragon : public GameObject
{
public:
    void Initialize() override
    {
        // モデルや初期位置を準備する
    }

    void Update() override
    {
        // 空を飛び、炎を吐く
    }

    void Draw() const override
    {
        // ドラゴンを描画する
    }
};

作成したDragonを、ほかのゲームオブジェクトと同じリストへ追加します。

objects.push_back(new Dragon());

必要なのは、Dragonクラスを作って管理対象へ加えることです。更新処理へDragon専用の呼び出しを追加する必要はありません。

for (GameObject* object : objects)
{
    object->Update();
}

Dragonobjectsへ追加すれば、更新処理側にDragon専用の呼び出しを追加する必要はありません。既存のobject->Update()という共通処理から、実体に応じてDragon::Update()が呼び出されます。

ゲーム固有の種類を増やしても、エンジン側の一括処理は変更しない。これがゲームオブジェクト型エンジンの大きな利点です。

ただし、クラスを作っただけで自動的にゲームへ登場するわけではありません。インスタンスを生成し、ゲームで管理しているオブジェクトの一覧へ加える処理は必要です。

ポリモーフィズムが自動化するのは、登録されたオブジェクトに対して正しい処理を呼び分ける部分です。


10.生成と削除、寿命管理まで含めてゲームエンジン

ゲームでは、敵のHPが0になったり、弾が画面外へ出たりすると、オブジェクトが不要になります。

ここで注意したいのは、ゲーム上の「死亡」と、プログラム上の「オブジェクトを破棄する」は別の話だということです。例えばプレイヤーが死亡しても、死亡演出やリザルト処理のために、しばらくオブジェクトを残しておくことがあります。

そのためGameObject側では、HPのようなゲーム内容の状態とは分けて、「このオブジェクトは削除してよい」という削除予約を管理します。先ほどのdestroyRequested_はそのためのフラグです。

不要になったオブジェクトはDestroy()を呼びます。

void Bullet::Update()
{
    // 弾を移動する

    if (画面外へ出た)
    {
        Destroy();
    }
}

しかし、更新の繰り返し中にその場で配列から削除すると、走査中の配列が変化して繰り返し処理を壊すことがあります。

そこでDestroy()では、その場でdeleteしたり配列から消したりせず、削除予約だけを行います。実際の削除は、すべての更新が終わった後など、安全なタイミングでまとめて行います。

全オブジェクトをUpdate
        ↓
削除予約されたオブジェクトを除去
        ↓
残ったオブジェクトをDraw

ObjectManagerにまとめると寿命管理が楽になる

ここまでの説明では、仕組みを見やすくするためにobjectsというリストを直接扱ってきました。

しかし、ゲームが大きくなると、オブジェクトの追加、初期化、更新、描画、削除予約の確認、実際の削除といった処理を、ゲームループのあちこちへ書きたくはありません。

そこで自前エンジンでは、このような処理をObjectManagerのような専用クラスへまとめる設計がよく使われます。

class ObjectManager
{
public:
    void Add(GameObject* object);
    void UpdateAll();
    void RemoveDestroyedObjects();
    void DrawAll() const;
};

ゲームループ側は、例えば次のようにできます。

objectManager.UpdateAll();
objectManager.RemoveDestroyedObjects();
objectManager.DrawAll();

ObjectManagerを作る利点は、単にゲームループが短くなることではありません。

  • オブジェクトをどこへ登録するか
  • いつInitialize()するか
  • いつUpdate()するか
  • Destroy()されたものをいつ実際に除去するか
  • 削除中に新しいオブジェクトが追加された場合にどう扱うか

といった、オブジェクトの寿命に関するルールを一か所へ集められることが大きな利点です。

ただし、ObjectManagerが必ずオブジェクトそのものを所有するとは限りません。std::unique_ptrでManagerが所有する設計もあれば、Sceneが所有する設計、オブジェクトプールやハンドルで管理する設計もあります。

大切なのは、所有方法を一つに決めつけることではなく、生成・登録・削除のルールを明確にし、寿命管理を一元化しやすくすることです。

GameObjectのデストラクタがvirtualなのも重要です。

virtual ~GameObject() = default;

基底クラスのポインタを通して派生クラスのオブジェクトを破棄する設計では、基底クラスのデストラクタをvirtualにしておく必要があります。そうでない状態で基底クラスのポインタからdeleteすると、C++では未定義動作になります。

ゲームエンジンは、単にUpdate()Draw()を呼ぶ仕組みではありません。オブジェクトを作り、登録し、安全なタイミングで削除する、寿命管理の仕組みでもあります。

ただし、〇〇マネージャーってクラスを作ることをこよなく憎んでいる層も居るので、機能は同じでも名前の付け方に気をつけたほうがいいかもしれないです。
あと、どこかに書いた気もするけど、最近はこの管理方法をスマートポインタに任せてしまうのが定石になりつつあります。
ただ、未だに、生ポインタを教えずにスマートポインタから教える人はかなり少ないと思いますので(その意味ではC++の前にC教えるのもだよね)この資料では、主に生ポインタを扱います。


11.なぜC++なのか

ここまで見てきたGameObject、継承、仮想関数、ポリモーフィズムは、C++がゲームエンジンで力を発揮する理由の一部です。しかし、C++が使われる理由は「オブジェクト指向ができるから」だけではありません。

特にDirectXを使って自前で描画基盤まで作ろうとすると、ゲームエンジンには相反する二つの要求が出てきます。

  1. 大量の機能を整理できる、高い抽象化能力
  2. メモリやハードウェアを細かく制御できる、低レベルな操作能力

C++は、この両方を扱えます。

クラスで大規模な機能を整理できる

ゲームエンジンには、GameObject以外にも、SceneRendererCameraInputSoundResourceManagerなど、多くの仕組みがあります。C++では、これらをクラスとして整理し、役割を分けられます。

必要なら低レベルまで降りられる

ゲームエンジンは、GPUバッファ、画像データ、音声データ、ファイル、メモリアドレスなどを直接扱います。

void* mappedMemory;
unsigned char* imageData;
ID3D11Buffer* vertexBuffer;

GameObjectRendererのような高水準なクラスを使いながら、DirectXへ渡すGPUリソースやメモリを扱うところでは、C言語に近い低レベルな操作まで降りられます。

オブジェクトの寿命を管理できる

C++では、変数の寿命が終わるとデストラクタが呼ばれます。メモリ、ファイル、GPUリソース、サウンドなどの解放を、オブジェクトの寿命と結び付けられます。この考え方がRAIIです。

ゲームでは大量のリソースを扱うため、「いつ解放されるか」を把握できることが重要です。

オブジェクト指向だけを強制されない

C++では、すべてを継承と仮想関数で作る必要はありません。

ゲーム全体の管理にはGameObject型のポリモーフィズムを使い、重い計算では単純な配列をまとめて処理できます。

for (Transform& transform : transforms)
{
    transform.position += transform.velocity * deltaTime;
}

C++では、手続き型、オブジェクト指向、ジェネリックプログラミング、データ指向、低レベルプログラミングを、目的に応じて使い分けられます。

つまり、C++がゲームエンジンに向いている理由は、単にオブジェクト指向言語だからではありません。

高度な抽象化を使いながら、必要になればメモリやハードウェアに近い場所まで降りられる。

この柔軟性が、ゲームエンジンと相性がよいのです。


12.古い設計だからこそ、理解する意味がある

GameObjectを継承し、仮想関数でUpdate()Draw()を呼ぶ設計は、現代のゲームエンジンにおける唯一の正解ではありません。

継承だけで機能を増やすと、クラス階層が深くなります。

GameObject
 └─ Character
     └─ Enemy
         └─ FlyingEnemy
             └─ ShootingFlyingEnemy

そのため、現代のゲームエンジンでは、機能を部品として組み合わせるコンポーネント型の設計が使われます。

GameObject
 ├─ Transform
 ├─ Renderer
 ├─ Collider
 ├─ Controller
 └─ AudioSource

さらに、大量のオブジェクトを高速に処理するため、ECSやデータ指向設計も利用されます。

しかし、GameObject型エンジンが古いことと、学ぶ必要がないことは別です。

コンポーネント型が何を解決したのかを理解するには、その前に、継承型のGameObjectがどこで苦しくなるのかを知る必要があります。ECSを理解するにも、まずオブジェクト単位で管理する方式を知る必要があります。


13.ゲーム業界を目指すなら、ここは避けて通れない

ゲームオブジェクト型エンジンは、最新技術ではありません。

しかし、ここにはゲームループ、クラス、継承、仮想関数、オーバーライド、ポリモーフィズム、オブジェクトの生成と削除、寿命管理、所有権をどこへ置くかという設計判断、共通の管理処理と各オブジェクト固有処理の役割分担といった基本事項が詰まっています。

コンシューマーゲームやAAAタイトル、その基盤となるゲームエンジン側を目指すのであれば、C++やDirectXを「使ったことがある」だけでは足りません。なぜこのような構造にするのか、どの問題を解決するための仕組みなのかまで理解して、自分で組み立てられる必要があります。

GameObject、継承、仮想関数、ポリモーフィズムも、用語として説明できるだけでは足りません。

for (GameObject* object : objects)
{
    object->Update();
}

この短いコードが、C言語で種類ごとに書いていた大量の更新処理をどのように置き換え、種類の違うオブジェクトを同じ管理処理で扱えるようにしたのか。さらに、Destroy()で削除予約を行い、ObjectManagerのような管理側で安全なタイミングにまとめて除去することで、オブジェクトの寿命をどう整理できるのか。

そこまでつながって、初めてゲームエンジンの中でオブジェクト指向が何のために使われているのかを実践的に理解したと言えます。古い仕組みです。しかし、古いということは、長い間ゲーム開発の基礎として使われ、そこから次の設計へ発展してきた仕組みでもあります。

自前でゲームエンジンを作るなら避けて通れない基本事項です。

現代風のプログラミングでは、どうしても、これらにスマートポインタが絡んできます。この古い仕組みをスマートポインタで実装するところから始めるのもいい修行になるかと思います。


付録:C言語でも同じ仕組みは作れるのか

C言語にも関数ポインタがあるため、GameObject型エンジンに近い仕組みを作ることはできます。

struct GameObject;

typedef void (*InitializeFunction)(struct GameObject* object);
typedef void (*UpdateFunction)(struct GameObject* object);
typedef void (*DrawFunction)(const struct GameObject* object);

struct GameObject
{
    InitializeFunction initialize;
    UpdateFunction update;
    DrawFunction draw;
};

プレイヤー構造体の先頭に、共通部分となるGameObjectを置きます。

struct Player
{
    struct GameObject base;
    float x;
    float y;
    int hp;
};

以下は、更新処理だけを抜粋した例です。関数ポインタへ、GameObject用の窓口となる関数を手動で登録します。

void Player_UpdateObject(struct GameObject* object)
{
    struct Player* player = (struct Player*)object;

    // Player固有の更新処理
}

void Player_Setup(struct Player* player)
{
    player->base.update = Player_UpdateObject;
}

呼び出す側は、次のように共通化できます。

object->update(object);

技術的には可能です。しかし、実際には次のような規約を人間が管理しなければなりません。

  • 基底構造体を必ず先頭へ置く
  • 関数ポインタを正しく登録する
  • 登録忘れや誤登録を防ぐ
  • 派生型へのキャストを正しく行う
  • 初期化と終了処理を正しい順番で呼ぶ

C++では、クラス、継承、仮想関数、override、コンストラクタ、デストラクタ、アクセス制御、型チェックによって、これらの多くを言語とコンパイラが支援してくれます。

C言語でも不可能ではありません。しかし、ゲームエンジンのように多くの種類と大量のオブジェクトを扱うプログラムでは、手作業で守る規約が増えすぎます。

作れるかどうかと、継続的に安全に管理できるかどうかは別の問題です。