16 人が閲覧(直近 30 日) Rust

Bevy ECSとは?Rust製ゲームエンジンの仕組みと書き方を0.20で実行検証

Bevy ECSとは?Rust製ゲームエンジンの仕組みと書き方を0.20で実行検証

Bevyは、Rustで書かれたオープンソースのゲームエンジンです。ゲームの状態をすべてECS(Entity Component System)で扱うのが最大の特徴で、キャラクターも画面遷移の状態も、0.19からはリソースまで「エンティティに付いたコンポーネント」として管理します。

この記事では、2026年10月時点の最新安定版Bevy 0.20.0で、ECSの基本・Queryの書き方・Commandsの反映タイミング・Stateによる画面遷移・Pluginでの分割をコードで示します。掲載したコード(抜粋を含む)はすべてRust 1.99.1で実行し、出力をそのまま載せています。0.19以前の解説記事のコードが0.20でどこから動かなくなるかも、実際のコンパイルエラーで示します。

まとめ:Bevy ECSを書き始める前に押さえる要点

  • 最新安定版は0.20.0(2026年10月8日)で、Rust 1.97.1以上が必要です。1.0前で、マイナー版の更新には破壊的変更が含まれます。0.14から0.20までの公開間隔は約3か月半〜5か月半ですが、今後の公開間隔が保証されているわけではありません。
  • データはコンポーネント、処理はシステム(普通のRust関数)に分けます。システムは引数の型(Query・Res・Commands)で何を読み書きするかを宣言します。
  • 0.20ではオブザーバーの引数がOn<Add, Player>からOn<Add<Player>>に変わり、旧い書き方はコンパイルエラーになります。NextState::set_if_neqもset_if_differentに改称されました。
  • Commandsによるエンティティ生成は、その場では反映されません。.chain()や0.20の.chain_weak()で順序を付けると間に同期点が入り、後続のシステムから見えるようになります。
  • 格納方式は既定のTableと、付け外しが速いSparseSetの2種類です。0.20.0の実測では付け外しがSparseSetで約3倍速く、走査はTableが1〜2割速い結果でした。
  • 公式エディタと組み込みの物理エンジンはまだありません。コードだけで組むことを受け入れられるかが、採用の分かれ目です。

Bevyの位置づけ:名前の意味・最新版・注目される理由

英単語の「bevy」は「似たもの同士の大きな集まり」「鳥の群れ」を指し、発音記号は/ˈbev.i/です(Cambridge Dictionaryの語釈)。

Bevy 0.20.0の公開日・要求Rustと破壊的変更

Bevyは2020年8月に0.1が公開されて以来、0.x系のまま開発が続いています。0.14以降のマイナー版の間隔は3か月半〜5か月半で、そのたびに移行ガイドが公開されます。公式のセットアップガイドはRustの最低対応版(MSRV)を「最新の安定版」と定めており、crates.ioのbevy 0.20.0はRust 1.97.1を要求します。

版 公開日 要求Rust ECSまわりの主な変更
0.14 2024-07-04 1.79.0 フック・Observers
0.15 2024-11-29 1.82.0 Required Components
0.16 2025-04-24 1.85.0 Relationships
0.17 2025-09-30 1.88.0 EventをMessageへ改称
0.18 2026-01-13 1.89.0 同一ステートへの遷移
0.19 2026-06-19 1.95.0 リソースのコンポーネント化
0.20 2026-10-08 1.97.1 Observerの型引数・chain_weak

公開日と要求Rustはcrates.ioの登録値(UTC)です。依存に書く版はbevy = "0.20"とマイナー版まで固定し、上げるときは移行ガイドを読みながら1版ずつ進めるのが安全です。

「Rust Bevyはえぐい」と言われる理由と、その裏返し

Bevyが評価される根拠は、設計・開発規模・ライセンスにあります。まず、ゲームのロジックが普通のRust関数で書け、引数の型から読み書きの競合をエンジンが判定して、ぶつからないシステムを自動で並列実行します。次に、開発の勢いです。Bevy 0.20のリリースノートによれば、0.20は227人のコントリビューターによる817件のプルリクエストで作られました。そして、ライセンスがMIT OR Apache-2.0のデュアルライセンスで、商用ゲームにもロイヤリティなしで使えます。

裏返すと、この勢いは「数か月ごとに書き方が変わる」ことを意味します。検索で見つかる日本語の入門記事は0.6〜0.17を前提に書かれたものが多く、0.20ではそのまま動かないコードが混じります。学ぶ側にとってはこれが最大の障害です。

ECSの基本:Entity・Component・System・Resourceの役割

ECSは、ゲーム内のモノを「IDだけの入れ物」と「データの部品」と「部品を処理する関数」に分ける設計です。Bevyではこれにグローバルな単一データのResourceが加わります。

要素 Bevyでの実体 例
Entity インデックスと世代を持つID 4v0
Component #[derive(Component)]の構造体 位置・速度・目印
System 引数でデータを宣言する関数 移動・重力
Resource 1つだけ存在するデータ 重力値・スコア

オブジェクト指向の継承では「動く敵」「動かない敵」のようにクラス階層で振る舞いを決めますが、ECSではVelocityコンポーネントを付けるか外すかで決めます。振る舞いを後から足し引きできるので、継承の階層が深くなる問題が起きません。Rustの所有権や借用の基本はRust入門の環境構築と所有権の解説にまとめています。

最小コード:コンポーネント定義からシステム実行まで

この例を試すには、cargo new ecs_exampleで作成したプロジェクトへ移動し、cargo add bevy_ecs@=0.20.0で依存を追加します。次のコードをsrc/main.rsへ保存し、cargo runで実行します。

次のコードは、ウィンドウを開かないbevy_ecsクレート単体(0.20.0)で動く最小例です。重力で速度を減らし、位置を動かし、位置のY座標が0になっている各フレームでメッセージを送ります。この例では、着地した瞬間だけに送信を限定していません。

use bevy_ecs::prelude::*;

#[derive(Component, Debug, Default)]
struct Position(f32, f32);

// Velocity を付けると Position も自動で付く(Required Components)
#[derive(Component, Debug)]
#[require(Position)]
struct Velocity(f32, f32);

#[derive(Component)]
struct Player;

#[derive(Resource)]
struct Gravity(f32);

#[derive(Message)]
struct Landed(Entity);

fn apply_gravity(g: Res<Gravity>, mut q: Query<&mut Velocity>) {
    for mut v in &mut q {
        v.1 -= g.0;
    }
}

fn movement(mut q: Query<(Entity, &mut Position, &Velocity)>, mut w: MessageWriter<Landed>) {
    for (e, mut p, v) in &mut q {
        p.0 += v.0;
        p.1 = (p.1 + v.1).max(0.0);
        if p.1 == 0.0 {
            w.write(Landed(e));
        }
    }
}

fn on_landed(mut r: MessageReader<Landed>, q: Query<&Position>) {
    for m in r.read() {
        println!("  landed: {} at {:?}", m.0, q.get(m.0).unwrap());
    }
}

fn main() {
    let mut world = World::new();
    world.insert_resource(Gravity(1.0));
    world.init_resource::<Messages<Landed>>();
    // 0.20 の書き方。0.19 までは On<Add, Player>
    world.add_observer(|add: On<Add<Player>>| println!("observer: Player -> {}", add.entity));

    let a = world.spawn((Velocity(1.0, 0.0), Player)).id();
    println!("a has Position: {}", world.get::<Position>(a).is_some());
    world.spawn((Position(0.0, 2.0), Velocity(0.0, 0.0)));

    let mut schedule = Schedule::default();
    schedule.add_systems((apply_gravity, movement, on_landed).chain());
    for frame in 1..=2 {
        println!("frame {frame}");
        schedule.run(&mut world);
        world.resource_mut::<Messages<Landed>>().update();
    }
}

実行結果は次のとおりです。

observer: Player -> 4v0
a has Position: true
frame 1
  landed: 4v0 at Position(1.0, 0.0)
frame 2
  landed: 4v0 at Position(2.0, 0.0)
  landed: 5v0 at Position(0.0, 0.0)

読み取れることは3点あります。Velocityだけを渡したエンティティにもPositionが付いている(Required Components)こと、Playerが付いた瞬間にオブザーバーが呼ばれていること、そしてエンティティ番号が0から始まっていないことです。3点目は、0.19からリソースもエンティティ上のコンポーネントとして保存されるためです。オブザーバーも1つのエンティティとして登録されるので、この例ではリソース2つとオブザーバー1つの後の4v0から始まっています。

AppとPluginによるシステム登録と機能分割

ゲーム全体はAppに組み立てます。add_systemsの第1引数がスケジュールで、起動時に1回だけ動くStartupと毎フレーム動くUpdateをよく使います。機能が増えたら、関連するシステムをPluginにまとめてadd_pluginsで差し込みます。次の例はウィンドウを出さないMinimalPluginsで実行しました。

use bevy::prelude::*;

#[derive(Component)]
struct Enemy;

pub struct EnemyPlugin;

impl Plugin for EnemyPlugin {
    fn build(&self, app: &mut App) {
        app.add_systems(Startup, spawn_enemies)
            .add_systems(Update, (count_enemies, quit).chain());
    }
}

fn spawn_enemies(mut c: Commands) {
    c.spawn_batch((0..3).map(|_| Enemy));
}

fn count_enemies(q: Query<(), With<Enemy>>) {
    println!("enemies: {}", q.iter().count());
}

fn quit(mut exit: MessageWriter<AppExit>) {
    exit.write(AppExit::Success);
}

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, EnemyPlugin))
        .run();
}

出力はenemies: 3の1行です。Startupで積んだ生成コマンドはUpdateより前に適用されるので、最初のフレームから3体見えています。ウィンドウ付きのゲームにするときはMinimalPluginsをDefaultPluginsに替えます。

Queryの書き方:フィルタと変更検知の落とし穴

システムが扱うエンティティはQuery<取得するデータ, フィルタ>で指定します。&Tは読み取り、&mut Tは書き込み、EntityはIDの取得です。フィルタにはWith<T>・Without<T>(データは取らずに有無で絞る)、Added<T>・Changed<T>(前回の実行以降に付いた・変わったものだけ)、複数条件のOr<(...)>があります。1体しかいない前提のプレイヤーにはSingle<&Transform, With<Player>>が使え、該当が0体か2体以上ならシステム自体がスキップされます。

同じ型を&Tと&mut Tで2つのQueryから取ると、システムの初期化時にパニックします。Withoutで対象が重ならないことを型で示すか、ParamSetでまとめるのが定石です。

Changed<T>の検知条件とset_if_neqによる更新抑制

Changed<T>で「位置が変わった敵だけ当たり判定する」と書いたのに、毎フレーム全件が引っかかる。原因は変更検知の仕組みにあります。100体のPositionを地面より下に行かないよう補正するシステムの後ろで、Changed<Position>に一致した件数を数えました。どの個体も地面より上にいるので、値は一度も変わりません。

// set_if_neq には PartialEq が必要
#[derive(Component, PartialEq)]
struct Position(f32, f32);

// A: 可変参照で書き戻す
fn clamp_naive(mut q: Query<&mut Position>) {
    for mut p in &mut q {
        p.1 = p.1.max(0.0);
    }
}

// B: 値が違うときだけ書き込む
fn clamp_neq(mut q: Query<&mut Position>) {
    for mut p in &mut q {
        let y = p.1.max(0.0);
        p.set_if_neq(Position(p.0, y));
    }
}
書き方 1回目 2回目
A:p.1 = ... 100件 100件
B:set_if_neq 100件 0件

Aは値が同じでも、可変参照を通して書き込んだ時点で「変更あり」と記録されます。Bのset_if_neqはPartialEqで比較し、違うときだけ書き込みます。どちらも1回目が100件なのは、追加されたばかりのコンポーネントをChangedが変更として数えるためです。初回の全件一致を前提に書くか、Added<T>と分けて扱う必要があります。

0.20では、コンポーネントに#[component(summary_tick)]を付けると列全体の変更ティックも記録され、変更の少ない列をChangedの検査で丸ごと飛ばせるようになりました。書き込みのたびに記録が2回になるため、「頻繁に問い合わせるが、めったに変わらない」コンポーネントに限って付けます。

iter_manyの0.20での変更とResultによるエラー処理

手元に持っているエンティティIDの一覧からまとめて引くiter_manyは、0.20で要素がResultになりました。0.19までは条件に合わないIDが黙って読み飛ばされていましたが、今は理由付きのエラーとして返ります。

let a = w.spawn(Hp(10)).id();
let b = w.spawn_empty().id();     // Hp を持たない
let c = w.spawn(Hp(30)).id();
w.despawn(c);                     // 削除済み
let mut q = w.query::<&Hp>();
for r in q.iter_many(&w, [a, b, c]) {
    println!("{r:?}");
}
Ok(Hp(10))
Err(QueryDoesNotMatch(2v0, ArchetypeId(0)))
Err(NotSpawned(Invalid(InvalidEntityError { entity: 3v0, current_generation: EntityGeneration(1) })))

旧来の「合うものだけ処理する」動きにするなら.flatten()を挟みます。削除済みのIDを握り続けているバグを見つけたいなら、Errをログに出すほうが原因に早くたどり着けます。

Commandsが反映されるタイミングと同期点

Commandsで行うエンティティの生成・削除やコンポーネントの付け外しは、呼んだ瞬間には反映されず、キューに積まれます。並列実行中のシステムが同じテーブルを書き換えないようにするための仕組みです。では、生成したシステムの直後に並べたシステムからは見えるのか。.chain()・0.20で追加された.chain_weak()・.chain_ignore_deferred()で比べました。

#[derive(Component)] struct P;
fn spawner(mut c: Commands) { c.spawn(P); }
fn reader(q: Query<&P>) { println!("  reader sees {}", q.iter().count()); }

// mode ごとに World と Schedule を作り直して1回だけ実行
s.add_systems((spawner, reader).chain());
s.add_systems((spawner, reader).chain_weak());
s.add_systems((spawner, reader).chain_ignore_deferred());
chain:
  reader sees 1
  after schedule: 1
chain_weak:
  reader sees 1
  after schedule: 1
chain_ignore_deferred:
  reader sees 0
  after schedule: 1

.chain()や.before()・.after()で順序を付けると、BevyはCommandsを使うシステムの後ろに同期点(ApplyDeferred)を自動で挟み、キューを適用してから次を動かします。chain_weakはデータの読み書きが実際にぶつかるシステムの間にだけ順序を残す緩い版ですが、Commandsを出すシステムは常に「ぶつかる」扱いなので、同期点も残ります。同期点では並列実行が一度止まるので、性能が気になる箇所ではchain_ignore_deferredで外し、反映はスケジュールの最後に任せる選択肢があります。

注意点として、chain_weakはスケジューラから見えない依存(Resの内部可変性・チャネル・グローバル変数)を守りません。0.20では描画やUIの組み込みシステムセットがこの弱い順序に切り替わったため、そうしたセットに自作システムを差し込み、見えない依存で前のセットの完了を待っていたコードは、明示的に.after(...)を付ける必要があります(0.19から0.20への移行ガイド)。

despawn_allによる一括削除と処理時間の比較

0.20には、フィルタに合うエンティティを1つのコマンドでまとめて消すcommands.despawn_all::<With<Enemy>>()が入りました。条件付きのdespawn_all_whereもあります。手元で、1体ずつdespawnする書き方と比べました(releaseビルド・各3回)。

削除する数 1体ずつdespawn despawn_all
1万体 0.76〜1.04ms 0.51〜0.56ms
10万体 9.9〜10.3ms 5.4〜7.3ms

10万体で約1.4〜1.9倍の差で、リリースノートの計測(1.53〜1.70倍)と同じ程度です。ステージ切り替えで敵や弾をまとめて片付ける場面で効きます。後述のDespawnOnExitは内部でこの一括削除を使うようになったので、コードを変えなくても恩恵を受けます。

ストレージ:TableとSparseSetの使い分け(実測)

Bevyは同じコンポーネントの組み合わせを持つエンティティを1つのアーキタイプにまとめ、既定ではコンポーネントごとの連続した配列(Table)に並べて格納します。走査は速い一方、コンポーネントを付け外しするとエンティティを別のテーブルへ丸ごと移す必要があります。これを避けるのが#[component(storage = "SparseSet")]です。

#[derive(Component)]
#[component(storage = "SparseSet")]
struct Stunned;

位置と256バイトのデータを持つエンティティに目印を1つ付けたとき、アーキタイプとテーブルの数は次のように変わりました。SparseSetの目印はアーキタイプを増やしますが、テーブルは増やしません。

状態 アーキタイプ テーブル
付ける前 3 3
SparseSetの目印を追加 4 3
さらにTableの目印を追加 5 4

速度差も測りました。条件はbevy_ecs 0.20.0・releaseビルド・Rust 1.99.1・Intel Core i9-9880H(メモリ16GB)のMacです。付け外しは10万体への追加・削除を5周する計測(50万組、追加と削除を別々に数えると計100万操作)を3回、走査は100万体のうち半数に付けた50万件を20回読む計測を6回行い、表には最小〜最大を載せています。

操作 Table SparseSet
追加と削除50万回 164〜188ms 55〜60ms
値を読む走査(50万件×20回) 25〜26ms 30〜32ms
目印で絞るだけの走査(同) 25ms 27〜28ms

付け外しはSparseSetが約3倍速く、値を読む走査は6回ともTableが約2割速い結果でした。データを持たない目印をWithの絞り込みにだけ使う場合も、Tableが1割ほど速くなっています。この計測条件では、付け外しのコストを下げたい「気絶中」「選択中」のような目印はSparseSetを候補にし、位置や体力のように毎フレーム値を読むデータはTableのままにします。0.19で追加されたcontiguous_iter_mutはTableの連続配列をスライスで渡してSIMDの自動ベクトル化を効かせる機能で、取得データがContiguousQueryDataを満たし、クエリ全体がdenseであることが必要です。取得データだけでなくWith・Withoutの対象にもSparseSetを含めず、フィルタにChanged・Addedを含まないことが条件です。

古いBevyのコードが0.20で動かない理由と直し方

Bevyの解説記事を検索すると、EventWriter・SpriteBundle・On<Add, T>・Run Criteriaといった書き方が出てきます。どれも現行の0.20では書き方が変わっています。版ごとに見ていきます。

0.20で変わったECSの書き方

最も多くの既存コードに当たるのはオブザーバーの型引数です。0.19までのOn<Add, Player>は、0.20.0では次のエラーで止まります。

error[E0107]: struct takes 1 generic argument but 2 generic arguments were supplied

監視するコンポーネントはイベント側の型引数に移り、On<Add<Player>>と書きます。Insert・Discard・Remove・Despawnも同じです。ほかの変更は次のとおりです。

  • 排他システムの&mut Worldは、引数の先頭でなくても置けるようになりました(fn f(mut n: Local<u32>, world: &mut World)が通ります)。
  • debug featureを有効にすると、ScheduleBuildSettingsのshuffle_seedで順序の決まっていないシステムの実行順を乱数で入れ替えられます。たまたま正しい順で動いているだけのバグを炙り出す用途です。
  • スプライトの描画はMesh2dとSpriteMaterialの仕組みに移りました。同じZ座標で重なるスプライトの前後が変わることがあるので、重なりうるものにはZを明示します。
  • ui::widgets::ButtonとInteractionは非推奨になり、ui_widgets::ButtonとHovered・Pressedコンポーネントへ移りました。

NextState::set_if_neqはset_if_differentに改称されました。変更検知のset_if_neqと名前がぶつかるためです。移行ガイドは「非推奨の互換メソッドを残した」と書いていますが、公開されたbevy_state 0.20.0には旧名が1つも残っていません。ResMut<NextState<S>>越しに旧名を呼ぶと変更検知側のメソッドに解決され、次のような原因の見えにくいエラーになります(0.20.0で確認)。

error[E0277]: can't compare `bevy::bevy_state::state::NextState<S>` with `bevy::bevy_state::state::NextState<S>`

システムのパニックでアプリを落とさない設定(0.20)

0.20から、システム・コマンド・オブザーバーの中で起きたパニックも、Resultのエラーと同じくエラーハンドラに渡されるようになりました。既定のハンドラは再びパニックさせるので動きは従来どおりですが、ハンドラを差し替えれば処理を続けられます。2フレーム目だけパニックするシステムで確かめました。

use bevy_ecs::error::{FallbackErrorHandler, warn};

fn buggy(f: Res<Frame>) { if f.0 == 2 { panic!("frame 2 で落ちた"); } }

world.insert_resource(FallbackErrorHandler(warn));
s.add_systems((tick, buggy, report).chain());   // 3回実行
ハンドラ 出力された行
既定のまま frame 1 done の後に終了
warnに差し替え frame 1・2・3 done まで継続

Appでは.set_error_handler(bevy::ecs::error::warn)で同じ設定になります。開発中は既定のハンドラで原因を調べます。配布版でwarnに切り替えるのは、失敗後も状態の整合性を保てる処理に限り、継続できない失敗には終了や復旧の方針を定めます。エラーには.context("...")で説明を重ねられるようにもなりました。

EventとMessageの区別(0.17の改称)

0.17で、システム間でバッファを介して受け渡す従来の「イベント」はMessageに分離されました。EventWriter・EventReader・Events<E>は、それぞれMessageWriter・MessageReader・Messages<M>になり、送信メソッドはwriteです。現在のEventは、commands.trigger(...)で発火させてオブザーバー(On<E>を引数に取る関数)が受け取る仕組みを指します。ただし、commands.trigger(...)はコマンドを予約するため、オブザーバーが実行されるのはコマンド適用時です。毎フレームまとめて処理するならMessage、発生したイベントごとに個別のオブザーバーで反応させるならEventを選びます。

BundleからRequired Componentsへ(0.15)

0.15でSpriteBundle・NodeBundle・PbrBundleなどの組み込みBundleは非推奨になり、#[require(...)]で必要なコンポーネントを宣言する方式に置き換わりました。0.20.0のソースでもSpriteはTransform・Visibility・VisibilityClass・Anchorを要求しており、Spriteを1つspawnすればこれらが自動で付きます。さらに古い0.9以前の記事にあるStageとRun Criteriaは、0.10のスケジューラ刷新でシステムセットとRun Conditions(.run_if(...))に置き換わりました。0.19ではリソースのエンティティ化に加え、ライフサイクルイベントのReplaceがDiscardに改称されています。

移行するときは、cargo buildのエラーを移行ガイドの見出しで検索するのが最短です。改称だけの項目は一括置換で片付くので先に済ませ、同一ステート遷移のように挙動が変わった項目を後から確認します。

Stateで画面遷移を組む:タイトル・プレイ・ゲームオーバー

タイトル画面からゲーム本編へ、といった遷移はStatesで表します。次のコードは、ウィンドウを出さない最小構成(default-features = falseでbevy_state・std・multi_threadedだけを有効化)のbevy 0.20.0で実行しました。

use bevy::prelude::*;
use bevy::state::app::StatesPlugin;

#[derive(States, Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
enum GameState { #[default] Title, Playing, GameOver }

#[derive(Resource, Default)]
struct Frames(u32);

#[derive(Component)]
struct Enemy;

fn main() {
    App::new()
        .add_plugins((MinimalPlugins, StatesPlugin))
        .init_state::<GameState>()
        .init_resource::<Frames>()
        .add_systems(OnEnter(GameState::Title), || println!("enter Title"))
        .add_systems(OnEnter(GameState::Playing), spawn_level)
        .add_systems(OnExit(GameState::Playing), || println!("exit Playing"))
        .add_systems(OnEnter(GameState::GameOver), |mut exit: MessageWriter<AppExit>| {
            println!("enter GameOver");
            exit.write(AppExit::Success);
        })
        .add_systems(Update, (tick, play.run_if(in_state(GameState::Playing))).chain())
        .run();
}

fn spawn_level(mut c: Commands) {
    println!("enter Playing: spawn 3 enemies");
    for _ in 0..3 {
        c.spawn((Enemy, DespawnOnExit(GameState::Playing)));
    }
}

fn tick(mut f: ResMut<Frames>, s: Res<State<GameState>>, mut next: ResMut<NextState<GameState>>) {
    f.0 += 1;
    println!("frame {} state {:?}", f.0, s.get());
    if f.0 == 2 && *s.get() == GameState::Title {
        next.set(GameState::Playing);
    }
}

fn play(q: Query<&Enemy>, f: Res<Frames>, mut next: ResMut<NextState<GameState>>) {
    println!("  play: enemies={}", q.iter().count());
    if f.0 >= 4 {
        next.set(GameState::GameOver);
    }
}
enter Title
frame 1 state Title
frame 2 state Title
enter Playing: spawn 3 enemies
frame 3 state Playing
  play: enemies=3
frame 4 state Playing
  play: enemies=3
exit Playing
enter GameOver
frame 5 state GameOver

2フレーム目にNextStateへ予約した遷移は、そのフレームではなく次のフレームの冒頭で実行されています。遷移はPreUpdateの直後に走る専用のスケジュール(StateTransition)で処理されるため、同じフレームのうちにStateを読み直しても値は変わりません。DespawnOnExitを付けたエンティティはPlayingを抜けるときに自動で消えるので、ステージ片付けのシステムを書く必要はありません。

NextState::setで現在と同じステートを指定すると、0.18からはOnExit・OnEnterが走り、0.19からはDespawnOnExit・DespawnOnEnterも発火します。0.20.0で、Playingのまま1フレーム目にset(Playing)、2フレーム目にset_if_different(Playing)を呼ぶと、出力は次のとおりでした。

frame 1 entities=1
 set(Playing)
  OnExit Playing
  OnEnter Playing
frame 2 entities=1
 set_if_different(Playing)
frame 3 entities=1

リトライで同じステージをやり直したいならset、意図しない再入を避けたいならset_if_differentを使います。ポーズ中だけ存在する子ステートにはSubStates、他のステートから導出するだけのものにはComputedStatesがあります。

Bevyの始め方:Cargo.tomlとビルド時間の短縮

Rustを入れたらcargo new my_gameでプロジェクトを作り、cargo add bevyで依存を追加します。Cargoの基本操作はCargoのインストールと主要コマンド一覧で確認できます。

featureの選び方と3D・UIの有効化

0.20.0の既定featureは2d・3d・ui・audioの4つの束です。3Dゲームを作るなら既定のままで足ります。2Dだけのゲームならdefault-features = falseにして2dとaudioだけを選ぶと、依存が減ってビルドが軽くなります。2d・3dにはuiが含まれないため、メニューやスコア表示にbevy_uiを使うならuiも加えます。描画はRust製のグラフィックスAPIであるwgpu(bevy_render 0.20.0が依存するのは30系)の上に作られており、仕組みはwgpuで三角形描画とGPU計算を動かす手順で解説しています。

Linuxでは、ウィンドウ・音声・入力のためにOSのライブラリが別途必要です。Ubuntuなら公式のLinux依存一覧どおりsudo apt-get install g++ pkg-config libx11-dev libasound2-dev libudev-dev libxkbcommon-x11-0を入れます。既定featureにwaylandが含まれるため、libwayland-dev libxkbcommon-devも入れます(wayland featureを外した場合は不要)。

初回ビルドの長さとコンパイル短縮の設定

Bevyは依存クレートが多く、初回ビルドに時間がかかります。手元のIntel版Mac(Core i9-9880H・メモリ16GB・Rust 1.99.1)で新規プロジェクトをcargo build(devプロファイル)した初回の所要時間は、cargoの表示でbevy_ecs単体57秒、上のState例の最小構成1分16秒でした。依存クレートの一部はダウンロード済みの状態で、所要時間は有効にするfeatureやキャッシュの状態で変わります。既定featureをすべて入れた構成はさらに長くなります。公式のセットアップガイドが勧める設定は次の3つです。

[profile.dev]
opt-level = 1

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

自分のコードは軽く最適化し、変更しない依存クレートだけ強く最適化する設定です。デバッグビルドのままだとゲームの動作が極端に遅くなるのを防げます。2つ目はcargo run --features bevy/dynamic_linkingでBevyを動的ライブラリとして扱い、変更のたびの静的リンクを省く方法です。配布用のビルドでは無効にします。3つ目はリンカーをlldやmoldに替える方法です。ただしmacOSについては、同じガイドが「既定のリンカーld-primeのほうがLLDより速い」としているので、替える必要はありません。

Bevyを選ぶべきでない場面と他エンジンとの分かれ目

Bevyが向かないのは、次のどちらかに当てはまるプロジェクトです。

  • デザイナーやプランナーがGUIのエディタでシーンを組む前提のチーム。0.20のリリースノートも、シーン形式BSNやリモートインスペクターを「エディタの部品」として順に出している段階で、エディタ本体は入っていません。
  • 長期の商用案件で、必要な不具合修正や対象OSへの対応に伴うエンジン更新・移行検証の工数を確保できないプロジェクト。0.xのまま数か月ごとに破壊的変更が入り、0.20のObserverのように多くのコードに当たる変更もあります。

逆に、Rustで書きたい、ロジックをテストしやすい形で持ちたい、エンジン内部まで読んで直したい、という条件ならBevyは有力です。GUIエディタ込みの無料エンジンが欲しいならGodotのインストール手順と動作要件、アセットと求人の多さを重視するならUnity 6.3 LTSの新機能と変更点も比較してください。

よくある質問

Bevyは商用利用できますか?

できます。BevyはMIT OR Apache-2.0のデュアルライセンスで、利用者はどちらかを選べます。ロイヤリティや売上に応じた支払いはありません。配布時は、選んだライセンスの条件に従います。MITでは著作権表示と許諾文を含め、Apache-2.0ではライセンスの写しの提供に加え、変更したファイルの変更表示や、該当するNOTICEの保持などの条件を確認します。依存クレートや同梱素材のライセンスも別途確認します。

古いBevyチュートリアルのコードが動かないのはなぜですか?

多くの記事が0.19以前のAPIで書かれているためです。0.20ではOn<Add, T>がOn<Add<T>>に、NextState::set_if_neqがNextState::set_if_differentになり、0.17ではEventWriterがMessageWriterに、0.15ではSpriteBundleなどの組み込みBundleが非推奨になりました。記事の冒頭かCargo.tomlで対象の版を確かめ、公式の移行ガイドで差分を追ってください。

BevyでUIやGUIは作れますか?

作れます。bevy_uiのNodeコンポーネントにFlexboxかCSS Gridのレイアウトを指定して画面を組み、レイアウト計算にはtaffyクレート(0.20.0では0.14系)が使われています。0.20ではエディタ向けのウィジェット集Feathersに色入力・スクロールリスト・ドロップダウンが加わり、旧Buttonはui_widgets::Buttonへ移りました。

bevy_ecsだけを他のRustプロジェクトで使えますか?

使えます。bevy_ecsは単独のクレートとして公開されており、この記事の最小コードもウィンドウを持たないbevy_ecs単体で実行しました。シミュレーションやサーバー側の状態管理にECSだけを持ち込む使い方ができます。

Bevyに物理エンジンは入っていますか?

本体には含まれていません。0.20.0のfeature一覧177項目にも物理演算の項目はありません。公式のアセット一覧のPhysicsカテゴリに、ECSで動く2D/3D物理エンジンのAvianと、Rapierを組み込むbevy_rapierが第三者製として掲載されています。

関連記事

お気に入りに入れた記事の一覧

一創の公式 SNS

X では毎朝の新着記事と IT ニュースの速報を、YouTube・TikTok・Instagram・LinkedIn・Facebook では記事の解説動画を配信しています。

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.11.03 テックブログ ネットマイル不正アクセスで最大549万人:対象確認と退会会員データを消す実装
  2. 2026.11.06 テックブログ ドコモ不正アクセス:最大29.5万件の流出項目と見舞金、My docomo停止の経過と偽連絡の見分け方
  3. 2026.11.06 テックブログ あいおいニッセイ同和の不正アクセス:44.4万件の漏えい項目と窓口、止まった手続きの代わり
  4. 2026.07.09 テックブログ Muse Imageとは?Metaの画像生成AIの使い方・API料金(1枚0.01ドル)と日本での提供状況【2026年10月】
  5. 2026.11.07 コラム サイバーセキュリティ緊急対策パッケージとは?4本柱の施策と期限、企業と委託先が今やること

RELATED POSTS 関連記事

目次