💎

Rustで3Dサンドボックスゲームを作る #1 Bevy

2022/05/15に公開

続編が書かれるかは未定です。今回は平面を表示しその上をカメラが移動するところまでです。

完成品

スクリーンショット

Wasm化してブラウザで動かすこともできます↓。

https://bevy-zenn-2022-05-15.netlify.app

ただしSafariでは上手く動作しないようなので、最新版のGoogle ChromeやFirefoxでお試しください。操作はキーボードのみ(WASD/矢印)です。

ソースコードはGitHub上で公開しています。

https://github.com/publictheta/bevy-examples/tree/main/crates/zenn-2022-05-15

はじめに: Bevy

BevyはRustで書かれているゲームエンジンです。現在はまだ開発初期段階でAPIが不安定ですが、活発に開発が進められており今後が楽しみなプロジェクトでもあります。

https://bevyengine.org

ソースコードはオープンソース(MIT OR Apache 2.0)となっており、もちろんライセンス料なしで使えます。

https://github.com/bevyengine/bevy

現在サポートされているプラットフォームは、

  • Windows
  • macOS
  • Linux
  • Web

ですが、現在、

  • iOS
  • Android

のサポートも進められているようです(内部的にはwinitwgpuが使われています)。

Bevyは2Dゲームを作るのにも使え日本語でも既にいくつか記事が書かれていますが、今回はこのBevyで3Dサンドボックスを作ってみたいと思います。

使用バージョン

Rust、Bevyともに現時点(2022-05-15)での最新版を使います。特にBevyはバージョンによる違いが大きいと思うので注意してください。

rustc 1.60.0
[dependencies]
bevy = "0.7"

コンパイルオプション

依存パッケージについてはRustのキャッシュが効くので初回以降のコンパイルはかなり短くなると思いますが、それでもイテレーションを繰り返すのが重要なゲーム開発においてコンパイルの速さは死活問題です。

コンパイルの高速化のための設定についてはBevyの公式セットアップガイドに記載があります。

https://bevyengine.org/learn/book/getting-started/setup/#enable-fast-compiles-optional

今回はその中のダイナミックリンクのみを有効にして進めてみます(ただしこのオプションはリリースビルドのときは外すようにしましょう)。

cargo run --features bevy/dynamic

なおアセットの再読み込みについてはBevyがホットリロードを提供しているのでコンパイルをし直す必要はありません。現在ホットリロードに対応しているのはシーン、テクスチャ、メッシュのみとなっています。

概観: アプリケーション

今回作るアプリケーションのmain関数は次のようになっています。

use bevy::prelude::*;

fn main() {
    App::new()
        .insert_resource(WindowDescriptor {
            title: "bevy-2022-05-15".to_string(),
            ..default()
        })
        .add_plugins(DefaultPlugins)
        .init_resource::<Player>()
        .add_event::<PlayerMoveEvent>()
        .add_startup_system(setup_lights)
        .add_startup_system(setup_cameras)
        .add_startup_system(setup_objects)
        .add_startup_system(setup_texts)
        .add_system(update)
        .add_system(update_cameras.after(update))
        .add_system(update_texts.after(update))
        .run();
}

よく使うものをいちいちインポートしなくてもいいように、まずbevy::prelude::*をインポートしています。

main関数は見てわかるように、Bevyのアプリケーション(App)に必要な構成を追加して、実行しているだけです。

BevyはECS(Entity Component System)という設計をとっていて、ワールド(world)に保持されたエンティティの各コンポーネントに対し、あらかじめ定義されたスケジュール(schedule)にしたがって、各システムを実行する(runner)ことで、アプリケーションを動作させます。

https://docs.rs/bevy_app/latest/bevy_app/struct.App.html

ECS

ECSはゲーム内のオブジェクトを、すべての基底となるエンティティと、そのエンティティに付加されるコンポーネントによって構成し、それらをシステム呼ばれるプロセスによって変更するというゲームエンジンでよく用いられるアーキテクチャです。

Bevyでは、エンティティはID(u32)と世代を保持するstructEntity)、コンポーネントとシステムはtraitComponentSystem)としてそれぞれ表現されています。

ESC

https://bevyengine.org/learn/book/getting-started/ecs/

エンティティ

エンティティは次のようにシステムから利用可能なコマンド(spawndespawn)によって生成・削除されます。

fn spawn_and_despawn_entity(mut commands: Commands) {
    // エンティティを生成しID(型は`Entity`)を取得する
    let id = commands.spawn().id();
    
    // エンティティを削除する(`entity(id)`で`EntityCommands`が得られる)
    commands.entity(id).despawn();
}

コンポーネント

コンポーネントは次のようにderiveマクロで実装することができます(今回はコンポーネントの実装は行いません)。

/// エンティティが仲間であることを示す
#[derive(Component)]
struct Friend;

/// キャラクター情報
#[derive(Component)]
struct Info {
    name: String,
    description: String,
}

/// ステータス
#[derive(Component)]
struct Status {
    health: u32,
    attack: u32,
}

エンティティにコンポーネントを追加するには次のようにinsertコマンドを使います。

fn spawn_friend(mut commands: Commands) {
    commands
        .spawn()
        .insert(Friend)
        .insert(Info {
            name: "".to_string(),
            description: "".to_string(),
        })
        .insert(Status {
            health: 32,
            attack: 16,
        });
}

複数のコンポーネントは次のようにバンドル(Bundle)としてまとめることもできます。

// バンドルには`derive(Bundle)`を使う
#[derive(Bundle)]
struct CharacterBundle {
    // バンドルのフィールドはコンポーネント
    info: Info,
    status: Status,
}

#[derive(Bundle)]
struct FriendBundle {
    friend: Friend,
    // 別のバンドルをフィールドとして追加するには`#[bundle]`をつける
    #[bundle]
    character: CharacterBundle,
}

なおバンドルを追加するにはinsertの代わりにinsert_bundleを使います。

システム

すべての引数がSystemParamトレイトを満たしている関数には自動的にSystemトレイトが実装されます。

エンティティやコンポーネントにアクセスするためのクエリ(Query)やリソースへの参照(ResResMut)、コマンド(Commands)、イベントの送受信を行うEventWriterEventReaderなどがSystemParamトレイトを実装しています。

システムはそれらの引数を通して必要なデータにアクセスし変更を加えます。

リソース

Bevyではアプリケーション内でグローバルに共有されるデータをリソース(Resource)と呼んでいます。

https://bevyengine.org/learn/book/getting-started/resources/

リソースはinsert_resourceで直接追加することができますが、デフォルトの初期化方法が定まっているものについてはinit_resourceによって追加することもできます。

今回はプレイヤーの状態を保持するための構造体Player(自前で実装)と、DefaultPluginsWindowPluginによって参照されるWindowDescriptorを追加しています。ちなみにプラグインから参照されるリソースは必ずプラグインを追加する前に追加しておく必要があるので注意しましょう。

プラグイン

Bevyはプラグイン(Plugin)によって様々な機能を追加することができます。ウィンドウの表示やアセットの管理、デフォルトのrunnerの設定などの標準機能の多くもプラグインとして実装されています。

https://bevyengine.org/learn/book/getting-started/plugins/

DefaultPluginsはそのようなプラグインをまとめたものです。もし追加せずに次のようなmain関数を実行すると、ウィンドウすら表示されずアプリケーションが即時終了するはずです。

use bevy::prelude::*;

fn main() {
    App::new().run();
}

システムやタスクの実行など最小限の機能のみを追加するにはMinimalPluginsを使うことができます。

ちなみにプラグインの実装は次のようになっており、main関数で行うことをただまとめたものとなっています。

struct MyPlugin;

impl Plugin for MyPlugin {
    fn build(&self, app: &mut App) {
        todo!()
    }
}

自作のプラグインを公開する場合はガイドラインを参考にしましょう。

イベント

システム間のコミュニケーションのためにBevyではイベント(Events)システムが用意されています。

イベントはEventWriterで送り、EventReaderで受け取ります。どちらもSystemParamを実装しているので、システムから利用可能です。

今回はプレイヤーが移動したことを知らせるPlayerMoveEventを定義し、プレイヤーの座標を示すUIテキストの更新に使います。

struct PlayerMoveEvent;

ここではフィールドのない構造体を使っていますが、イベントの型はResourceトレイトを満たしていればなんでもいいので、システム間の様々なデータの受け渡しにイベントを使うことができます。

なおイベントを使うにはadd_eventなどを使ってあらかじめアプリケーションにイベントを追加しておく必要があります。

ステージ

システムの実行順序はステージによって定められています。デフォルトでは次のようなステージが設定されています。

初回のみ実行されるステージ(StartupStage)以外のステージ(CoreStage)に追加されたシステムはすべて毎フレーム(毎tick)実行されます。

ただし同じステージに追加されたシステムは可能であれば並列で実行されるのがデフォルトの動作となっています。追加順での実行は保証されません。

今回はupdate_camerasupdate_textsupdateの後で実行させたいので、afterメソッドを使って実行順序を明示しています。

したがって今回追加するシステムの実行順序は次のようになります。

  • 0(初回のみ)
    • setup_lights
    • setup_cameras
    • setup_objects
    • setup_texts
  • 1
    • update
  • 2
    • update_cameras
    • update_texts

以降、Playerを定義した後で、それぞれシステムのそれぞれを見ていきます。

状態: プレイヤー

今回はプレイヤーをエンティティとしてではなくリソースとしてグローバルに保持することにしています。

フィールドは位置と向きを保持するのに使うTransformのみとなっています。

struct Player {
    transform: Transform,
}

初期位置は原点で、Y方向を上としてX方向を向いていることにします。

impl Default for Player {
    fn default() -> Self {
        Self {
            transform: Transform::default().looking_at(Vec3::X, Vec3::Y),
        }
    }
}

ちなみにBevyの座標系はY軸上向きの右手系です。

Y軸上向きの右手系

https://blog.publictheta.com/memos/p9KAaQGQaaTTc1yAv5IGB

テキストとして座標が表示できるようstd::fmt::Displayも実装しておきます。

impl std::fmt::Display for Player {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let translation = self.transform.translation;

        write!(
            f,
            "X: {:.3}, Y: {:.3}, Z: {:.3}",
            translation.x, translation.y, translation.z
        )
    }
}

セットアップ: 光源

光源にはDirectionalLightBundleをデフォルトのまま使用します。

fn setup_lights(mut commands: Commands) {
    commands.spawn_bundle(DirectionalLightBundle::default());
}

DirectionalLightは太陽光のように光源が遠くにある光を定義します。

セットアップ: カメラ

カメラとしてはプレイヤーの視点カメラと、UIを表示するためのカメラの2つを追加します。

fn setup_cameras(mut commands: Commands, player: Res<Player>) {
    commands.spawn_bundle(PerspectiveCameraBundle {
        transform: Transform {
            translation: player.transform.translation + Vec3::Y,
            rotation: player.transform.rotation,
            ..default()
        },
        ..default()
    });

    commands.spawn_bundle(UiCameraBundle::default());
}

プレイヤーの視点にはPerspectiveCameraBundle(透視投影カメラ)を使います。なお2Dゲームのように遠近による拡縮が不要な場合はOrthographicCameraBundle(垂直投影カメラ)を使います。

カメラの位置はプレイヤーの位置よりY軸1.0上にし、向きはプレイヤーと同じにしてあります。三人称視点にしたい場合は、カメラエンティティのtransformを変えることで実現可能です。プレイヤーリソースを得るために、引数にRes<Player>を追加しています(ResResourceへの共有参照)。

UIカメラにはUiCameraBundleをそのまま使います。

セットアップ: 3Dオブジェクト

今回は3Dオブジェクトとして平面だけを追加しています。

fn setup_objects(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn_bundle(PbrBundle {
        mesh: meshes.add(Mesh::from(shape::Plane { size: 10.0 })),
        material: materials.add(Color::BLACK.into()),
        transform: Transform::from_translation(Vec3::ZERO),
        ..default()
    });
}

通常のMeshStandardMaterialを持った3DオブジェクトにはPbrBundleを使います。

メッシュやマテリアルはAssetsリソースとして管理されているので、ここではそれぞれのアセットリソースへの可変参照(ResMut)を取得し追加しながら3Dオブジェクトを作成しています。PbrBundlemeshフィールドやmaterialフィールドは各アセットへのHandleとなっていることに注意が必要です。

基本的な形状のメッシュの作成にはbevy::prelude::shapeに定義されている形を使うことができます。ここではサイズが10.0Planeを使っています。

マテリアルはテクスチャ(ImageHandle)から作ることもできますが、単色であれば色(Color)から作ることも可能です。ここではあらかじめ定義されているBLACKを指定しています。

セットアップ: UIテキスト

UIテキストにはTextBundleを使います。2Dゲームに使うText2dBundleとは異なることに注意してください。

fn setup_texts(mut commands: Commands, asset_server: Res<AssetServer>, player: Res<Player>) {
    commands.spawn_bundle(TextBundle {
        style: Style {
            position_type: PositionType::Absolute,
            position: Rect {
                left: Val::Px(10.0),
                bottom: Val::Px(10.0),
                ..default()
            },
            ..default()
        },
        text: Text::with_section(
            format!("{}", player.into_inner()),
            TextStyle {
                font: asset_server.load("fonts/NotoSans-Regular.ttf"),
                font_size: 20.0,
                color: Color::WHITE,
            },
            TextAlignment {
                horizontal: HorizontalAlign::Left,
                ..default()
            },
        ),
        ..default()
    });
}

まずbevy::ui::Styleで左下から10.0ピクセルの位置にUIノードを表示するよう指定し、Textで左寄せのテキストを追加しています。TextSectionTextStyle(フォント、サイズ、色)を共有する文字列です。

なおBevyのUIはフレックスボックスをベースとしたレイアウト方式をとっていますが原点が左下となっていることに注意が必要です。

フォントはAssetServerリソースから読み込みます。loadに指定するパスはROOT/ASSET_FOLDER_NAME/からの相対パスです。

ROOTのデフォルトはアプリケーションの実行ファイル(Wasmの場合は.wasmファイル)のあるディレクトリですが、Cargoから実行する場合はCARGO_MANIFEST_DIR環境変数で上書きされます。通常はプロジェクトのルートディレクトリです。ASSET_FOLDER_NAMEAssetServerSettingsで定義されており、デフォルトはassetsです。

したがって、上記の場合プロジェクトディレクトリにあるassets/fonts/NotoSans-Regular.ttfが読み込まれることになります。

更新: 入力

updateではキーボードからの入力を受け取り、Playerリソースを更新します。

キーやボタンなどのON/OFFのある入力はInputリソースを通して受け取ります。また最後のフレームからの経過時間を取得するのにTimeリソースを使い、プレイヤーの移動を通知するのにEventWriter<PlayerMoveEvent>も引数にとっています。

fn update(
    keyboard_input: Res<Input<KeyCode>>,
    time: Res<Time>,
    mut player: ResMut<Player>,
    mut player_move_events: EventWriter<PlayerMoveEvent>,
) {
    let mut move_forward = 0.0f32;
    let mut turn_left = 0.0f32;

    const MOVE_UNIT: f32 = 10.0;
    const TURN_UNIT: f32 = 1.0;

    if keyboard_input.pressed(KeyCode::W) || keyboard_input.pressed(KeyCode::Up) {
        move_forward += MOVE_UNIT;
    }

    if keyboard_input.pressed(KeyCode::S) || keyboard_input.pressed(KeyCode::Down) {
        move_forward -= MOVE_UNIT;
    }

    if keyboard_input.pressed(KeyCode::A) || keyboard_input.pressed(KeyCode::Left) {
        turn_left += TURN_UNIT;
    }

    if keyboard_input.pressed(KeyCode::D) || keyboard_input.pressed(KeyCode::Right) {
        turn_left -= TURN_UNIT;
    }

    if move_forward != 0.0 {
        let translation = player.transform.forward() * (move_forward * time.delta_seconds());
        player.transform.translation += translation;
        player_move_events.send(PlayerMoveEvent)
    }

    if turn_left != 0.0 {
        let rotation = Quat::from_rotation_y(turn_left * time.delta_seconds());
        player.transform.rotate(rotation);
    }
}

コードとしてはWASD/矢印キーが押されているかを検知し、定数 * 最後のフレームからの経過秒数だけ移動・回転させているだけです。

プレイヤーのローカル座標での前方向を取得するのにはTransformforwardメソッドを使っています。イベントの送信にはEventWritersendメソッドを使います。

更新: カメラ

update_camerasPlayerリソースに変更があった場合のみ、カメラの位置と向き(Transform)を更新します。

fn update_cameras(
    player: Res<Player>,
    mut camera_transforms: Query<&mut Transform, With<Camera3d>>,
) {
    if !player.is_changed() {
        return;
    }

    let mut camera_transform = match camera_transforms.get_single_mut() {
        Ok(camera_transform) => camera_transform,
        _ => {
            error!("Transform not found.");
            return;
        }
    };

    camera_transform.translation = player.transform.translation + Vec3::Y;
    camera_transform.rotation = player.transform.rotation;
}

リソースの変更はis_changedメソッドで検知できるようになっています。変更されていなければ更新する必要はないので、そのままreturnしています。

コンポーネントの取得にはQueryを使います。Queryの第一型引数には取得したいコンポーネントへの参照やEntity、またはそれらのタプルを指定し、第二型引数にはオプションで絞り込むためのフィルターを指定します。

タプルを指定した場合、AND条件になります。次のクエリはComponentAComponentBの両方を持つエンティティとその各コンポーネントを(ComponentAは共有参照、ComponentBは可変参照で)取得します。

Query<(Entity, &ComponentA, &mut ComponentB)>

あるコンポーネントをオプションで取得したい場合はOptionにします。次のクエリはComponentAを持つエンティティとそのComponentAの共有参照を取得し、ComponentBを持っている場合はその可変参照も取得します。

Query<(Entity, &ComponentA, Option<&mut ComponentB>)>

クエリには次のフィルターを指定できます。

  • Added<T> コンポーネントTが追加されたエンティティ
  • Changed<T> コンポーネントTに変更があったエンティティ
  • With<T> コンポーネントTを持つエンティティ
  • Without<T> コンポーネントTを持たないエンティティ
  • Or<T> タプルTで指定されたいずれかのフィルターを満たすエンティティ

フィルターのタプルをそのまま指定した場合AND条件になります。

今回はCamera3dコンポーネントでマークされたエンティティのTransformを可変参照で取得するために、Query<&mut Transform, With<Camera3d>>を使っています。

なおCamera3dbevy::prelude::*に含まれていないので、冒頭のインポートを次のように変更しておきます。

use bevy::{prelude::*, render::camera::Camera3d};

Queryからその結果を取得するには、iteriter_mutなどのメソッドを使いますが、今回は存在してかつ唯一であることがわかっているので、get_single_mutメソッドを使っています。

このメソッドでエラーが返ってくるのはクエリに一致するものが存在しなかったり複数あったりするときです。今回の場合正しく実装されていればエラーとなることはないと思いますが、念の為Bevy標準ログプラグインのerror!マクロを使って、エラーログ出力するようにしています。

更新: テキスト

update_textsではプレイヤーの移動をPlayerMoveEventで検知して、Textを更新しています。イベントの受け取りにはEventReaderを使います。またTextコンポーネントは唯一であることがわかっているので、そのままクエリしています。

fn update_texts(
    player: Res<Player>,
    mut player_move_events: EventReader<PlayerMoveEvent>,
    mut texts: Query<&mut Text>,
) {
    if player_move_events.iter().count() <= 0 {
        return;
    }

    let mut text = match texts.get_single_mut() {
        Ok(text) => text,
        _ => {
            error!("Text not found.");
            return;
        }
    };

    let mut text_section = match text.sections.get_mut(0) {
        Some(text_section) => text_section,
        _ => {
            error!("TextSection not found.");
            return;
        }
    };

    text_section.value = format!("{}", player.into_inner());
}

最終コード

最終コードは次のようになります。

use bevy::{prelude::*, render::camera::Camera3d};

fn main() {
    App::new()
        .insert_resource(WindowDescriptor {
            title: "bevy-2022-05-15".to_string(),
            ..default()
        })
        .add_plugins(DefaultPlugins)
        .init_resource::<Player>()
        .add_event::<PlayerMoveEvent>()
        .add_startup_system(setup_lights)
        .add_startup_system(setup_cameras)
        .add_startup_system(setup_objects)
        .add_startup_system(setup_texts)
        .add_system(update)
        .add_system(update_cameras.after(update))
        .add_system(update_texts.after(update))
        .run();
}

struct Player {
    transform: Transform,
}

impl Default for Player {
    fn default() -> Self {
        Self {
            transform: Transform::default().looking_at(Vec3::X, Vec3::Y),
        }
    }
}

impl std::fmt::Display for Player {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let translation = self.transform.translation;

        write!(
            f,
            "X: {:.3}, Y: {:.3}, Z: {:.3}",
            translation.x, translation.y, translation.z
        )
    }
}

struct PlayerMoveEvent;

fn setup_lights(mut commands: Commands) {
    commands.spawn_bundle(DirectionalLightBundle::default());
}

fn setup_cameras(mut commands: Commands, player: Res<Player>) {
    commands.spawn_bundle(PerspectiveCameraBundle {
        transform: Transform {
            translation: player.transform.translation + Vec3::Y,
            rotation: player.transform.rotation,
            ..default()
        },
        ..default()
    });

    commands.spawn_bundle(UiCameraBundle::default());
}

fn setup_objects(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StandardMaterial>>,
) {
    commands.spawn_bundle(PbrBundle {
        mesh: meshes.add(Mesh::from(shape::Plane { size: 10.0 })),
        material: materials.add(Color::BLACK.into()),
        transform: Transform::from_translation(Vec3::ZERO),
        ..default()
    });
}

fn setup_texts(mut commands: Commands, asset_server: Res<AssetServer>, player: Res<Player>) {
    commands.spawn_bundle(TextBundle {
        style: Style {
            position_type: PositionType::Absolute,
            position: Rect {
                left: Val::Px(10.0),
                bottom: Val::Px(10.0),
                ..default()
            },
            ..default()
        },
        text: Text::with_section(
            format!("{}", player.into_inner()),
            TextStyle {
                font: asset_server.load("fonts/NotoSans-Regular.ttf"),
                font_size: 20.0,
                color: Color::WHITE,
            },
            TextAlignment {
                horizontal: HorizontalAlign::Left,
                ..default()
            },
        ),
        ..default()
    });
}

fn update(
    keyboard_input: Res<Input<KeyCode>>,
    time: Res<Time>,
    mut player: ResMut<Player>,
    mut player_move_events: EventWriter<PlayerMoveEvent>,
) {
    let mut move_forward = 0.0f32;
    let mut turn_left = 0.0f32;

    const MOVE_UNIT: f32 = 10.0;
    const TURN_UNIT: f32 = 1.0;

    if keyboard_input.pressed(KeyCode::W) || keyboard_input.pressed(KeyCode::Up) {
        move_forward += MOVE_UNIT;
    }

    if keyboard_input.pressed(KeyCode::S) || keyboard_input.pressed(KeyCode::Down) {
        move_forward -= MOVE_UNIT;
    }

    if keyboard_input.pressed(KeyCode::A) || keyboard_input.pressed(KeyCode::Left) {
        turn_left += TURN_UNIT;
    }

    if keyboard_input.pressed(KeyCode::D) || keyboard_input.pressed(KeyCode::Right) {
        turn_left -= TURN_UNIT;
    }

    if move_forward != 0.0 {
        let translation = player.transform.forward() * (move_forward * time.delta_seconds());
        player.transform.translation += translation;
        player_move_events.send(PlayerMoveEvent)
    }

    if turn_left != 0.0 {
        let rotation = Quat::from_rotation_y(turn_left * time.delta_seconds());
        player.transform.rotate(rotation);
    }
}

fn update_cameras(
    player: Res<Player>,
    mut camera_transforms: Query<&mut Transform, With<Camera3d>>,
) {
    if !player.is_changed() {
        return;
    }

    let mut camera_transform = match camera_transforms.get_single_mut() {
        Ok(camera_transform) => camera_transform,
        _ => {
            error!("Transform not found.");
            return;
        }
    };

    camera_transform.translation = player.transform.translation + Vec3::Y;
    camera_transform.rotation = player.transform.rotation;
}

fn update_texts(
    player: Res<Player>,
    mut player_move_events: EventReader<PlayerMoveEvent>,
    mut texts: Query<&mut Text>,
) {
    if player_move_events.iter().count() <= 0 {
        return;
    }

    let mut text = match texts.get_single_mut() {
        Ok(text) => text,
        _ => {
            error!("Text not found.");
            return;
        }
    };

    let mut text_section = match text.sections.get_mut(0) {
        Some(text_section) => text_section,
        _ => {
            error!("TextSection not found.");
            return;
        }
    };

    text_section.value = format!("{}", player.into_inner());
}

見て分かるように、Bevyの基本的な概念さえ理解してしまえば、コードとしてはわりと素直なものになってい流と思います。

欲をいえばバンドルの構成とエラーからreturnするときのmatch文が冗長な気はしますが、前者は BevyのAPI整備や拡張トレイトでなんとかなるとして、後者はRustのlet-else待ちでしょうか。何かいいアイディアがあればコメント等で教えていただけると幸いです。

おわりに: 感想

もっと複雑なアプリケーションになったとき設計やパフォーマンスにどのような影響が出てくるかはまだ分かりませんが、動く3Dアプリケーションを200行未満のRustのコードだけで書けるのはなかなかに気持ちいい体験でした。個人的にUnityやUnreal Engineはファイルがいっぱい出てきて苦手なので、Bevyには期待したいところです。

次回は機会があれば続編としてもう少しゲームらしいロジックを実装してみたいと思います。

参考情報

公式サイト:

https://bevyengine.org

公式ブック:

https://bevyengine.org/learn/book/introduction/

公式サンプルコード:

https://github.com/bevyengine/bevy/tree/main/examples

公式APIドキュメンテーション:

https://docs.rs/bevy/latest/bevy/

非公式チートブック:

https://bevy-cheatbook.github.io

付録: TrunkでRustをWasmアプリ化する

複雑な設定なしにRustのアプリケーションをWasmアプリ化するにはTrunkを使うのが簡単です。

https://trunkrs.dev

プロジェクトのルートディレクトリに次のようなindex.htmlを用意するだけで、Wasmへのビルドからファイルのコピーまですべて自動で行ってくれます。なおデフォルトの出力先ディレクトリはdistです。

<html lang="ja">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>bevy3d-2022-05-15</title>
    <link data-trunk rel="copy-dir" href="assets"/>
    <link data-trunk rel="icon" href="favicon.ico"/>
    <link data-trunk rel="css" href="index.css"/>
    <link data-trunk rel="inline" href="index.js"/>
  </head>
  <body>
    <noscript>実行するにはJavaScriptを有効にしてください。</noscript>
    <p>読み込み中です。しばらく時間がかかります。</p>
  </body>
</html>

TrunkのインストールにはHomebrew、

brew install trunk

またはCargoを使うことができます。

cargo install --locked trunk

CIで使う場合については公式サイトのインストールのセクションを参照してください。

https://trunkrs.dev/#install

Trunkで追加するアセットはdata-trunk属性のついた<link>要素で指定することができます。指定することのできるアセットのタイプと挙動については公式サイトのアセットのページに記載があります。

https://trunkrs.dev/assets

ビルドには、

trunk build

ウォッチしながらローカルでホストするには、

trunk serve

コマンドを使用します。

Wasmのための環境構築が面倒だという方はぜひお試しください。

変更履歴

2022-05-29

  • サンプルコード用のリポジトリの変更に伴いリンクと説明を修正
  • デモサイトのURLの変更に伴いリンクを修正

Discussion