敵の種類によって処理を分割することができた。
しかし個々のオブジェクトの更新処理でゲームの各情報を参照する際に借用が被るのを回避するためにunsafeに手を染めていた。 もっといい方法がないかと調べていたところ、 ECS という方法があることを知った。
ECS とは?
ECSは Entity Component System の略で、プログラムをエンティティ、コンポーネント、システムとして構築する:
- エンティティ:任意のコンポーネントたちを持つ実体
- コンポーネント:単なるデータ
- システム:コンポーネントに対する処理を行う
オブジェクト指向ではデータとメソッドをオブジェクトとしてまとめたのに対して、 ECSではデータと処理が分離されているのが大きな違い (データ指向というらしい)。 それに伴って考え方・プログラムの構成も大きく変わってくる。
ECSは実行時にコンポーネントを柔軟に追加・削除できる、 同じ種類のコンポーネントを連続したメモリに配置することによってキャッシュ的に有利という利点があるらしいが、 それに加えてRustでは借用が被ってしまう問題を解決できる、ということで使われているっぽい。
ECSライブラリ:Specs
RustでECSを使ってみようと思い、 Specs というライブラリを試してみた。 これは Amethyst というゲームエンジンで使われているらしいが、単独でも使用できる。
使い方はチュートリアルや ドキュメント、 またAmethystでのサンプルゲームの説明Pong Tutorial を参考にするといい。
以下に逆引き的にまとめておく:
システムでコンポーネントを受け取る
システムはコンポーネントをストレージの形で受け取って処理する。
type SystemData
に ReadStorage
や WriteStorage
でコンポーネントを指定する。
複数のストレージを join
することで、それらのコンポーネントを併せ持つエンティティが列挙される。
特定のコンポーネントを持たないという絞り込みや、
あってもなくてもよいオプショナル
という指定もできる。
リソースの挿入
コンポーネント以外に、システムで参照できるリソースというものがある。 ワールドに登録するには:
world.insert(SomeData(123)); |
リソースの型は Default
を実装する必要がある。
レンダリング
Specsではシステムを動かすためにディスパッチャというものを登録する。
ディスパッチャは自動的にマルチスレッドで実行されるとのことで、
レンダリングなどで単独のスレッドで実行させたい場合には with_thread_local
を使う ということが書いてある。
ただ自分が作成しているプログラムの場合だと描画時に使用するトレイトを借用で渡すようにしているので、システム側でリソースで取得できないので with_thread_local
では解決できなかった。
そこで描画のシステムをディスパッチャに登録するのではなく、 run_now
を呼び出すと動作させることができた:
pub struct SysDrawer<'a>(pub &'a mut dyn RendererTrait); |
またはワールドの read_storage
でコンポーネントに対するストレージを取得できるので、それを使用して直接実行してもよい:
fn draw<R: RendererTrait>(&mut self, renderer: &mut R) { |
エンティティのコンポーネント取得
ストレージから取り出す:
some_storage.get(entity).unwrap(); // イミュータブル参照 |
コンポーネントが属するエンティティの取得
直接取得するメソッドはないようで、 entities
と join
して使用する:
pub struct SysFooBar; |
エンティティの生成
初期化時などワールドが使える場合: world.create_entity()
world.create_entity() |
システム内でワールドが使えない場合: entities.build_entity()
type SystemData = ( |
対象の WriteStorage
も必要。
エンティティ生成時の注意点
システム内で join
でのループ内でエンティティを生成する場合にはストレージが借用されているので、生成するエンティティに追加するコンポーネントと同じコンポーネントを参照する必要がある場合はうまくいかない。
例えば自機から弾を撃つ場合に、自機の位置を参照して弾エンティティを生成しようとしても、同じ位置コンポーネントにはループ内で書き込みができない。
その場合には join
したものを map
して collect::<Vec<T>>
でいったん別バッファにコピーするか、
join
には含めずに pos_storage.get(entity).clone()
して対象のストレージを借用しないようにして回避する。
エンティティの削除
entities.delete(entity).unwrap(); |
コンポーネントの追加、削除
pos_storage.insert(entity, pos).unwrap(); |
雑感
まだ慣れてないので、結構難しい。
- オブジェクト指向の組み方とはまったく考え方を変える必要がある。
- すでにOOで作成していた場合、組み直すのは大変。
- オブジェクト指向で組む場合に派生型の呼び出しが自動的に分岐されていたものが、自分で分岐させる必要がある。
- コンポーネントを管理してくれはするが、それ以外はすべてのコンポーネント・リソースの情報を持ったワールドを各システムがそれぞれ触るだけで、 「分割して統治」する階層構造がなくなりすべてフラットになるので、混乱しそう。
- コンポーネントに対する操作が色々なシステムに分かれてしまうため、コンポーネントの状態管理が難しい。
- エンティティが持ってると想定しているコンポーネントをうかつに削除してしまうと、対応するシステムが呼び出されなくなる可能性がある。
- システムで扱うストレージやリソースがやたらと増えてしまい、それを受け渡すのが面倒。
- ストレージから情報を取り出すユーティリティ関数を作ろうとしたときに、
ReadStorage
だけじゃなくWriteStorage
でも受け付けるようにしたいが方法がわからなかった。 - コンポーネントは種類ごとに1つのストレージに格納されている?が、コンポーネントの組み合わせで
join
した際には連続しているとは限らないので、 Specsではキャッシュ効率がよいとはいえない気がする(実装を見てみないとなんともいえないが)。- Amethystが2020のロードマップでSpecsに変えて置き換えようとしているLegionは アーキタイプという仕組みで解消するっぽい(Specs and Legion, two very different approaches to ECS)。
慣れてないのでつい否定的になってしまうが、依存関係がぶつからないシステム同士はマルチスレッドで動かせるというのと、 Rustの場合では借用でエラーが出るのをうまく回避できる、 エンティティを保持しておくことで他のコンポーネントを参照できる、 というメリットがある。
参考
- Rust + Entity Component System で仕様変更に強いゲーム設計 その1 〜 序文
- Specs - Parallel ECS, Introduction - The Specs Book
- ECSは200X年頃からあるとのことで、昔の記事:
- データ指向
- 【Unity】 ECSへ 思考の移行ガイド - エフアンダーバー
Slide, Blog: My RustConf 2018 Closing Keynote