ECSとJobSystem 基礎
はじめに
やる気がある。全部Uniteのせい。
今回はECS(Entity Component System)とJobSystemについて色々触ってみたので、基礎部分のメモを残しておきます。
サンプルのプロジェクトは以下から見れます。
どの程度自分の解釈が正しいのかちょっと怪しい部分もあるので、今後も色々と検証も行っていきます。その段階で間違いとか新たな発見とかがあったらその都度記事を更新します。
あと、今回の内容は非同期処理について多少知見があると理解が早いです。 なんか4年前くらいに書いた資料が発掘されたので、興味がある方は見てみてください。 後ろの方(スライド92枚目あたり)に非同期処理についての何かが書いてあります。
ECSとJobSystemを使うメリット
そもそも、ECSやJobSystemはどんな時に役立ってくれるのでしょうか。 これからECSとJobSystemそれぞれ、または両方利用する場合のについてメリットについて見ていきます。
今関わってるプロダクトやこれから関わるプロダクトでECSやJobSystemの恩恵が殆ど感じられないようなケースもあるかもしれません。これらのメリットがあまり享受できなそうだと感じたら、無理に使う必要はありません。
ただ、ECSとJobSystemを使いこなす事が出来れば、今までのUnityでは実現が難しかったことが出来るようになります。 具体的には「大量のGameObjectをシーンに配置してそれぞれを動かしたり何かしら処理をさせたりする」みたいな事が高速に処理出来るようになります。 加えて、メモリ効率も格段に良くなります。
既存プロダクトの最適化でも役に立つケースがあるかもしれませんし、今後より便利になっていく機能だと思うので、今のうちに概要だけでも知っておくのが良さそうです。
メモリ効率を良くする(使用量/パフォーマンス)
GameObjectをシーンに配置するとどのくらいのメモリを喰うか、意識したことはありますか? それぞれのComponentはどうでしょうか?
これらのリソースがアプリケーションでどのくらいのメモリを確保するかは、実行している環境によって変わってきます。
これらを詳細にチェックするには実際の環境での実行時にメモリをプロファイルする必要がありますが、Unityエディタ上でもProfilerウィンドウから参考数値程度のものであれば確認することが出来ます(エディタで表示されるメモリ使用量は一般的に実機で実行しているときよりも大きくなります)。
例えば、ParticleSystemを持つGameObjectを大量生成した場合、エディタ上ではこうなります。
500個くらい生成してProfilerを確認した結果です。勿論実機ではもっと軽量になりますし、そもそもこんなに生成しないと思いますが... ParticleSystem結構重たいなっていうのはちょっとした発見。
Unityではオブジェクトにコンポーネントとしてデータをもたせることが出来ますが、このデータの多くは冗長で、実際には使われないようなものが多く含まれます。
ECSはDOA(Data Oriented Approach : データ中心設計)のため、このようなムダなメモリの確保を限りなく無くすことが出来ます。
また、データはハードウェアにとって扱いやすいよう連続的にメモリに配置され、キャッシュ効率の良いメモリアクセスが実現できます。
つまり、ECSは「大量のデータを効率よく処理する」ことに長けています。 「大量のGameObjectやComponentを扱う必要がありそう...」と感じたら「ECSを利用してGameObjectや無駄なComponent を作らず、必要なデータだけを定義して処理することができないか」を考えましょう。
処理の並列化
一般に、マルチスレッドでコードを書く際に注意しなければならないのが、データへの同時アクセスです。これは意図しないタイミングでの値の読み書きにより、期待した動作と異なるような結果を招いたり、あるいはデッドロックを発生させたりします。 これを回避するために多数の同期用データや処理を足すと、コードが煩雑になるだけでなく、あるThreadが他のThreadの完了待ちで長時間待機状態になってしまう...というような事も少なくありません。 結果、パフォーマンスがあまり改善しなかったり、デバッグが困難であったりと、様々な問題を抱えています。
JobSystemはUnityEngineと上手い具合にやり取りするマルチスレッドコードを簡単に書くことができるように設計された機能であり、UnityEngineが内部で利用するThreadを効率よく使いまわすことが出来ます。また、扱えるデータに制約を加えることで、MainThreadの共有リソースへの同時アクセスが発生しにくいような仕組みになっており、加えてデバッグも容易になっていたりします。複数のCPUコアを効率よく使うことで、アプリケーションの様々なロジックを高速に完了させることが出来、適切な箇所に適用することでかなり大きな恩恵が得られます。
そして、ECSは並列処理ととても相性の良いアーキテクチャです。 ECSで扱うデータは、シンプルな値がメモリ上に連続して並んでいるような格好になります。 このデータのそれぞれに並列で何か処理を行いたい場合「入力されたデータを処理してその結果を出力する関数」をマルチスレッドで走らせれば良いという、とても単純な設計を行えば良くなります。
まとめ
ざっくり纏めると、以下のようなメリットがあります。
- メモリ使用量が減る
- メモリレイアウトが良くなりキャッシュ効率が上がる
- 並列化が容易
使い所としては
- 大量のデータを扱う必要がある場合
- 処理の並列化を行いたい場合
といった感じでしょうか。
JobSystem
JobSystemについて基本的なところメモしておきます。 以下のサンプルを参考にさせていただきました。
JobSystemとは何か
JobSystemはUnityEngineと上手い具合にやり取りするマルチスレッドコードを簡単に書くことができるように設計された機能です。
JobSystemの重要な機能は、ユーザーの書いたコードとUnityEngineが内部的に使用しているネイティブ実装のJobSystemを統合することです。 これは、ユーザーが書いたコードとUnityEngineがJobSystem用に生成されたWorkerThreadを使う事を意味します。
JobSystem用に生成されたWorkerThreadを共有することによって、ユーザーがThreadの生成や管理を行う必要がなくなり、更に「CPUの論理コア数より多くのスレッドが生成されるのを防ぐ」ことが出来ます。
これは、コンテキストスイッチの発生を防ぐことに繋がります。
コンテキストスイッチは、 「処理実行中のスレッドの状態を保存して中断し、別途必要になった処理を行うために別のスレッドに切り替えて作業を行い、その後で最初のスレッドを再構成して処理を続ける」プロセスです。
コンテキストスイッチはリソースを大量に消費するため、可能な限り避けることが重要になります。 因みにUnityEngineの生成するWorkerThreadはコンテキストスイッチを避けるために、論理CPUコアと同等のThread数になるよう生成されます(MainThreadと合わせて論理コア数と同数になるっぽいです)。
JobSystemではUnityEngineが用意した WorkerThreadで実行可能なJobと呼ばれるものを作成します。 Jobは、メソッド呼び出しの振る舞いと同様にパラメータを受け取り、 データを操作します。Jobは小さく、1つの特定のタスクを実行するように作る必要があります。
Jobは継続タスク(async/await)のような従属性をサポートします。 つまり、AとBというJobがあり、BはAの終了後に実行させないといけないといったようなケースをサポートしています。
また、JobSystemはマルチスレッドプログラミングを行う上で常に問題となる「データ参照の競合」が発生しないよう、 操作する必要があるデータのコピーを作成します。
コピーするデータはBlittableデータ型 (マネージコードとネイティブコードの間で渡されたときに変換を必要としないデータ型)のみで、JobSystemではこれに該当する型にしかアクセス出来ないという制約があります。
マネージコードからネイティブコードへのデータの受け渡し方法ですが、JobSystemはScheduleメソッドを呼び出すことでBlittable型を内部的にmemcpy関数でコピーし、受け渡します。
コピーされたデータはネイティブメモリに格納され、Jobに定義されたExecuteメソッドがこの値を使ってWorkerThread上で処理を実行します。 当然ですが、このコピーされた値をWorkerThreadで変更したとしても、MainThread上の値には何の影響もありません。
では、WorkerThreadで処理した後の値を受け取るにはどうしたらいいでしょうか。これは
NativeContainer属性が付加されたコンテナを使うことで実現可能です。
例えば、NativeContainer
属性の付加されているNativeArray
というコンテナを使うと、Jobはそのデータに対してコピーを行わず、メインスレッド上の共有データにアクセスすることができます。
この共有データを使った処理が複数のJobで行われて競合した場合、例外が送出されます。
Unityが用意してくれているNativeContainer
属性の付加されている型は
- NativeArray
- NativeList
- NativeHashMap
- NativeQueue
など、様々なものがあります。これが全てではありません。
JobSystemのサンプル
以下のコードはUnityマニュアル のサンプルコードをちょこっと弄ったものです。
velocity
とdeltaTime
からposition
を計算するJobを作成し、実行しています(計算後にログを出力するだけで、ゲーム的な動きがあるサンプルではありません)。
using UnityEngine; using Unity.Collections; using Unity.Jobs; namespace Es.JobSystem.Sample._01 { public class JobSystemSample01 : MonoBehaviour { // Jobを作る際、Jobでアクセスされる全てのデータをJob内に宣言します。 // 宣言が可能なのはNativeContainer及びBlittable型のみです。 struct VelocityJob : IJob { // 読み取り専用という付加情報を与えることで複数のJobが並列にデータにアクセスできるようになります。 [ReadOnly] public NativeArray<Vector3> velocity; // デフォルトでは、コンテナは読み書きが可能です(つまり、MainThreadで結果を取り出すことができます)。 public NativeArray<Vector3> position; // Jobには一般的にフレームの概念がないため、deltaTimeをJobにコピーする必要があります。 // MainThreadは同じフレームまたは次のフレームでJobを待機しますが、Jobは // WorkerThreadで独立して処理が実行されます。 public float deltaTime; // Jobが実行するコードです。 public void Execute() { for (var i = 0; i < position.Length; i++) position[i] = position[i] + velocity[i] * deltaTime; } } public void Update() { // NativeArrayはNativeContainer属性が付加されているので // MainThreadとWorkerThreadでデータを安全に共有することができます。 // また、使い終えたらDisposeする必要があります。 var position = new NativeArray<Vector3>(100000, Allocator.Persistent); var velocity = new NativeArray<Vector3>(100000, Allocator.Persistent); for (var i = 0; i < velocity.Length; i++) velocity[i] = new Vector3(0, 10, 0); // Jobの初期化処理です。 var job = new VelocityJob() { deltaTime = Time.deltaTime, position = position, velocity = velocity }; // Jobをスケジューリングし、後でJobの完了を待つことができるJobHandleを返します。 JobHandle jobHandle = job.Schedule(); // メインスレッドで何か計算している最中にJobを動かしておきたい場合は以下のメソッドを呼ぶ JobHandle.ScheduleBatchedJobs(); // ...... // 何かMainThreadで行っておきたい処理 // MainThreadで10[ms]かかる重い処理を想定 // ...... System.Threading.Thread.Sleep(10); // Jobが完了したことを確認します(完了してなければ完了まで待ちます) // Schedule実行後、すぐにCompleteを呼び出すことはお勧めできません。 // 並列処理の恩恵を受けることがほぼできなくなるためです。 // フレームの早い段階でJobをScheduleし、他の処理を行った後でCompleteを呼び出すのが最適です jobHandle.Complete(); Debug.Log(job.position[0]); position.Dispose(); velocity.Dispose(); } } }
Jobを使うにはIJobインターフェースを実装した構造体を定義します。
もしJob側では読み取りにしか利用しないデータの場合にはReadOnly
属性で修飾してあげましょう。こうすることで複数のJobで並列してデータにアクセスできるようになります
(デフォルトでは読み書き両方できるデータになります)。
IJobを定義した構造体を作ると、Unityが用意した以下のように定義されているSchedule
拡張メソッドが利用可能になる仕組みです。
public static JobHandle Schedule<T>(this T jobData, JobHandle dependsOn = default(JobHandle)) where T : struct, IJob;
このSchedule
を呼び出すことで、JobがJobSystemの管理するQueue(JobQueue)にEnqueueされます。
Schedule
の戻り値はJobHandleです。
JobはSchedule
メソッドのパラメーターにJobHandle
を指定して、特定のJob実行後に継続して他のJobを実行させるようにできます。また、JobHandle.CombineDependenciesを使うことで、複数のJobHandle
を纏めることができます。この纏めた結果のJobHandle
をSchedule
のパラメーターとして利用する場合、纏めた全てのJobの完了後にSchedule
されたJobが実行されます。
Schedule
をしても、実際にはJobは実行されません。Jobを開始するには、バッチ(仕事を一定数ごとに分割したもの)をflushする必要があります。これはComplete
を呼び出すと行われます。しかし、
Schedule
後直ぐにComplete
を呼び出すと、MainThreadがその段階でJobの完了待ちをしてしまうため、並列化の恩恵が薄れてしまいます。また、Jobがすぐに完了待ちを行うようなコードになっている場合、この勿体無い待ち時間をなんとか潰すために、MainThreadでもJobが実行されます(これの判断はJobSystemが勝手にしてくれます)。
これを解決するには、
Schedule
後にJobHandle.ScheduleBatchedJobs
メソッドを呼び出します。このメソッドを呼び出すことでバッチがflushされ、Jobが開始されます。あとはサンプルのように、Jobの結果を待つタイミングでComplete
を呼び出します。
上記はサンプルコードの
JobHandle.ScheduleBatchedJobs
をコメントアウトして実行したときの結果です。 無駄な待ち時間が発生するため、MainThreadでJobが実行されています。半々くらいの頻度でWorkerThreadで処理を行ってくれようとするんですが、結局MainThreadでは待ちが発生するので、どちらにせよ並列化の恩恵は受けられません。
Schedule
したら、後はCompleteやJobHandle.CompleteAllでJobの完了を待ちます。
サンプルコードで使っているNativeArrayはNativeContainer
属性が付加されたコンテナです。
NativeContainer
属性はジョブデバッグシステムが競合状態を確実に検出可能であることや、Thread間での安全な値の受け渡しが可能であるということを示すための属性です。なので、(この属性を付加された構造体の実装がちゃんとしていれば)MainThreadから安全に値にアクセスできます。NativeArray
は固定長の配列で、生成時には配列のサイズとメモリの割り当て、解放について指定するAllocatorを渡します。
MainThreadから安全に値にアクセスできると書きましたが、競合した場合は「例外が出てくれる。ログにも出力される」的な意味で安全と言っています。 もし独自で
NativeContainer
属性を付加したコンテナを自作しても、実装がちゃんとしてなければクラッシュする可能性があります。 これに関してはNativeContainer
のドキュメントで詳細が見れます。
Allocatorについて
- Allocator.Temp
- メモリ確保が最も高速です。これは1フレーム以下のアロケーションでの使用を目的としており、スレッドセーフではありません。この方法でメモリを確保した場合、関数呼び出しから戻る前にDisposeメソッドを呼び出す必要があります。
- Allocator.TempJob
- Allocator.Tempよりメモリ確保が低速ですが、Allocator.Persistentより高速です。これは、4フレーム以下の寿命を持つ場合に利用出来、スレッドセーフです。ほとんどの短期ジョブはこのNativeContainer割り当てタイプを使用します。4フレーム経過前にDisposeしないとメモリリークします。
- Allocator.Persistent
- メモリ確保が最も遅いですが、アプリケーションの存続期間中存続します。mallocへの直接呼び出しのラッパーです。より長いJobがこのNativeContainer割り当てタイプを使用することがあります。使い終わったらDisposeします。
さて、これを動かしてProfilerで確認すると、VelocityJob
のExecute
がJobSystemの管理するWorkerThreadで動いているのが確認できます。
並列化Job
先ほどのサンプルでは、MainThreadから切り離したい処理をJobとして登録し、JobSystem上のWorkerThreadで実行されるようにしました。
次はこのJobを並列化してしまいます。先ほどのサンプルでは、Execute
内でfor文を使って、position
を計算していました。
データだけが異なる同じ演算を繰り返している部分なので、このExecute
の中も並列で計算してもらう方がよさそうです。
これを実現するために、IJobParallelForが用意されています。IJob
の代わりにこちらを実装するようにします。
using UnityEngine; using Unity.Collections; using Unity.Jobs; namespace Es.JobSystem.Sample._02 { public class JobSystemSample02 : MonoBehaviour { // Jobの並列化のためにIJobParallelForを実装するよう変更 struct VelocityJob : IJobParallelFor { [ReadOnly] public NativeArray<Vector3> velocity; public NativeArray<Vector3> position; public float deltaTime; // 並列アクセスのためにインデックスを受け取って処理を行うExecuteを実装 public void Execute(int i) { position[i] = position[i] + velocity[i] * deltaTime; } } public void Update() { var position = new NativeArray<Vector3>(100000, Allocator.Persistent); var velocity = new NativeArray<Vector3>(100000, Allocator.Persistent); for (var i = 0; i < velocity.Length; i++) velocity[i] = new Vector3(0, 10, 0); var job = new VelocityJob() { deltaTime = Time.deltaTime, position = position, velocity = velocity }; // 並列実行のJobをスケジュールします。 // 最初のパラメータは、各反復が何回実行されるかです。 // 2番目のパラメータは、内部でのループ分割数(バッチ数)です。 JobHandle jobHandle = job.Schedule(position.Length, 128); // メインスレッドで何か計算している最中にJobを動かしておきたい場合は以下のメソッドを呼ぶ JobHandle.ScheduleBatchedJobs(); // ...... // 何かMainThreadで行っておきたい処理 // MainThreadで10[ms]かかる重い処理を想定 // ...... System.Threading.Thread.Sleep(10); jobHandle.Complete(); Debug.Log(job.position[0]); position.Dispose(); velocity.Dispose(); } } }
上記はUnityマニュアルのサンプルコードです。
サンプルの変更点は
IJob
の代わりにIJobParallelFor
を実装Execute
が引数付きになり、インデックスが渡ってくるのでそれを使うように実装を変更(今回は配列のインデックスとして利用)Schedule
Executeメソッド実行回数(今回は配列の長さ分)、内部でのループ分割数(バッチ数)を指定するように変更
たったこれだけです。
IJobParallelFor
では指定されたバッチ数以上のチャンクに作業を分割し、適切な数のJobをスケジュールします。
複数のJobが複数のWorkerThreadで実行されるようになるため、サンプルのようなケースではとても有効です。
バッチ数に1に近い値がくるほど、スレッド間での作業の分散がより均一になります。 ただし、作業が細かく分割されすぎるとオーバーヘッドがあるので、バッチ数を増やす方がよい場合もあります。1から始まり、パフォーマンスの向上が得られる間バッチ数を動的に増やすことは有効な戦略です。
さて、こちらもProfilerで確認すると、JobSystemのWorkerThreadで処理を行っていることが確認できます。
先程のIJob
を実装したJobのProfilerと比較すると、IJobParallelFor
が複数のWorkerThreadを使って処理を行っていることがわかると思います。
JobSystemを使う上での注意
JobSystemを利用する上で、以下のことに気をつける必要があります。
- Jobから外部データにアクセスしないこと
- 計算を安全に行うために、Job内部で安全に利用できる型には制約がある(Blittable型とNativeContainer)
- この安全性を享受するためには、JobSystemの処理では、この制約を受けた変数のみを使う必要がある
- Job外部のデータを参照して利用した場合、安全性が担保できなくなる可能性がある
- 最悪Unityがクラッシュする
- 今後のバージョンのUnityでは、静的解析によってJobから外部データへのアクセスが禁止される可能性がある
- このため、これに該当するようなコードを書いた場合、将来のUnityでは動かなくなる可能性がある
- Jobを開始するには、Scheduleされたバッチをflushする必要があることに注意する
Complete
は呼び出した際にJobが開始されてなければflushして実行し、完了を待つJobHandle.ScheduleBatchedJobs
を使えばComplete
以前にJobを開始しておくことができる- Jobを
Schedule
したら、なるべくJobHandle.ScheduleBatchedJobs
してその後Complete
を呼び出す
NativeArray
の要素を変更する際に注意する- 常に
Complete
を呼ぶことComplete
を呼ぶことで、WorkerThreadに委譲していたNativeContainer
の所有権がMainThreadに返されるComplete
はJobデバッガの状態も更新する(更新されないままだと、デバッガはメモリリークしたと判断し、警告を出す)
ECS
続いてECSについて。
みんな大好きテラシュールさんがめちゃくちゃわかりやすく、参考にさせていただきました。
- 【Unity】Unity 2018のEntity Component System(通称ECS)について(1) - テラシュールブログ
- 【Unity】Unity 2018のEntity Component System(通称ECS)について(2) - テラシュールブログ
ECSの概念的な事とかは長くなるので書かないことにします。 先ほどの参考ページや、以下を参照してください。
ECSを利用するサンプルコードと、それの解説のみ書いていくことにします。
上記で紹介したテラシュールさんでも言われていますが、本家ECSとUnityのECSだと用語が微妙に紛らわしいです。 本記事内では本家ECSの用語を使っていきます。つまり
- Data
- 何かしらの情報
- Entity
- Dataの集まり
- System
- Entityに対して行う処理
- Group
- Systemが要求するDataの纏まり
といった意味合いで解釈してください。
ECSを使うサンプル
using Unity.Collections; using Unity.Entities; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; namespace Es.Ecs.Sample._01 { // 独自のDataを定義する場合、IComponentDataかISharedComponentDataを実装します。 // IComponentDataは、座標情報などのEntityごとに異なるデータに適しています。 // ISharedComponentDataは、多くのEntityに共通するものがある場合に適しています。 public struct SpeedData : ISharedComponentData { public float Value; public SpeedData(float value) { Value = value; } } // Group(Systemに渡されるEntityの纏まり。つまり要求されるデータの配列のようなもの)を定義。 // IComponentDataかISharedComponentDataを実装したDataがSystemに要求されるデータになります。 // Lengthには要求するDataを持つEntityの数が格納されます。 public struct SampleGroup { // ComponentDataArrayはNativeContainer属性が付加されているので // Thread間でデータを共有できます。 public ComponentDataArray<Position> postion; public ComponentDataArray<Rotation> rotation; // SharedComponentDataArrayはReadOnlyを指定しないとエラーになります。 // SharedComponentDataArrayはEntity間で共通の値であり、NativeContainerではないため、 // 値の代入行為が不適切であるからです。 // SharedComponentDataArrayは、Systemで計算に使う値を格納する用途で使います。 [ReadOnly] public SharedComponentDataArray<SpeedData> speed; public int Length; } // ComponentSystemを継承したクラスを作ることで // GroupがEntityの持つ型と一致する場合に処理を実行するSystemを作ることができる。 public class SampleSystem : ComponentSystem { // Inject属性で要求するグループを指定する // (Systemに特定のDataへの依存性を注入する) [Inject] private SampleGroup sampleGroup; float deltaTime; // Systemが毎フレーム呼び出す処理 protected override void OnUpdate() { deltaTime = Time.deltaTime; for (int i = 0; i < sampleGroup.Length; i++) { // 落下させる var newPos = sampleGroup.postion[i]; newPos.Value.y -= sampleGroup.speed[i].Value * deltaTime; sampleGroup.postion[i] = newPos; // 回転させる var newRot = sampleGroup.rotation[i]; newRot.Value = math.mul(math.normalize(newRot.Value), math.axisAngle(math.up(), sampleGroup.speed[i].Value * deltaTime)); sampleGroup.rotation[i] = newRot; } } } // ECSを利用するサンプルクラス。 // JobSystemを利用していないため、MainThreadで動く。 // このサンプルでは、大量のMeshの移動と回転を行い、描画する。 public class EcsSample01 : MonoBehaviour { public Mesh mesh; public Material material; public int createEntityPerFrame = 100; private EntityManager entityManager; private EntityArchetype archetype; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private void Start() { // Entityの管理者を取得 entityManager = World.Active.GetOrCreateManager<EntityManager>(); // アーキタイプ(EntityがもつDataタイプの配列)の登録 archetype = entityManager.CreateArchetype( typeof(Position), // Unity.Transformでデフォルトで定義してくれている「位置」を表すData typeof(Rotation), // Unity.Transformでデフォルトで定義してくれている「回転」を表すData typeof(SpeedData) // 独自定義した「微小な値」を表すData ); } private void Update() { // Spaceキーが押さていたらMeshを生成 if (Input.GetKey(KeyCode.Space)) { for (int i = 0; i < createEntityPerFrame; i++) { // 管理者にEntityの生成と管理をお願いする var entity = entityManager.CreateEntity(archetype); // 生成したEntityに対して、Dataを登録してもらう entityManager.SetComponentData(entity, new Position { Value = new float3(Random.Range(-20, 20), 20, Random.Range(-20, 20)) }); entityManager.SetComponentData(entity, new Rotation { Value = Quaternion.Euler(0, Random.Range(0, 180), 90) }); entityManager.SetSharedComponentData(entity, new SpeedData(10)); } } //=================================================================================================/ // HACK: // 本来であれば Mesh / Position / Rotation / Material を持つEntityを // 描画するようなSystemを作るべきですが、サンプルコードが冗長になるためここに描画処理を書いてあります。 //=================================================================================================/ // DrawMeshで描画を行う // エンティティの Position / Rotation を取得しつつメッシュを描画 var entities = entityManager.GetAllEntities(); foreach (var entity in entities) { var position = entityManager.GetComponentData<Position>(entity); var rotation = entityManager.GetComponentData<Rotation>(entity); Graphics.DrawMesh(mesh, position.Value, rotation.Value, material, 0); } // GetAllEntitiesで取得したNativeArrayは明示的に破棄する。 // また、GetAllEntityではAllocatorを指定できるが、デフォルトのTempだと // フレームをまたいで生存しているとメモリリークするので注意。 entities.Dispose(); } } }
上記はSpaceキーを押していたら「位置・回転」情報を持つEntityを生成し、SystemがそのEntityに対して「位置・回転情報の更新」を行うサンプルです。
ECSとは直接関係なく蛇足ですが、更新を行なった「位置・回転」情報を使ってDrawMeshでMeshを描画しています(特定のDataを持つEntityの場合に描画を行うSystemを作って任せるようにしたほうがいいと思いますが、サンプルコードが長くなるので今回は割愛)。
実行するとこんな感じになります。
コードの解説ですが、ECSの考え方が掴めていればあとはUnityでそれをどう実装していけばいいのかを把握するだけでほぼ終わりです。ただし、多少気を付けなければならない部分もあるので、踏み抜かないよう注意していきましょう。
Dataの定義
サンプルのSpeedData
の定義がこれにあたります。
public struct SpeedData : ISharedComponentData { public float Value; public SpeedData(float value) { Value = value; } }
Dataを定義する場合は
IComponentData
ISharedComponentData
のどちらかを実装した構造体を定義します。
IComponentData
は、座標情報などのEntityごとに異なるDataの実装に適しています。
Entityごとに異なる値になるので、Systemで読み書きが可能です。
ISharedComponentData
は、多くのEntityに共通するものがある場合に適しています。
値が同じものは各Entityで共通のDataインスタンスが利用されるようになります。
Groupの定義
サンプルのSampleGroup
がGroupの定義です。
public struct SampleGroup { public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; [ReadOnly] public SharedComponentDataArray<SpeedData> speed; public int Length; }
IComponentData
ISharedComponentData
のどちらかが実装されたDataのコンテナを格納する構造体を定義します。 コンテナには
ComponentDataArray
SharedComponentDataArray
が利用できます。
SharedComponentDataArray
はEntity間で共有される値で、Systemが勝手に変更を行うことはできません。コンテナの定義の際に[ReadOnly]
属性を付加し忘れるとエラーが出ます。
因みにComponentDataArray
はNativeContainer
です。SharedComponentDataArray
はNativeContainer
ではありません。
ComponentDataArray
がNativeContainer
であることはJobSystemを利用する際に結構役立つ知識なので覚えておきましょう。
Systemの定義
SampleSystem
がSystemの定義部分です。
public class SampleSystem : ComponentSystem { [Inject] private SampleGroup sampleGroup; float deltaTime; // Systemが毎フレーム呼び出す処理 protected override void OnUpdate() { // ......Systemの処理...... } }
ComponentSystem
を実装し、Systemが要求するEntityのコンテナ実装(つまりGroup)に対して[Inject]
属性を付加します。
OnUpdate
はComponentSystem
の抽象メソッドで、これがSystemの行う処理になります。
後述しますが、JobSystemで並列に処理を実行するSystemを定義したい場合JobComponentSystem
を実装します。
Entityの生成
まずEntityを管理するManagerのインスタンスを生成します。
entityManager = World.Active.GetOrCreateManager<EntityManager>();
次に、EntityがもつDataタイプを先ほどのManagerに渡してEntityを作ってもらいます。
archetype = entityManager.CreateArchetype( typeof(Position), typeof(Rotation), typeof(SpeedData) ); ... var entity = entityManager.CreateEntity(archetype);
生成されたEntityに対してDataを登録すれば終わりです。 Entityは生成・登録を行なったManagerによって管理されます。
entityManager.SetComponentData(entity, new Position { Value = new float3(Random.Range(-20, 20), 20, Random.Range(-20, 20)) }); entityManager.SetComponentData(entity, new Rotation { Value = Quaternion.Euler(0, Random.Range(0, 180), 90) }); entityManager.SetSharedComponentData(entity, new SpeedData(10));
ECSで処理の並列化
ECSの利点は色々あるのですが、そのうちの1つが「データと処理が完全に分離される」点です。 この特徴は、Systemで行われる処理を非同期にするのにとても都合が良いです。
つまり、ECSは非同期処理を行うJobSystemと相性が良く、大量のデータを扱う場合には大きなパフォーマンス向上に繋がる可能性があります。
以下の画像は、先ほどのサンプルを実行し、Profilerで確認したものです。
Systemの処理が全てMainThreadで実行されているため、WorkerThreadが遊んでいます(使ってないので当然ですが)。 これはちょっと勿体無いですね。
以降では、Systemの処理をWorkerThreadで動かすようなサンプルを見ていきます。
JobSystem + ECS
ECSとJobSystemを使う例を見ていきます。 先ほどのECSのサンプルコードを改造してSystemの処理をJob化し、WorkerThreadで動作するようにしています。
using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; namespace Es.EcsJobSystem.Sample._01 { public struct SpeedData : ISharedComponentData { public float Value; public SpeedData(float value) { Value = value; } } public struct SampleGroup { public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; [ReadOnly] public SharedComponentDataArray<SpeedData> speed; public int Length; } // 移動と回転処理を行うJobを定義。 // IJobProcessComponentDataを実装することで、Genericパラメータに指定したDataを // 対象とするJobを定義することができる。 // Job内で宣言が可能なのはNativeContainer及びBlittable型のみなことに注意 struct MoveRotateJob : IJobParallelFor { public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; public SharedComponentDataArray<SpeedData> speed; public float deltaTime; public void Execute(int i) { var newPos = position[i]; newPos.Value.y -= speed[i].Value * deltaTime; position[i] = newPos; var newRot = rotation[i]; newRot.Value = math.mul(math.normalize(newRot.Value), math.axisAngle(math.up(), speed[i].Value * deltaTime)); rotation[i] = newRot; } } // JobComponentSystemは抽象クラス。 // IJobProcessComponentDataを利用するとInjectが不要になったりと // スマートに書けるようになるが、本サンプルでは先ほどのJobSystem // サンプルベースで見ることができるように愚直にSystemをJob化する public class SampleSystem : JobComponentSystem { [Inject] private SampleGroup sampleGroup; // SystemではJobを生成し、ScheduleしてJobHandleを返す protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new MoveRotateJob() { position = sampleGroup.position, rotation = sampleGroup.rotation, speed = sampleGroup.speed, deltaTime = Time.deltaTime }; var handle = job.Schedule(sampleGroup.Length, 32, inputDeps); return handle; } } // ECS + JobSystemを利用するサンプルクラス。 public class EcsJobSystemSample01 : MonoBehaviour { public Mesh mesh; public Material material; public int createEntityPerFrame = 100; private EntityManager entityManager; private EntityArchetype archetype; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private void Start() { entityManager = World.Active.GetOrCreateManager<EntityManager>(); archetype = entityManager.CreateArchetype( typeof(Position), typeof(Rotation), typeof(SpeedData) ); } private void Update() { if (Input.GetKey(KeyCode.Space)) { for (int i = 0; i < createEntityPerFrame; i++) { var entity = entityManager.CreateEntity(archetype); entityManager.SetComponentData(entity, new Position { Value = new float3(Random.Range(-20, 20), 20, Random.Range(-20, 20)) }); entityManager.SetComponentData(entity, new Rotation { Value = Quaternion.Euler(0, Random.Range(0, 180), 90) }); entityManager.SetSharedComponentData(entity, new SpeedData(10)); } } var entities = entityManager.GetAllEntities(); foreach (var entity in entities) { var position = entityManager.GetComponentData<Position>(entity); var rotation = entityManager.GetComponentData<Rotation>(entity); Graphics.DrawMesh(mesh, position.Value, rotation.Value, material, 0); } entities.Dispose(); } } }
1つ目のECSサンプルと比較して、変更箇所は以下の通りです。
IJobParallelFor
を実装したMoveRotateJob
というJobが定義された- このJobには、1つ目のサンプルでSystemのOnUpdateが実行していた処理が移植されている
JobComponentSystem
を実装したSystemが定義された- Systemは、1つ目のサンプルでは
ComponentSystem
が実装されていた JobComponentSystem
のOnUpdate
は引数および戻り値がJobHandle
になるOnUpdate
の引数inputDeps
は、SystemにDataへの依存性注入を行うJobをSchedule
して返されたJobHandle
- なので、
inputDeps
を任意のJobのSchedule
に渡すことで、依存性注入後にJobの実行を行うことができる - 戻り値は
Schedule
したJobが返すJobHandle
- Systemは、1つ目のサンプルでは
ECSのみのサンプルとあまり大きな違いはなく、Systemの処理をJobSystemで行わせるようにしただけです。特に難しくは無いと思います。
因みに、パフォーマンスはこんな感じです。
このサンプルだとMainThreadがUpdate
の最後に記述した描画のための処理で圧迫されてしまっているのがちょっと気になりますが、とりあえず気にしないでください。
今重要なのは複数のWorkerThreadでJobが実行できているということです。
MainThreadのみで実行していた時に比べると、「Update開始からSystemの処理が終わるまでの時間」が大体17[ms]から10[ms]まで短縮されました。
両サンプルの計測時には、Spaceキーを押している間生成を行うのではなく、一度に10000回Entityを生成し、3秒後の結果を表示しています。
因みにJobにComputeJobOptimization
属性を付加してBurstコンパイラに最適化してもらうようにすると、もっと速くなります(今はEditor上でのみBurstコンパイラの恩恵が受けられると聞いたような聞かないような...どうでしたっけ...)。
以下はJobにComputeJobOptimization
属性を付加して同じように測定した結果です。
WorkerThreadの処理が速くなったことが確認できます。
ECS + JobSystemを良い感じに書く構成
今まで載せてきたサンプルコードではあえて、それぞれの定義をバラバラにしていました。
実践で使う場合、以下のような感じで構造分けするのがよさそうです (先ほどのECS+JobSystemのサンプルをちょっと手直しして描画もSystemが行うよう変更してあります)。
Data定義
using Unity.Entities; namespace Es.EcsJobSystem.Sample._04.Data { public struct Speed : IComponentData { public float Value; public Speed(float value) { Value = value; } } }
using Unity.Entities; using UnityEngine; namespace Es.EcsJobSystem.Sample._04.Data { public struct DrawMesh : ISharedComponentData { public Mesh mesh; public Material material; public DrawMesh(Mesh mesh, Material material) { this.mesh = mesh; this.material = material; } } }
System(JobSystem) + Group
using Unity.Entities; using Unity.Transforms; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using Es.EcsJobSystem.Sample._04.Data; namespace Es.EcsJobSystem.Sample._04.System { public class MoveRotateSystem : JobComponentSystem { public struct MoveRotateGroup { public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; public ComponentDataArray<Speed> speed; public int Length; } public struct MoveRotateJob : IJobParallelFor { public MoveRotateGroup moveRotateGroup; public float deltaTime; public void Execute(int i) { var pos = moveRotateGroup.position[i]; pos.Value.y -= moveRotateGroup.speed[i].Value * deltaTime; moveRotateGroup.position[i] = pos; var rot = moveRotateGroup.rotation[i]; rot.Value = math.mul(math.normalize(rot.Value), math.axisAngle(math.up(), moveRotateGroup.speed[i].Value * deltaTime)); moveRotateGroup.rotation[i] = rot; } } [Inject] private MoveRotateGroup moveRotateGroup; protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new MoveRotateJob() { moveRotateGroup = moveRotateGroup, deltaTime = Time.deltaTime }; return job.Schedule(moveRotateGroup.Length, 32, inputDeps); } } }
using Unity.Entities; using Unity.Transforms; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; using Unity.Collections; using Es.EcsJobSystem.Sample._04.Data; namespace Es.EcsJobSystem.Sample._04.System { [UpdateAfter(typeof(MoveRotateSystem))] public class DrawMeshSystem : ComponentSystem { public struct DrawMeshGroup { [ReadOnly] public SharedComponentDataArray<DrawMesh> drawMesh; public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; public int Length; } [Inject] DrawMeshGroup drawMeshGroup; protected override void OnUpdate() { for(int i = 0; i < drawMeshGroup.Length; ++i) { Graphics.DrawMesh(drawMeshGroup.drawMesh[i].mesh, drawMeshGroup.position[i].Value, drawMeshGroup.rotation[i].Value, drawMeshGroup.drawMesh[i].material, 0 ); } } } }
Entity生成
using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; using Es.EcsJobSystem.Sample._04.Data; namespace Es.EcsJobSystem.Sample._04 { public class EcsJobSystemSample04 : MonoBehaviour { public Mesh mesh; public Material material; public int createEntityPerFrame = 100; private EntityManager entityManager; private EntityArchetype archetype; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private void Start() { entityManager = World.Active.GetOrCreateManager<EntityManager>(); archetype = entityManager.CreateArchetype( typeof(Position), typeof(Rotation), typeof(Speed), typeof(DrawMesh) ); } private void Update() { if (Input.GetKey(KeyCode.Space)) { for (int i = 0; i < createEntityPerFrame; i++) { var entity = entityManager.CreateEntity(archetype); entityManager.SetComponentData(entity, new Position { Value = new float3(Random.Range(-20, 20), 20, Random.Range(-20, 20)) }); entityManager.SetComponentData(entity, new Rotation { Value = Quaternion.Euler(0, Random.Range(0, 180), 90) }); entityManager.SetComponentData(entity, new Speed(Random.Range(5, 20))); entityManager.SetSharedComponentData(entity, new DrawMesh(mesh, material)); } } } } }
ダメと言われてもやってみた
ちょっと話を戻して、先程のMainThreadでの描画処理の負荷が高かったサンプルについて、もうちょっとなんとかならないか考えます。
サンプルだと、MainThreadの処理中にWorkerThreadが遊んでムズムズしますね。
今回のサンプルでMainThreadのUpdate処理負荷が高い理由は
var entities = entityManager.GetAllEntities(); foreach (var entity in entities) { var position = entityManager.GetComponentData<Position>(entity); var rotation = entityManager.GetComponentData<Rotation>(entity); Graphics.DrawMesh(mesh, position.Value, rotation.Value, material, 0); } entities.Dispose();
この部分です。全てのEntityを取得して、それぞれのEntityからDataを取得し描画する部分です。この部分を少しでもなんとかしたい...。
Graphics.DrawMesh
はMainThread以外から呼ぶとUnityが怒ります。
なので、素直に並列化ってわけにはいきません。
そこで試しに、全てのEntityからDataを取得する部分のみをWorkerThreadで動かすようにJobを作ってみました。
using Unity.Collections; using Unity.Entities; using Unity.Jobs; using Unity.Mathematics; using Unity.Transforms; using UnityEngine; namespace Es.EcsJobSystem.Sample._03 { public struct SpeedData : ISharedComponentData { public float Value; public SpeedData(float value) { Value = value; } } public struct SampleGroup { public ComponentDataArray<Position> position; public ComponentDataArray<Rotation> rotation; [ReadOnly] public SharedComponentDataArray<SpeedData> speed; public int Length; } struct MoveRotateJob : IJobParallelFor { public SampleGroup sampleGroup; public float deltaTime; public void Execute(int i) { var newPos = sampleGroup.position[i]; newPos.Value.y -= sampleGroup.speed[i].Value * deltaTime; sampleGroup.position[i] = newPos; var newRot = sampleGroup.rotation[i]; newRot.Value = math.mul(math.normalize(newRot.Value), math.axisAngle(math.up(), sampleGroup.speed[i].Value * deltaTime)); sampleGroup.rotation[i] = newRot; } } // Entityからデータを得る作業を並列化するためのJob struct GetDataJob : IJobParallelFor { public NativeArray<Position> position; public NativeArray<Rotation> rotation; [ReadOnly] public NativeArray<Entity> entity; public void Execute(int i) { //! 静的データへのアクセス。 //! 通常時に実行はできたが、Burstコンパイラで最適化をかけたら実行時エラーが出た。 var entityManager = World.Active.GetOrCreateManager<EntityManager>(); position[i] = entityManager.GetComponentData<Position>(entity[i]); rotation[i] = entityManager.GetComponentData<Rotation>(entity[i]); } } public class SampleSystem : JobComponentSystem { [Inject] private SampleGroup sampleGroup; protected override JobHandle OnUpdate(JobHandle inputDeps) { var job = new MoveRotateJob() { sampleGroup = sampleGroup, deltaTime = Time.deltaTime }; var handle = job.Schedule(sampleGroup.Length, 32, inputDeps); return handle; } } public class EcsJobSystemSample03 : MonoBehaviour { public Mesh mesh; public Material material; public int createEntityPerFrame = 100; private EntityManager entityManager; private EntityArchetype archetype; [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] private void Start() { entityManager = World.Active.GetOrCreateManager<EntityManager>(); archetype = entityManager.CreateArchetype( typeof(Position), typeof(Rotation), typeof(SpeedData) ); } private void Update() { if (Input.GetKey(KeyCode.Space)) { for (int i = 0; i < createEntityPerFrame; i++) { var entity = entityManager.CreateEntity(archetype); entityManager.SetComponentData(entity, new Position { Value = new float3(Random.Range(-20, 20), 20, Random.Range(-20, 20)) }); entityManager.SetComponentData(entity, new Rotation { Value = Quaternion.Euler(0, Random.Range(0, 180), 90) }); entityManager.SetSharedComponentData(entity, new SpeedData(10)); } } var entities = entityManager.GetAllEntities(); var job = new GetDataJob() { position = new NativeArray<Position>(entities.Length, Allocator.Temp), rotation = new NativeArray<Rotation>(entities.Length, Allocator.Temp), entity = entities }; var jobHandle = job.Schedule(entities.Length, 32); jobHandle.Complete(); for (int i = 0; i < entities.Length; ++i) Graphics.DrawMesh(mesh, job.position[i].Value, job.rotation[i].Value, material, 0); job.position.Dispose(); job.rotation.Dispose(); entities.Dispose(); } } }
こんな感じになるんですが、JobのExecute
に注目してください。
試しにJobから
World.Active.GetOrCreateManager<EntityManager>();
を呼び出してみました。 「JobSystemを使う上での注意」で書いた、やっちゃいけない奴です。 参考にしないでください。
でもとりあえず、今の段階では普通に動きます。 Profilerで確認したところ、目論見通り多少マシな感じになりました。
マジか。
因みにこのサンプルではJobにComputeJobOptimization
属性を付加すると実行時にCompiler exceptionが発生します。将来的にも使えなくなりそうなので、くれぐれも参考にしないで下さい。
さいごに
まだまだ理解不足な部分も多いですが、楽しかったのでもっと深く色々触ってみようと思います。
SRPもやらなきゃ。