📌

[Rust] Bevyのはまりどころ

2022/08/14に公開約11,800字

Bevy は比較的前衛的な Rust 製ゲームエンジンです。
PistonAmethyst が技術的および組織的な理由で廃れていっているので、 Rust でクロスプラットフォームなゲームを作りたいと思ったら、有力な候補として Bevy が挙がるようになってきました。

Bevy の特徴

Bevy の特徴は公式サイトにも紹介されているので簡潔にまとめると、

  • データ志向
  • 2D/3D レンダラーサポート
  • ECS (Entity Component System)
  • Windows/Linux/MacOS/Web/(Android?/iOS?) クロスプラットフォームターゲット
  • 「電池同梱」ですぐに動くものが作れる

といったところです。

この中で特に異彩を放っているのは ECS でしょう。
他の特徴はゲームエンジンの中でも珍しくありませんが、 ECS をここまでプログラミングモデルのレベルで推しているものは珍しいです。 [1]
よって、本稿でも ECS を使う際につまずきがちだったりする点を主に述べることになると思います。
本稿はチュートリアルではないので基本的な概念は説明していません。
まずは公式 introductionを読んでください。

ECS の良し悪し

まずは Bevy における ECS の全体的なことを述べたいと思います。
Bevy では ECS はオプションではありません。
すべての仕組み(レンダラーやアセット管理まで)が ECS をベースにしているので、これを避けるという選択肢はありません。
これは IDE を持たないゲームエンジン上で、すべてをコードで記述する以上、 Unity 以上のインパクトを持ちます。
ECS を使いたくない人は別のエンジンを使うか、自前で書くことになると思います。

ある意味思い切った設計なのは実験的で良いとは思いますが、その分功罪の評価が重要になります。

良い点

まずいい点を挙げると

  • データ並列性
  • Bad OOP からの脱却(Composition over inheritance)
  • モジュール性
  • ライフタイムをほとんど意識しなくてよい

といった特徴があると思います。

データ並列性

クエリの型によって自動的に依存関係にない System の並列計算を行うというもので、理論上はマルチコアを活用するのに役立ちます。実際どれだけ効果的かは、ベンチマークしてみないとわかりませんが、おそらく自前でゲームエンジンに相当するロジックを書いて最適化するほどのパフォーマンスは得られないと思います。

Bad OOP からの脱却

ちょっと主観的な表現ですが、巨大な継承ツリーとモノリシックな Entity クラスを定義する代わりに、 Component に分けることで保守性とパフォーマンス上の恩恵が得られるということです。特に Rust のオーナーシップモデルとは相性がいいです。

モジュール性

少し繰り返しになりますが、 ECS では自然にモジュール性の高いコードを書くことになり、複雑度が一か所に集中するのを避けやすいといえます。
この恩恵を受けるためには、データをアクセスする単位で可能な限り Component へ分割する必要があります。
伝統的なオブジェクト指向に毒されている場合は最初だけ奇妙に思えるでしょうが、慣れるのにそれほど時間はかからないでしょう。

ライフタイムをほとんど意識しなくてよい

個人的に感心したのが、それなりの規模のコードベースでもライフタイムを指定する必要がほとんどないことです。
自前でゲームエンジンを書くと、どこかでライフタイムを書く必要が出てくると思うのですが、 Bevy ではほとんど書く必要はありません。
もちろん、 Component の中で複雑な処理をしようとしたら必要になってくるかもしれませんが、 Component は必要最小限の大きさまで分割すべしという方針に従っていればほとんどそのような場面は出てきません。

Rust の初心者の中ではライフタイムがつまづきの元になることが多いと思われるので、これは参入障壁を下げるという意味では大きな意味があります。

ライフタイムに相当する概念は、 QueryResResMut というラッパー型で実現されており、実質的にランタイムチェックになっています。
ユーザーは Query を使うだけで、ライフタイムという形ではほとんど意識しなくてよいのですが、もちろんライフタイムで解決される問題と同等の問題には直面します(後述)。

悪いところ

悪いところというほどでもないのですが、設計上仕方ないということはいくつかあります。

  • 自前の ECS へのロックイン
  • 互換性のあるプラグインの種類が限定的
  • 既存のプラグインを提供するクレートの拡張が困難

といったところでしょうか。

自前の ECS へのロックイン

Bevy が使っているのは独自の ECS 実装であり、 specs のような外部ライブラリを使っていないため、 Bevy 用に書いた Component および System は再利用できません。
しかしこれはまあ、裏を返せば Bevy 用に最適化された ECS が用意されているということであり、開発者の使い勝手という意味では悪くはないです。ただし汎用的なライブラリを作りたい作者にとっては Bevy 専用の実装になるため、少々やる気が削がれるかもしれません。
しかも Bevy は 1.0.0 に達しておらず、将来外部ライブラリの互換性が壊れるのは確実だといえます。

互換性のあるプラグインの種類が限定的

比較的歴史が浅いのもあって、サードパーティーのプラグイン・ライブラリの種類はそれほど多くありません。特にグラフィック系のライブラリは、 Bevy 用に作られたもの以外を導入するのは結構困難でしょう。

既存のプラグインを提供するクレートの拡張が困難

プラグインの仕組みは、 Rust のクレートの仕組みにうまく乗って再利用が可能ですが、基本的にはプラグイン単位で閉じており、拡張可能な仕組みにするのは簡単ではありません。これは ECS が水平的にモジュール化する(継承を軸にした垂直的なモジュール化ではないという意味で)という設計上の特性とも言えます。

原理的にはジェネリックスを使って拡張可能なプラグインを設計するのは可能だと思いますが、ライブラリ作者がよほど注意深く設計しないと使いやすいものにはならないでしょう。

Entity の恩恵

これはとても大きいです。 E と C と S のうち E だけでも今後の全てのゲーム開発に採用したいというぐらい素晴らしいです。
オブジェクトが多数生成・消滅を繰り返すゲームやリアルタイムシミュレーションを自前で書いたことのある方ならわかると思いますが、オブジェクトの寿命を管理するのはどんな言語でも苦痛です。 GC があれば多少はましになりますが、死んだはずのオブジェクトの参照が残ってしまっていることによって生き延びるゾンビオブジェクトを駆逐するのは骨が折れます。
リファレンスカウンタによる実装では循環参照がリークするという問題もあります。
ゲームにおいては明示的にオブジェクトの寿命を管理したいことのほうが多いでしょう。

ECS でいう Entity は、ポインタや参照の代わりになるもので、寿命が動的に変わるオブジェクトへの参照を安全に保持することができます。

Entity は実体としては全ての Entity を集めた配列へのインデックスと世代で構成されます。
Bevy 0.7 では次のように定義されています。

#[derive(Clone, Copy, Hash, Eq, Ord, PartialEq, PartialOrd)]
pub struct Entity {
    pub(crate) generation: u32,
    pub(crate) id: u32,
}

Entity が削除され、インデックスが再利用されたとしても、世代がインクリメントされるので誤って参照してしまうことはありません。
具体的には query.get(entity)None を返すので、その Entity がすでに削除されていることが分かります。

セマンティックスは RcWeak に似ていますが、実装のシンプルさと寿命が明示的に制御される (despawn() が呼ばれるまで) という点が異なります。

何よりも id は配列へのインデックスなので、 CPU キャッシュに対して非常に親和性が高いのが良いところです。
そして、この仕組みはとてもシンプルなので Component System と組み合わせなくても、他のアプリケーションで応用が利きます。
自前での実装についてはこちらに少し詳し目に書いておきました。

Event

Event はその名の通り、何らかの瞬間的なイベントを System 間で通知するのに役に立ちます。
しかしドキュメントがあまり親切ではないので、使い方をサンプルから推測するしかありません。
むしろチュートリアルでは触れられてすらいないので、機能のの存在に気付かない可能性すらあります。
こんな時は Unofficial Bevy Cheat Book を見るのが良いです。

実際の使い方はさして難しくありません。
まずイベントを識別するための型を定義します。以下では MyEvent とします。
それを add_event で登録します。

use bevy::prelude::*;

struct MyEvent;

fn main() {
    App::new()
        .add_event::<MyEvent>()
	// ...
	.run();
}

イベントを発生させる側の system で EventWriter<MyEvent> を引数に置きます。
EventWriter なのにメソッド名は send なのはご愛敬ですね。

fn event_producer(mut writer: EventWriter<MyEvent>) {
    writer.send(MyEvent);
}

本来は Events::<MyEvent> というオブジェクトの update メソッドを毎フレーム呼ぶ必要があるのですが、これは add_event が暗黙的にやってくれます。

受け取る側の system では EventReader<MyEvent> を使います。
読み取りなのにも関わらず mut をつける理由は、内部的にはイベントキューとして実装されているため、読み取りが EventReader の内部状態を変更するためです。
また、1フレーム内で複数のイベントが発生する可能性があるため、 reader 側ではループで処理します。

fn event_consumer(mut events: EventReader<MyEvent> {
    for event in reader.iter() {
        println!("MyEvent received!");
    }
}

さて、イベントキューである以上、イベントの重複は避けられますが、複数の system でイベントを読み取ったときにどうなるのでしょうか。どちらか早い方が消費してしまうのでしょうか?
そんなことはありません。イベントキューは system ごとに用意され、それぞれの読み取り進捗は個別に管理されます。これによって読み落としがないことが保証されます。

しかし、一つの system の中で EventReader を読み取らなかったらどうなるのでしょうか。
例えば次のような条件付き読み取りの場合です。
次のフレームまで溜まり続けるのでしょうか。

fn event_consumer(mut events: EventReader<MyEvent> {
    if want_to_handle_events {
        for event in reader.iter() {
            println!("MyEvent received!");
        }
    }
}

これは実は Events::update_event() が呼び出されるとクリアされてしまいます。
add_event() メソッドを使って登録した場合は、デフォルトで毎フレームクリアする system が登録されるので、次のフレームまでは溜まらないということになります。
しかしこれは望ましい挙動である場合が多いでしょう。古いイベントがずっと残り続け、それに応答する System が遅れて反応すると、予想外の挙動になる可能性があります。

イベントは一定以上の複雑度を持つゲームには必ず必要になる重要な機能なので、チュートリアルにぐらいは丁寧に書いてほしいところです。

Component の同時アクセス

これは結構はまりがちです。

たとえば、プレイヤーに一番近いオブジェクトからの距離に応じてプレイヤーの位置を変えるような System を次のように書いたとします。

fn player_pos_system(
    query1: Query<&Position>,
    mut query2: Query<&mut Position, With<Player>>,
) {
    if let Some(player_position) = query2.get_single_mut() {
	 for position in query1.iter() {
             *player_position = do_something(position);
	 }
    }
}

これは実行時に panic します。
query1Player の持っている Position コンポーネントも返すので、可変参照と不変参照で同時に同じオブジェクトを参照してしまうことになります。
Rust の借用ルールではこのようなことは許されないので、 Bevy のランタイムがそれを止めているわけです。
しかし、コンパイル時にこのチェックはできないので、コンパイルエラーにはなってくれないのが少々残念なところです。

実行時エラーは、そこそこ親切に次のように教えてくれます。

Consider using `Without<T>` to create disjoint Queries or merging conflicting Queries into a `ParamSet`.'

つまり次のようにすればいいわけです。

fn player_pos_system(
    query1: Query<&Position, Without<Player>>,
    mut query2: Query<&mut Position, With<Player>>,
);

しかし、コンポーネントの数が増えてきて、色々なタイプの Entity で共有するようになってくると、どこで借用ルールが破られているか把握するのが困難になってきます。
コンパイラの助けが欲しいところですが、これは RefCell を使ったときと同種の問題です。

ゲーム状態の保存

Bevy は標準的なゲーム状態の保存方法を持ちません。
ファイルへのシリアライズ・デシリアライズは自前で実装する必要があります。
ただ、 Rust には serde という強力な crate があるので、シリアライズ・デシリアライズで困ることはないでしょう。

例えば、次のようにコンポーネントと Serialize, Deserialize を同時に derive することができます。

use ::serde::{Serialize, Deserialize};
use ::bevy::prelude::*;

#[derive(Component, Serialize, Deserialize)]
struct Player {
    // ...
}

#[derive(Component, Serialize, Deserialize)]
struct Score {
    // ...
}

こうしておけば、 serde_json クレートを使って JSON に保存するには次のようにできます。

use ::serde_json::{json, to_string};

fn save_game(query_score: Query<&Score>, query_player: Query<&Player>) {

    let score = query_score.get_single().unwrap();
    let player = query_player.get_single().unwrap();

    let json_container = json!({
        "score": score,
        "player": player,
    });

    std::fs::write("save.json",
        to_string(&json_container).unwrap()).unwrap();
}

読みだすには次のようにします[2]

use serde_json::{from_str, from_value, Value};

fn load_game(mut commands: Commands) {
    let json_str = std::fs::read_to_string("save.json").unwrap();

    let mut json_container: Value = from_str(&json_str).unwrap();

    let json_score: Score = 
        from_value(json_container.get_mut("score").unwrap().take()).unwrap();
    commands.spawn().insert(json_score);

    let json_player: Player = 
        from_value(json_container.get_mut("player").unwrap().take()).unwrap();
    commands.spawn().insert(json_player);
}

WebAssembly への対応とゲーム保存

ゲームエンジンを利用する大きなメリットの一つは、クロスプラットフォーム開発が非常に楽になるということです。
実際、 Bevy では全く同じソースコードから Windows, Linux, Mac ネイティブアプリケーションおよび WebAssembly を使った Web アプリケーションをコンパイルできます[3]
基本的にはターゲットを次のように指定してビルドするだけです。

 cargo b --release --target wasm32-unknown-unknown

もちろん Web ページとして公開するにはいくつか必要な作業があります。詳しくは Unofficial Bevy Cheat Book を参照。[4]

特に Web アプリケーションはランタイム環境が大幅にネイティブと異なるので、このような機能は開発者の負担を大幅に減らしてくれます。

しかし、当然ながら全てが自動的にクロスプラットフォームになるわけではありません。
セーブデータの保存はその一つです。

ブラウザでは localStorage が、ネイティブアプリケーションのファイルの代わりに使えます。
Rust の条件コンパイルを使えばネイティブ・web 両対応のセーブ機能が割と簡単に書けます。

fn save_game(query_score: Query<&Score>, query_player: Query<&Player>) {

    let score = query_score.get_single().unwrap();
    let player = query_player.get_single().unwrap();

    let json_container = json!({
        "score": score,
        "player": player,
    });

    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    std::fs::write("save.json",
        to_string(&json_container).unwrap()).unwrap();

    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
    {
        let local_storage = web_sys::window().unwrap().local_storage().unwrap().unwrap();
        local_storage
            .set_item("saveData", &serde_json::to_string(&json_container)?)
            .unwrap();
    }
}

fn load_game(mut commands: Commands) {
    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
    let json_str = std::fs::read_to_string("save.json").unwrap();

    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
    let json_str = {
        let local_storage = web_sys::window().unwrap().local_storage().unwrap().unwrap();
        local_storage.get_item("saveData").unwrap().unwrap()
    };

    let mut json_container: Value = from_str(&json_str).unwrap();

    let json_score: Score =
        from_value(json_container.get_mut("score").unwrap().take()).unwrap();
    commands.spawn().insert(json_score);

    let json_player: Player =
        from_value(json_container.get_mut("player").unwrap().take()).unwrap();
    commands.spawn().insert(json_player);
}

まとめ

  • Bevy は比較的新しく、前衛的な設計のゲームエンジンですが、シンプルかつ高速で初心者にもとっつきやすいと思います。

  • ECS という概念の教材としても優れていると思います。

  • 困ったときは Unofficial Bevy Cheat Book を見ましょう。

  • Rust のゲーム開発では、業界標準的なゲームエンジンが存在しないことが課題でしたが、今のところ Bevy がその地位に一番近いと思います。

  • Bevy はまだ 1.0 に達していないため、 API の安定性については保証がないという点は気を付ける必要があります。

  • ECS を使っていても、借用ルールから完全に解放されるわけではありません。

  • タイトルを「はまりどころ」にしたのですが、あまりはまりませんでした…

脚注
  1. Amethyst も ECS ベースです。
    また、 Unity も ECS をベースにしていますが、 Component が主に顔を出すのは IDE 上であり、コードを書く量は圧倒的に少ないといえます。 ↩︎

  2. ここで serde_json::Value のフィールドを取得するのに .get_mut("name").unwrap().take() というパターンを使っていますが、これはムーブを使ってデータのコピーを最小限に抑えるというテクニックです。
    serde_json::Value::take というメソッドは、中身を取り出して Null と交換するというものです。
    元の serde_json::Value は改変するので mut をつける必要があります。
    改変したくない場合は .get("name").unwrap().clone() とも書けます。
    コンポーネントがいろいろな動的リソース(文字列など)を大量に持っている場合はパフォーマンスに影響するかもしれませんが、ゲームのロードは1回すればそれ以後は頻繁にするものではないので、あまり大きな影響はないでしょう。 ↩︎

  3. Android, iOS アプリへの対応も開発中のようですが、実用上はまだ未完成のようです。 ↩︎

  4. Bevy の WebAssembly サポートは行き届いているのですが、簡単なゲームでもバイナリサイズが大きくなりがちなところがあります。
    Bevy 0.7 では少なくとも 15MB, 0.8 では 19MB ほどの WebAssembly バイナリが生成されます。
    自前でランタイムシステムを書けば数百KB程度に抑えられるので、相当な贅肉がついているとは言えます。 ↩︎

Discussion

ログインするとコメントできます