LabHub

ブログ

Bevyゲームエンジン — RustとECSで作る現代ゲーム開発ハンズオン(深掘り、2026)

한국어English日本語

プロローグ — なぜRustとECSがゲーム開発に合うのか

ゲームエンジンの歴史は2行に圧縮できる。

  1. 継承の時代(1995〜2015) — UnityのMonoBehaviour、UnrealのUObject、GodotのNode。すべてのゲームオブジェクトはクラスを継承し、「GameObject is a Component」式の深い階層を作った。C++やC#といったOOP言語が自然だった。
  2. データ指向の時代(2015〜) — Naughty Dog、Insomniac、EpicのMass(Unreal 5)が「キャッシュミスはフレーム落ちと同義」と認めた。Data-Oriented Design(DOD)が台頭し、Entity-Component-System(ECS)が模範解答として現れた。

ECSは一段落でこうだ。

継承ツリーは無い。Player extends Character extends Pawnのようなものは消える。「プレイヤー」は単にPlayerPositionVelocityHealthというcomponentを一緒に持つentityでしかない。

そこにRustを乗せると、ECSの弱点だった2つが即座に解決する。1つめは並行性。借用検査器(borrow checker)が「2つのsystemが同じcomponentを同時にmutateしない」をコンパイル時に保証する。Bevyのスケジューラはそれを利用してsystemを自動で並列化する。2つめは安定性。ゲームコードはポインタ・null・範囲外アクセスのバグでいつも傷ついてきた。Rustはそのカテゴリ自体を消す。

問題は「現実」だ。2026年5月、Unityはモバイル・インディ・VRの首位、UnrealはAAA・シネマティックの首位、Godotはインディ2D陣営で大きなシェア、Bevyはオープンソース陣営の星だが商用タイトルは数えられる程度。だからこの記事は「Year of Bevy」を叫ばない。代わりに正直に答える — Bevyは何が得意で、どこまで来ていて、どこで詰まっているか。

この記事の構成。

  1. ECSパラダイムを理解する — MonoBehaviourと何が違うか
  2. Bevyの構造 — App、Schedule、Plugin
  3. プラグインアーキテクチャ — すべてはプラグインだ
  4. wgpuでレンダリング — ブラウザ・デスクトップ・モバイルを一度に
  5. 最初のBevyゲーム30分 — 矢印キーで動く円
  6. Godot・Unity・Unreal・Macroquadとの比較
  7. 実際に出荷されたBevyゲーム — Foresight、Tunnet、そしてTiny Gladeの真相
  8. Bevyがまだ準備できていないところ

覚えておく一文: 「エンジンは道具であって信仰ではない。だがECSは道具の前にパラダイムであり、BevyはそれをRust上で最も正直に実装した答えだ」


1章 · ECSパラダイム — UnityやUnrealと何が違うか

1.1 継承モデルの動き方

Unityで敵キャラを作ると、たいていこうなる。

// Unity / MonoBehaviour 風
public class Enemy : MonoBehaviour {
    public int health = 100;
    public float speed = 2.0f;
    private Transform target;

    void Start() { target = GameObject.FindWithTag("Player").transform; }
    void Update() {
        transform.position = Vector3.MoveTowards(
            transform.position, target.position, speed * Time.deltaTime);
        if (health <= 0) Destroy(gameObject);
    }
}

EnemyMonoBehaviourを継承し、状態(体力・速度)と振る舞い(Update)を同じクラスに持つ。100体いればUpdateが100回呼ばれる。仮想関数呼び出し+キャッシュミス+分岐予測ミスが積み上がる。

UnrealのAActor+UCharacterMovementComponentの組み合わせも本質は同じ。AEnemy : public ACharacter : public APawn : public AActorという深い継承チェーン、各コンポーネントは親actorへのポインタを保持する。

1.2 ECSモデルの動き方

同じ敵をBevyで書くと、

// Bevy / ECS 風
use bevy::prelude::*;

#[derive(Component)]
struct Enemy;

#[derive(Component)]
struct Health(i32);

#[derive(Component)]
struct Speed(f32);

#[derive(Component)]
struct Target(Entity);

fn move_enemies(
    time: Res<Time>,
    mut enemies: Query<(&mut Transform, &Speed, &Target), With<Enemy>>,
    targets: Query<&Transform, Without<Enemy>>,
) {
    for (mut tf, speed, target) in &mut enemies {
        if let Ok(target_tf) = targets.get(target.0) {
            let dir = (target_tf.translation - tf.translation).normalize_or_zero();
            tf.translation += dir * speed.0 * time.delta_secs();
        }
    }
}

fn kill_dead_enemies(mut commands: Commands, q: Query<(Entity, &Health), With<Enemy>>) {
    for (e, hp) in &q {
        if hp.0 <= 0 { commands.entity(e).despawn(); }
    }
}

違いは明確だ。

1.3 Archetypeとメモリ配置

Bevy(そしてFlecs、Unity DOTS、Unreal Mass)はarchetypeベースのECSだ。「どのcomponent集合を持つentityか」でメモリチャンクを作る。

archetype A: [Position, Velocity, Sprite] → 1000体のentityでPositionが連続、Velocityが連続、Spriteが連続。 archetype B: [Position, Velocity, Sprite, Health, Enemy] → 別のチャンク。

PositionVelocityへのクエリはAとB両方のチャンクをたどりながら、各チャンクの中では単純な配列走査で済む。仮想呼び出し無し、キャッシュミスもほとんど無し。これがECSが速い理由。

1.4 ECSの欠点も正直に

ECSは万能薬ではない。

覚えておく一文: 「OOPは動詞を名詞の中に閉じ込める。ECSは名詞(データ)と動詞(system)を最後まで分離する」


2章 · Bevyの構造 — App、Schedule、System

2.1 App

BevyプログラムのエントリポイントはApp。main関数は通常こんな形。

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, spawn_camera)
        .add_systems(Update, (move_player, count_fps).chain())
        .run();
}

コード全体でclass GameManager : MonoBehaviour { void Awake() }のようなマネージャクラスが要らないという点が肝。App自体がマネージャで、ゲームロジックは自由関数だ。

2.2 Schedule — いつ実行されるか

Bevy 0.10で「ステージ」が消えてスケジュールが入った。0.15時点の主要スケジュールは次のとおり。

system間の順序は.before().after().chain()SystemSetで制御する。

app.add_systems(
    Update,
    (
        input_system,
        movement_system.after(input_system),
        collision_system.after(movement_system),
    ),
);

2.3 Component・Resource・Event

ECSワールドにデータを入れる3通り。

#[derive(Resource)]
struct Score(u32);

#[derive(Event)]
struct EnemyKilled { entity: Entity, by: Entity }

fn award_score(mut score: ResMut<Score>, mut events: EventReader<EnemyKilled>) {
    for _ in events.read() {
        score.0 += 100;
    }
}

Resは読み取り専用、ResMutは書き込み可能。2つのsystemが同じResMutを取ると自動で直列化され、両方がResなら並列。

2.4 Query — systemがデータを取りに行く方法

fn move_player(
    time: Res<Time>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    for (mut tf, speed) in &mut q {
        tf.translation.x += speed.0 * time.delta_secs();
    }
}

Queryの型シグネチャがそのままsystemの「データ要求書」になる。With<Player>はフィルタ(componentを引っ張ってこないが、entityが持っていることを要求する)。2つのsystemが衝突しないクエリを持てば、Bevyは自動的に並列実行する。

覚えておく一文: 「Bevyコードの読み方 = systemのシグネチャだけで、何を読み何を書くかすべて見える」


3章 · プラグインアーキテクチャ — すべてはプラグインだ

Bevyで最大の美的決定は「エンジン自体がプラグインの集合体である」ことだ。

DefaultPluginsをほどくと約20個のプラグインが入っている。

要らないものは丸ごと外せる。

// ヘッドレスサーバ — ウィンドウもレンダも無くECSだけ
App::new()
    .add_plugins(MinimalPlugins)
    .add_plugins(LogPlugin::default())
    .add_systems(Update, game_logic)
    .run();

3.1 自分のプラグインを書く

自分のゲームコードもプラグインに束ねるとモジュール化がきれいになる。

use bevy::prelude::*;

pub struct PlayerPlugin;

impl Plugin for PlayerPlugin {
    fn build(&self, app: &mut App) {
        app
            .add_event::<PlayerHurt>()
            .add_systems(Startup, spawn_player)
            .add_systems(Update, (
                player_input,
                player_movement.after(player_input),
                player_animation,
            ));
    }
}

#[derive(Event)]
pub struct PlayerHurt { pub amount: i32 }

#[derive(Component)]
pub struct Player;

fn spawn_player(mut commands: Commands) {
    commands.spawn((
        Player,
        Transform::default(),
        // sprite, collider など
    ));
}

fn player_input(/* ... */) {}
fn player_movement(/* ... */) {}
fn player_animation(/* ... */) {}

mainはこれだけ短くなる。

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_plugins((PlayerPlugin, EnemyPlugin, AudioPlugin, UiPlugin))
        .run();
}

3.2 エコシステムのプラグイン(2026年5月時点)

bevy.orgbevy_assetsのGitHubリポジトリにキュレーション済みリストがある。

覚えておく一文: 「プラグインは単なるモジュールではなくBevyの思想 — エンジンとゲームコードの境界が無い」


4章 · wgpuレンダリング — ブラウザ・デスクトップ・モバイルを一度に

Bevyのレンダラはwgpuの上に乗っている。wgpuはRust陣営のWebGPU実装で、1つのコードでVulkan・Metal・DirectX 12・WebGPU・OpenGL ESをすべてターゲットにする。

4.1 良いところ

4.2 弱いところ

4.3 カスタムシェーダ

WGSLでシェーダを書き、Material traitを実装してBevyのmaterialとして登録する。

use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef},
    sprite::Material2d,
};

#[derive(Asset, TypePath, AsBindGroup, Clone, Debug)]
struct WaveMaterial {
    #[uniform(0)] time: f32,
    #[uniform(0)] color: LinearRgba,
}

impl Material2d for WaveMaterial {
    fn fragment_shader() -> ShaderRef { "shaders/wave.wgsl".into() }
}

そしてシェーダファイル。

struct Uniforms {
    time: f32,
    color: vec4<f32>,
};

@group(2) @binding(0) var<uniform> u: Uniforms;

@fragment
fn fragment(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
    let wave = sin(uv.x * 10.0 + u.time) * 0.5 + 0.5;
    return vec4<f32>(u.color.rgb * wave, 1.0);
}

覚えておく一文: 「wgpuのおかげでBevyは『1コードでどこでも動くレンダラ』をほぼ無償で得た — ただし『どこでも』が『最適』ではない」


5章 · 最初のBevyゲーム30分 — 矢印キーで動く円

ここからコードだ。「画面に円があり、矢印キーで動く」。30分で終わらせる。

5.1 プロジェクト準備

# Rustが入っていなければrustupで
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 新規プロジェクト
cargo new moving_circle && cd moving_circle

Cargo.tomlを開く。

[package]
name = "moving_circle"
version = "0.1.0"
edition = "2021"

[dependencies]
bevy = "0.15"

# 高速コンパイル用のdevプロファイル — 推奨
[profile.dev]
opt-level = 1

[profile.dev.package."*"]
opt-level = 3

5.2 最初のコード — 空ウィンドウ

src/main.rs

use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(mut commands: Commands) {
    commands.spawn(Camera2d);
}
cargo run

黒い空ウィンドウが出る。初回ビルドは2〜3分(依存のコンパイル)。それ以降のインクリメンタルビルドは数秒。

5.3 円を描く

use bevy::prelude::*;

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Speed(f32);

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .run();
}

fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<ColorMaterial>>,
) {
    commands.spawn(Camera2d);

    commands.spawn((
        Player,
        Speed(300.0),
        Mesh2d(meshes.add(Circle::new(40.0))),
        MeshMaterial2d(materials.add(Color::srgb(0.2, 0.7, 1.0))),
        Transform::from_xyz(0.0, 0.0, 0.0),
    ));
}

再度cargo run。青い円が真ん中に表示される。

コードの肝。

5.4 入力で動かす

Updateスケジュールにsystemを追加する。

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .add_systems(Startup, setup)
        .add_systems(Update, move_player)
        .run();
}

fn move_player(
    time: Res<Time>,
    keyboard: Res<ButtonInput<KeyCode>>,
    mut q: Query<(&mut Transform, &Speed), With<Player>>,
) {
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut q {
        let mut dir = Vec2::ZERO;
        if keyboard.pressed(KeyCode::ArrowLeft)  { dir.x -= 1.0; }
        if keyboard.pressed(KeyCode::ArrowRight) { dir.x += 1.0; }
        if keyboard.pressed(KeyCode::ArrowUp)    { dir.y += 1.0; }
        if keyboard.pressed(KeyCode::ArrowDown)  { dir.y -= 1.0; }
        if dir != Vec2::ZERO {
            tf.translation += (dir.normalize() * speed.0 * dt).extend(0.0);
        }
    }
}

cargo run。矢印キーで円が動く。終わり。

肝になるパターン。

5.5 敵を1体追加

#[derive(Component)]
struct Enemy;

fn setup(/* ... */) {
    // 上と同じ

    commands.spawn((
        Enemy,
        Speed(150.0),
        Mesh2d(meshes.add(Circle::new(30.0))),
        MeshMaterial2d(materials.add(Color::srgb(1.0, 0.3, 0.3))),
        Transform::from_xyz(200.0, 200.0, 0.0),
    ));
}

fn chase_player(
    time: Res<Time>,
    player_q: Query<&Transform, With<Player>>,
    mut enemy_q: Query<(&mut Transform, &Speed), (With<Enemy>, Without<Player>)>,
) {
    let Ok(player_tf) = player_q.single() else { return };
    let dt = time.delta_secs();
    for (mut tf, speed) in &mut enemy_q {
        let dir = (player_tf.translation - tf.translation).normalize_or_zero();
        tf.translation += dir * speed.0 * dt;
    }
}

// main に .add_systems(Update, (move_player, chase_player)) を追加

赤い敵がプレイヤーを追ってくる。ECSの本領発揮 — 新しいクラス定義を増やさず、新しいcomponentとsystemを足すだけで新しい振る舞いが生まれる。

覚えておく一文: 「30分以内に『キーで動く円』まで届くことが、エンジンの第一印象テストだ。Bevyはそのテストを通る」


6章 · Godot・Unity・Unreal・Macroquadとの正直な比較

エンジンは宗教ではない。表を見よう。

6.1 一行サマリ

6.2 項目別の比較

言語とパラダイム

エディタ

コンパイル速度

ランタイム性能

モバイル/コンソールビルド

オープンソース方針

6.3 何を選ぶか

覚えておく一文: 「Bevyは『Unityの代替』ではない。別の道具だ — Rust・ECS・オープンソース・モジュール性を同時に欲しいときだけ正解」


7章 · 実際に出荷されたBevyゲーム — 何が本当か

ここは正直であるべき領域だ。ネットに出回る「Bevyで作られたゲーム」リストには嘘が混ざることがある。

7.1 本当にBevyで作られた商用ゲーム

7.2 よく誤解されるケース — Tiny Glade

Pounce LightのTiny Gladeは2024年9月のリリース直後にSteam人気1位まで上がったインディ名作シミュレータだ。このゲームはBevyを使っていない — 自前のRustエンジンを作っており、Rustエコシステムのライブラリ(wgpuなど)は一部活用しているが、「Bevyのゲーム」と括ると間違い。開発元はいくつものインタビューやGDCトークで自前エンジンであることを明言している。

「Tiny GladeはBevyで作られた」と言う記事は無視してよい。Bevyコミュニティはその成功で「Rustゲームの可能性」を示せたことを喜んだが、エンジン自体は別だ。

7.3 非ゲーム用途

BevyはECS + wgpuの組み合わせのおかげで、ゲーム以外でも使われる。

エンジンをライブラリのようにインポートできるのはBevyの大きな特徴で、UnityやUnrealでは事実上不可能に近い。

覚えておく一文: 「Bevyは『Tiny Gladeのようなゲームを作っている』と嘘を言わない。『Foresight・Jarl・Tunnetのようなゲームを作り、データ可視化やシミュレーションにも使われている』と正直に語る」


8章 · Bevyがまだ準備できていないところ — 正直な限界

「Year of Bevy」は2024年にも、2025年にも、2026年にも毎年叫ばれた。その間に本当に成熟した部分とまだ荒い部分を分けよう。

8.1 成熟したもの

8.2 まだ荒いもの

8.3 0.15から1.0への道

Bevy公式ロードマップ(2026年5月更新)が1.0までに残している大きな項目。

  1. BSNの定着と公式エディタの最小版。
  2. 安定したABI(動的プラグインロード)。
  3. ホットリロードのシナリオ確立。
  4. マルチウィンドウ・高DPI・ドラッグ&ドロップなどデスクトップ仕上げ。
  5. ビジュアルスクリプティング(必要ならば外部プラグインとして)。

「1.0はいつか」と聞いてはいけない。答えは常に「準備ができたら」。ただし0.15は小さなインディゲームを始めるのに十分安定している。

覚えておく一文: 「Bevy 1.0を待つな。0.15で作れるゲームを今作れ。1.0マイグレーションはそのとき」


エピローグ — 始める人への案内

30分ハンズオン チェックリスト

Bevyのアンチパターン7つ

  1. system内でVec<Entity>を持ち回り毎フレームlookup — queryを上手に書けば不要。
  2. すべてをcomponentに — ステートマシンや小さなenumはcomponent内に入れる方が良い。
  3. どこからでもCommandsでmut — 可能だが反映は次フレーム。即時反映が必要ならWorldを直接受け取るexclusive systemを使う。
  4. Query::single()をpanicで解くlet Ok(...) = q.single() else { return };が安全。
  5. イベント取りこぼしEventReaderを毎フレーム持っていないとイベントが消える。2つのsystemが同じイベントを読むなら、add_event + 一貫したsystem順序。
  6. render systemをUpdateに置く — render systemは通常Bevy内部の責務。ユーザコードはデータを書くだけでよい。
  7. 0.x マイグレーションを無視 — Bevy 0.x → 0.(x+1)は毎回4〜12時間の作業。1.0前の大型プロジェクトはマイグレーションbufferを日程に入れること。

次回予告

覚えておく一文: 「Rust + ECSはゲームエンジンパラダイムの答えのなかで最も正直な答え。Bevyはその答えを自分の手で直接書ける最速の道だ」


参考 / References

コメント

まだコメントはありません。

ログインするとコメントできます