Rustで3Dサンドボックスゲームを作る #1 Bevy
続編が書かれるかは未定です。今回は平面を表示しその上をカメラが移動するところまでです。
完成品
Wasm化してブラウザで動かすこともできます↓。
ただしSafariでは上手く動作しないようなので、最新版のGoogle ChromeやFirefoxでお試しください。操作はキーボードのみ(WASD/矢印)です。
ソースコードはGitHub上で公開しています。
はじめに: Bevy
BevyはRustで書かれているゲームエンジンです。現在はまだ開発初期段階でAPIが不安定ですが、活発に開発が進められており今後が楽しみなプロジェクトでもあります。
ソースコードはオープンソース(MIT OR Apache 2.0)となっており、もちろんライセンス料なしで使えます。
現在サポートされているプラットフォームは、
- Windows
- macOS
- Linux
- Web
ですが、現在、
- iOS
- Android
のサポートも進められているようです(内部的にはwinit
とwgpu
が使われています)。
Bevyは2Dゲームを作るのにも使え日本語でも既にいくつか記事が書かれていますが、今回はこのBevyで3Dサンドボックスを作ってみたいと思います。
使用バージョン
Rust、Bevyともに現時点(2022-05-15)での最新版を使います。特にBevyはバージョンによる違いが大きいと思うので注意してください。
rustc 1.60.0
[dependencies]
bevy = "0.7"
コンパイルオプション
依存パッケージについてはRustのキャッシュが効くので初回以降のコンパイルはかなり短くなると思いますが、それでもイテレーションを繰り返すのが重要なゲーム開発においてコンパイルの速さは死活問題です。
コンパイルの高速化のための設定についてはBevyの公式セットアップガイドに記載があります。
今回はその中のダイナミックリンクのみを有効にして進めてみます(ただしこのオプションはリリースビルドのときは外すようにしましょう)。
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
)ことで、アプリケーションを動作させます。
ECS
ECSはゲーム内のオブジェクトを、すべての基底となるエンティティと、そのエンティティに付加されるコンポーネントによって構成し、それらをシステム呼ばれるプロセスによって変更するというゲームエンジンでよく用いられるアーキテクチャです。
Bevyでは、エンティティはID(u32
)と世代を保持するstruct
(Entity
)、コンポーネントとシステムはtrait
(Component
、System
)としてそれぞれ表現されています。
エンティティ
エンティティは次のようにシステムから利用可能なコマンド(spawn
、despawn
)によって生成・削除されます。
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
)やリソースへの参照(Res
、ResMut
)、コマンド(Commands
)、イベントの送受信を行うEventWriter
とEventReader
などがSystemParam
トレイトを実装しています。
システムはそれらの引数を通して必要なデータにアクセスし変更を加えます。
リソース
Bevyではアプリケーション内でグローバルに共有されるデータをリソース(Resource
)と呼んでいます。
リソースはinsert_resource
で直接追加することができますが、デフォルトの初期化方法が定まっているものについてはinit_resource
によって追加することもできます。
今回はプレイヤーの状態を保持するための構造体Player
(自前で実装)と、DefaultPlugins
のWindowPlugin
によって参照されるWindowDescriptor
を追加しています。ちなみにプラグインから参照されるリソースは必ずプラグインを追加する前に追加しておく必要があるので注意しましょう。
プラグイン
Bevyはプラグイン(Plugin
)によって様々な機能を追加することができます。ウィンドウの表示やアセットの管理、デフォルトのrunner
の設定などの標準機能の多くもプラグインとして実装されています。
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
などを使ってあらかじめアプリケーションにイベントを追加しておく必要があります。
ステージ
システムの実行順序はステージによって定められています。デフォルトでは次のようなステージが設定されています。
First
- 初回のみ実行
-
PreUpdate
(プラグインの前処理に使用) -
Update
(add_system
で追加) -
PostUpdate
(プラグインの後処理に使用) Last
初回のみ実行されるステージ(StartupStage
)以外のステージ(CoreStage
)に追加されたシステムはすべて毎フレーム(毎tick)実行されます。
ただし同じステージに追加されたシステムは可能であれば並列で実行されるのがデフォルトの動作となっています。追加順での実行は保証されません。
今回はupdate_cameras
とupdate_texts
をupdate
の後で実行させたいので、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軸上向きの右手系です。
テキストとして座標が表示できるよう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>
を追加しています(Res
はResource
への共有参照)。
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()
});
}
通常のMesh
とStandardMaterial
を持った3DオブジェクトにはPbrBundle
を使います。
メッシュやマテリアルはAssets
リソースとして管理されているので、ここではそれぞれのアセットリソースへの可変参照(ResMut
)を取得し追加しながら3Dオブジェクトを作成しています。PbrBundle
のmesh
フィールドやmaterial
フィールドは各アセットへのHandle
となっていることに注意が必要です。
基本的な形状のメッシュの作成にはbevy::prelude::shape
に定義されている形を使うことができます。ここではサイズが10.0
のPlane
を使っています。
マテリアルはテクスチャ(Image
のHandle
)から作ることもできますが、単色であれば色(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
で左寄せのテキストを追加しています。TextSection
はTextStyle
(フォント、サイズ、色)を共有する文字列です。
なおBevyのUIはフレックスボックスをベースとしたレイアウト方式をとっていますが原点が左下となっていることに注意が必要です。
フォントはAssetServer
リソースから読み込みます。load
に指定するパスはROOT/ASSET_FOLDER_NAME/
からの相対パスです。
ROOT
のデフォルトはアプリケーションの実行ファイル(Wasmの場合は.wasm
ファイル)のあるディレクトリですが、Cargoから実行する場合はCARGO_MANIFEST_DIR
環境変数で上書きされます。通常はプロジェクトのルートディレクトリです。ASSET_FOLDER_NAME
はAssetServerSettings
で定義されており、デフォルトは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/矢印キーが押されているかを検知し、定数 * 最後のフレームからの経過秒数
だけ移動・回転させているだけです。
プレイヤーのローカル座標での前方向を取得するのにはTransform
のforward
メソッドを使っています。イベントの送信にはEventWriter
のsend
メソッドを使います。
更新: カメラ
update_cameras
はPlayer
リソースに変更があった場合のみ、カメラの位置と向き(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条件になります。次のクエリはComponentA
とComponentB
の両方を持つエンティティとその各コンポーネントを(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>>
を使っています。
なおCamera3d
はbevy::prelude::*
に含まれていないので、冒頭のインポートを次のように変更しておきます。
use bevy::{prelude::*, render::camera::Camera3d};
Query
からその結果を取得するには、iter
やiter_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には期待したいところです。
次回は機会があれば続編としてもう少しゲームらしいロジックを実装してみたいと思います。
参考情報
公式サイト:
公式ブック:
公式サンプルコード:
公式APIドキュメンテーション:
非公式チートブック:
付録: TrunkでRustをWasmアプリ化する
複雑な設定なしにRustのアプリケーションをWasmアプリ化するにはTrunkを使うのが簡単です。
プロジェクトのルートディレクトリに次のような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で使う場合については公式サイトのインストールのセクションを参照してください。
Trunkで追加するアセットはdata-trunk
属性のついた<link>
要素で指定することができます。指定することのできるアセットのタイプと挙動については公式サイトのアセットのページに記載があります。
ビルドには、
trunk build
ウォッチしながらローカルでホストするには、
trunk serve
コマンドを使用します。
Wasmのための環境構築が面倒だという方はぜひお試しください。
変更履歴
2022-05-29
- サンプルコード用のリポジトリの変更に伴いリンクと説明を修正
- デモサイトのURLの変更に伴いリンクを修正
Discussion