Bevy Engineの最大の特徴に、全体がECSをもとに設計されているというものがあります。この記事シリーズではあまり機能をひとつひとつ列挙したりせずに実践的に説明していきたいと思っていますが、Bevyではさまざまな説明にECSの用語が出てきますので、最初にECSについて触れておきます。
Entity Component Systemとは
Bevy Engineは Entity Component System (ECS)というアーキテクチャに基づいて設計されています。ECSはその名の通り エンティティ、コンポーネント、システムという3つの要素から主に成り立っています。
- エンティティ(Entity)……複数のコンポーネントを束ねたデータの塊。またはそれを指すハンドルとなる構造体
Entity
のこと - コンポーネント(Component)……システムから参照されるデータの単位となる構造体
- システム(System)……特定のコンポーネントを参照して更新を行う関数
画面上に現れるキャラクター、アイテムなどがそれぞれ『エンティティ』であると考えるとイメージしやすいでしょう。各キャラクターは自分の位置やテクスチャ画像などを内部に持ちますが、それらが『コンポーネント』です。そして、たとえばユーザーがゲームパッドのボタンを押したときに、その操作に応じて位置コンポーネントの内部の状態を変更する関数が『システム』です。
もっと具体的には、たとえばBevyでもっともよくつかわれるコンポーネントに、位置や回転などの変換を表すTransform
コンポーネントがあります。このTransform
は構造体で、中には平行移動を表すtranslation
などのフィールドを持ち、これを変更すればキャラクターの表示される位置を変えたりできます。
また、Bevyのエンティティは木構造をなしており、親のエンティティのTransform
と子のエンティティのTransform
を合成したものが、最終的に画面に表示される位置になります。このとき、計算済みの最終的な座標を表すコンポーネントGlobalTransform
があり、sync_simple_transform()
というシステムが適切なタイミングでGlobalTransform
を再計算して更新しています。
オブジェクト指向や関係データベースとの対比
多くのゲームエンジンはオブジェクト指向プログラミングに基づいて設計されており、画面上に表示されるそれぞれのキャラクターや物体などはゲームオブジェクトとして表現されます。一方で、BevyのようなECSベースのエンジンでは、それらはエンティティとして表されます。その意味では、ECSのエンティティはゲームオブジェクトと似たようなものに見えるかもしれません。
エンティティとゲームオブジェクトが異なるポイントに、エンティティは動的であるというものがあります。静的な言語のオブジェクト指向では、オブジェクトのクラスや基底クラスは静的に決定され、動的に変化することはありません。オブジェクトコンポジションを使えばインターフェイスのみを静的に決めて実装は動的に切り替えられますが、インターフェイス自体は静的です。それに対し、Bevyのエンティティは動的なもので、中に含むコンポーネントはいくらでも追加や削除ができます。
また、オブジェクト指向のゲームエンジンでは参照するデータの単位はあくまでオブジェクトですが、ECSにおいてデータ参照の単位はエンティティではなくコンポーネントの集合です。
古典的なオブジェクト指向的なアーキテクチャであれば、キャラクターなどのゲームオブジェクトはクラスとして表現され、その実体であるオブジェクトに対してアクセス演算子 .
を打てば、そのオブジェクトに含まれるプロパティすべてにアクセスできるところでしょう。
しかし Bevy の ECS では、エンティティはただのIDにすぎず、そこからでは直接データの実体にアクセスできません。「クエリ」を定義してそこからコンポーネントごとに取り出すことで、はじめてデータの実体にアクセスできます。関係データベースでテーブルごとにクエリを送って、プライマリキーを指定してデータを取り出すイメージに近いです。
なぜECSを使うのか
なぜこのECSアーキテクチャが採用されるのかについては、公式サイトでは次のように説明がされています。
- ECS パターンを使うとデータとロジックをコンポーネントとシステムへと切り分けるようになり、設計がクリーンになる
- 同種のデータがまとめられることで、メモリアクセスの効率が良くなる
- 独立した処理ごとにシステムが分割されるようになり、並列処理が容易になる
オブジェクト指向的なアーキテクチャでは、常にゲームオブジェクトのすべてのフィールドに容易にアクセスできるので、それぞれの関数が自由にフィールドにアクセスできてしまい、それぞれの関数がどの役割を持っているのかを管理するのが難しくなります。
それに対して、ECSではそれぞれのシステム(つまり関数)がどのコンポーネント(つまりデータ)にアクセスするかが明示されて管理されるので、関数が複数の役割を持ってしまいややこしくなることが大きく減ります。実際にコードを書いてみて実感したのですが、ECSでは確かにアクセスするデータの種類ごとに自然とシステムが分割されるようになります。
以下は私が書いた実際のコードで、キー入力に応じてプレイヤーキャラクターを移動するシステムです。キー入力やゲームパッド入力を扱うのでそれらが引数に現れてますし、ゲームメニューを開いているときは移動できないので、ゲームメニューを開いているかどうかにもアクセスしています。一方で、このシステムではプレイヤーキャラクターの外力のみを指定しています。壁があったりして必ずしも移動できるとは限らないですし、他の物体に衝突するかどうかを物理エンジンが計算して実際に移動させます。
fn move_player(
// プレイヤーの状態と、物理エンジンでの外力を読み書きします
mut player_query: Query<(&mut Actor, &mut ExternalForce), With<Player>>,
// キー入力を読み取ります
keys: Res<ButtonInput<KeyCode>>,
// ゲームパッド入力も読み取ります
gamepads: Option<Res<MyGamepad>>,
axes: Res<Axis<GamepadAxis>>,
// メニューが開いているかも読み取ります
menu: Res<State<GameMenuState>>,
) {
if let Ok((mut actor, mut player_force)) = player_query.get_single_mut() {
if *menu == GameMenuState::Closed {
let direction = get_direction(keys, axes, &gamepads);
player_force.force = direction * PLAYER_MOVE_FORCE;
actor.move_state = if 0.0 < player_force.force.length() {
ActorMoveState::Run
} else {
ActorMoveState::Idle
};
} else {
player_force.force = Vec2::ZERO;
actor.move_state = ActorMoveState::Idle;
}
}
}
逆にいえば、このシステムではスプライト(画像)の状態は変更しませんし、効果音なども処理していません。そのほかのゲームのデータにもアクセスしません。また、キー入力やゲームパッド入力、メニューの状態は読み取るものの、mut
がついていないことから、それらはこのシステムでは変更しないことがわかります。このようなことが、このシステムのパラメータから読み取れます。
このシステムも分割しようとすればさらに分割できると思いますし、どこまで分割するかは開発者の匙加減しだいだと思います。
ECSの性能については、私の使ってみた範囲では評価が難しいです。
「このシステムはキャラクターの座標を扱うけど、別のあのシステムはキャラクターのヒットポイントの処理を行う、これらは独立しているから並列処理できるね」というのが自然に促進されますし、実際にそれぞれのシステムの依存関係をBevyが自動的に判定して、可能な限り別々のスレッドに割り当てて並列処理されるようです。もっとも、ECSで実行速度がどう改善されるかは体感できるようなものではないです。
ゲームプログラミングでは1フレームあたり15ミリ秒で処理を完了させないといけないなど、要求がかなりシビアで、そのわりに並列化が難しくてマルチスレッドをなかなか生かせなかったりします。その点、ECSだとうまく並行処理できる部分が増えるっぽいです。もっとも、ゲームではボトルネックが物理演算だったりGPUのレンダリングであることも多いわけで、ECSによる並列処理に効果があるかどうかはゲームの性質に大きく依存するでしょう。たとえばテトリスのような単純なゲームを作るのには、ECSは手間のわりに利点に乏しい気がします。逆に、ヴァンパイアサバイバーズのように無数のキャラクターや弾丸が飛び交うようなゲームでは、ECSの効果が大きそうな気もします。
今作っているゲームについて
ここまで記事を読んでくれた方は、 「はあ!!? ECS??? ちょっとなに言ってるかわからない!!!」 となったと思います。プログラミングの話ばかりでも飽きるので、ゲームの内容の話も添えておきます。
筆者のプレイしたことのあるゲームで例えてしまうと、基本的なコンセプトは ゆるいNoita です。ちょうどこの記事の投稿時点で、60%オフ、通常2050円がたったの820円というセールをやっていました。未プレイの人はぜひ買ってプレイしてみてください!
Noitaの理不尽な面白さに、変異で一撃死、明らかな地雷パーク、ギガディスクのような初見殺し呪文、プロパンガスでの爆発事故なんかがありますが、Noitaからそういう難しさと理不尽さと取り払って、自由に杖を編集しやすくして、PvPを足したもの、というイメージです。
現在はゲームの基本的な枠組みができてきたかなあというところです。
- プレイヤーキャラクターの移動や攻撃
- 敵キャラクターの移動と攻撃
- 灯篭や宝箱などのエンティティ
- レベル(ステージ)間の移動
- 最低限のプレイヤーの目標設定(ゴールド集め)
- メインメニュー、プレイ画面などの画面切り替え
- BGMや効果音
- ボリューム調整などの最低限のコンフィグ
- オンライン要素の検証
あとはアイテムの種類を増やして収集要素を付けたり、プレイヤーの装備変更ができるようにしたり、敵モンスターの種類を増やしたりなど、ゲームの幅を増やしていきたいです。あと固定マップは飽きるのが速いので、ローグライクのようにランダムマップも検討していますが、そういうのは作るのが難しいので要検討です。
実はもうゲームはデプロイしてあって、ブラウザですぐ起動できる状態になっているのですが、遊んでもらうにはあまりに未完成なのでせめてもう少しまとまってからデモの案内などをしたいと思います。