しゅみぷろ

プログラミングとか

ECSとJobSystem 基礎

はじめに

やる気がある。全部Uniteのせい。

今回はECS(Entity Component System)とJobSystemについて色々触ってみたので、基礎部分のメモを残しておきます。

サンプルのプロジェクトは以下から見れます。

github.com

どの程度自分の解釈が正しいのかちょっと怪しい部分もあるので、今後も色々と検証も行っていきます。その段階で間違いとか新たな発見とかがあったらその都度記事を更新します。

あと、今回の内容は非同期処理について多少知見があると理解が早いです。 なんか4年前くらいに書いた資料が発掘されたので、興味がある方は見てみてください。 後ろの方(スライド92枚目あたり)に非同期処理についての何かが書いてあります。

ECSとJobSystemを使うメリット

そもそも、ECSやJobSystemはどんな時に役立ってくれるのでしょうか。 これからECSとJobSystemそれぞれ、または両方利用する場合のについてメリットについて見ていきます。

今関わってるプロダクトやこれから関わるプロダクトでECSやJobSystemの恩恵が殆ど感じられないようなケースもあるかもしれません。これらのメリットがあまり享受できなそうだと感じたら、無理に使う必要はありません。

ただ、ECSとJobSystemを使いこなす事が出来れば、今までのUnityでは実現が難しかったことが出来るようになります。 具体的には「大量のGameObjectをシーンに配置してそれぞれを動かしたり何かしら処理をさせたりする」みたいな事が高速に処理出来るようになります。 加えて、メモリ効率も格段に良くなります。

既存プロダクトの最適化でも役に立つケースがあるかもしれませんし、今後より便利になっていく機能だと思うので、今のうちに概要だけでも知っておくのが良さそうです。

メモリ効率を良くする(使用量/パフォーマンス)

GameObjectをシーンに配置するとどのくらいのメモリを喰うか、意識したことはありますか? それぞれのComponentはどうでしょうか?

これらのリソースがアプリケーションでどのくらいのメモリを確保するかは、実行している環境によって変わってきます。

これらを詳細にチェックするには実際の環境での実行時にメモリをプロファイルする必要がありますが、Unityエディタ上でもProfilerウィンドウから参考数値程度のものであれば確認することが出来ます(エディタで表示されるメモリ使用量は一般的に実機で実行しているときよりも大きくなります)。

例えば、ParticleSystemを持つGameObjectを大量生成した場合、エディタ上ではこうなります。

f:id:es_program:20180517012510p:plain:w500

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について基本的なところメモしておきます。 以下のサンプルを参考にさせていただきました。

github.com

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マニュアル のサンプルコードをちょこっと弄ったものです。

velocitydeltaTimeから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を纏めることができます。この纏めた結果のJobHandleScheduleのパラメーターとして利用する場合、纏めた全てのJobの完了後にScheduleされたJobが実行されます。

Scheduleをしても、実際にはJobは実行されません。Jobを開始するには、バッチ(仕事を一定数ごとに分割したもの)をflushする必要があります。これはCompleteを呼び出すと行われます。

しかし、Schedule後直ぐにCompleteを呼び出すと、MainThreadがその段階でJobの完了待ちをしてしまうため、並列化の恩恵が薄れてしまいます。

また、Jobがすぐに完了待ちを行うようなコードになっている場合、この勿体無い待ち時間をなんとか潰すために、MainThreadでもJobが実行されます(これの判断はJobSystemが勝手にしてくれます)。

これを解決するには、Schedule後にJobHandle.ScheduleBatchedJobsメソッドを呼び出します。このメソッドを呼び出すことでバッチがflushされ、Jobが開始されます。あとはサンプルのように、Jobの結果を待つタイミングでCompleteを呼び出します。

f:id:es_program:20180519142750p:plain:w600

上記はサンプルコードのJobHandle.ScheduleBatchedJobsコメントアウトして実行したときの結果です。 無駄な待ち時間が発生するため、MainThreadでJobが実行されています。

半々くらいの頻度でWorkerThreadで処理を行ってくれようとするんですが、結局MainThreadでは待ちが発生するので、どちらにせよ並列化の恩恵は受けられません。

f:id:es_program:20180519142716p:plain:w600

Scheduleしたら、後はCompleteJobHandle.CompleteAllでJobの完了を待ちます。

サンプルコードで使っているNativeArrayNativeContainer属性が付加されたコンテナです。 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で確認すると、VelocityJobExecuteがJobSystemの管理するWorkerThreadで動いているのが確認できます。

f:id:es_program:20180518185636p:plain:w600

並列化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が引数付きになり、インデックスが渡ってくるのでそれを使うように実装を変更(今回は配列のインデックスとして利用)
  • ScheduleExecuteメソッド実行回数(今回は配列の長さ分)、内部でのループ分割数(バッチ数)を指定するように変更

たったこれだけです。

IJobParallelForでは指定されたバッチ数以上のチャンクに作業を分割し、適切な数のJobをスケジュールします。 複数のJobが複数のWorkerThreadで実行されるようになるため、サンプルのようなケースではとても有効です。

バッチ数に1に近い値がくるほど、スレッド間での作業の分散がより均一になります。 ただし、作業が細かく分割されすぎるとオーバーヘッドがあるので、バッチ数を増やす方がよい場合もあります。1から始まり、パフォーマンスの向上が得られる間バッチ数を動的に増やすことは有効な戦略です。

さて、こちらもProfilerで確認すると、JobSystemのWorkerThreadで処理を行っていることが確認できます。

f:id:es_program:20180518190137p:plain:w600

先程の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の要素を変更する際に注意する
    • UnityではまたC# 7.0相当の機能が使えず、参照戻り値が使えない
    • そのため、NativeArrayから戻り値の参照を得ることが出来ない
    • nativeArray[0]++;みたいなコードはvar temp = nativeArray[0]; temp++;のようになるため、本来意図した値の更新を行わない
    • もしUnityでC# 7.0相当の機能が使えるようになったら改善される見込みっぽい
  • 常にCompleteを呼ぶこと
    • Completeを呼ぶことで、WorkerThreadに委譲していたNativeContainerの所有権がMainThreadに返される
    • CompleteはJobデバッガの状態も更新する(更新されないままだと、デバッガはメモリリークしたと判断し、警告を出す)

ECS

続いてECSについて。

みんな大好きテラシュールさんがめちゃくちゃわかりやすく、参考にさせていただきました。

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を作って任せるようにしたほうがいいと思いますが、サンプルコードが長くなるので今回は割愛)。

実行するとこんな感じになります。

f:id:es_program:20180520145247g:plain:w600

コードの解説ですが、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]属性を付加し忘れるとエラーが出ます。

f:id:es_program:20180518193021p:plain:w600

因みにComponentDataArrayNativeContainerです。SharedComponentDataArrayNativeContainerではありません。

ComponentDataArrayNativeContainerであることは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]属性を付加します。 OnUpdateComponentSystemの抽象メソッドで、これが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で確認したものです。

f:id:es_program:20180517173013p:plain:w600

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が実装されていた
    • JobComponentSystemOnUpdateは引数および戻り値がJobHandleになる
    • OnUpdateの引数inputDepsは、SystemにDataへの依存性注入を行うJobをScheduleして返されたJobHandle
    • なので、inputDepsを任意のJobのScheduleに渡すことで、依存性注入後にJobの実行を行うことができる
    • 戻り値はScheduleしたJobが返すJobHandle

ECSのみのサンプルとあまり大きな違いはなく、Systemの処理をJobSystemで行わせるようにしただけです。特に難しくは無いと思います。

因みに、パフォーマンスはこんな感じです。

f:id:es_program:20180517172402p:plain:w600

このサンプルだとMainThreadがUpdateの最後に記述した描画のための処理で圧迫されてしまっているのがちょっと気になりますが、とりあえず気にしないでください。

今重要なのは複数のWorkerThreadでJobが実行できているということです。

MainThreadのみで実行していた時に比べると、「Update開始からSystemの処理が終わるまでの時間」が大体17[ms]から10[ms]まで短縮されました。

両サンプルの計測時には、Spaceキーを押している間生成を行うのではなく、一度に10000回Entityを生成し、3秒後の結果を表示しています。

因みにJobにComputeJobOptimization属性を付加してBurstコンパイラに最適化してもらうようにすると、もっと速くなります(今はEditor上でのみBurstコンパイラの恩恵が受けられると聞いたような聞かないような...どうでしたっけ...)。

以下はJobにComputeJobOptimization属性を付加して同じように測定した結果です。

f:id:es_program:20180517174554p:plain:w600

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で確認したところ、目論見通り多少マシな感じになりました。

マジか。

f:id:es_program:20180518201549p:plain:w600

因みにこのサンプルではJobにComputeJobOptimization属性を付加すると実行時にCompiler exceptionが発生します。将来的にも使えなくなりそうなので、くれぐれも参考にしないで下さい。

さいごに

まだまだ理解不足な部分も多いですが、楽しかったのでもっと深く色々触ってみようと思います。

SRPもやらなきゃ。