simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

Unity:CEDEC2018のECSの発表から学ぶ

Unity にはコンポーネント指向という作法がここ10年間築かれてきましたが、他のゲームエンジンに並ぶような速度で計算するためには、そのコンポーネント指向をやめる必要があります。
ECSと呼ばれる新しいシステムを導入して、パフォーマンスを上げていくことになります。

ここまでECSでパフォーマンスが上がる仕組みをなんとなく理解してきたけど
ここで、もう一度 ユニティ・テクノロジーズ・ジャパンの発表資料を使って勉強していくことにします。

今回参考にする発表資料はこちら
「CPUを使い切れ!Entity Component System(通称ECS)が切り開く新しいプログラミング」
CEDEC2018

発表動画はそのうちこちらに追加されるのかな?
CEDECチャンネルYouTube版 - YouTube

これからは Unity を使う = ECS + Job Systems の書式でゲーム作る
という式が成り立つので、早いところ慣れておかないとUnityの現役エンジニアでも、一年後くらいに仕事無くなっちゃうと思います
人は誰しも常に勉強し続けて、最新技術に追従していかないと生きていけないんですよね

さて今回のECSへの移行ですが C++ で仕事してきた人たちがまた脚光を浴び、C# であまり見かけないポインタって何?、CPUにやさしいメモリ配置って何?って言ってた人が立場を失うドラマが各所で始まるわけです(ウソ)

発表スライド資料はこちら、簡単に読んで内容をまとめてみます。

www.slideshare.net

コンピュータが作られたのは第二次世界大戦中、ちょうど祖母が成人したころです。(祖母は今も元気に生きています。歴史浅い)
仕組みはずっと変わっておらず、集積率だけが劇的に向上して今のスマホがあります。(大枠でとらえると)
大枠でとらえたコンピュータの仕事は、CPU がメモリから情報を引き出して、足し算した結果をメモリに戻す作業なのですが
単純な処理を大量にこなす処理の中、まったく同じ計算式を実行するときに、一気に複数の式を実行する機能があります。
これには特定のメモリ領域に、決まったルールで情報を配置する必要があるのですが、要するにメモリに効率的にデータを詰めておけば、コンピュータの仕事が早く終わるというアイディアが存在します。

ゲームオブジェクトに共通の要素、例えば Transform Matrix や Position, Rotation 情報など、これらを更新する処理を対象に、
登場するデータをメモリに効率的に配置したいので、その配置処理に必要な情報を拾えるように ECS へ必要最低限の情報提供を行う必要があります。

ということで、その ECS が求める情報と、渡し方のコード書式を具体的に覚えることが、これからの Unity 開発時代を生き抜くことにつながります。

そこで、具体的には発表資料の 33p ~ 35p の部分の絵を覚えるのが良さそうです。
情報を行列と見立てた場合
Entity が列ベクトル(column)
Component が行列の要素(element)
System が行ベクトル(row)
CPUがアクセスするメモリの構造をイメージしながらコードを書く感じですね。

この行列の図から、Entity は Component を要素とする列ベクトルなので、プログラムでは Entity に Component を登録する記述が必要になります。
そこで Entity へ渡すComponet を列挙するコードが ArcheType 作成コードです。

ECS は全ての Entity がどのような Component を持つのかを把握してメモリ配置の効率化を行いたいので、すべての ArcheType の登録は EntityManager を介して行うことになります。
登録した ArcheType を指定して、初めて Entity を作成するコードが書けるようになります。

資料の 39p から 44p までの書式は
行列要素の Component 定義
行列の列ベクトルの Entity の定義
その Entity の作成
作成した Entity の Component へ値の代入 (SetComponentData)
となっています。

EntityComponentSystem では動的に Entity を定義して利用することになっていきます。
プログラミング時のメンタルモデルを変えないといけないですね。(型は基本コンパイル時(静的)に決めていたと思いますので)

BurstCompiler を有効化する時の属性が
[BurstCompile]
とわかりやすくなっている。(Jobの属性に追加するのは変わらず、昨日見た Unite Europe 2017 動画だと、もっと長いやつだった)

Job 構造体の Execute 関数内部のみが高速化用のコードに変換されるが、参照型にアクセスできない、static 変数にアクセスできないという制約あり(逆に副作用とされる競合状態を事前に避けられるのでうれしいかも)
ところで、デバッグ時の Debug.Log を Execute 関数に仕込みたいですよね、その気持ちわかる!ということで関数の属性に [BurstDiscard] を与えると、Burst するときに除外されるので、非 Burst のデバッグ時に利用できることが紹介されています。

マルチスレッド処理のおさらい
コンテキストスイッチ
いつも仕事の効率化について使う単語だけど、CPUに割り当てるスレッドの切り替えも同じ単語使う

そして最重要なのが ComponentDataArray

残念ながら、このパワポ資料単体では ComponentSystem の動きをイメージしきれません。
いったんイメージ作成のための要素知識を作成する作業として次の記事を読んでおきます。
tsubakit1.hateblo.jp

さらに残念なことに、[Inject] などの属性のイメージがまだつかめないことがわかりました。

パワポ資料に戻って、コンポーネント指向のこれまでの実装と、ECS によるこれからの実装の変化についてみていきます。
MonoBehaviour → JobComponentSystem
MonoBehaviour.Update 関数 → JobComponentSystem.OnUpdate 関数

行列で ECS を表した時、行ベクトルが System であることを示しました。
System は複数の Entity から抽出した同一の ComponentData 配列であることをイメージできます。

その ComponentData 配列こそ ComponentDataArray であり、具体的に利用するために ComponentSystem クラス内に注入(Injection)します。(なんか表現エロいですね)
正確には複数の ComponentDataArray を Group として定義して、その Group に [Inject] 属性を与えて注入することを宣言します。
この ComponentDataArray の Group の Injection が只者じゃないってことで、発表資料では面白いと紹介されています。

実装例では ComponentDataArray と IJobParallelFor の組み合わせで、Group の Injection の代わりに Job の依存関係を記述することで示されています。
ComponentDataArray からの読み出しと書き込みの例が示されていますが

ECS 学習中に、Job System との連携コードが示されて、急激に認識負荷が上昇していますね。
ここもっと優しい解説が必要なんじゃないかな?

そこで、私の解説を追加します。
Job の依存関係は前回の記事にて
[UpdateAfter(typeof(DamageSystem))]
といった属性を ComponentSystem クラスに設定することで解決できると示しましたので、これで行列の行ベクトルの処理の実行順を ECS に教えることができます。
この記事を書いた後に、とても理解の助けとなる解説記事が作られましたので紹介します。
tsubakit1.hateblo.jp
プロファイラからもEntitySystemの実行順が確認できるのは助かりますね。(精神が)
さらに OnUpdate 関数内の Job の Schedule にて、このクラス属性の依存関係を参照できるように
AddDependency(job.Schedule(m_transforms, GetDependency()));
と ComponentSystem 側から Job Handle を取得して、ECS と Job System との融合を果たします。

やっと書式に納得いく理解できた。

あとは行列の要素である Component にどんな型が指定できるかの解説が続きます。
IComponentData で書ける型は blitable なもののみ
blitable ってのは、よく DllImport などで渡しできる型が限定される例と同じで、メモリサイズが確定する型のみ指定可能
ゆえに NativeArray もだめです。

高度な解説ですが、Job 内の Execute 処理中に Entity を生成したいという要望にも応えるギミックがあることが示されています。
具体的には EntityCommandBuffer と BarrierSystem の存在です。
Job の中での Entity 生成は、いったんその Job がすべてのスレッドで完了し終えた時に、Entity の生成を行うということで
コードを書いているときに、やっぱりスレッドの動きを想像しながら書かなければいけません。(ここはこれまでのマルチスレッドの記述で気を付けていたことと同じですね)

この辺の書式は使っていかないと、感覚をつかみづらそうですね
すべての Job を停止してから、キューに積まれたコマンドを実行する遅延実行の機構が BarrierSystem で
そのコマンドのキューが EntityCommandBuffer と覚えておきます

あとはゲームロジックから見た ECS ですね。
OnUpdate で一気に複数の Entity の ComponentData を処理することになりますが、やっぱり特定の Entity について特別に処理を分岐したいじゃないですか。
例えば大量のミサイルの追尾となったとき、ミサイルが追尾する対象は Entity なので、その Entity の ComponentData を取得したいケース
エンティティから Component Data を取得可能とはそういうことで
ComponentDataFromEntity が用意されています。
ほかにも、Entity が存在しているかどうかをExists 関数を用いて Job 内で見ることができるので、なんだかんだゲームオブジェクトへの参照が Job 内で使えなくても、ゲーム特有のオブジェクト依存のロジックが組めそうです。

ゲームロジックをどうしても Job で書きたい、ゲームオブジェクトへの参照を Execute 関数内で実現したい、そんなときの書式紹介もありました。
static 変数へのアクセス制限があるので BurstCompile をあきらめることになりますが、Static 変数として定義したクラスのインタフェースを介してゲームオブジェクトの参照を利用したコードを Job 内で実行することができます。

後半は汎用性ではなく、ゲーム特化のテクニックの紹介ですね、実装時に困ったら参考にしてみようと思います。
こんなところでしょうか?

結局作りやすさは従来のコンポーネント指向で、これは無くならずに
今後パフォーマンスが求められる場面で ECS が導入されていくことになる未来が見えてきました。

一度コンポーネント指向で作り、パフォーマンスの負荷が高い場所について、 ECS への移行ができないかを見極める眼力が今回の勉強で備わった気がします。