ECSについて学んでみた
最近はRustが流行りなので再開してみました。1年ほど前に競プロで遊んでそれっきり。「Rustで何作ろう?」で詰まってしまい学習が完全にストップしてました。
ところが最近、Rustのゲームエンジンがあることを発見。ゲームなら作る気がでます(趣味領域)。
ぱっと探したところ主流なのは2個。Amethyst と Bevy というエンジン。両方とも ECS
という仕組みを採用してました。
...なんだそれ?...
調べると最近のUnityでも採用されているとか。自分の勉強不足を痛感です。そこでサンプルアプリを眺めながら構造を読み解き、勉強のため簡単なECSエンジンのサンプルをSwiftで組んでみました。その過程を紹介します。
この手のフレームワークを理解するには使うよりも「バグバグ&簡素なオレオレ仕様でいいから同じ仕組みのものを作る」のが有効と思ってます。RxやReactなどもそうやって覚えました。
ECS
Entity Component System の略らしいです。ECSだけきくと Amazon ECS(Elastic Container Service) のほうが出てきますが違います。
まだまだ勉強したてで間違いあるかもしれませんが、以下のように解釈しました(便宜上、全体を管理するものをEngineと呼びます)
Component
自体は単純なデータComponent
を組み合わせてEntity
を作る- Entity(Person("Name"), Score(0)) のようなイメージ
System
がデータ(座標など)を更新したりするもの- ほしいComponentを実装したEntity一覧を取得、更新する(であってると思う...)
- Swift的に書くと func update(entities: [T]) where T: Person & Score
Engine
にSystem
を複数個追加。やりたい処理を組み合わせる- キー操作システム、描画システム、物理演算システム など
いざ書くとこれでは理解できませんね...理解するまでは結構長かったんですが、説明するとなると簡素になってしまう。これ以上説明が難しい。なので擬似的なコードで説明しましょう。
疑似Swiftコード
Rustのゲームサンプルコードを参考に、試しにボールを描画/移動させるコードを擬似的にSwiftで書くとこのようになるでしょう。(※解説のため単純化しておりビルド通りません)
//Component struct Circle {} struct Position {} struct Velocity {} //System class DrawSystem { //円かつ場所情報があるEntity一覧を取得し描画 func update<T>(_ entities: [T]) where T: Circle & Position } class MoveSystem { //場所と速度情報があるEntity一覧を取得し更新 func update<T>(_ entities: [T]) where T: Position & Velocity } //Engine class Engine { } //---------------------------------------- //setup let engine = Engine() engine.addSystem(DrawSystem()) engine.addSystem(MoveSystem()) //ボール追加。円で位置と速度がある engine.addEntity( Circle(), Position(), Velocity() ) //前者の描画、および両方の位置情報更新が走る engine.update()
ちょっと解説
わざわざEntity用のクラスを作らないのが重要ポイントでしょうか? コンポーネントの組み合わせだけで表現する。明示的に xxEntity というものはない。別の言語の Mixin や trait に近い存在に見えました。
また Systemのupdate
が重要ポイント。where T: Position & Velocity
ですね。ようは Engine がもつ Entityのうち、上記の要素を含む Entity のみを取得して更新するわけです。 Entity が他に何の要素があるかは気にしない。その2つの要素を含んでいればなんだろうが処理します。
Systemのとる型と、Componentの定義の組み合わせがすごい大事ではないかと思います。組んだ感覚として Haskell/Elmなどそっちに近いもの に近い。関数型的思考+型が命!という印象です。
これが大雑把なECSのコード(だと思います)
Swiftに落とし込む
では実際にSwiftのコードに落とし込みます。ここから妥協だらけ。もっといい構造があるでしょう(※自分の実力不足です...精進)
Component/Systemに分けてみていきましょう
Component
まず Componentをstructからclassにします。というのもstructの場合、次のupdate関数は動作しないからです。
struct Ball { var x: Float } func update(_ balls: [Ball]) { for var b in balls { b.x += 1 } }
StructはCopy-On-Writeなので、こうするとこの関数内でのBallは更新されてもそれの 大元を保持しているEngineには反映されません。inout
等を駆使して参照渡しすればできるでしょうが、結局参照を渡すのなら class
にしてしまったほうが楽です。Copy-On−Writeはこの場合バグ要因になりかねません。
さらにComponentをまとめてEntityを作るので、単純にComponentを束ねるだけのclassも定義。するとComponent周りはこうなります。(※短くしたいのでビルドとおりません。適時書き直してください)
//各ComponentをEntityで統一的に扱いたいのでprotocolを宣言しておく protocol Component: class { } final class Circle: Component { var color: Color = .white } final class Position: Component { var x, y: Float = 0 } final class Entity { let components: [Component] init(_ components: [Component]) { self.compnents = compnents } }
対応するEngineはこのようになるでしょう。可変長引数を利用することでちょっと記述を楽にします。
//Engine final class Engine { var entity = [Entity]() //書きやすさのため可変長引数にする func addEntity(_ components: Component...) { entity.append(Entity(components)) } } //こうかけるようになった let engine = Engine() engine.addEntity( Circle(), Position() )
こうすることでEntityを生成します。再度記述しますが、新しい種類のEntityがでるたびにEntityクラスを定義するのではなく、Componentの組み合わせのみで定義してるのが重要ポイント(だと思います)。
System
Systemはまず単純にProtocolでまとめます。ジェネリクスも活用すると以下のようになるでしょう。
protocol System { associatedtype T func update(_ entities: [T]) } class DrawSystem { typealias T = Circle & Position func update(_ entities: [T]) { } } class Engine { var systems = [System]() }
...が、 問題発生。ビルドが通りません。原因は以下。
- Circle/Positionともclassのため、
Circle & Position
という書き方が根本的にできない - SystemはGenerics付きなので、配列にして保持できない
厄介なのは後者。前者は一個一個Protocol作れば不可能ではないのですが(手間はかかる)、後者が手に負えない。仮にできたとしても どう Engine が DrawSystem に必要な [T] を生成するかが問題です。
...解決策が思い浮かびませんでした。実力不足。おそらく上手い人なら型消去など駆使していけるでしょう。自分のSwift力では無理でした。何らかの形で自動コード生成を使えばいけそうですが面倒なのでやめます。
そこで妥協し、SystemにEngineをわたし、Engineから必要な要素を取得できる 方向にします。コードの骨格はこう。
protocol System: class { func update(_ engine: Engine) } class DrawSystem: System { func update(_ engine: Engine) { let entities: [?] = engine.query() } }
こういうイメージ。これなら Engine側で System配列 を保持できます。
次に ?
の部分をどうするか。 Rust-Bevy をみると Component ごとに Tuple として来てるようなのでこれを真似てみます。つまりこう。
class DrawSystem { func update(_ engine: Engine) { //2つのクラスをもつEntityから、該当要素のみTupleで受け取る let entities: [(Circle, Position)] = engine.query() _ = entities.0.color entities.1.x += 1 } }
これならなんとかなりそうです。Engine側はEntityの各Componentが一致してるかみるだけでいけます。
class Engine { func query<(T1, T2)>() -> [(T1, T2)] { //ビルド高速化のため明示的に型を書いてます return entities.compactMap {(e: Entity) -> (T1, T2)? in var t1: T1? var t2: T2? for c in e.components { if let t = c as? T1 { t1 = t } else if let t = c as? t2 { t2 = t } } if let c1 = t1, let c2 = t2 { return (c1, c2) } return nil } } }
ちょっと泥臭いコードですがなんとかできました。ただこの方法の欠点として、2個のタプルに限定される ことです。単一の値や3つ以上に対応できない。スマートにやる方法があるといいのですが、みつからないので愚直にメソッドを分けます。
class Engine { func query1<T>() -> [T] {} func query2<(T1, T2)>() -> [(T1, T2)] {} func query3<(T1, T2, T3)>() -> [(T1, T2, T3)] {} func query4<(T1, T2, T3, T4)>() -> [(T1, T2, T3, T4)] {} }
どこまで作るかはお好みで。サンプル程度なら3つまでやっておけば十分でしょう。
これで一通り基本的なエンジン自体はできました!全体像はこちらになります。
まとめ
極めて簡素ですが Entity Component Systemを構築してみました。もちろんここから描画システムやキー入力など、まだまだ考えなければならない項目は多いです。ただ ECS の概要を掴むことはできました。そして非常に面白い設計でした。自分の好きなパターンということもあり、普段のソフトウェア設計にも役に立ちそうです(このまま使うことはないがこの知識は使えるかも、ということです)。
次はいよいよRustでゲームを作ってみます! 前置きながすぎですね...システムがよく分からなかったのでだいぶ遠回りになったしまいました。
題材はぷよ○よ、もしくはコ○ムスの予定。○トリスはブロック定義が面倒、数独みたいなものは問題作るのが面倒で作りたくないという怠けた精神のせいです(笑)