aptpod Tech Blog

株式会社アプトポッドのテクノロジーブログです

Rust + bevy で wasm 向けゲーム開発

製品開発グループの大久保です。aptpod Advent Calendar 2022の5日目を担当します。

社内ではRustのエッジ製品への適用が本格化し、接続するデバイスに応じたプラグインのデバイスコネクタやSDK等への広がりを見せています。

個人的にもRustでのゲーム開発についての話題を追いかけているのですが、最近は bevy というゲームエンジンに勢いがあるようです。このbevyはWebAssemblyにビルドし、ブラウザ上で動作させることにも対応しています。というわけで、bevyで作ったアプリケーションをブラウザ上で動作させてみます。

bevyとは

bevyはECSに基づいたRust製のゲームエンジンで、本体はシンプルに保ちつつ、プラグインを導入して拡張を容易にする設計になってます。bevy用のプラグインは有志によって多数開発されているようです。

Bevy - Assets

bevy自体は頻繁にアップデートが続けられているため、サードパーティ製のプラグインも更新されなければ使えなくなってしまう心配がありますが、有用なものはやはり使っていきたいものです。Rustで人気のあるGUIライブラリであるeguiをbevyで使用できるようにしたbevy_eguiもあり、今回はこれでGUIを作ってみます。

実装

Cargo.tomlを以下のように記述します。bevyは2022年11月時点で最新のバージョンです。

[package]
name = "bevy-test"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.9"
bevy_asset_loader = "0.14"
bevy_egui = "0.17"
rand = "0.8"

main.rsは以下のように記述します。

use bevy::prelude::*;
use bevy_asset_loader::prelude::*;
use bevy_egui::{egui, EguiContext, EguiPlugin};
use rand::Rng;

fn main() {
    App::new()
        .add_loading_state(
            LoadingState::new(GameState::AssetLoading)
                .continue_to_state(GameState::Running)
                .with_collection::<MyAssets>(),
        )
        .add_state(GameState::AssetLoading)
        .add_system_set(SystemSet::on_update(GameState::Running).with_system(ui_system))
        .add_system_set(SystemSet::on_enter(GameState::Running).with_system(setup))
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            window: WindowDescriptor {
                title: "bevy test".into(),
                width: 320.0,
                height: 320.0,
                ..Default::default()
            },
            ..default()
        }))
        .add_plugin(EguiPlugin)
        .run();
}

#[derive(Resource, AssetCollection)]
struct MyAssets {
    #[asset(path = "rust-logo.png")]
    img: Handle<Image>,
}

fn setup(mut commands: Commands) {
    let camera = Camera2dBundle::default();
    commands.spawn(camera);
}

fn ui_system(
    mut commands: Commands,
    mut egui_context: ResMut<EguiContext>,
    my_assets: Res<MyAssets>,
    mut tex_entities: Local<Vec<Entity>>,
) {
    egui::Window::new("test").show(egui_context.ctx_mut(), |ui| {
        if ui.button("add").clicked() {
            let mut rng = rand::thread_rng();
            let id = commands
                .spawn(SpriteBundle {
                    texture: my_assets.img.clone(),
                    transform: Transform::from_xyz(
                        rng.gen_range(-160.0..160.0),
                        rng.gen_range(-160.0..160.0),
                        0.0,
                    ),
                    ..default()
                })
                .id();
            tex_entities.push(id);
        }

        if ui.button("clear").clicked() {
            for id in tex_entities.iter() {
                commands.entity(*id).despawn();
            }
            tex_entities.clear();
        }
    });
}

#[derive(Clone, Eq, PartialEq, Debug, Hash)]
enum GameState {
    AssetLoading,
    Running,
}

少し解説を入れます。

    App::new()
        .add_loading_state(
            LoadingState::new(GameState::AssetLoading)
                .continue_to_state(GameState::Running)
                .with_collection::<MyAssets>(),
        )
        .add_state(GameState::AssetLoading)
        .add_system_set(SystemSet::on_update(GameState::Running).with_system(ui_system))
        .add_system_set(SystemSet::on_enter(GameState::Running).with_system(setup))

bevy単体だとアセットの扱いが結構苦行なので、bevy_asset_loaderを使っています。MyAssetsのロードが完了するとGameStateAssetLoadingからRunningに移行します。Runningに移行したときにSystemSet::on_enterで指定したシステムsetupが動作します。ui_systemRunning状態でしか呼ばれないこともここで指定しています。

#[derive(Resource, AssetCollection)]
struct MyAssets {
    #[asset(path = "rust-logo.png")]
    img: Handle<Image>,
}

AssetCollectionderiveで指定して、ロードしたいアセットを定義します。ここでは、rust-logo.pngというファイルがロードされるようにします。

fn ui_system(
    mut commands: Commands,
    mut egui_context: ResMut<EguiContext>,
    my_assets: Res<MyAssets>,
    mut tex_entities: Local<Vec<Entity>>,
) {
    egui::Window::new("test").show(egui_context.ctx_mut(), |ui| {
        if ui.button("add").clicked() {
            let mut rng = rand::thread_rng();
            let id = commands
                .spawn(SpriteBundle {
                    texture: my_assets.img.clone(),
                    transform: Transform::from_xyz(
                        rng.gen_range(-160.0..160.0),
                        rng.gen_range(-160.0..160.0),
                        0.0,
                    ),
                    ..default()
                })
                .id();
            tex_entities.push(id);
        }

        if ui.button("clear").clicked() {
            for id in tex_entities.iter() {
                commands.entity(*id).despawn();
            }
            tex_entities.clear();
        }
    });
}

ui_systemはGUIを定義するシステムで、eguiで生成したウィンドウ上にボタンを2つ用意します。addボタンはクリックされたとき、MyAssetsで読み込んだテクスチャを乱数で決めた位置に貼り付けます。このときspawnしたエンティティのIDを記録しておき、clearボタンが押されたときに消去するようにします。

ビルド

Unofficial Bevy Cheat Bookにはwasm向けビルド方法がいくつか紹介されていますが、今回はwasm-bindgenを用います。

wasm-bindgenは以下でインストールします。

cargo install wasm-bindgen-cli

以下のコマンドでビルドできます。

cargo build --release --target wasm32-unknown-unknown
wasm-bindgen --target web --out-dir . --no-typescript target/wasm32-unknown-unknown/release/bevy-test.wasm

ビルドが成功すると、bevy-test_bg.wasmbevy-test.jsという2つのファイルができるので、それを呼び出すindex.htmlファイルを用意します。

<html>
  <head>
    <meta charset="utf-8"/>
  </head>
  <script type="module">
    import init from './bevy-test.js'
    init()
  </script>
</html>

あとは適当なHTTPサーバーを用意し、

python3 -m http.server 8000

ブラウザで開けば動作確認できます。addボタンを押せば画像がランダムに配置され、clearボタンを押せばそれが消えるのを確認できるはずです。

まとめ

今回はRust+bevyで簡単なアプリケーションをwasm向けにビルド、動作確認してみました。eguiによりGUIも比較的簡単に記述することができ、ビルド自体も非常に簡単に行えました。Rustでさくっと書いたものがwebブラウザ上で動くのはなかなか感慨深いものがあります。まだまだRustでのゲーム開発は発展途上であり、bevyもまた開発中ではありますが、webブラウザで動かしたい場合にbevyは有力な選択肢になるのではないでしょうか。