【Rust】Legion ECSを触ってみた

2020-10-21

Rustでオブジェクト指向的にプログラムを構築するのにちょっと難しさを感じて、ECSという手法を見てみた。 借用の被りを回避できるのはいいんだけど、システムで扱うコンポーネントが増えるに従ってストレージの引数が増えていくのがなかなか辛いと感じた。

これは試したSpecsというライブラリの問題なのかもしれない、と思い別のライブラリも見てみることにした。 AmethystというゲームエンジンがSpecsから載せ替えを計画しているという、Legionというライブラリを見てみた。

使用したバージョン: Legion 0.3.1

Legionの特徴

v0.3の説明にベンチマークで速いとかいろいろ書いてある。 性能面での比較はさておき、使用の際のSpecsに対する利点としては、

  • システムでコンポーネントを扱う際に個々のストレージとしてじゃなく、 必要なコンポーネントのストレージを含むサブワールドとして受け取るので、 記述がそんなに増えずに済む
  • エンティティやコンポーネントの生成・削除をコマンドバッファ経由で行うので、 その際に扱うコンポーネントのストレージを要求せずに済む
  • システムごとに型を定義する必要がなく、関数に#[system]アトリビュートを指定するだけで作れる(扱うコンポーネントを2度書かずに済む)
  • コンポーネント、リソースに Default 実装が必要ない

以下に逆引き:

コンポーネント定義

任意の型が自動的にコンポーネントとして使える。

use legion::*;
use legion::systems::CommandBuffer;
use legion::world::SubWorld;

pub struct Position { pub x: i32, pub y: i32 }
pub struct Velocity { pub vx: i32, pub vy: i32 }

エンティティの生成、削除

システム外:ワールドに push

pub fn init(world: &mut World) {
world.push((Position{x:0, y:0}, Velocity{vx:2, vy:1}));
}

システム内:コマンドバッファに push

#[system]
pub fn foo(commands: &mut CommandBuffer) {
// 生成
let entity = commands.push((Position{x:0, y:0}, Velocity{vx:2, vy:1}));
// 削除
commands.remove(entity);
}
  • 複数のコンポーネントを持たせる場合、タプルで渡す
  • スケジュール実行後、またはスケジュール中の flush() で実行される?

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

// 追加
commands.add_component(entity, Position{x:0, y:0});
// 削除
commands.remove_component::<Position>(entity);

システム(for_each)

#[system(for_each)] で、関数の引数に指定した型のコンポーネントを持つエンティティが自動的に列挙されて呼び出される:

#[system(for_each)]
pub fn move_pos(pos: &mut Position, vel: &Velocity, entity: &Entity) {
pos.x += vel.vx;
pos.y += vel.vy;
}
  • &Entity で対応するエンティティを受け取れる
  • その他、リソースやサブワールドやコマンドバッファも受け取れる

コンポーネントの列挙

単純なループでは済まないような場合(二重ループなど)、システムでサブワールドを受け取って自分でループさせる:

#[system]
#[read_component(Position)]
#[read_component(Player)]
#[read_component(Enemy)]
pub fn check_collision(world: &mut SubWorld) {
for (_player, player_pos, player_entity) in <(&Player, &Position, Entity)>::query().iter(world) {
for (_enemy, enemy_pos, enemy_entity) in <(&Enemy, &Position, Entity)>::query().iter(world) {
....
}
}
}
  • #[read_component(型)] または #[write_component(型)] アトリビュートを指定すると、それらを含む SubWorld として受け取れる
  • クエリのイテレータ: <(型, 型, ...)>::query().iter(world)、ミュータブルの場合は iter_mut
  • Entity 指定でエンティティが得られる

サブワールドの分割

サブワールドに対してクエリを行うと借用されてしまい他の操作ができなくなってしまうので、 複数のコンポーネントを別々に触りたい場合には分割する必要がある:

let (mut target, mut rest) = world.split::<&mut Position>();
  • タプルの第1項目に指定したコンポーネント、第2項目に残りのコンポーネントを含むサブワールドに分割されて返される
  • イミュータブルの場合には2番目のサブワールドでも参照可能

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

他のエンティティのコンポーネントを参照したい場合、クエリしてゲット

let (pos, vel) = <(&mut Position, &mut Velocity)>::query().get_mut(world, entity).unwrap();
  • エントリ経由だと1つのコンポーネントにしか触れなかった…

リソースの登録(システム外)

let mut resources = Resources::default();

resources.insert(リソース);

システムでのリソースの受け取り

システムの引数に #[resource] を指定:

#[system]
pub fn bar(#[resource] bar: &mut Bar) {
...
}

システム外でのリソース取得

let res = resources.get_mut::<リソース型>().unwrap();

Legionの使用感

Specsに比べてLegionではストレージを個別に扱わずに済むので、ある程度記述が楽になる。 ではいいこと尽くめで利点だけかといわれるとそうでもない:

  • サブワールドに含まれないコンポーネントのクエリがコンパイル時にエラー検出できず、実行時に panic! する
  • システム間の依存関係が指定できず、スケジュールに flush() を挟む必要がある
  • エンティティやコンポーネントの生成・削除がコマンドバッファ経由で呼び出し時点では変更されないため、 状態にずれが生じて問題が出るケースがあるのではないか?
  • 「システム内でこのコンポーネントにもアクセスしたい」と変更になった場合には同様に大幅な書き換えが必要
  • システムの並列化に関して:多くのシステムで同じリソースを触ることになって、結局あまり並列化されないんじゃないかと疑っている

特にシステムにどのコンポーネントが必要かというのをシステム内から呼び出している関数を全部調べないといけないのは痛くて、 その点では自分でストレージを受け渡さないとコンパイルエラーが出る(し不要なものはワーニングが出る)Specsのほうがいいかもしれない。 ま、魔法のような方法はないということで…。

参考