🛠️

【DirectX11】ゲーム作りのためのなんちゃってUnity設計メモ

に公開

DirectX11、c++でほとんど0からゲームを作る際、オブジェクト指向で設計を組んでいくと色々限界が見えてくる。

「位置座標は全てのオブジェクトに共通するから基底クラスに……」
GameObjectを継承したPlayerクラスと、Enemyクラスがあって……」
「んー、被弾した時にエフェクトを出す関数、Playerに入ってるけどEnemyにも付けたいぞ」
「あれ?当たり判定系の処理って、もしかして1つにまとめて行った方が効率良いんじゃ?」


↑見るだけでしんどいplayer.cppkakasi.cppball.cppなんかにもほぼ同じ内容が書いてある

ゲームが大規模になるにつれ、肥大化していくPlayerクラスに、どこに書いたか思い出せない処理群。ほぼ全てを自分で構築していく(ような気持ちになれる)のはDirectXの楽しいところだが、こういった壁にぶつかるたびにUnityがどれだけゲームが作りやすい環境だったかを思い知る。

……だったらUnityっぽい設計を作ればいいんじゃない?そう思い立って色々ごちゃごちゃやってみました。初心者の初めての設計メモ。

本設計のゴール

・極力シンプルに作り上げること。あくまでゲーム制作が最終目的
・自身の学習も兼ねているので、理解できない知識は極力使わないこと。
(本音を言えば、ImGuiだとかファイルのインポートだとかができる「ほぼUnity!DirectX11製自作ゲームエンジン!」を作りたい所だが、ゲームが完成しなくなる未来が見えるのでやめる)

背景:ECSアーキテクチャについて

コンポーネント指向自体にあまり馴染みがなかったので、ひとまずECSアーキテクチャという考え方を部分的に取り入れてみた。

↓分かりやすい先人のまとめ
https://zenn.dev/rita0222/articles/c22a8367e31b4d5f4eeb

ECS本来の力が発揮されるような設計ではないのだが、主にSystemの考え方に感動したので自分の設計にも採用。
Componentと呼ばれるデータ群を、プレイヤーや敵といったゲームオブジェクトとは別の場所(System)で処理する。
(当たり判定のチェックをplayer.cpp、ball.cppそれぞれで書かなくても良いってこと!画期的!)

全体の構造

今回の設計で作成した心臓部分となるクラスはGameObjectComponentProcessorBehaviorの4つ。ProcessorはECSのS(System)のこと。Behaviorは独自に作成したクラスになる。(後で詳しく記述)

実際に作成してみたGameObjectクラス、Componentクラスの内容がこちら。
(他にも便利に使うためのAddComponent()GetComponent()も存在するが、一旦割愛)

game_object.h
class GameObject {
private:
    bool    m_active = true;
    
    std::vector<Component*> m_pComponents = {};
    std::vector<Behavior*>  m_pBehaviors = {};
public:
    void    Update() {
        // GameObjectの更新毎、Behavior.Update()を呼び出す。
        for (Behavior* be : m_pBehaviors) {
            if (!be->GetEnable())continue;
            be->Update();
        }
    }

    // ...
};
component.h
class GameObject; // 前方宣言
class Component {
private:
    bool        m_enable = true;
    GameObject* m_pOwner = nullptr;

public:
    virtual ~Component() = default;
};

Component基底クラスを継承し、TransformComponentRigidbodyComponentなんかを作っていく。(ComponentはECSの考え方に基づいて、あくまでデータのみで構成。処理は無し)

Componentの派生クラスの例
transform_component.h
class TransformComponent : public Component {
private:
    DirectX::XMFLOAT3   m_position = { 0.0f,0.0f,0.0f };
    DirectX::XMFLOAT3   m_rotation = { 0.0f,0.0f,0.0f };
    DirectX::XMFLOAT3   m_scaling = { 1.0f,1.0f,1.0f };
public: // ゲッター、セッターが続く
};
rigidbody_component.h
class RigidbodyComponent : public Component {
private:
    float               m_mass = 1.0f;
    float               m_gravityScale = 9.8f;
    DirectX::XMFLOAT3   m_velocity = { 0.0f, 0.0f, 0.0f };
public: // ゲッター、セッターが続く
};

Processor(System)部分を作る

Componentを用いて実際に処理を行うProcessorクラスを作っていく。
更新処理についてだが、描画用Processorなども存在するので、更新処理は'Process()'としておく。('Update()'、'Draw()'と混同しないようにするため)

dynamics_processor.h
// 前方宣言
class TransformComponent;
class ColliderComponent;

class DynamicsProcessor : public Processor {
private:
    struct Components {
        TransformComponent* m_transform;
        ColliderComponent* m_collider;
    };
    std::vector<Components> m_components;

public:
    void    Initialize()override;
    void    Finalize()override;

    void    Process()override;

    void    Entry(TransformComponent* transform, ColliderComponent* collider) {
        Components cmps = { transform,collider };
        m_components.push_back(cmps);
    }
};

↓実際に、この設計を用いて作成した物理演算システムがこちら。
 特に順序が大切な処理群なので、Processorに分離するやり方は非常に良かった。
https://zenn.dev/kitam37/articles/62ef608554c3a5

Behaviorクラスを作った目的

Componentクラスとほぼ一緒なBehaviorクラス。大きな違いはUpdate()が存在すること。

behavior.h
class GameObject;
class Behavior {
private:
    bool        m_enable = true;
    GameObject* m_pOwner = nullptr;

public:
    virtual ~Behavior() = default;
    virtual void    Update() {}
};

ECSの考え方に基づいて、Componentクラスには更新処理などを行う関数が存在しない。

ただゲームを作るなら当然、「プレイヤーを動かす」「エフェクトを出す」「{0.0f,0.0f,0.0f}から{10.0f,0.0f,0.0f}を往復し続ける」とかいうオブジェクト固有の振る舞いを作りたい。(ECSに基づけば、それらも全てComponent&Systemで構成するべきなのだが、さすがにちょっと面倒くさい…。)

そんなわけなので、データも振る舞いも併せ持つ存在として、Behaviorを作ってみた。(UnityでいうMonoBehaviorみたいな?)

PlayerBehaviorの中身
player_behavior.h
class TransformComponent;
class CubemeshComponent;
class BoxColliderComponent;

class PlayerBehavior :public Behavior {
private:
    TransformComponent* m_transform = nullptr;
    CubemeshComponent* m_cubemesh = nullptr;
    BoxColliderComponent* m_collider = nullptr;

    float m_speed = 0.0f;
public:
    PlayerBehavior(GameObject* owner);
    ~PlayerBehavior();

    void    Update()override;
};
player_behavior.cpp
PlayerBehavior::PlayerBehavior(GameObject* owner)
{
    m_transform = owner->GetComponent<TransformComponent>();
    m_cubemesh = owner->GetComponent<CubemeshComponent>();
    m_collider = owner->GetComponent<BoxColliderComponent>();

    m_speed = 0.03f;
}

PlayerBehavior::~PlayerBehavior()
{

}

void PlayerBehavior::Update()
{
    // 移動
    DirectX::XMFLOAT3 position = m_transform->GetPosition();
    if (Keyboard_IsKeyDown(KK_W)) {
        position.z += m_speed;
    }
    if (Keyboard_IsKeyDown(KK_S)) {
        position.z -= m_speed;
    }
    if (Keyboard_IsKeyDown(KK_D)) {
        position.x += m_speed;
    }
    if (Keyboard_IsKeyDown(KK_A)) {
        position.x -= m_speed;
    }
    m_transform->SetPosition(position);
}

GameObjectを生成するとき

ComponentBehaviorの生成&アタッチ、Processorへの登録などを一緒に行う。
(自分のプログラミング能力を加味して予期せぬ不具合を防ぐため、登録系は全て手動で行うことにした。)

factory.cpp
GameObject* Factory::CreateTestPlayer(DirectX::XMFLOAT3 position)
{
    GameObject* player = new GameObject();

    // component生成・登録
    TransformComponent* transform = new TransformComponent();
    CubemeshComponent* cubemesh = new CubemeshComponent();
    BoxColliderComponent* collider = new BoxColliderComponent();
    player->AddComponent(transform);
    player->AddComponent(cubemesh);
    player->AddComponent(collider);

    // component設定
    transform->SetPosition(position);
    cubemesh->SetColor({ 1.0f, 1.0f, 1.0f, 1.0f });

    // processor登録
    GetRenderer3DCubeProcessor()->Entry(transform, cubemesh);
    GetCollisionProcessor()->Entry(transform, collider);

    // behavior生成・登録
    PlayerBehavior* playerBe = new PlayerBehavior(player);
    player->AttachBehavior(playerBe);

    return player;
}

最後に(良かったこと・反省点)

良かったのは、なんといっても機能の追加が簡単なこと。

以下は昔にコンソール画面&アスキーアートで作成したゲームだが、アニメーションの変更、当たり判定のループ文、被弾処理などがGameObjectの派生クラスにコピペすることで出来ている状態だった。
(無事完成して動いたのが奇跡の代物)
https://youtu.be/J2Al9K8SLeQ?si=nocDfJbRndirmOHK

それが今回作成したゲームでは、AddComponent()もしくはAttachBehavior()だけで済んだので、時短にもなったし、予期せぬエラーも(だいたいは)防げた。
特にコンテスト期日間近の開発スピードの差が尋常じゃなく、今まで作った機能を他の所にも付けたり、ちょっと変えた新しい機能を付けたりがすぐに出来たのがとても良かった。

反省点は、コンポーネント指向の扱いに慣れておらず、おそらくどこかで解放を忘れていること。
(今回作ったゲーム、タイトル→ゲーム→リザルト…のループを繰り返すと、なんとFPSが下がる。非常によろしくない!大問題)

感想

色々反省点・問題点はあるが、これを通して設計の大切さを学べた。最初は考えるたびに頭痛いし、ゲーム作り本体が進められないのが辛かったが、驚くほど作りやすくなったので結果的に大正解
ただ、設計の作りやすさに感動しすぎて後処理や保守性などをあまり考えられなかったので、次に設計を組むときはそのあたりを気を付けたい。

Discussion