Bevy Engineでおさえておくべきポイント

概要

この記事では、Bevy Engineを活用していく上で、特におさえておくべきポイントをまとめています。実際にはそこからさらに深堀りして調べていく必要があるかと思いますが、入門記事の次の段階として、マイグレーションガイドとしての一助になれば幸いです。(なお執筆時点のBevyの最新版はv0.13.2です。)

この記事の対象読者

ポイント1: ハンドル (Handle)

Bevyには、ハンドル(Handle)という概念が頻繁に登場します。このハンドルは、画像や3Dモデルなど、実体は重たいものを、IDなどとして参照したり、軽量に扱うための役割を持ちます。

C++などの経験者は、ポインタや参照 (Reference) を利用すれば良いのではないかと思うことかと思います。実際Bevyでも、参照を利用することはありますが、Rustの所有権システムとの兼ね合いから、あまり参照を多用するとライフタイム表記などでコードが煩雑になる傾向にあり、ハンドルはそうした部分をうまく回避してくれます。

一方で、例えばマテリアル (色情報等) などの実体にアクセスしたいのに、どうしたらいいかわからない、ということも多く発生します。

ハンドルは必ず対応するアセットが存在し、そのアセットから.get()または.get_mut()することで実体にアクセスすることができます。

let material = materials.get_mut(my_handle).unwrap();
material.base_color = Color::RED;

逆にいえば、実体が不要な処理はハンドルだけで可能です。例えばある場所に画像を表示したい、フォントはこれにしたいなど、多くの処理がハンドルだけで可能です。

ちなみにアセットの読み込みは、bevy_asset_loaderなどのプラグインを活用するとさらに便利です。

ポイント2: クエリ (Query)

Bevyでは、クエリを書くことで欲しいエンティティ (ゲームオブジェクト) に簡単にアクセスすることができます。

例えば MyComponent というコンポーネントがついたエンティティのTransform (位置・回転・大きさ) にアクセスしたい場合は、システムの引数に次のように書きます。

mut transforms: Query<&mut Transform, With<MyComponent>>

もし、Transformを変更する必要がない(不変、immutableで良い)ならば、次のようにします。1

transforms: Query<&Transform, With<MyComponent>>

さらに、MyComponent2 もついた要素にアクセスしたいし、背景色にもアクセスしたいなら次のようにします。

query: Query<(&Transform, &BackgroundColor), (With<MyComponent>, With<MyComponent2>)>

さらにさらに、MyComponent3 は持っていないという条件をつけたい場合は以下です。

query: Query<(&Transform, &BackgroundColor), (With<MyComponent>, With<MyComponent2>, Without<MyComponent3>)>

もうおわかりかと思いますが、左のタプル(カッコ、簡易構造体)にあるものはアクセスしたいもの、右のタプルにあるものは条件です。左のものに&mutをつけると可変 (mutable) にできます。

query: Query<(&欲しいものA, &欲しいものB, ...), (With<条件1>, With<条件2>, Without<条件3>, ...)>

ちなみに、Rustでは単一所有者の原則から、Withoutをうまく使わないと実行時エラー (= パニック) になってしまうことがあります(→参考記事 2)。同じ理由で似たクエリ文を複数の引数として分割するとエラーになってしまうことがありますので、タプルを活用してできるだけ同じ条件のものは同時に取得するのがポイントです。

ポイント3: イベント (Event)

Bevyは、様々なことをイベントにより処理します。例えばマウスの位置を取得したい場合、ウィンドウにアクセスして位置を取得する方法と、CursorMovedなどのイベントを受信して読み取る方法があります。

ちなみに他のフレームワークでよくある、イベントハンドラの登録などの作業は特に必要はありません。クエリ(Query)と同じく、引数にEventReaderなどを書けば自動的にバインドされる仕組みになっています。

fn debug_levelups(
    mut ev_levelup: EventReader<LevelUpEvent>,
) {
    for ev in ev_levelup.read() {
        eprintln!("Entity {:?} leveled up!", ev.0);
    }
}

なお、.read() しているループはイベント待ちで処理がブロックされるとかそういうことはなく、イベントが何もないときは空の配列が渡されると思えば良いです。1フレームに複数のイベントが起きる場合があるのでイテレータになっています。なのでイベント処理以外 (Updateなど) と同じシステム上に書いて問題ありません。

ちなみに、非公式チートブックにも書いてあるように、カスタムイベントを登録するのも簡単です。

#[derive(Event)]
struct LevelUpEvent;

// (中略)

app.add_event::<LevelUpEvent>();

例えばサウンド再生など、複数のシステムをまたぐような処理は、イベント化してしまうと楽です 3

ポイント4: ローカル (Local) と リソース (Resource)

Bevyで、システム内だけでいわゆるstaticな変数がほしいと思うとき、Localを利用します。複数のシステムで共通の変数や定数がほしい場合は、Resourceを利用します。

例えば、あるシステム内で処理するたびに前の値を残しておき、次の処理に使いたいようなときは Local が最適です。一方で例えばプレイヤーの状態など、ゲーム内で共通して使う情報は Resource が最適です。

Localの例
fn my_system (
    mut my_time: Local<f32>,
    time: Res<Time>,
) {
    // 毎回経過時間を記録しておく、など
    *my_time += time.delta_seconds();
}
Resourceの定義例
#[derive(Resource)]
struct GameProgress {
    game_completed: bool,
    secrets_unlocked: u32,
}

#[derive(Resource)]
struct StartingLevel(usize);
Resourceの使用例
fn my_system(
    mut goals: ResMut<GoalsReached>,
    other: Res<MyOtherResource>,
    mut fancy: Option<ResMut<MyFancyResource>>,
) {
    // 訳注: fancyが存在していたらifの中に入る
    if let Some(fancy) = &mut fancy {
        // TODO: do things with `fancy`
    }
    // TODO: do things with `goals` and `other`
}

ちなみになぜLocalという、他のプログラミング言語では馴染みのない型がBevyに準備されているかというと、一つはRustではstaticな変数を作るのに苦労するという背景があります。また、Bevyではシステムは並列処理される可能性があるため、安易にstaticな変数を作るとMutexなどの排他処理にハマる可能性もあります。

無闇にグローバル変数を増やさないためにも、Localをうまく使っていくと、システム固有の変数管理が楽になるはずです。

ポイント5: ステート (State)

Bevyにはステート (State) というものが準備されていて、特に状態に応じてシステムを切り替えたい場合に便利です。

// メニューがメイン状態のときだけ実行されるシステム
app.add_systems(Update, my_system1.run_if(in_state(MenuState::MainMenu)));
// アプリがゲーム中のときだけ実行されるシステム
app.add_systems(Update, my_system2.run_if(in_state(AppState::InGame)));

リソース (Resource) だけでもいろんなことができますが、特にステートはシステムまわりで便利に活用でき、例えばあるステートのときだけ特定のシステムを走らせたいなどを容易に書くことができます。

実行スケジュールについては、ポイント8で詳しく解説します。

ポイント6: タイマー (Timer)

時間経過による処理を扱いたい場合は多くあると思いますが、例えば経過時間などを知るための Time と並んで便利なのがタイマー (Timer) です。

タイマーの使用例
#[derive(Resource)]
struct BombsSpawnConfig {
    timer: Timer,
}

// ゲーム起動時のセットアップ
fn setup_bomb_spawning(
    mut commands: Commands,
) {
    commands.insert_resource(BombsSpawnConfig {
        // 10秒で繰り返すタイマーを設置
        timer: Timer::new(Duration::from_secs(10), TimerMode::Repeating),
    })
}

// 時限爆弾をスポーン(生成)させるシステム
fn spawn_bombs(
    mut commands: Commands,
    time: Res<Time>,
    mut config: ResMut<BombsSpawnConfig>,
) {
    // 10秒で繰り返すタイマーを進める (tick)
    config.timer.tick(time.delta());

    // もしタイマーが終わったら
    if config.timer.finished() {
        commands.spawn((
            FuseTime {
                // 5秒の時限爆弾タイマーをスポーン(生成)させる
                timer: Timer::new(Duration::from_secs(5), TimerMode::Once),
            },
            // ...
        ));
    }
}

注意点は、タイマーはtickさせないと時間経過しない点です。

タイマーは指定時間のどれくらいが経ったかを比率で取得できたり (fraction())、残り時間を取得したりが便利にできます。

タイマーをうまく活用すると、複数のシステムで時間共有したりできますが、tickやスポーン(生成)・デスポーン(削除)の管理が一つポイントになります。

ポイント7: スポーン (spawn) と デスポーン (despawn)

Bevyには Commands というものがあり、Commandsを使ってエンティティのスポーン(作成)とデスポーン(削除)を行います。

注意点は、コマンドの実処理は最大で1フレームの遅延が起こることです。コマンドにキューを登録して、その処理がすぐに行われるとは限りません。なので例えば、エンティティの存在を仮定して行うような処理は、スポーン処理とは別システムで行うなどする必要があります。

ちなみに、このような時間的な前後関係を考えていくと、Unityでいうところのコルーチンがほしくなることがあります。これに該当するのはawait/asyncを使った非同期処理です。ゆくゆくは公式APIが便利になると思いますが、執筆時点ではbevy_flurxbevy_tweeningなどのアドオンを使うと便利です。

例えばbevy_flurxを使って、1秒後に Hello と出力するには次のようにします。

commands.spawn(Reactor::schedule(|task| async move {
    task.will(Update, {
        delay::time().with(Duration::from_secs_f32(1.0))
            .then(once::run(|| {
                println!("Hello");
            }))
    }).await;
}));

他にもCommandsによるスポーン・デスポーンを活用すると、アイディア次第で単一システムの枠を超えたいろんなことができます。特にエンティティ以外にも、リソースの追加や削除もCommandsからできるので、可能性は無限大です。

ポイント8: システムの実行スケジュール

Bevyのシステムは、基本的に StartupUpdate の大きく2種類のスケジュールで実行することができ、Startupに初期セットアップ(スポーンなど)、Updateに毎フレーム処理したい内容を登録します。

システムの実行スケジュールの例
// 毎フレーム実行したい処理 (Update)
app.add_systems(Update, camera_movement);

// 起動時に実行したい処理 (Startup)
app.add_systems(Startup, setup_camera);

PreUpdatePostUpdateなどの細かな制御も可能ですが、実際にはそれよりも、前述のステートと連動した、OnExit(State)OnEnter(State)のほうがよく利用されます。この2つは指定のステートに状態が切り替わったとき、状態が変化するときに呼ばれます。

例えばよく使うのは、アセットがすべて読み込まれたあとに何かを処理する場合で、bevy_asset_loaderなどのプラグインを使うと、そのあたりの実行スケジュール・ステート管理が楽にできます。

bevy_asset_loaderを使った、アセット読み込みの例
app
    // ステートを登録
    .init_state::<MyStates>()
    .add_loading_state(
        LoadingState::new(MyStates::AssetLoading)
            // 読み込み終わったら AssetLoaded ステートにする
            .continue_to_state(MyStates::AssetLoaded)
            // オーディオ関係のアセットを読み込み
            .load_collection::<AudioAssets>(), 
    )
    // 読み込みが終わったタイミングで、BGMを再生するシステム
    .add_systems(OnEnter(MyStates::AssetLoaded), start_background_audio)

また、実行スケジュールとは書き方は異なるものの、エンティティやコンポーネントの追加や削除、変更のタイミングを検知して何かを実行することも可能で、Added<T>Changed<T>をクエリ内に書くことで、イベントみたいにトリガーすることができます。

変更検知の例
fn debug_stats_change(
    query: Query<
        // コンポーネント
        (&Health, &PlayerXp),
        // フィルター
        (Without<Enemy>, Or<(Changed<Health>, Changed<PlayerXp>)>), 
    >,
) {
    for (health, xp) in query.iter() {
        // 変更時のみ実行される
        eprintln!(
            "hp: {}+{}, xp: {}",
            health.hp, health.extra, xp.0
        );
    }
}

ポイント9: UIと2Dの違い

Bevyでは、UI処理と2D処理は分かれています。それぞれに似たバンドルが準備されていて、例えばテキストはUIではTextBundle、2DではText2dBundleを使います。

UIと2Dの一番の違いは、Flexboxがレイアウトに使えるか否かですが、その他にも、2Dを前提にしたアドオン、UIを前提にしたアドオンなど、どちらを利用するかで使えるアドオンも変わってきたり、マテリアルの処理などに違いがあります。

Flexboxは非常に柔軟なレイアウトができる一方で、CSSなどと同じく難しい部分もあり、シンプルな2Dとどちらを使うべきかはケースバイケースです。基本的にはゲームオブジェクトは2D、メニューなどはUIと分けておくと良いかと思いますが、Bevyのバージョンを重ねるごとにUIもカメラドリブンになっているので、両者を混在させた使い方もできたりします。

ポイント10: プラグイン (Plugin)

ふつうプラグインというと、外部のアドオンのようなものをイメージすると思いますが、BevyでのPluginは、システムやリソースの登録などをまとめるためにも使えます。(もちろん外部のプラグインを利用する場合にも使います。)

なので、ある程度の機能別にプラグインにまとめるようにしておくと、関連するリソースやシステム登録を一箇所のソースにまとめることができるので、見た目上も綺麗になります。

struct MyPlugin;

impl Plugin for MyPlugin {
    fn build(&self, app: &mut App) {
        app.init_resource::<MyOtherResource>();
        app.add_event::<MyEvent>();
        app.add_systems(Startup, plugin_init);
        app.add_systems(Update, my_system);
    }
}

こうして作ったプラグインを、別のゲームなどで活用することも簡単にできますし、一つのアドオンとして分離したり、クレートとしてライブラリに公開することもできます。

プログラムをパーツに分けていく利点は再利用の面でとても大きいと感じていて、ECSの大きな恩恵はこの「モジュラー」な部分にあると思います。

ポイント11: ギズモ (Gizmos) や egui などの即時描画

Bevyは基本的にはUnityなどと同じく、エンティティやコンポーネントなどの構造体を制御して描画をコントロールしていきますが、即時描画も可能です。その代表格がギズモ (Gizmos) と egui (immediate-mode GUI)です。

特にデバッグなどの試行錯誤において、即時描画はとても便利です。C++やOpenGLでもImGuiという、eguiに似た即時描画できるGUIがよく使われていますが、eguiは同じように使えますし、他にもbevy-debug-text-overlayなど、即時描画できるものをいくつか知っておくと、デバッグと試行錯誤が非常に楽になると思います。

ちなみに執筆時点ではまだ開発段階ですが、クリエイティブ・コーディング向けフレームワークであるnannouがBevyプラグインとして生まれ変わると、さらなる即時描画APIの選択肢が増えることになるので、こちらも将来性が楽しみです。(Unity×Processing = Unicessingと似たノリの使い方ができるようになるのでは。)

  1. 可変・不変・定数の違いはJavaScriptのvar,let,constの違いと似ていると考えて差し支えありませんが、Rustの定数 (const) は実行時に変更できない点が違っていて、その意味ではJavaScriptのconstは不変(イミュータブル)に近いです。また、Rustではmutを多用すると所有権まわりのエラーに悩まされがちなのもあり、できるだけimmutable (不変) にすることが望まれます。この辺は参照透過性を重視する関数型言語らしい部分でもあります。

  2. [Rust] Bevyのはまりどころ - Componentの同時アクセス

  3. ワンショットシステムを使うというのも一つの手ですが、SystemId を管理しなければならなかったりするので、特定用途以外はイベントが簡単で便利だと思います。