しゅみぷろ

プログラミングとか

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もやらなきゃ。

描画の効率化について

はじめに

今回の記事ではUnite2018で得た有り余るやる気を発散するために、描画の効率化について書いていこうと思います。

また、MaterialPropertyBlockという機能が結構誤解されがちなので、これについても纏めていこうと思います。

バッチングを効かせる上で考慮すべきこと

ここで言うバッチングは静的なものではなく、動的に行われるバッチングを指すこととします。

バッチングはざっくり説明すると「同じマテリアルを使っているものの描画は出来る限り纏めちゃおうね」っていうやつです。

このバッチングはUnityのバッチング条件に該当する場合自動的に行ってくれます。

これからバッチングが切れる条件について見ていきます

頂点数多いとバッチングされない

メッシュ(サブメッシュ)の頂点数がある特定の数を超えるとバッチングが効かなくなります。 このある特定の数はShaderによって代わり、非常に面倒なのですが、以下のような仕様になっているそうです(この特定の数は今後変更される可能性があります)。

  • シェーダーが頂点位置や法線や一つの UV 情報を使っていたら300頂点までバッチングが行われる
  • シェーダーが頂点位置、法線、UV0、UV1、Tangentを使っていたら180頂点までバッチングが行われる
  • シェーダーがそれらの情報を使っていない場合は900頂点までバッチングが行われる

以下の画像を見てください。オブジェクトを大量に生成したときの負荷を検証している画像ですが、左はCylinderを、右はSphereを大量生成しています。 Cylinderの頂点数は88Sphereの頂点数は515です。この画像の例では、Shaderは一つの法線情報を使っているものを利用しているので、Sphereを描画している方はバッチングが効かず、Batchesの数がものすごいことになっています。

f:id:es_program:20180513000226p:plain:w600

Frame Debuggerでも、バッチングが効かない理由が表示されます。

f:id:es_program:20180513000814p:plain:w400

一度にバッチングできる頂点数の上限を超えるとバッチングされない

一度にバッチングできる頂点数および頂点インデックス数には上限があります。 多くのプラットフォームではこの数が64000に制限されます。 OpenGL ES では48000macOSでは32000に制限されるようです。

f:id:es_program:20180513041651g:plain:w600

上記のGifでは同じマテリアルを割り当てたCylinderをバッチ毎に徐々に表示させています。全て同じマテリアルを利用しているので1度に全て描画して欲しいところですが、バッチングには上記の通り上限があるため、数回に分けて描画が行われていることがわかると思います。macOS環境でチェックしたため、頂点インデックス数32000で頭打ちとなっています。

ちなみに、Frame Debuggerでバッチングが切れた理由が以下のように表示されます。

f:id:es_program:20180513042230p:plain:w400

スケールの設定値によってはバッチングされない

公式では

トランスフォームにミラーリングを含む場合は、オブジェクトはバッチされません。例えば、+1スケールのオブジェクト A と–1スケールのオブジェクト B は一緒にバッチできません。

と書かれていますが、ちょっとわかりにくいですね。 要するに、Scaleに奇数個の負数が指定されているとバッチングが効かなくなるようです。偶数個だけ負数にした場合は問題なくバッチングされました。

ちなみに親オブジェクトのTransformに設定されたScaleも考慮されます。つまり、ルートオブジェクトのScaleからRendererまでの全てのスケールを乗算し、最終的なスケールの(x, y, z)のうち奇数個が負数だった場合にバッチングが無効になります。 知らず知らずのうちに実は結構やらかしているタイプのやつですね。

ちなみに余談ですが、この時にFrame Debuggerで出てくるバッチングが効かなかった理由の表示が結構賢いです。

GPUInstancingが可能な環境の場合

f:id:es_program:20180513003412p:plain:w400

と表示されました。Instancingしてくれれば解決するよと。そしてGraphics EmulationでGPUInstancingが利用できない環境にエミュレートした場合

f:id:es_program:20180513004409p:plain:w400

と表示されます。Scaleに奇数個負数が入力されているよと。

マテリアルのインスタンスが違うとバッチングされない

これはSetPass callsも増えるので、普段から気をつけている方も多いと思います。 結構有名なやつですね。

既存のマテリアルをコピーするなどしてインスタンスを作成してRendererに対して割り当てた場合、たとえ同じシェーダー、同じプロパティであろうとも、「別のマテリアルを使っている」と判断されてしまい、バッチングが効かなくなります。

マテリアルをコピーして使うことが必要であるケースであれば、これは仕方がないことなのでまぁ良いのですが、問題はRenderer.materialプロパティの存在で、初学者にとってはかなり罠に近い仕様になっていることです。

Renderer.materialプロパティにアクセスしてマテリアルの変更を行うと、新しいマテリアルのインスタンスが作成されます。 Renderer.materialは、内部で

        public Material material
        {
            get { return GetMaterial(); }
            set { SetMaterial(value); }
        }

といった実装がされています(Unity Reference-Only Licenseに従い、本家のコードを参考に簡略化して書いています)。

GetMaterialSetMaterialはネイティブ側で実装されていて、それぞれマテリアルのコピーを行います。

マテリアルのコピーを行わずに参照を変更するには、Renderer.sharedMaterialを使用する必要があります。

Shaderがバッチングに対応していない場合はバッチングされない

Shaderによってはバッチングを阻害するものがあります。以下のケースです。

  1. ShaderTagにDisableBatchingが指定されている場合
  2. マルチパスのShaderの場合

1つ目の項目は、使用しているShader内のTagsにTags { "DisableBatching" = "True" }またはTags { "DisableBatching" = "LODFading" }が指定されていればバッチングが無効になる、という意味です。

バッチングされるとすべてのジオメトリがWorldSpaceに変換されるため、ModelSpaceでの座標系がShader内で利用できなくなります。それを防ぐために、ShaderLab内でバッチングが出来ないことを明示するタグがDisabledBatchingです。 なので、これらのタグはShader内でModelSpaceでの頂点変形を行う必要があるShaderで利用されます。

2つ目の項目は、Shaderがマルチパスレンダリングを行う場合です。複数のPassが存在する場合、バッチングは無効化されます。カスタムシェーダーではここらへんも考慮して、出来るのであればなるべくシングルパスが理想です。

さて、ここで頭に入れておいてほしいのは、「Unityが標準で提供するレンダリングパスを利用している場合、多くの場合バッチングを有効化出来る」ということです。 例えばUnity標準のShadowMapを利用したShadowCaster及びShadowReceiverパスはUnityが提供するShadowMap生成及び参照のためのパスであり、このパスを使う場合、自作であろうがなかろうがバッチングを有効にすることが出来ます(もちろんその他のバッチング条件に該当すればの話です)。その他にも、Unityが提供する様々なパスではバッチングが有効になります。

SetPass callsを増やさないために考慮すること

次はSetPass callsを増やさないためにはどうしたら良いかについて見ていきます。

Setpass callsはマテリアルのデータをCPUからGPUに受け渡す際に呼ばれるSetPass命令の呼び出し回数です。これが結構CPU側でボトルネックとなります。

Unity界隈で「SetPass callsは悪だ!なるべく潰していけ!」といろんな記事やら資料やらで書かれているため、これについては多くの人が知っている内容かもしれません。

SetPass callsを減らすために必要な一番基本的な事は、利用するマテリアルのインスタンスを減らすことです。

ここでは必要なマテリアルのインスタンスをなるべく減らし、SetPass callsを抑える手法についてざっくり見ていきます。

因みに、たとえマテリアルのインスタンスを少なくしたとしてもSetPass callsが絶対に減るというわけではありません。 マテリアルのインスタンスを共有したとしてもSetPass callsが増えてしまう場合もあります。 詳しくは「RenderQueueによるドローコルの変化」で書きます。

同じような描画を行うマテリアルをなるべく1つに纏める

  1. レンダリング対象に「Shaderもマテリアルのプロパティも同じ値なのに、マテリアルのインスタンスだけ違う」ようなものがある場合は迷わず1つのマテリアルを使いまわすようにしましょう。もしマテリアルを分けておいて、何かアクションがあった時にプロパティを変更したいのであれば、MaterialPropertyBlockの利用を検討します。
  2. 似たような処理を行い、一部分だけ処理が違うようなShaderを使っていて、マテリアルのプロパティを同じ値にできるようなものがある場合、Shaderを拡張して1つのShaderでうまいこと描画できないかを考えましょう。処理の分岐のようなものが必要になる場合、それを判断するパラメータはマテリアルのプロパティではなく頂点データに格納してやります。

頂点データをうまく活用する

マテリアルのプロパティでいろんなパラメータを設定する必要があり、その組み合わせ分マテリアルのインスタンスを増やす必要があるような場合、このパラメータを頂点データに設定することで解決できないかを考えましょう。

たとえばUnityのStandardShaderを使っていて「赤・緑・青」3種類のマテリアルを作るようなことがあるかもしれません。

f:id:es_program:20180513060910p:plain:w400

これらを使ってそれぞれ3つのオブジェクトを1つのカメラで描画する場合、SetPass callsは3になります。しかし、Shaderの処理自体は全て同じことをしていて、パラメータが違うだけです。これでは無駄が多いので、それぞれのオブジェクトのメッシュが持つ頂点データに、この色のデータを格納してやります。そして、StandardShaderを改造して、Albedoに格納しておいた色データを乗算して出力するようにします。

f:id:es_program:20180513063413p:plain:w600

上記の画像では、Blenderで頂点カラーを赤・緑・青に設定したCubeをそれぞれ出力してUnityにInportし、以下のシェーダーを適用したマテリアルを1つ作って、3つのCubeオブジェクト全てにこのマテリアルを割り当てたときの画像です。 このShaderはUnityのStandardSurfaceShaderに入力として頂点カラーを取ってくるようにして、Albedoに乗算しているだけです。 SetPass callsが1に抑えられていることがわかると思います。また、全く同じマテリアルのインスタンスを使用しているためバッチング条件に該当するようになり、Batchesも抑えられています。

Shader "Custom/NewSurfaceShader" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0
    }
    SubShader {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
       #pragma surface surf Standard fullforwardshadows

        // Use shader model 3.0 target, to get nicer looking lighting
       #pragma target 3.0

        sampler2D _MainTex;

        struct Input {
            float2 uv_MainTex;
            float4 color : COLOR;
        };

        half _Glossiness;
        half _Metallic;
        fixed4 _Color;

        // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
        // See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
        // #pragma instancing_options assumeuniformscaling
        UNITY_INSTANCING_BUFFER_START(Props)
            // put more per-instance properties here
        UNITY_INSTANCING_BUFFER_END(Props)

        void surf (Input IN, inout SurfaceOutputStandard o) {
            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color * IN.color;
            o.Albedo = c.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}

更にこの例の場合、どんな色を頂点カラーとして与えてもSetPass callsが増えません。

この要領で、必要なデータは可能であれば頂点データに入れてあげるようにしましょう。

今回は頂点カラーを使いましたが、他にも色々格納できるスペースはあるので、興味があれば「hlsl セマンティック」あたりで検索してみてください。

また、ちょっと応用編になりますが、テクスチャに細かなデータ列を格納してShaderでそれを参照できるように紐付ける情報を設定し、Textureを大量のデータ保管庫として利用することも出来ます。

uGUIのSetPass calls

ちょっとした番外編です。

uGUIのImageやRawImageで異なるテクスチャを描画する際、標準だとUnityのDefault UI Materialが使われます。 異なるテクスチャを描画するので勿論マテリアルのプロパティを変更する必要があるはずなのですが、SetPass callsは増えません(Batchesはちゃんと増えている)。

以下の画像では、異なる2枚のテクスチャをuGUIで描画しています。SetPass callsは1です。

f:id:es_program:20180513072201p:plain:w600

このカラクリは以下の記事で解説されています。

UIのバックエンドを高速化する – Unity Blog

CanvasRendererの描画処理(描画前の下準備も含む)はMainThreadから切り離され、別スレッドで並列に実行されていきます。実際にUIを描画していくフェーズになると、用意したUI描画用のバッファに全てのUI要素のレンダリング後、これを合成することで最終的な結果を得ています。

f:id:es_program:20180513082048p:plain:w600

以下はProfilerで確認したUI生成処理です。 Canvas毎に、各レンダラーの描画についての情報を見ることが出来ます。UI描画用バッファも確認できます。

uGUIに関して、Frame Debuggerではバッチングが無効化された理由を見ることは今のところ出来ず、UI描画用バッファの確認等も行えません そのため、uGUIの場合はFrame DebuggerではなくProfilerで色々チェックするのが良さそうです。

RenderQueueと複数オブジェクトによるドローコールの変化

マテリアル毎に割り当てることが出来るQueueに関しての描画順コントロールとパフォーマンスへの影響について考えていきます。

レンダリングを行うオブジェクトはマテリアルに割り当てられたQueue毎にソートされ、マテリアル毎に描画されていきます。 この描画は、基本的に不透明オブジェクトは手前にあるものから行われていき、透明オブジェクトは奥側から行われます(これらは変更可能です)。

不透明オブジェクトが手前から描画されていく理由は、一度描画を行ったピクセルに対しては計算をスキップし、効率よくフラグメントを求めることが出来るからです。 透明オブジェクトが奥側から描画されていく理由は、Blendingを正しく行うためです。

Unity内ではこの「透明か不透明か」の判断を、Queueが2500(2500以下なら不透明で2501以上なら透明)より大きいかどうかで判断しているっぽいです。

因みにQueueには特定の値を表す文字列があり、ではBackgroundは1000、Geometryは2000、AlphaTestは2450、Transparentは3000、Overlayは4000を表します。

f:id:es_program:20180513084713g:plain:w600

上記のGifでは、マテリアルのQueueが2500以下なら手前から、2500より大きければ奥から描画されている事が確認できます。

誤解の無いよう補足しておくと、Queueが2500以下でもアルファをつけることは可能です。ただ、レンダリングが手前から行われるので、深度テストを有効にしていると奥の不透明オブジェクトが描画されなくなります(それはそれで綺麗な半透明なオブジェクトの並びを表現できるので結構使うこともあります)。

試しに以下のように深度テストとBlendingを記述した適当なShaderを使って半透明Shaderのテストをしてみます。

Shader "Custom/Lambert"{
    Properties{
        _Diffuse("Color", COLOR) = (1, 1, 1, 1)
        _LightPos("Light position", Vector) = (0, 0, 0, 0)
    }
    SubShader{
        Tags
        {
            "Queue" = "AlphaTest"
        }
        ZWrite On
        ZTest On
        Cull Back
        Blend SrcAlpha OneMinusSrcAlpha
        Pass{

            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct app_data {
                float4 vertex:POSITION;
                float3 normal:NORMAL;
            };
            struct v2f {
                float4 vertex:SV_POSITION;
                float4 color:COLOR0;
            };

            float4 _Diffuse;
            float4 _LightPos;

            v2f vert(app_data i) {
                v2f o;
                float3 vs_normal = mul(UNITY_MATRIX_IT_MV, float4(i.normal, 1));
                float3 vs_light = mul(UNITY_MATRIX_V, _LightPos);
                float3 vs_pos = UnityObjectToViewPos(i.vertex.xyz);
                float3 vs_l = normalize(vs_light - vs_pos);

                float r_diffuse = max(dot(vs_normal, vs_l), 0);

                o.vertex = UnityObjectToClipPos(i.vertex);
                o.color = _Diffuse * r_diffuse;
                o.color.a = _Diffuse.a;
                return o;
            }

            float4 frag(v2f i) :SV_TARGET{
                return i.color;
            }
            ENDCG
        }
    }
}

f:id:es_program:20180513100831p:plain:w600

上の画像はQueueを2500より大きな値に設定してレンダリングを行った結果です。 透明なオブジェクトは奥側から描画されるため、このようになります。

f:id:es_program:20180513100900p:plain:w600

そしてこちらの画像はQueueを2500以下の値でレンダリングを行った結果です。 手前の半透明のみが描画され、綺麗な透過に見えます。

さて、ここからが本題です。オブジェクトを複数描画するような場合に注意しなければならないことがあります。 異なるマテリアル(Queueの値は同じ)のインスタンスを持つオブジェクトを複数配置する場合です。 以下のGifを見てください。

f:id:es_program:20180513161714g:plain:w600

StandardShaderを割り当てた2つの色違いのマテリアルを作り、それぞれを適用したオブジェクトを交互に並べています。Queueがどちらも2000の時、SetPass calls及びBatchesが増えているのがわかると思います。その後、片方のマテリアルのQueueを2100(もう一方とは異なる適当な値)にするとSetPass calls及びBatchesが意図した値まで下がっています。

透明なオブジェクトの場合、より注意する必要があります。

f:id:es_program:20180513164511g:plain:w600

SetPass calls及びBatchesに注目してください。先程と比べて数値が跳ね上がったのが確認できると思います。この程度の数なら良いのですが、大量に透明なオブジェクトを描画する場合は細心の注意をはらいましょう。Queueに3000を指定した2つのマテリアルを適当に割り当てて大量のオブジェクトを描画した場合、以下のGifようになります。

f:id:es_program:20180513205235g:plain:w600

SetPass callsもBatchesもエグいですね。 奥側から破綻の無いようBlendingを行っていく必要があるためこんな事になってしまいます。

この負荷が問題となってしまう場合、応急処置としてQueueの値を変えることで対応できます(そのかわり意図したようにBlendingが行われなくなります)。

以下の画像では、片方のマテリアルのQueueを適当に3001として値をずらしています。

f:id:es_program:20180513210801p:plain:w600

MaterialPropertyBlock

そもそもMaterialPropertyBlockってなんでしょう。どんなときに役立つのでしょうか。 昔のバージョンのリファレンスに、多少詳しく書いてくれてます(現状最新バージョンのリファレンスだと説明が簡素なので注意)。

docs.unity3d.com

つまりMaterialPropertyBlockは

  • 特定のマテリアルのプロパティのみ変更して使いまわしたいときに利用できる(Materialを複数作る必要がない)
  • Graphics.DrawMeshでメッシュを描画する際のパラメーターとして指定できる
  • Renderer.SetPropertyBlockでレンダラーに設定できる

といった性質を持ちます。 注意しておきたいのは

  • MaterialPropertyBlockを使ったからといって、違うプロパティを与えた描画処理が1つにまとまってくれるとかではない
  • 全く同じプロパティを利用する描画が行われる場合には、Unityのバッチング条件に該当する場合に限りバッチングしてくれます(SetPass callsを無駄に増やすこともないので、CPU負荷を抑えることが出来ます)
  • 逆を言えば、違うプロパティを与えた場合は通常時(2つの別々のマテリアルを使ったとき)と同じようにSetPass callsが増えます

ということです。

ユースケースによりますが、MaterialPropertyBlockを扱うことでUnityEditor上で複数のマテリアルを作成する必要が無くなる場合もありますし、スクリプト上からは微妙にプロパティの値が異なるだけのマテリアル管理をスマートに行うことが出来ると考えれば良いでしょう。

さて、ここまでの説明で分かる通り、MaterialPropertyBlockはレンダリングの効率化を行う銀の弾丸ではありません。あくまで複数のRenderer(もしくは描画命令)間でマテリアルをなるべく共通化するための機能だと考えるべきです(Renderer単位でプロパティのオーバーライドを保持できるのでメモリ効率は良くなります)。

「MaterialPropertyBlockを使えばなんやかんやでSetPass callsが減らせてCPU負荷を減らせる」というわけではないので注意してください。MaterialPropertyBlock使っても必要なSetPass callsは減らせないし、バッチング条件を満たせなかったらバッチングも行われません。

以下はTransparentな1つのマテリアルに対して、ちょっと透明な赤と青のカラーをMaterialPripertyBlockで渡してやる例です。

f:id:es_program:20180513215507g:plain:w600

たった2色の透明なオブジェクトを描画しているだけですが、これだけでSetPass callsもBatchesも700を超えてしまいます。 「RenderQueueと複数オブジェクトによるドローコールの変化」でやったような応急処置を施せば多少マシにはなりますが、正しくBlendingが行われなくなります。

f:id:es_program:20180513234916g:plain:w600

因みにMaterialPropertyBlockについては以下の記事にとても詳細な解説が載っているので、使い方等は以下を参考にするといいです。

The magic of Material Property Blocks – Thomas Mountainborn

ただ、上記記事内の

There is but one disappointing element to an otherwise great tool: even though all objects share the same material, MaterialPropertyBlocks break dynamic batching.

といった説明ですが、おそらく間違いです。 上記記事内ではSphereを大量に描画していますが、Unity標準のSphereの場合頂点数が多く、バッチングが効かないので、それを勘違いしたのだと思われます。 ちゃんとMaterialPropertyBlockでもバッチングは有効になりますので安心してください。

最後に

何故か最初書こうと思っていたことと全く違うこと書いてました。 なんでこんなの書いてるんだろう。