Closed22

ECS を読むメモ 2 (ECS back and forth など)

sparsey を読んだ後も、俺たちの戦いは終わらない……。

Entity Component System を読むメモ第二弾です。 その 1 はこちら

以下常体です。

ENTT の作者が綴る ECS back and forth を読む欄

魔獣 C++ ECS に迫る! API を見るに、 EnTT が shipyard や sparsey の元ネタみたい。


実装 (sparsey) を読んでから (EnTT の) 解説を読む、という流れになったためサクサク行けるぞ!

再解釈:

Part 1 - Introduction
一般人にとって ECS の目的はコード構成で、要素に対して働きかけるというモデルが冴えている。 EntityEachComponentArray の添字にするとスカスカ (sparse) な Vec<T> ができてよくないが、ここに素晴らしいアイデアがある:

Part 2 - Where are my entities?
他の問題と同様に、添字とデータ配列の間に間接層を挟むことが問題解決の鍵となり、空欄の削除や並び替え、さらに遥かなこともできるようになる。

  • Archetype は component の組毎にストレージを用意するアイデアで、組が静的なゲームを想定する。ただし用途よりも実装ベースのため無駄が多いとしており、たとえば component の組を巡回する場合は複数の archetype を回らねばならない。 ← おそらくこれを指して "fragmentation" と言っている。
  • Sparse set では添字配列経由で目当ての配列にアクセスする。単に全データを巡回する場合は packed array を見れば良い。この dual access mode により速くて柔軟な運用が可能!
    1. System が sparsey array を所有し、巡回すべき entity のキャッシュを持つ。さらには component を system が所有する。 ← この方法は廃れた気がする。
    2. Component pool に sparse set を使用する。 EnTT もこの実装で、複数の component を巡回する場合は EnTT はグルーピング (左寄せ、結果として同グループの component は常に同じ添字でアクセス可能) を行なっている。一本の array で全 archetype のストレージを兼ねたようなものと言える。 (詳細は前回の ECS を読むメモにも載っている) 。グループをユーザが設定しなければならないのが欠点であり利点でもある。

Part 2, insights - Sparse sets and grouping functionalities
各 component に共通の (生) 添字でアクセスできることを perfect SoA と呼ぶことにする。 Sparse set-based な component storage は grouping (左寄せ) によりこの性質を得て、キャッシュが良くなる。 Swap 時は添字配列の同期も忘れないこと。コストは Entity の所属グループが変わる度に component の追加・削除が重くなる可能性があることだが、毎フレームやることではないため問題ないだろう。
グルーピングは component の『所有権』を取ってソートさせるか、『自由型』を取る。『自由型』を含むグループ (のサブセット) のイテレーションの際は存在チェックをスキップできる。 ← これはそこまで旨くない気がするし、 sparsey にも無かった。

EnTT tips & tricks - Groups on sale
TODO: 読む

Part 3 - Why you don't need to store deleted entities
Index に version (generation) をつけてリサイクル可能にしよう (index 16 bits, version 16 bits の系 4 bytes. 2^16 = 65,536) 。 Component set, sparse sets, archetypes, いずれも累計 entity 数の上限やメモリ削減に有効だ。
EnTT はかつて消去した Entity の配列を持ってリサイクルを行なっていた。これはシンプルだが避けるべきコストを作ってしまう。そこで union を活かし空きスロットで一方向の連結リストを作る。キャラの削除時はルートを削除スロットに移動し、削除スロットの添字をルートに入れる。

thunderdome と同じだ。一般的なんだろう。ところで省メモリは競プロに出るパタンなんだろうか。

The C++ of EnTT - Level UP Conference 2019
スライド。

ECS back and forth - Italian C++ Conference 2019
スライド。

ECS back and forth Part 4 - Hierarchies
ECS でいかに階層を表し、あちこちメモリ上を飛び回ることなく巡回するか 。完全な解、柔軟で最高効率の解は無い。常に less is better の原則に従うのが良く、我々の件も例外ではない。
以下で問題への一般的手法を説明する。未来の投稿では、私たちのプールをほぼソートされた形にし、さらに進めて階層を歩くときのジャンプを減らす技術を解説することに努める。
簡単のため純粋な木のみを考えるが、必要ならば同じ概念をグラフの場合に拡張することもすこぶる易しい。ともかく現実的な用途に可能な限り近づけたコードを持って解を提示できるよう、私は EnTT を ECS として用い、このライブラリの用語を使ったコード断片を示す。

  • 子の数を限定した Relation を作る
    *indextree 風 連結リスト。多数の子がいる場合、 n_children を生やしておけば巡回が速い。
    ただし巡回中は余分なジャンプが必要になる。次回は階層を扱うときに物の配列によりこの影響を減らすか抹殺するあるアイデアを冒険する。実は私たちの目的によく効くかもしれない技術がある。完全なソートから EnTT のグループを使って創造に応じてプールを分ける方法まで、それらから最良の結果を得るため。

ふーむ。深さに応じて Depth2, Depth4 みたいな component をグループに足し、 EnTT のグルーピング機能で左寄せにする感じだろうか?

ECS back and forth Part 4, insights - Hierarchies and beyond
もはや階層は悪夢ではない。引き続いて巡回時のメモリジャンプを減らす。
暗黙的リスト (連結リスト) のおかげで動的なメモリ確保が不要であったこと、とりわけこのリストの存在が型ごとのプールからは透明に見えることが興味深かった。しかし子がぎっしりメモリに詰まるとは限らず、 異なる親の子が混ざる 可能性もあった。
まず第一に これは問題では無いかもしれない 。だから数ナノ秒を救おうと躍起になる前に ソートされたプール があなたが本当に必要なものかを注意して考えることだ。あまりに多くの人が存在しない問題に集中し過ぎている。もしもあなたがそのような存在ではなくソートされたプールが本当に必要な物であるなら、何か興味深いものが見つかるよう願って続きを読んでほしい。
まずプールの全 item を親が左に来るようソートすることが考えられる。しかし例えば Transform の更新時、訪れる必要がある component は限られている。そこで Dirty component を用意する。 EnTT においては Registry の signal を購読し、 Transform の更新には直接書き換える代わりに registry::update を使う:

entt::connect<dirty>(registry.on_replace<transform>());
entt::connect<dirty>(registry.on_construct<transform>());

そして dirty を parent 順にソートして巡回すればヨシ!

Archetypeの場合は fragmentation.. というかストレージの分割を活かす。独特な用語だ:

Long story short, fragmentation is the measure that indicates the number of arrays a particular component is stored in. The higher the fragmentation, the more arrays to iterate and the higher the potential for cache misses.

** flecs では archetype で親子関係を表すことができる** (childof | entity ) 。この手法の良い点は、 entity を挿入・削除する際にソートが不要なこと。しかしたとえば階層 1 の entity に複数の archetype が容易されてしまう。そのため flecs は同じ階層の (かつ同じセットの component を持つ) entity を 1 つのストレージに入れる方法を持っている (代わりに挿入・削除のコストは上がる) 。
ECS において効率よく階層を扱うには、それぞれの特性と制限があることを理解することが重要だ。

さらに進めると、

  • ほぼ常にほぼソートされたプール
    プール全体をソートするとなると、重過ぎてピークが出てしまう。だが目的がキャッシュ効率のみで不完全なソートで良いならば手がある。 たとえば bubble sort を複数フレームに分割して実行する

  • 1 度だけ計算し可能な限りそのまま
    たとえば画家モード (2D ゲームなど) でレイヤを component にする。たとえば僕のゲームなら、背景・マップ・マップ上・キャラ・キャラ上・エフェクトとレイヤ数は有限。この場合、 レイヤ内の順序付けが定まっていれば、レイヤを跨いだグローバルな順序付けは不要 である。また それぞれのレイヤ内データのソートは独立した操作であり、複数のスレッドに分けて実行できる 。またシーンの変化が少ないゲームでは、何が動いたときにだけレイヤ全体を再計算すれば良い。同じ手法を 1 つのレイヤに当てはめれば、レイヤをゾーンに分割し、それぞれのゾーンで変化ができた時のみそのゾーンを再計算すれば良い。

どの方法を取るにせよ、 ほとんどのフレームでは上から絵を重ねていくだけ になる。

ECS back and forth Part 5 - Sparse sets and sorting
In-place のソートは要素の入れ替えを鍵とする。これはキャッシュ効率が悪い動作である。いくつもの配列をソートすると、単純にこの操作を使う訳にはいかない。
まずは単体の sparse set をソートする場合を考える:

std::sort(dense.begin(), dense.end(), compare);

for(auto pos = 0; pos < dense.size(); pos++) {
    sparse[dense[pos]] = pos;
}

そして sparse set 専用の in-place なソートを用意する。入れ替えの操作で sparse index の入れ替え直後に対応する dense vec のアイテムを入れ替えすれば良い。
Out-of-place なソートをする場合は、まず外部の配列で in-place なソートを用いて順列を計算し、それを sparse set に適用する。 In-place なソートは整数のソートであり大したコストではない。一方この順列を sparse set に適用するのはコンテナサイズに比例した計算量であり、全てを加速するのにとても役立つ。

TODO: ^ の続きを読む

Part 9 - Sparse sets and EnTT

  • Component の削除では要素をスライドする代わりに swap_remove を使う
  • Sparse array はページ制にする (Entity の数が極端に多い場合も負荷のピークをある程度抑えラエル)
  • Sparse array の dense index は Option<NonMaxU*> 的なものにする (lookup が速くなる)
  • グループの衝突などに関してグループを使っていない人、使おうともしなかった人から多数批判が寄せられる。
  • コマンドキューなどは隠されたコストを持ち、基本的に同じ操作を 2 回繰り返すことになる。 ※ sparsey はコマンドにクロージャを入れてEntity の追加・削除をする。
  • グループのいい所は巡回中も一定コストで entity の挿入・削除ができること
    • N-1 から 0 への逆巡回なら末尾への挿入はイテレータを無効化しない
    • remove_swap で要素を削除するのは安全

ECS back and forth Part 11 - The Big Matrix revised
Big Matrix (ストレージがVec<Option<T>>` 風なやつ)にも可能性があるとしてページ制を紹介している。

  • ページ制にするとアロケーションの遅延により多くの場合メモリを大量に節約できるし、負荷のピークも抑えられる。そして一度アロケートした component はメモリ位置が変わらない!
  • 巡回の高速化
    • 要素の少ないプール
    • ページ制だと巡回時に大幅なスキップができるときがある
    • 両者のいいとこ取り? extension が何のことかわからない

[Crash Course: entity component system | EnTT]

sparsey に無かった機能をピックアップ:

  • Observe changes つまりコールバック。
  • Group を明示的に使ってイテレートする。 Rust なら def_group! とか #[group(members = "..")] とかで書けそうな気はする。

ECS back and forth 以外の単発記事を読む欄


ECS FAQ では基本的な内容を再確認できる。


Specs and Legion, two very different approaches to ECS は bitset vs archetype だ!

測定を通じて妥当な結果を確認するという意味で、脳科学に基づいて書くエッセイと似たところがある。

  • 所有権に苦しむあなたへ:

Rust punishes poor architecture, so ECS’ are here to help.

  • Specs について
    ↑ 細部が分からないけれど、 Specs は (任意のストレージ) にコンポーネントを入れていて、 join の際は hibitset 経由で巡回している? bit 積を取ってどの component を巡回すべきか検出し、スカスカな部分は一気にスキップできる。
    見てきた けどやっぱり BitSet 経由で巡回しているみたいだ。 Specs では sparse set (DenseVec) の旨味 (dual access mode) は引き出せない。

なお sparsey では component の組を追跡せず、 entity を削除する際は全 storage を巡回していた。

  • Legion について
    • ストレージの registor 不要
    • Archetype とは component の組み毎に作る SoA
    • tags もクールらしい?

両者比較

  • 挿入・削除

    • Legion は entity の削除が遅い (それが問題かは別の話)
    • Legion は entity の挿入が若干遅い (archetype の判別があるため。問題かは略)
  • 巡回
    AoS / SoA の Vec を比較に出して様々な方法で比較

    • Legion は archetype のマッチに時間がかかり、データ数が少ない場合はやや不利。Entity 数が相当増えるとこのオーバーヘッドよりも巡回の速さが活きる。
    • キャッシュを完全に消去してから巡回を始めると一定の時間が必要になる。
    • それぞれの archetype に多くの entity がいる場合は Legion が速い
    • Specs は bitmask を巡回して entity を見るためフィルタリングに弱い
  • DenseVec とは SparseSet のこと。ただし Specs で使っても grouping に基づくソートは無い。 Vec と DenseVec ではほぼ差がなかった。 ← Specs が BitSet 経由で巡回するためだね……

  • 巡回の並列実行
    Legion で並列実行ができず benchmark なし

そういえば sparsey では system の並列はあったものの巡回の並列は無かったな。

解釈がどれも定量的でないのは勘弁してください。。

Entity が 100 未満ならば Specs の方が常に速い。庶民としては ECS は API で選べばいいかなと思えた。

(関係ない) Sparse set の場合は設定により archetype が有利な場合を (より効率的) に模倣できるので、ボトルネックが見つかる度に修正できて良いかもなと思った。

なお archetype の移行で生じる legion におけるオーバーヘッドは 連結リストの巡回コストに準ずるもの だと書いてあり、確かに連結リストで transform 適用を巡回するのは良くないと直感できた。もっとも sparse set の場合は 1 つの配列に全データが入っているため archetype を移動するよりはデータ局所性が良い気はする。


AoS vs SoA performance

A, B, C ごとに Vec<(A, B, C)>Vec<A>, Vec<B>, Vec<C> を巡回するとキャッシュ効率のため後者の方が速い。 .join した計測も欲しかった。自分でやればいいんだけれど、ううむ……


Finite State Machines with Ash entity component system framework

Shipyard の guideから。キャラの component でフィルタリングするシステムを追加することで FSM を表現する。

Scene 切り替えに対応する表現はなんだるう。 Stage の on/off かな。


Crash Course: entity component system

Packed arrays of components are also paged to have pointer stability upon additions. Packed arrays of entities are not instead.

TODO: 読む

ECS schedule thoughts: part 1 | Ratys' website thing

TODO: 読む

Ghost cell について観る欄

Rust を使って ECS を使わない場合、メイントピックは借用の分割になる。 Ghost cell とはそこで一役買って出るものなのか、どの程度広範囲に影響するものなのか調べておきたい。


GhostCell: Separating Permissions from Data in Rust

論文を読むのは大変そうなので動画を観た。構成が上手い !!

  • Rust ではツリー風データを作るのは簡単だが、グラフ風データを作るのは大変
    • AXM (Aliasing xor Mutability) は非巡回的で共有の無いデータ構造の実装に有効
    • AXM は内部共有に対して上手く働かない (例: グラフ、双方向リンクリスト) 。内部共有の方法としては
      • Unsafe code を書く (生ポインタを使うなど) 。速いが Rust の安全性が活かせない !!
      • Interior mutability を使う。比較的安全だがロックは重い !!
  • Rust はデータへの操作権 (permission) をデータに関連づける
  • GhostCell は Haskell の ST monad などが元ネタ
    • GhostCell<'id, T> はコンテナと結び付けられたデータをラップする
    • GhostToken<'id>GhostCell<'id, T> へのアクセス権

docs.rs/ghost-cell

GhostCell 論文を読む - Zennいいリンク があった。ふーむ ECS には活用できなそうだし、他のゲーム開発の文脈でも使い道を思いつかない。でも devlog の中でサクッと ghost cell を使い始めたらカッコいいな〜〜〜。保留。

Viva ECS 🎮 を書く欄

絶対に Amazon ECS のことだと思って見に来た人がガッカリするけれど許してほしい…… !!


最低限のゲーム用

  • ストレージ
    • ResourceMap
    • SparseSet<T>
    • EntityPool
    • ComponentPoolMap
  • World API
    • WorldDisplay
    • ComponentSet
  • Iterator
    • SingleIter
    • SparseIter
  • ParallelSystem
    • Send / Sync
    • Dispatcher

入れたいけれど時間が……

  • API
    • Schedule の代わりになるもの
    • World::query
    • Command
    • In-place な component の挿入・削除?
    • In-place な Entity の挿入・削除?
  • Groups
    • Group, Family
    • grouping
    • DenseIter

Optional:

  • Query
    • include / exclude
  • App?
  • Advanced
    • 連結リストでフリースロット管理
    • Best effort な SparseIter
    • World への排他的アクセスをもつ system (Entity の即時 alloc など)
    • Builtin の disable 機能?
    • Optional な system param
    • Shorthand としての query (system param)

もうサンプルゲームを作っている暇は無いかも。バグが怖いな……


実装メモ

  • まだ 1,000 行しか書いていないけれど結構疲れた
  • かつてなく rebase している
  • イテレータ実装が意外と分からない。自信なくなってきた……
  • &[SparseIndex]&[Entity] のキャストなど細部への理解が密になっていく
  • もくもく会で残り 40% と言った翌日に残り 60% になった
  • 僕の World は bevy で言うところの WorldCell に近いが、アグレッシブに簡素な API を作ることを優先する
  • あと一週間しかないのだけれど、まだ join できない。 rust さんコンパイルさせて〜〜
  • Outer 並列と肝心の grouping がまだ残っている。間に合うのか、、??
  • get_unchecked* を使う
  • Stage やら Schedule が拡張性のための苦肉の策に見えてしまう。 System のツリーはユーザランドで組み立てれば良い気がする。
  • Bevy を見た時の感動ポイントが Entity の親子関係や巡回しながらの Entity 削除になったりする。専用ストレージや逆順の .iter() 実装かな。と思ったら Child/Parent component や遅延コマンド + marker component だった。 凄く普通だ

メモ

  • Entity が並列実行の文脈でアロケートできるようになっていない。
  • Entity の空きスロットで連結リストを作っていない。
  • 公開時は book branch を分けよう。
  • World への排他的アクセスを持った System
  • Failable system

ECS の運用を考える欄

本格的に使わないと細部まで見えない気がするが、そこまでやる時間が無い。Practical ECS って本が欲しい !?


ECS で UI を実装する

  • 連結リストを活かして親子関係を作る
  • Transform の適用にはグルーピングを利用する

Scene 切り替えに相当する FSM を作る

  • Dispatcher を切り替える
    ECS を使っていない場合と同じ。やはり ECS と non-ECS の違いは AoS と SoA の違いか。

Unofficial Bevy Cheat Book を切り口に Bevy Engine の機能と実装を垣間見る欄

やはり Bevy が高機能で面白い!


Commands

fn write(&mut World) を実装した 任意の 構造体を連続したメモリ上にバイト列として保存する。これは参考になる!

/// A [`World`] mutation.
pub trait Command: Send + Sync + 'static {
    fn write(self, world: &mut World);
}

pub struct CommandQueue {
    bytes: Vec<u8>,
    metas: Vec<CommandMeta>,
}

バイト列をいかに Command として実行するかというと、関数オブジェクトを持っておく:

struct CommandMeta {
    offset: usize,
    func: unsafe fn(value: *mut u8, world: &mut World),
}

Meta 生成はこんな感じ:

let meta = CommandMeta {
    offset: old_len,
    func: write_command::<C>,
};

unsafe fn write_command<T: Command>(command: *mut u8, world: &mut World) {
    let command = command.cast::<T>().read_unaligned();
    command.write(world);
}

Events

EventWriter<T>ResMut<Events<T>> の wrapper で、 T の double buffer:

pub struct Events<T> {
    events_a: Vec<EventInstance<T>>,
    events_b: Vec<EventInstance<T>>,
    a_start_event_count: usize,
    b_start_event_count: usize,
    event_count: usize,
    state: State,
}

enum State {
    A,
    B,
}

そういえば Res<T>, ResMut<T>T を直接開示してしまい、 read / write の API を分けることができない。なぜ EventReaderEventWriter は同じ Events<T> へのアクセスなのに型を分けることができるかというと、これらは World からデータを借りる間接層だったらしい:

#[derive(SystemParam)]
pub struct EventReader<'w, 's, T: Resource> {
    last_event_count: Local<'s, (usize, PhantomData<T>)>,
    events: Res<'w, Events<T>>,
}

#[derive(SystemParam)]
pub struct EventWriter<'w, 's, T: Resource> {
    events: ResMut<'w, Events<T>>,
    #[system_param(ignore)]
    marker: PhantomData<&'s usize>,
}

正体は SystemParam:

pub trait SystemParam: Sized {
    type Fetch: for<'w, 's> SystemParamFetch<'w, 's>;
}

pub type SystemParamItem<'w, 's, P> = <<P as SystemParam>::Fetch as SystemParamFetch<'w, 's>>::Item;

Local

System 専用の resource 。もはや system のフィールド。

pub struct Local<'a, T: Resource>(&'a mut T);
pub struct LocalState<T: Resource>(T);

impl<'a, T: Resource + FromWorld> SystemParam for Local<'a, T> {
    type Fetch = LocalState<T>;
}

LocalState<T> がどこに保存されるかというと、 ParamSystem の中にある:

pub struct SystemState<Param: SystemParam> {
    meta: SystemMeta,
    param_state: <Param as SystemParam>::Fetch,
    world_id: WorldId,
    archetype_generation: ArchetypeGeneration,
}

pub struct ParamSystem<P: SystemParam> {
    state: SystemState<P>,
    run: fn(SystemParamItem<P>),
}

ParamSystem には RunSystem が実装されている:

pub trait RunSystem: Send + Sync + 'static {
    type Param: SystemParam;

    fn run(param: SystemParamItem<Self::Param>);

    fn system(world: &mut World) -> ParamSystem<Self::Param> {
        ParamSystem {
            run: Self::run,
            state: SystemState::new(world),
        }
    }
}

おそらく App::add_system ではこの .system() を利用して ParamSystem を作っているはず。

まあ無理して関数で書けるようにしなくても、構造体に trait 実装してもらえばいいんじゃないかな。

Hierarchical (Parent/Child) Entities

前見た時は、単なる連結リストの component だった。実際、 ChildrenParent component は bevy_transform の中で定義されている。

Change Detection

sparsey と同じなら、 Tick をフレーム数として

  • 追加時のフレーム
  • 変更 (.deref_mut()) があったときのフレーム

などを追跡しており、これを条件にフィルタリングできる。

System Chaining

単なる composite パタンかな?

pub struct ChainSystem<SystemA, SystemB> {
    system_a: SystemA,
    system_b: SystemB,
    name: Cow<'static, str>,
    component_access: Access<ComponentId>,
    archetype_component_access: Access<ArchetypeComponentId>,
}

Query Sets

アクセスの干渉するクエリを同時に要求できる (両方同時に使用するとパニック) 。実装もイメージできる。

States

いわゆるシーンに相当する enum で、この値を条件に system 実行を分岐できるらしい。

中身は見ていないけれど、これも composite パタンで実装しちゃっていいんじゃないかな。

本によると Run Criteria の上に作られているとか。ラベルとか何とかで複雑に見えるけれど、必要な機能なのだろうか。ひとまず true / false を返す単なるフィルタだと思っておこう……

Removal Detection

World が削除を記録している:

pub struct World {
    // ~~
    pub(crate) removed_components: SparseSet<ComponentId, Vec<Entity>>,
    // ~~
}}

フレームというか Tick の進行時に削除履歴もクリアしている:

impl World {
    /// Clears component tracker state
    pub fn clear_trackers(&mut self) {
        for entities in self.removed_components.values_mut() {
            entities.clear();
        }

        self.last_change_tick = self.increment_change_tick();
    }
}

Fixed Timestep

件の Run Criteria で system をフィルタリングする。

#[derive(Default)]
pub struct FixedTimesteps {
    fixed_timesteps: HashMap<String, FixedTimestepState>,
}

Bevy を知らないと String !? となるが、 stage を指す Label 毎に状態を持っているらしい。

#[derive(Clone)]
pub struct LocalFixedTimestepState {
    label: Option<String>, // TODO: consider making this a TypedLabel
    step: f64,
    accumulator: f64,
    looping: bool,
}

いかにも FPS タイマーという感じ。 TODO: どうしてラベル毎に持つのだろう?

Generic Systems

実装は変えなくても普通に書ける気がする?

Quitting the App

Bevy には app runnner という概念がある:

pub struct App {
    pub world: World,
    // App の所有権を取ることに注意
    pub runner: Box<dyn Fn(App)>,
    pub schedule: Schedule,
    sub_apps: HashMap<Box<dyn AppLabel>, SubApp>,
}

impl App {
    // 本当は `run(self)` なんだけれど、メソッドチェインできるように `&mut self` になっている
    pub fn run(&mut self) {
        #[cfg(feature = "trace")]
        let bevy_app_run_span = info_span!("bevy_app");
        #[cfg(feature = "trace")]
        let _bevy_app_run_guard = bevy_app_run_span.enter();

        // 本当は `run(self)` なんだけれど、`&mut self` になっているので
        // swap して所有権を奪う (ここスキ)
        let mut app = std::mem::replace(self, App::empty());

        // `app.runner.run(app)` ができないので、 `runner` の所有権を奪う
        let runner = std::mem::replace(&mut app.runner, Box::new(run_once));

        // 改めて実行
        (runner)(app);
    }
}

その runnner がゲームループで、 AppExit の扱いを実装しているように見れる。 app.runner は全く必要の無い機能だけれど、構文のためには脂肪を増やすことも辞さない感じがいい。


ハイライト:

  • 任意の Command 列をバイト列に保存しつつ、後からそのメソッドも呼べる CommandQueue
  • Resource を fetch してラッピングし、新たな API を提供する EventReader<T>EventWriter<T>

また昔は理解できなかった App::run を読めたのが嬉しかった。

Bevy 0.6.system() の呼び出しが消えた。 .system() を消す方法は:

  1. BoxSystem<'w, P, R> みたくライフタイムを付けて制限に屈する
  2. type Item<'w>; みたいな GAT を unstable Rust で使う
  3. stable Rust で type Item<'w> を再現する

3 のやり方が分かったので sparsey から .system() 呼び出しを消す PR を送った。昔は GAT が無かったんじゃぞ、と言える時代が来てほしい。

その他 ECS 記事を出した後の情報収集を書く欄


そういえば zig-ecs があった! EnTT の port と言いつつ僅か 4,000 行強 という衝撃……。静的ダックタイピングで無理なく強力なコードが書けるのが良さそう。カスタムアロケータが合理的にマニアックで良さそう (Rust は無駄にマニアックになる時が多いから (それがいい)) 。ptr.field.*.* でデリファレンスする構文は Rust にも欲しかった。

やはり shipyard が良いクレートな気がする。今日良かったのは Hierarchy のレシピで連結リストのソートが載っていた点。あまり使い所は思いつかないけれどハマる時は良さそう。

普通描画するときは z 軸でソートして Vec<Entity> を作ると思う。 Sparse set-based な ECS で Vec<Entity> を経由してイテレートする場合は、グループを生かして &[Entity]&[DenseIndex] とみなせば効率良さそう。

toecs を使い始めた欄

Resource

  • Resource の自動実装を止める?

  • プレイヤーを表現する
    今は Player(Entity) をリソースとして使っている。

  • ResMut の借用の分割
    ResMut<T>&mut T に変換しておく (let x = x.deref_mut()) 。

  • Read-only な resource
    CrateOnly<T>(pub(crate) T) みたいな system param (BorrowWorld) を作れば read-only な T を ECS の引数にできる。

  • Private な resource
    Bevy だと system が Local<T> を持てて便利……

Component

  • &Comp<T>&CompMut<T> を区別したくないシーンがある
    セマンティクス的には同じだが内部的には異なる。 &[T]&ComponentPool<T> にキャストすればいいかな。

スケジューラ不要説

  • One-shot な World::run で十分かも

    • &mut World を引数に取る関数に BororwWorld を実装する?
    • 直列/並列のシステム呼び出しを短く書きたい
  • SystemResult を止める?
    任意の値を返す system の one-shot call でいい気がしてきた

  • System param とは別に引数を取る関数
    fn system(a: u32, b: u32, (x, y, z): (Res<u32>, Res<i32>, Res<f32>)) みたいな system を system(0, 1, world.borrow()) の形で呼ぶ?

System

  • Custom system parameter
    System は任意の BorrowWorld を引数に取ることができるため、ユーザ定義の型も引数に取るようにできる。たとえば drop 時に描画コマンドを発行する Screen<'w> を提供できるはず。

  • Resource を作成する system が欲しい
    いよいよ Command の出番


Bevy の SystemSet でステートマシンが作れるみたい:

impl Plugin for SplashPlugin {
    fn build(&self, app: &mut App) {
        // As this plugin is managing the splash screen, it will focus on the state `GameState::Splash`
        app
            // When entering the state, spawn everything needed for this screen
            .add_system_set(SystemSet::on_enter(GameState::Splash).with_system(splash_setup))
            // While in this state, run the `countdown` system
            .add_system_set(SystemSet::on_update(GameState::Splash).with_system(countdown))
            // When exiting the state, despawn everything that was spawned for this screen
            .add_system_set(
                SystemSet::on_exit(GameState::Splash)
                    .with_system(despawn_screen::<OnSplashScreen>),
            );
    }
}

そこまでは ECS に求めてないかな……

Component の動的着脱

inkfs devlog 2toecs 上にシーングラフを作りたい。まだ具体的なシナリオが見えていないものの、やはり Child / Parent の取り外しが難しそう (グルーピング処理が走るため) 。

考えられる選択肢としては、

常に付けておく

Node { Option<Child>, Option<Paren> } を常に付けておく。ヌルチェックが面倒で、メモリも余計に食いそうだけれど、安定している点が良さそう。これにしようかな。

遅延実行する

&mut World を取れるタイミングで挿入・削除を実行する。

即実行する

Component family を丸ごと借りてくれば、その場で挿入・削除できる。

複数の World

開発中のローグライクでやりたいことが 2 つある:

  • App world と render world を分けたい (pipelined rendering)
  • Three-Worlds Theory のように計算用の World と描画用の World を 2 つ持ちたい

このとき Component / Resource が所属する World を静的に決めたい。 Res<T>Comp<T>WorldId を型引数に取ればよさそう:

pub struct Res<'w, T, Id = DefaultWorldId> { /* ~~ */ }

pub type RenderRes<'w, T> = Res<'w, T, RenderWorldId>;

リソースとして World を持てば十分な気もする……。わざわざ Universe を作る必要はあるのだろうか。保留

#[derive(Resource)]

Pros

  • ドキュメント性
  • 非リソースな BorrowWorld 型を間違って Res<T> で指定しなくて済む

Cons

  • これぞという型を直接 resource として使用できない
    ECS を使わないフレームワークの上に ECS フレームワークを作るときに不便。 Component だとこういうことは無かった。

ひとまず Deref な型を用意してみた:

#[derive(Component, Debug, Derivative)]
#[derivative(Clone, Default, PartialEq, Eq, Hash)]
#[repr(transparent)]
pub struct C<T: 'static + fmt::Debug>(pub T);

#[derive(Resource)] を必須にすると致命的に不便な場合が出て来た。 Orphan rules の都合上

Re-export して隠すしかないか:

pub type X = R<::external_crate::X>:

うーむ無駄なコード。。。

いや、これが結構良さそう。リソースとして fetch する文脈、しない文脈を分けることができて良い。

(たまたま Deref だけで間に合う型だったのはある)

かなり頑張った感じのコード:

pub fn bake_system(
    mut queued_glyphs: ResMut<R<text::RasterGlyphQueue>>,
    mut fonts: ResMut<R<text::FontArena>>,
    mut baker: ResMut<R<text::RasterGlyphBaker>>,
    device: Res<RenderDevice>,
    queue: Res<RenderQueue>,
) {
    baker.bake(&mut queued_glyphs, &mut fonts, &device, &queue);
}
  • Device, Queue はあらゆる場所で使うためエイリアスを作る
  • 他のデータはプラグインの範囲内で R<T> にラップする

プラグインだけがその型は Resource かどうかを判断すればいい。 Resource を実装する型に R<T> を使うと死亡するが……

あああっ

pub fn batch_queue(
    device: Res<RenderDevice>,
    queue: Res<RenderQueue>,
    cam: Res<R<Camera2d>>,

カメラはプラグインで収まる範囲では無いにも関わらずこんな場所に。。

  • R<T> スタメンが続々登場!
  • ソース変更箇所は 30 行に到達!
  • .res_mut() で型推論が働かない不都合が発生!
  • R<T> の誤用をやらかす!

そもそも Res<T> と書いた時点でグローバル変数なのは分かっているので、R<T> と書く意味は無いな……。

機能も揃ってきたし、ゲーム開発が順調になって来た!

それでは締めます。お疲れ様でした〜

このスクラップは4ヶ月前にクローズされました
ログインするとコメントできます