【Rust】ECSを使ってみる(Specs)

2020-10-03
オブジェクト指向的な組み方でRustでゲームを作り、それを継承より合成を使用するようにして、

敵の種類によって処理を分割することができた。

しかし個々のオブジェクトの更新処理でゲームの各情報を参照する際に借用が被るのを回避するためにunsafeに手を染めていた。 もっといい方法がないかと調べていたところ、 ECS という方法があることを知った。

ECS とは?

ECSは Entity Component System の略で、プログラムをエンティティ、コンポーネント、システムとして構築する:

  • エンティティ:任意のコンポーネントたちを持つ実体
  • コンポーネント:単なるデータ
  • システム:コンポーネントに対する処理を行う

オブジェクト指向ではデータとメソッドをオブジェクトとしてまとめたのに対して、 ECSではデータと処理が分離されているのが大きな違い (データ指向というらしい)。 それに伴って考え方・プログラムの構成も大きく変わってくる。

ECSは実行時にコンポーネントを柔軟に追加・削除できる、 同じ種類のコンポーネントを連続したメモリに配置することによってキャッシュ的に有利という利点があるらしいが、 それに加えてRustでは借用が被ってしまう問題を解決できる、ということで使われているっぽい。

ECSライブラリ:Specs

RustでECSを使ってみようと思い、 Specs というライブラリを試してみた。 これは Amethyst というゲームエンジンで使われているらしいが、単独でも使用できる。

使い方はチュートリアルドキュメント、 またAmethystでのサンプルゲームの説明Pong Tutorial を参考にするといい。

以下に逆引き的にまとめておく:

システムでコンポーネントを受け取る

システムはコンポーネントをストレージの形で受け取って処理する。 type SystemDataReadStorageWriteStorage でコンポーネントを指定する。

複数のストレージを join することで、それらのコンポーネントを併せ持つエンティティが列挙される。 特定のコンポーネントを持たないという絞り込みや、 あってもなくてもよいオプショナル という指定もできる。

リソースの挿入

コンポーネント以外に、システムで参照できるリソースというものがある。 ワールドに登録するには:

world.insert(SomeData(123));

リソースの型は Default を実装する必要がある。

レンダリング

Specsではシステムを動かすためにディスパッチャというものを登録する。 ディスパッチャは自動的にマルチスレッドで実行されるとのことで、 レンダリングなどで単独のスレッドで実行させたい場合には with_thread_local を使う ということが書いてある。 ただ自分が作成しているプログラムの場合だと描画時に使用するトレイトを借用で渡すようにしているので、システム側でリソースで取得できないので with_thread_local では解決できなかった。

そこで描画のシステムをディスパッチャに登録するのではなく、 run_now を呼び出すと動作させることができた:

pub struct SysDrawer<'a>(pub &'a mut dyn RendererTrait);
impl<'a> System<'a> for SysDrawer<'a> {
type SystemData = ...;

fn run(&mut self, (pos_storage, drawable_storage): Self::SystemData) {
...
}
}

impl App {
fn draw<R: RendererTrait>(&mut self, renderer: &mut R) {
let mut sys_drawer = SysDrawer(renderer);
sys_drawer.run_now(&mut self.world);
}
}

またはワールドの read_storage でコンポーネントに対するストレージを取得できるので、それを使用して直接実行してもよい:

fn draw<R: RendererTrait>(&mut self, renderer: &mut R) {
let pos_storage = self.world.read_storage::<Position>();
let drawable_storage = self.world.read_storage::<SpriteDrawable>();
for (pos, drawable) in (&pos_storage, &drawable_storage).join() {
...
}
}

エンティティのコンポーネント取得

ストレージから取り出す:

some_storage.get(entity).unwrap();  // イミュータブル参照
other_storage.get_mut(entity).unwrap(); // ミュータブル参照

コンポーネントが属するエンティティの取得

直接取得するメソッドはないようで、 entitiesjoin して使用する:

pub struct SysFooBar;
impl<'a> System<'a> for SysFooBar {
type SystemData = (WriteStorage<'a, Position>, Entities<'a>);

fn run(&mut self, (mut pos_storage, entities): Self::SystemData) {
for (pos, entity) in (&mut pos_storage, &*entities).join() {
// entity がコンポーネントが属するエンティティ
}
}
}

エンティティの生成

初期化時などワールドが使える場合: world.create_entity()

world.create_entity()
.with(Position { x: 4.0, y: 7.0 })
.build();

システム内でワールドが使えない場合: entities.build_entity()

type SystemData = (
Entities<'a>,
WriteStorage<'a, Position>,
);

entities.build_entity()
.with(Position { x: 4.0, y: 7.0 }, &mut pos_storage)
.build();

対象の WriteStorage も必要。

エンティティ生成時の注意点

システム内で join でのループ内でエンティティを生成する場合にはストレージが借用されているので、生成するエンティティに追加するコンポーネントと同じコンポーネントを参照する必要がある場合はうまくいかない。 例えば自機から弾を撃つ場合に、自機の位置を参照して弾エンティティを生成しようとしても、同じ位置コンポーネントにはループ内で書き込みができない。

その場合には join したものを map して collect::<Vec<T>> でいったん別バッファにコピーするか、 join には含めずに pos_storage.get(entity).clone() して対象のストレージを借用しないようにして回避する。

エンティティの削除

entities.delete(entity).unwrap();

コンポーネントの追加、削除

pos_storage.insert(entity, pos).unwrap();
pos_storage.remove(entity);

雑感

まだ慣れてないので、結構難しい。

  • オブジェクト指向の組み方とはまったく考え方を変える必要がある。
    • すでにOOで作成していた場合、組み直すのは大変。
    • オブジェクト指向で組む場合に派生型の呼び出しが自動的に分岐されていたものが、自分で分岐させる必要がある。
  • コンポーネントを管理してくれはするが、それ以外はすべてのコンポーネント・リソースの情報を持ったワールドを各システムがそれぞれ触るだけで、 「分割して統治」する階層構造がなくなりすべてフラットになるので、混乱しそう。
  • コンポーネントに対する操作が色々なシステムに分かれてしまうため、コンポーネントの状態管理が難しい。
  • エンティティが持ってると想定しているコンポーネントをうかつに削除してしまうと、対応するシステムが呼び出されなくなる可能性がある。
  • システムで扱うストレージやリソースがやたらと増えてしまい、それを受け渡すのが面倒。
  • ストレージから情報を取り出すユーティリティ関数を作ろうとしたときに、 ReadStorage だけじゃなく WriteStorage でも受け付けるようにしたいが方法がわからなかった。
  • コンポーネントは種類ごとに1つのストレージに格納されている?が、コンポーネントの組み合わせで join した際には連続しているとは限らないので、 Specsではキャッシュ効率がよいとはいえない気がする(実装を見てみないとなんともいえないが)。

慣れてないのでつい否定的になってしまうが、依存関係がぶつからないシステム同士はマルチスレッドで動かせるというのと、 Rustの場合では借用でエラーが出るのをうまく回避できる、 エンティティを保持しておくことで他のコンポーネントを参照できる、 というメリットがある。

参考

RustConf 2018 でのプレゼン

Slide, Blog: My RustConf 2018 Closing Keynote