うどんてっくメモ

技術的なメモをまったりと

【Rust】ゲームエンジンBevyのサンプルゲームを作った話 -ECSの基本実装の説明-

はじめに

先日Rust製のゲームエンジンBevyを使ったサンプルゲームを公開しました。 github.com

こちらの公開したサンプルゲームをベースに、Bevyで実装する際の基礎的な知識となるECSアーキテクチャの実装を説明しようと思います。

Bevy

BevyはRust製のデータ指向なゲームエンジンです。

bevyengine.org

無料のオープンソースで、ソースコードGitHub上に公開されています。

github.com

ECSアーキテクチャを採用しており、開発も結構盛んに行われています。執筆時点ではver0.5が最新の安定バージョンとして公開されています。

2Dおよび3Dにも対応しており、クロスプラットフォーム対応、ホットリロード可能など、発展途上ながらも多くの機能を備えています。筆者はAmethystというゲームエンジンの本も書いたりしたのですが、このBevyも触ってみて期待しているゲームエンジンのひとつです。

サンプルゲームについて

サンプルゲームではBevyの提供するECSアーキテクチャに則って、シンプルな2Dゲームのフローを実装しています。

f:id:myudon:20210918185533g:plain

プレイヤーを操作して落ちてくる岩を回避しながら林檎をとる、ごくごくシンプルなゲームです。
レベルデザインなどをしっかりやっている訳ではなく、ゲームのサンプルを実装したというよりはシステムのサンプルを実装したというのが正しいかもしれません。
Rustの文法としても比較的シンプルに実装しているので、初学者の方でも読み解きやすいかなと思います。(The Rust Programming Languageを一通り読めば多分大丈夫です。)

このゲームフローを構成するEntity、Component、Systemそれぞれの実装について説明します。

BevyにおけるECSの実装

BevyではEntityとComponentについては構造体、Systemについてはメソッドで定義を行います。それぞれの実装について、サンプルゲームの例を元に見ていきます。

EntityとComponent

まずはEntityですが、識別するidを持つシンプルなstructとして定義されています。

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

EntityはWorldというゲーム中のEntityとComponentを統括する機能で管理されます。

次にComponentです。サンプルゲーム内では林檎、岩、プレイヤーがあり、これらの機能はComponentとしてシンプルなstructで実装されています。 例としてプレイヤーの実装を示します。

const PLAYER_LIFE: i32 = 3;

// プレイヤーを表すComponent、基本的にライフの値のModel
pub struct Player {
    pub life: i32,
}

impl Default for Player {
    fn default() -> Self {
        Player::new(PLAYER_LIFE)
    }
}

impl Player {
    pub fn new(life: i32) -> Self {
        Player {
            life
        }
    }
    
    pub fn reset(&mut self) {
        self.life = PLAYER_LIFE;
    }
}

このPlayerをプレイヤーの機能を果たすComponentとして取り扱います。
structをComponentとして処理する工程は至ってシンプルで、Entityの生成時に紐付けるだけです。Unofficial Bevy Cheat Bookのサンプルを下記に示します。

struct SampleComponent;

pub fn setup(mut commands: Commands) {
    commands
        .spawn()
        .insert(SampleComponent);
}

CommandはWorldのEntityとComponentに対してmutableな操作を行う機能です。Entityの生成や削除、Componentの操作などを行う際にはCommandを介して処理します。 実際のPlayerの紐付けの実装を次に示します。

// 必要なデータを用意
let player_texture_handle = asset_server.load("textures/player.png");
let player_position = Vec3::new(0., -200., 0.);
let player_rotation = Quat::from_axis_angle(Vec3::Y, 0.);
let player_scale = Vec3::splat(1.);
commands
// スプライト表示系のComponentと共にEntityを作る
    .spawn_bundle(SpriteBundle {
        material: materials.add(player_texture_handle.into()),
        sprite: Sprite::new(Vec2::new(90., 90.)),
        transform: Transform {
        translation: player_position,
            rotation: player_rotation,
            scale: player_scale,
        },
        ..Default::default()
    })
// ゲーム側でプレイヤーのEntityとして紐付けたいComponent
    .insert_bundle((
        Player::default(),
        Mover::default(),
        GameScene
    ));

CommandからEntityを生成してメソッドチェーンの形でComponentを紐付けています。ちなみにPlayerと一緒に紐づけているMoverはその名の通りサンプルゲーム中のプレイヤーや林檎といったものを動かすためのComponentです。このようにEntityを生成して必要な機能のComponentを紐付ける、というのが基本的なオブジェクトの生成フローとなります。

System

サンプルゲームではsrc/system配下にゲームロジックを形成する以下のsystemの実装があります。

├── collision.rs // プレイヤーと落ちてくるものの衝突判定
├── display.rs // UIなどの表示
├── object_spawn.rs // 落ちてくる林檎と岩の生成
├── player.rs // ユーザー入力の処理
├── time.rs // 時間の処理
└── translate.rs // プレイヤーや落ちてくるものの移動処理

それぞれがComponentへの作用を担っており、対象となるComponentの配列を舐めて処理を行います。ECSアーキテクチャの特徴的な処理ですね。 前述にもあるようにBevyではその作用をシンプルなメソッドとして実装します。例としてtranslate.rsの実装を示します。

// Moverの速度に沿った位置更新
pub fn translate_mover_system(mut query: Query<(&Mover, &mut Transform)>) {
    // 速度に伴ってtransformの位置情報を更新
    for (mover, mut transform) in query.iter_mut(){
        let velocity = &mover.velocity;
        transform.translation.x += velocity.x;
        transform.translation.y += velocity.y;
    }
}

引数としてQueryという型を渡していますが、これがComponentの参照に用いる機能です。ジェネリクスで対象となるComponentの型を宣言し、イテレータを使用して作用を処理します。 また、Entityの生成や破棄を行いたい場合にもCommandを引数にして処理します。collision.rsの実装の一部を抜粋します。

pub fn collision(
    mut commands: Commands,
    mut game: ResMut<Game>,
    mut player_query: Query<(&mut Player, &Transform, &Sprite)>,
    mut apple_query: Query<(Entity, &mut Apple, &Transform, &Sprite)>,
    mut block_query: Query<(Entity, &mut Block, &Transform, &Sprite)>) {
    
    if let Ok((mut player, player_transform, player_sprite)) = player_query.single_mut() {
        for (block_entity, block, block_transform, block_sprite) in block_query.iter_mut() {
            if block_transform.translation.y < OBJECT_DESPAWN_Y {
                commands.entity(block_entity).despawn(); 
                break;
            }
    ...

このように、Systemの実装ではCommandおよびComponentのQueryを引数としたメソッドを実装することになります。 実装したメソッドは専用の機能を用いてゲームフローに組み込みます。Unofficial Bevy Cheat Bookより引用した実装を以下に示します。

fn main() {
    App::build()
        // ...

        // run it only once at launch
        .add_startup_system(init_menu.system())
        .add_startup_system(debug_start.system())

        // run it every frame update
        .add_system(move_player.system())
        .add_system(enemies_ai.system())
        .run();
}

into traitを介してメソッドはSystemに変換されています。Amethystだと専用のtraitを実装したstructとして表現していますが、こちらはよりシンプルな形と言えます。 また、サンプルゲームではSystemSetという機能を用いてタイトル画面やゲーム中といったゲーム中の状態ごとのSystemの分割を行っています。

// ゲーム内の状態
// lib.rs
#[derive(Clone, Eq, PartialEq, Debug, Hash)]
pub enum GameState {
    // クリックしてゲーム開始を待つ画面
    Title,
    // ゲーム中
    Playing,
    // クリックしてゲーム終了を待つ画面
    GameOver,
}

// SystemSetの構築
// system.rs
pub fn title_enter_system_set() -> SystemSet {
    // タイトル画面の文字を出す
    SystemSet::on_enter(GameState::Title)
        .with_system(setup_title_ui.system())
}

pub fn game_enter_system_set() -> SystemSet {
    // ゲーム中に必要な文字の表示とかを出す
    SystemSet::on_enter(GameState::Playing)
        .with_system(setup_game_ui.system())
}

pub fn game_update_system_set() -> SystemSet {
    // ゲームのルールを構成するためのSystem
    SystemSet::on_update(GameState::Playing)
        .with_system(collision.system())
        .with_system(check_spawn_object.system())
        .with_system(player_input_system.system())
        .with_system(translate_mover_system.system())
        .with_system(game_time_display_system.system())
        .with_system(game_score_display_system.system())
        .with_system(player_life_display_system.system())
        .with_system(update_game.system())
}

// SystemSetの組み込み
// main.rs
fn main() {
    App::build()
        // ...
        // Titleの開始、更新、終了でのSystem
        .add_system_set(title_enter_system_set())
        .add_system_set(title_update_system_set())
        .add_system_set(title_exit_system_set())
        // Playingの開始、更新、終了でのSystem
        .add_system_set(game_enter_system_set())
        .add_system_set(game_update_system_set())
        .add_system_set(game_exit_system_set())
        // GameOverの開始、更新、終了でのSystem
        .add_system_set(game_over_enter_system_set())
        .add_system_set(game_over_update_system_set())
        .add_system_set(game_over_exit_system_set())
        .run();
}

一定複雑なSystemの管理を行う場合にはSystemSetを活用するのがおすすめです。 以上がSystemの実装になります。

おわりに

ざっくりとBevyで開発する上で一番大切なECSアーキテクチャを実装する部分について説明しました。ゲーム内のオブジェクトとロジックをBevy上でどうやって組み上げるか、なんとなく伝わっていたら幸いです。
BevyはECSアーキテクチャをかなり直感的に実装できるな、という感覚が筆者的にはあって良かったです。

参考文献

公式ドキュメントのIntroductionです。Bevyを使う際にはまず一読しておくのがおすすめです。

bevyengine.org

Bevyの実装をする上で参考になる非公式のドキュメントです。Bevyの根幹となる機能についてある程度まとめてくれています。文中でも引用させていただきました。

Introduction - Unofficial Bevy Cheat Book

また、こちらのブログでもBevyでHello Worldするまでの手順を取り上げています。参考にさせていただきました。

blog.livedoor.jp