Unity

【Unity】C# JobSystemで大量のドカベンロゴをアニメーションさせてみた

テーテーテーテテテテッテテー

カァァァン!!!
ワァァァァ-----!!

doka.gif

テーテーテーテテテテッテテー

概要

Unity2018より入る機能の一つであるC# JobSystem。
こちらがあまりにも気になりすぎたので、早速大量のドカベンロゴをアニメーションさせるテストプログラムを実装して検証してみました。実装内容や知見などをメモして行ければと思います。

※なお、今回の検証についてはUnity2018のβ版での検証となります。
その上で実行結果についてもマシンのスペックによっては結果が変わってくるかと思われるので、その点ご了承下さい。

目次

今回のドカベンロゴアニメーションは2つの実装方法を用いて検証しております。
先ずはその2点の概要をザックリと説明します。

JobParallelForTest

  • こちらはIJobParallelForを使用してドカベンロゴを同時に1万個動かすテストとなります。
  • 実装としてはNativeArrayに回転計算用のデータをセットしておいて、IJobParallelForのExecuteにて任意点周りの回転行列を算出して結果をNativeArrayに詰めます。→全てのNativeArrayに対する回転行列の計算が終わったらUpdate内(MainThread)で全ドカベンロゴのMeshに対して回転行列を反映させる形でアニメーションを行っております。
    • ※この様な実装になっている理由としては、前回前々回共に「勉強序にMeshの原点を敢えて(モデルレベルで)下端に設定せずにプログラム上で回転させる」と言う謎の実装をしていたので、こちらの制約に倣って実装しているのが理由となります。

JobParallelForTransformTest

  • こちらはIJobParallelForTransformを使用してドカベンロゴを同時に10万個動かすテストとなります。
  • NativeArrayに回転計算用のデータを詰めておく所までは前者と同じですが、こちらはIJobParallelForTransformを経由してTransfromにアクセスする形で実装しております。
    • なお、IJobParallelForTransform.Executeの引数に渡ってくる「TransformAccess」ですが、こちらはworld/localのTRSの値しかアクセスできず、Transform.RotateAroundと言ったAPIを実行することが出来ないっぽかったので例外的にモデルレベルで原点をずらしております。(厳密に言えばPrefabを入れ子にする形で原点をずらしている)

各々の実装の詳細について記載していきます。
※ちなみに、今回は実装方法と結果に重点を置いて説明しているためにJobSystemの基本的な所についてはそんなに触れておりません。JobSystemについてはテラシュールブログさんの記事が分かりやすかったので以下を参考に。
参考 : 【Unity】C# Job Systemを自分なりに解説してみる

「JobParallelForTest」について

まず最初にIJobParallelForを使用した実装方法から解説していきます。
こちらでは一部のソースコードを引用しつつ要点を纏めて行くので、ソース全般については以下のコードを参照して下さい。

回転計算用データ及び実行用Jobについて

  • DokabenStructはNativeArrayで確保するためのデータとなります。

    • ※ちなみにNativeArrayについてはどんな型でも入るというわけではなく、(エラーコード曰く)Blittable型である必要があるみたいです。
      • →参考 : Blittable 型と非 Blittable 型
      • ※試しに非Blittable型であるbooleanDokabenStructのフィールドに追加して実行したら以下のエラーが発生しました。 error.png
  • MyParallelForUpdateについて、こちらはIJobParallelForを継承したジョブ実行用の構造体であり、内容としては任意点周りの回転行列計算用の物となります。計算周りの詳細については後述。

    /// <summary>
    /// 回転計算用データ
    /// </summary>
    struct DokabenStruct
    {
        // 経過時間計測用
        public float DeltaTimeCounter;
        // コマ数のカウンタ
        public int FrameCounter;
        // 1コマに於ける回転角度
        public float CurrentAngle;
        // 算出した回転情報を保持
        public Matrix4x4 Matrix;

        public DokabenStruct(float currentAngle)
        {
            this.CurrentAngle = currentAngle;
            this.DeltaTimeCounter = 0f;
            this.FrameCounter = 0;
            this.Matrix = new Matrix4x4();
        }
    }

    /// <summary>
    /// 回転計算用のJob
    /// </summary>
    struct MyParallelForUpdate : IJobParallelFor
    {
        public NativeArray<DokabenStruct> Accessor;
        public float DeltaTime;

        // Jobで実行されるコード
        public void Execute(int index)
        {
            DokabenStruct accessor = this.Accessor[index];
            this.Accessor[index] = JobParallelForTest.Rotate(this.DeltaTime, accessor);
        }
    }

任意点周りの回転行列の算出について

  • ドカベンロゴアニメーションの特性上、コマ落ちのタイミングで動く仕様となっているのでNativeArrayから時間計測用の値を取り出してカウント→特定タイミングで算出して結果の行列を詰めると言った処理となってます。
  • 任意点周りの回転については以下のサイトが参考になるかと思われます。
        // 回転の算出
        static DokabenStruct Rotate(float deltaTime, DokabenStruct data)
        {
            Matrix4x4 m = Matrix4x4.identity;
            float x = 0f, y = 0f, z = 0f;
            m.SetTRS(new Vector3(x, y, z), Quaternion.identity, Vector3.one);
            if (data.DeltaTimeCounter >= Constants.Interval)
            {
                // 原点を-0.5ずらして下端に設定
                float halfY = y - 0.5f;
                float rot = data.CurrentAngle * Mathf.Deg2Rad;
                float sin = Mathf.Sin(rot);
                float cos = Mathf.Cos(rot);
                // 任意の原点周りにX軸回転を行う
                m.m11 = cos;
                m.m12 = -sin;
                m.m21 = sin;
                m.m22 = cos;
                m.m13 = halfY - halfY * cos + z * sin;
                m.m23 = z - halfY * sin - z * cos;

                data.FrameCounter = data.FrameCounter + 1;
                if (data.FrameCounter >= Constants.Framerate)
                {
                    data.CurrentAngle = -data.CurrentAngle;
                    data.FrameCounter = 0;
                }

                data.DeltaTimeCounter = 0f;
            }
            else
            {
                data.DeltaTimeCounter += deltaTime;
            }
            data.Matrix = m;
            return data;
        }

Jobの実行について

  • 今回はパフォーマンスの違いを計測するために「MainThread上で配列を回して回転行列を計算するテスト」と「JobSystemで回転行列を並列計算するテスト」の2パターンを用意しております。
  • 両者とも回転行列の計算までが対応範囲であり、算出した結果については共通してMainThread上で各ドカベンロゴのMeshに計算結果を適用していく形の実装となっております。
        // 生成したドカベンのMeshFilter
        MeshFilter[] _dokabens = null;
        // Jobの終了待ち等を行うHandle
        JobHandle _jobHandle;
        // Job用の回転計算用データ
        NativeArray<DokabenStruct> _dokabenStructs;
        // mesh頂点の数
        int _vertsLen = 0;
        // メッシュの頂点のバッファ
        Vector3[] _vertsBuff = null;

        // ※初期化処理などは割愛..
        ............................................

        void Update()
        {
            float deltaTime = Time.deltaTime;
            if (this._jobSystem)
            {
                this.JobSystemCalculation(deltaTime);
            }
            else
            {
                this.UpdateCalculation(deltaTime);
            }

            // 計算結果を各ドカベンロゴに反映
            for (int i = 0; i < this._maxObjectNum; ++i)
            {
                var matrix = this._dokabenStructs[i];
                var mesh = this._dokabens[i].mesh;
                var origVerts = mesh.vertices;
                for (int j = 0; j < this._vertsLen; ++j)
                {
                    this._vertsBuff[j] = matrix.Matrix.MultiplyPoint3x4(origVerts[j]);
                }
                mesh.vertices = this._vertsBuff;
            }
        }

        // 配列を回して計算するテスト(MainThread上で実行)
        void UpdateCalculation(float deltaTime)
        {
            for (int i = 0; i < this._maxObjectNum; ++i)
            {
                // ※処理の単純化の為、この例だとMainThread側から直接NativeArrayにアクセスしている。(良いか悪いかは不明 & 未検証)
                this._dokabenStructs[i] = JobParallelForTest.Rotate(deltaTime, this._dokabenStructs[i]);
            }
        }

        // JobSystemで並列計算するテスト
        void JobSystemCalculation(float deltaTime)
        {
            // 回転計算用jobの作成
            MyParallelForUpdate rotateJob = new MyParallelForUpdate()
            {
                Accessor = this._dokabenStructs,
                DeltaTime = deltaTime,
            };
            // Jobの実行
            this._jobHandle = rotateJob.Schedule(this._maxObjectNum, this._innerloopBatchCount);
            // Jobが終わるまで処理をブロック
            this._jobHandle.Complete();
        }

実行結果について

※こちらEditor上での検証結果となります。

  • 最初の方はMainThreadで実行する形で回し、一定時間後にJobSystem側で計算するように切り替えた結果です。(切り替えタイミングについては赤いラインが大凡のタイミング)
  • やはり共通部分であるMainThread上で各ドカベンロゴのMeshに計算結果を適用していく箇所がネックとなっている為に全体的にパフォーマンス自体は出ていない感じとなっておりますが、JobSystemで並列計算することで回転行列の算出自体は早めることが出来ていると言った印象です。

Profiler.BeginSample Profiler.EndSampleで「回転行列の計算部分」と「計算結果の適用部分」を囲ってみた所、大凡の平均処理速度としては以下の様な結果となってました。(平均処理速度はProfilerの「Time ms」の項目を参照)

  • 回転行列の計算(MainThread) : 14~15ms
  • 回転行列の計算(JobSystem) : 4~5ms
  • 計算結果の適用(MainThread) : 29~31ms

IJobParallelFor.png

「JobParallelForTransformTest」について

次にIJobParallelForTransformを使用したサンプルについての解説です。
JobParallelForTestと同じく一部のソースコードを引用しつつ要点を纏めていくので、ソースコード全般については以下のコードを参照して下さい。

Art000.png

※10万個のドカベンロゴ(カメラに収まっていない感...)

回転計算用データ及び実行用Jobについて

  • DokabenStructについてはJobParallelForTestの時とほぼ同じ物となりますが、こちらでは行列ではなく角度だけを保持しておきます。
  • 次にMyParallelForTransformUpdateについて、こちらはIJobParallelForTransformを継承したジョブ実行用の構造体です。
    • 特徴としてはExecute関数の引数から渡ってくるTransformAccessを経由する事でTransformにアクセする事ができます。
      • ※ただ、TransformAccessについては冒頭の目次でも触れてある通り「world/localのTRSの値」しかアクセスする事ができないっぽいのでTransform.RotateAroundと言ったAPIを使用する事が出来ません。その為に今回の実装としてはモデルの原点を予め下端の方に設定しておき、Quaternion.AngleAxisで回す形の実装になってます。
        /// <summary>
        /// 回転計算用データ
        /// </summary>
        struct DokabenStruct
        {
            // 経過時間計測用
            public float DeltaTimeCounter;
            // コマ数のカウンタ
            public int FrameCounter;
            // 1コマに於ける回転角度
            public float CurrentAngle;
            // 現在の回転角度
            public float CurrentRot;

            public DokabenStruct(float currentAngle)
            {
                this.CurrentAngle = currentAngle;
                this.DeltaTimeCounter = 0f;
                this.FrameCounter = 0;
                this.CurrentRot = 0f;
            }
        }

        /// <summary>
        /// 回転計算用のJob(Transformに直接アクセスして計算)
        /// </summary>
        struct MyParallelForTransformUpdate : IJobParallelForTransform
        {
            public NativeArray<DokabenStruct> Accessor;
            public float DeltaTime;

            // JobSystem側で実行する処理
            public void Execute(int index, TransformAccess transform)
            {
                DokabenStruct accessor = this.Accessor[index];
                transform.rotation = Rotate(ref accessor, transform.rotation);
                this.Accessor[index] = accessor;
            }

            // MonoBehaviour.Updateで回す際に呼び出す処理
            public void ManualExecute(int index, Transform transform)
            {
                DokabenStruct accessor = this.Accessor[index];
                transform.rotation = Rotate(ref accessor, transform.rotation);
                this.Accessor[index] = accessor;
            }

            // 回転の算出
            Quaternion Rotate(ref DokabenStruct accessor, Quaternion rot)
            {
                if (accessor.DeltaTimeCounter >= Constants.Interval)
                {
                    accessor.CurrentRot += accessor.CurrentAngle;
                    rot = Quaternion.AngleAxis(accessor.CurrentRot, -Vector3.right);
                    accessor.FrameCounter = accessor.FrameCounter + 1;
                    if (accessor.FrameCounter >= Constants.Framerate)
                    {
                        accessor.CurrentAngle = -accessor.CurrentAngle;
                        accessor.FrameCounter = 0;
                    }
                    accessor.DeltaTimeCounter = 0f;
                }
                else
                {
                    accessor.DeltaTimeCounter += this.DeltaTime;
                }
                return rot;
            }
        }

Jobの実行について

  • 今回もパフォーマンスの違いを計測するために「MainThread上で配列を回して回転させるテスト」と「JobSystemで回転させるテスト」の2パターンを用意しております。
  • JobSystem側の処理について、初期化のタイミングで_dokabenTransformAccessArrayに生成したドカベンオブジェクトのTransformの参照を詰めておいてジョブを実行する際にScheduleに渡します。
    • →この時に登録したTransformの参照がIJobParallelForTransform.Executeの引数から渡ってくるようです。
        // Jobの終了待ち等を行うHandle
        JobHandle _jobHandle;
        // Job用の回転計算用データ
        NativeArray<DokabenStruct> _dokabenStructs;
        // JobSystem側で実行する際に用いるTransfromの配列
        TransformAccessArray _dokabenTransformAccessArray;
        // MonoBehaviour.Updateで実行する際に用いるTransformの配列
        Transform[] _dokabenTrses = null;

        // ※初期化処理などは割愛..
        ............................................

        void Update()
        {
            MyParallelForTransformUpdate rotateJob = new MyParallelForTransformUpdate()
            {
                Accessor = this._dokabenStructs,
                DeltaTime = Time.deltaTime,
            };

            if (this._isJobSystem)
            {
                // JobSystemで回転
                this._jobHandle.Complete();
                this._jobHandle = rotateJob.Schedule(this._dokabenTransformAccessArray);
                JobHandle.ScheduleBatchedJobs();
            }
            else
            {
                // MainThreadで回転
                for (int i = 0; i < this._maxObjectNum; ++i)
                {
                    // ※こちらも処理の単純化の為にMainThread側から直接NativeArrayにアクセスしている。
                    rotateJob.ManualExecute(i, this._dokabenTrses[i]);
                }
            }
        }

実行結果について

※こちらEditor上での検証結果となります。

  • こちらの方も最初はMainThread上で処理する形で実行し、一定時間経過後にJobSystemで処理する形に切り替えてます。
  • JobSystem側で処理を完結出来ていると言った事もあってか、結果としてはJobSystemで回すことで大幅にパフォーマンスを向上させる事が出来ている言った印象です。

Profiler.BeginSample Profiler.EndSampleで「回転処理の部分」を囲ってみた所、大凡の平均処理速度としては以下の様な結果となってました。(平均処理速度はProfilerの「Time ms」の項目を参照)

  • MainThread : 38~40ms (※重い時で50ms~)
  • JobSystem : 5~6ms (※重い時で8ms~)
    • ※回転処理の都合か、負荷にばらつきがあると言った印象です。

JobParallelForTransformTest.png

まとめ

※纏めと言うよりは所感です。

  • 扱える変数やAPIなどに制約があるものの、使い方自体は(理解すれば)シンプルで楽な印象。
    • 特にIJobParallelForTransformはTRSにアクセスする処理(移動など)を比較的低コストで実装できそう感。
  • 個人的には「バッファ(NativeArray)を確保してThread(Job)側で計算して結果を詰める」所がComputeShaderと似ている感じがしたので、こちらを使ったことある人であれば多分理解しやすいかと思われる?
  • NativeArrayについては文字通りネイティブメモリにバッファを確保している為に性質上メモリリークが発生する懸念こそあるものの、漏れた際には怒られる(警告が出るとの事)のでそんなに問題にはならなそうな印象。
  • CEDECの資料を見た感じだと、今後も「ECS」「Math Library」「C# Job Compiler」と要素が増えていくとのことなので期待。
  • モバイルでの使用について、今のところ検証できているのは一部のAndroid端末のみですが、こちらの方では問題なく動作している上で処理によってはパフォーマンスが出ているので十分に活用できるかもしれません。(要検証)
    • →Androidの実行結果については後述のオマケを参照

オマケ : Androidの実行結果について

試しにAndroid向けにビルドしてプロファイリングしてみました。
※プロファイリング結果全体は以下に置いてあるので必要に応じてご確認を。

検証時のビルド設定

以下に検証に於ける簡単なビルド設定を纏めます。(全体についてはGitHubのプロジェクトをご確認下さい)

  • DynamicBatching : 無効
    • ※Standaloneと設定を合わせるため
  • Multithreaded Rendering : 有効
    • こちらは有効にするとGraphicsAPIの呼び出しをMainThreadから別のWorkerThreadに移動する設定となります。
    • ※JobもWorkerThreadに差し込まれる都合上、これの有無で挙動が変わりそうな印象ですが、一先ずは有効状態でテスト。
  • Scripting Backend : IL2CPP

検証端末

検証端末は以下の2機種です。

  • Galaxy S6 edge
  • ZenFone AR

JobParallelForTest

Profiler.BeginSample Profiler.EndSampleで「回転行列の計算部分」と「計算結果の適用部分」を囲ってみた結果、大凡の平均処理速度としては以下の様な結果となってました。(平均処理速度はProfilerの「Time ms」の項目を参照)
※Profiler上で回転行列の算出は「 ----- RotateCalculation」、計算結果の適用は「 ===== MultiplyPoint3x4」と表記

  • Galaxy S6 edge

    • 回転行列の計算(MainThread) : 4~6ms
    • 回転行列の計算(JobSystem) : 10~13ms
    • 計算結果の適用(MainThread) : 80~90ms
  • ZonFone AR

    • 回転行列の計算(MainThread) : 4~6ms
    • 回転行列の計算(JobSystem) : 4~6ms
    • 計算結果の適用(MainThread) : 110~150?ms

そもそもの問題点として、描画負荷と計算結果の適用辺りがヤバイ感じとなっておりますが...肝心なJobSystemの処理部分については適用前と適用後で処理負荷が変わっていると言った印象を受けます。
物によってJobSystem側の処理負荷が上がっているのはCPUのコアの性能が各々違うためにMainThreadの処理の方が早く終わってしまって待ち受けで時間が掛かっているかもしれないと言った影響などが考えられます。(※予想です..ひょっとしたら間違っているかもしれません....)
※他にもMultithreaded Renderingの有無辺りが関わってきそうな感じがしなくもないですが..詳細についてはまだ見えておりません..。

JobParallelForTransformTest

Profiler.BeginSample Profiler.EndSampleで「回転処理の部分」を囲ってみた所、大凡の平均処理速度としては以下の様な結果となってました。(平均処理速度はProfilerの「Time ms」の項目を参照)
※Profiler上で回転処理の部分は「 ----- RotateCalculation」と表記

  • Galaxy S6 edge

    • MainThread : 60~80ms (※重いときで150ms~)
    • JobSystem : 8~10ms
  • ZonFone AR

    • MainThread : 70~90ms (※重いときで100ms~)
    • JobSystem : 7~9ms (※重いときで12ms~)
  • ※回転処理の都合か、両者とも負荷にばらつきがあると言った印象です

こちらも描画負荷辺りが大分ヤバイ感じになってますが..回転処理部分についてはMainThreadとJobSystemで大幅に変わってくると言った印象です。
Profilerを見た感じだとMultithreaded Renderingと思われるであろう描画関連の項目と、こちらで発行したJobが良い感じ?に詰め込まれている様に見受けられます。(以下はGalaxy S6で実行した物)

galaxy.png

参考/関連サイト

講演資料

参考サイト/ソース

その他