![[Rust] ECSアーキテクチャ [bevy_ecs]](https://devio2024-media.developers.io/image/upload/v1745807923/user-gen-eyecatch/ie7jty9ugjwsgrgtzj2l.png)
[Rust] ECSアーキテクチャ [bevy_ecs]
Introduction
Entity Component System(以下ECS)は主にゲーム開発の分野で使用されている
ソフトウェアアーキテクチャパターンです。
このアーキテクチャは、ゲーム開発における柔軟性と
パフォーマンスの向上を目的として発展してきました。
ECSの歴史はけっこう古く、1990年代後半から使われてきたようです。
(wikipediaによると1998年の「Thief: The Dark Project」というゲームが最古の事例らしい)
その後2010年以降にUnityの影響もありメインストリーム化していったとのこと。
そして現在はゲーム開発の枠を超えて、業務系システムやIoT、
ロボティクスなどの分野でも広く採用されてきているようです。
ECS採用の技術的背景
そもそもECSが広く採用されるようになった背景には、
ゲーム開発の規模と複雑性の増大があります。
従来のシステムだと、ゲームで使用する多種多様なオブジェクトを
効率的に作成、管理することが困難になっていました。
こういった問題を解決するため、データ指向設計の手法と
高い互換性を持つアプローチとしてECSを採用していったと考えられています。
ECS?
ECSアーキテクチャは、ゲーム開発やシミュレーションにおけるデータ管理パターンです。
このアーキテクチャは、データ志向設計とOOPを組み合わせた柔軟なシステムです。
ECSは「継承よりコンポジション(合成)」の原則をゲーム開発のコンテキストに適用したものです。
継承による深く複雑な階層構造の代わりに、
エンティティ(ゲーム内のすべてのオブジェクト)に
コンポーネント(特定の機能や振る舞い)を
動的に追加・削除できる設計を採用しています。
これにより柔軟なオブジェクト定義が可能になり、
保守性と拡張性が向上します。
ECSアーキテクチャの基本概念
OOP が「オブジェクト = データ + 振る舞い」をまとめるのに対し
ECSは、
Entity(エンティティ)、Component(コンポーネント)、System(システム)
から構成されます。
ECSを構成する3つの要素について解説します。
- エンティティ(Entity):
一意のIDを持つ汎用オブジェクトで、通常はシンプルな整数値として実装されます。
ゲームでいえばすべてのオブジェクト(キャラクター、背景などすべて)を表します。
pub struct Entity(pub u32);// ユニークなID
エンティティはオブジェクトを表すIDです。
ポイントは、エンティティそのものにはデータや振る舞いがなく、
単なる識別子として機能するところです。
このシンプルさがECSの柔軟性を実現しているとのこと。
- コンポーネント(Component):
Componentはエンティティに動的にアタッチできる
純粋なデータバンドルです。
振る舞い(関数)は含まれず、
構造体やクラスなどでオブジェクトの特定の側面や性質を表します。
pub struct GameObjectDefinition {
pub name: String, // 表示名
pub hp: u32, // 体力
pub speed: f32, // 移動速度
}
エンティティは複数のComponentを持つことができ、
それによってエンティティの特性が定義されます。
また、データを持たない「マーカーコンポーネント」も存在します。
これは、特定の状態を示すために使用され、
効率的にフィルタリング、クエリ可能です。
- システム(System):
システムは
「特定の種類のコンポーネントを持つすべてのエンティティに対して処理を行う」
コードです。
例えば、描画システムは可視コンポーネントを持つエンティティを描画する、
など。
ロジックはSystemに記述します。
//描画システム
fn display_system(query:
Query<(&GameObject, &GameObjectDefinition)>) {
for (id, def) in query.iter() {
println!("Entity {} => name:{}, hp:{}, speed:{},
id.0, def.name, def.hp, def.speed);
}
}
ECS以外の概念
↑のEntity、Component、Systemの3点セットが「純粋なECS」の最小単位です。
ここで解説するECS以外の概念は必須ではありませんが、
現実のライブラリやエンジンがECSを運用しやすくするために導入した
実装上の機能としてさまざまなケースで登場します。
基本的なECSが保証するのは以下だけです。
・Entity : ID だけを持つ「箱」
・Component : その Entity が持つデータ郡
・System : 特定の Component の集合に処理を施す関数
この3つがあれば「データと振る舞いを分離し、動的に組み合わせる」
という目的は果たせます。
World / Registry
多くの実装では「全エンティティと全コンポーネントを1か所に保持する構造体」を
「World」や「Registry」と呼びます。
※本記事で使用する bevy_ecsではWorld
WorldはIDからコンポーネントを引っ張ってきたりします。。
Resource
特定のEntityに属さないが複数のSystemで共有したいデータを保持するための仕組みです。
時間に関する情報やグローバル設定などがそれにあたります。
Schedule / Stage / Dispatcher
「どのSystemをどの順序で実行させるか」を管理する仕組みです。
並列化などを担当する、必須の要素です。
- BevyではScheduleとかStageとよばれる
Event
一般的にいうイベントとだいたい同義です。
「システム間通信を疎結合で行う」ための仕組みとして使用します。
イベントキューそのものを Resource として World に設定したりします。
Bevy?
BevyはRust製のゲームエンジンで、ECSアーキテクチャを採用しています。
ECS部分は独立したcrateになっており、ゲームエンジン以外の用途でも使用できます。
Bevy ECS Example
ではbevy_ecsを使ったシンプルな例でECSの実装を見てみます。
Bevy ECSを使用したプログラムを通じて、ECS(Entity Component System)アーキテクチャの主要な概念を解説します。
bevy_ecsクレートを追加後、ECS実装してうごかしてみます。
% cargo new bevy_ecs_example
% cd bevy_ecs_example
% cargo add bevy_ecs
例では、銀行口座の残高と取引履歴を管理するシステムをECSで表してみます。
1. Entity(エンティティ)
各銀行口座をエンティティとして表現します。
BevyではWorld::spawn()を使用してエンティティを生成します。
ここではworld.spawn()
を使用して新しいエンティティを作成し、
必要なコンポーネントを付与しています。
// 新しいWorldを作成
let mut world = World::new();
// 口座エンティティ
world.spawn((
Balance { amount: 1000.0 },
TransactionHistory {
history: VecDeque::new(),
max_history: 3,
},
));
エンティティはbevy_ecs::entityにEntity構造体が以下のように定義されてます。
#[repr(C, align(8))]
pub struct Entity { /* private fields */ }
2. Component(コンポーネント)
コンポーネントは、エンティティが持つデータを表します。
例では、銀行口座(Balance)と取引履歴(TransactionHistory)を
コンポーネントとして定義しています。
#[derive(Component)]
struct Balance {
amount: f64,
}
#[derive(Component)]
struct TransactionHistory {
history: VecDeque<String>,
max_history: usize,
}
各コンポーネントは#[derive(Component)]
属性を使用して、
ECSシステムで使用可能なコンポーネントとして宣言されています。
3. System(システム)
システムは、コンポーネントを持つエンティティに対して処理を行う関数です。
口座の取引を処理するシステムを定義します。
ここでは、Queryを使用して必要なコンポーネントを持つエンティティにアクセスしています。
複数のコンポーネントを同時に操作可能で、リソース(Time)にもアクセス可能です。
// 取引システム
fn process_transaction_system(
mut query: Query<(&mut Balance, &mut TransactionHistory)>,
time: Res<Time>,
) {
for (mut balance, mut history) in &mut query {
・・・・・
}
}
4. Resource(リソース)
リソースは、特定のエンティティに属さないグローバルな状態を管理します。
ここでは時間(Time)をリソースとして定義しています。
// 時間を管理するリソース
#[derive(Resource, Default)]
struct Time {
seconds: f64,
}
リソースは↓のように使ってます。
// 時間を更新
let mut time = world.resource_mut::<Time>();
time.seconds += 1.0;
5. World(ワールド)とSchedule(スケジュール)
Worldはエンティティ、コンポーネント、リソースを保持するデータストアとしての役割を持ちます。
Scheduleは、システムの実行順序と依存関係を管理します。
// 新しいWorldを作成
let mut world = World::new();
// スケジュールの作成
let mut schedule = Schedule::default();
schedule.add_systems((process_transaction_system, print_balance_system));
// メインループ
println!("銀行口座シミュレーション開始...");
for i in 0..5 {
println!("\n=== ステップ {} ===", i + 1);
// 時間を更新
let mut time = world.resource_mut::<Time>();
time.seconds += 1.0;
schedule.run(&mut world);
}
このように、ECSで実装することで
データとロジックが明確に分離されていることがわかります。
※コード全文は記事の最後に記述
Benefits of ECS Architecture
ECSの特徴や利点について確認しておきましょう。
ECS Use Cases
1. ゲーム開発
もともとゲームを効率よく処理するために使われてきたので、当然ゲーム開発に有用です。
キャラクターやオブジェクトの属性をコンポーネントで表現し、
物理演算、描画などのシステムが、該当する属性を持つエンティティに対して一括処理します。
2. シミュレーション
WebGISや都市シミュレーションなどでは、膨大な数の地理情報などを管理する必要があります。
ECSの「エンティティ=地物」「コンポーネント=属性」「システム=処理」という構造は
地図エンジンの設計に適しているため得意分野です。
大量のエンティティを柔軟に管理し、各種シミュレーション処理を効率的に実装します。
3. IoTやデバイス管理
センサーやデバイスをエンティティとし、状態・位置・稼働状況などの属性をコンポーネントで管理。
「大量のオブジェクトを動的に管理・更新する」分野であればECSは有効です。
最近はこういった分野でもECSが使われはじめているとのこと。
その他、フロントエンド・UIエンジンでもECSの考え方を応用した
ライブラリやフレームワークの設計やアイデアが出てきているようです。
Summary
ECS(Entity Component System)は、ゲーム開発から始まり現在は幅広い分野で採用されている
アーキテクチャパターンです。
エンティティ、コンポーネント、システムの3要素から構成され、
データと振る舞いを明確に分離して効率的なデータ処理が可能になります。
Bevy ECSなど、ECSのライブラリもいろいろとあり、
ゲーム分野以外でも使うことが可能なので、
多数のデータを効率よく管理する必要があるときには採用を検討してみてください。
Code
bevy_ecs_example.rs
use bevy_ecs::prelude::*;
use std::collections::VecDeque;
// コンポーネントの定義
#[derive(Component)]
struct Balance {
amount: f64,
}
#[derive(Component)]
struct TransactionHistory {
history: VecDeque<String>,
max_history: usize,
}
// 取引システム
fn process_transaction_system(
mut query: Query<(&mut Balance, &mut TransactionHistory)>,
time: Res<Time>,
) {
for (mut balance, mut history) in &mut query {
// デモ用にランダムな取引を生成
let transaction_amount = if time.seconds % 2.0 == 0.0 { 100.0 } else { -50.0 };
// 残高を更新
balance.amount += transaction_amount;
// 取引履歴に追加
let transaction = format!(
"{}: {} {}",
time.seconds,
if transaction_amount > 0.0 { "入金" } else { "出金" },
transaction_amount.abs()
);
history.history.push_back(transaction);
// 履歴の最大数を超えた場合、古い履歴を削除
while history.history.len() > history.max_history {
history.history.pop_front();
}
}
}
// 残高表示システム
fn print_balance_system(query: Query<(Entity, &Balance, &TransactionHistory)>) {
for (entity, balance, history) in &query {
println!("\n口座 {} の状態:", entity);
println!("現在の残高: {}円", balance.amount);
println!("最近の取引履歴:");
for transaction in history.history.iter() {
println!(" {}", transaction);
}
}
}
// 時間を管理するリソース
#[derive(Resource, Default)]
struct Time {
seconds: f64,
}
fn main() {
// 新しいWorldを作成
let mut world = World::new();
// 時間リソースを追加
world.insert_resource(Time::default());
// 口座エンティティの生成
world.spawn((
Balance { amount: 1000.0 },
TransactionHistory {
history: VecDeque::new(),
max_history: 3,
},
));
world.spawn((
Balance { amount: 5000.0 },
TransactionHistory {
history: VecDeque::new(),
max_history: 3,
},
));
// スケジュールの作成
let mut schedule = Schedule::default();
schedule.add_systems((process_transaction_system, print_balance_system));
// メインループ
println!("銀行口座シミュレーション開始...");
for i in 0..5 {
println!("\n=== ステップ {} ===", i + 1);
// 時間を更新
let mut time = world.resource_mut::<Time>();
time.seconds += 1.0;
schedule.run(&mut world);
}
}