Help us understand the problem. What is going on with this article?

【Unity】ECS + JobSystemで10万個のドカベンロゴをアニメーションさせてみた

More than 1 year has passed since last update.

※2018/05/31. Unity.Mathematics及びスウィズル演算子について追記

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

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

doka2.gif

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

概要

前回の記事にてC# JobSystemで大量のドカベンロゴを回しましたが、今回は同じくUnity2018から入る新機能であるECS(Entity Component System)で10万個のドカベンロゴを回してみようと思います。

今回取り扱う項目としては「Pure ECSのみでの実装」と「Pure ECSとC# JobSystemを合わせた実装」の2点です。(※Pure ECSについては後ほど解説)
ECS & C# JobSytemについてはまだ完全に理解していない部分もあり、一部雰囲気でやっている箇所もあるかもしれませんが...分かっている範囲でこちらの実装内容や知見などをメモしていければと思います。

実装/動作環境

  • Unity2018.1.0f2
  • Package : Entities 0.0.11

    • ※Entitiesパッケージについて、現状落とせるものとしては(記事を書いた時点では)0.0.12 - preview.3となりますが、こちらでの動作保証は致しかねるので動作の際には上記のバージョンで確認することをオススメします。(確かpreview.2ぐらいで動作確認したらエラーが出たような...preview.3以降は不明。。)
      • → manifest.jsonに旧verを直接編集する形で指定してやることで旧verにする事が可能です。(簡単にやるならこちらをコピペすればok)
  • Windows10

  • CPU : Core i7-4790(4コア8スレ)

  • GPU : GeForce GTX 970

※各項目にてProfilerの結果などを載せておりますが、こちら全てEditor上での実行結果となるのでその点ご了承下さい。

ECSとは?

ECS(Entity Component System)とはUynity2018から入る新しいプログラミングモデルです。
特徴としては従来のオブジェクト指向のアプローチからデータ指向設計のデザインとなるために処理の効率化によるパフォーマンスの向上やコードの再利用のしやすさ等があり、その他にもJobSystemを用いた並列処理での高速化やBurst Compilerによるパフォーマンスの向上なども挙げられます。

詳細については公式のドキュメントに目を通しておくことをオススメします。

日本語での解説としては以下のサイトが非常に分かりやすくオススメです。

Pure ECSとは?

概要の所で「Pure ECS」と言う単語が出てきておりますが、先ずはこちらについてザックリと説明していこうと思います。

まず最初に前提としてECSの実装形式の説明から入ると、ECSは大きく分けて「現行のGameObjectやComponentを使用しないECSのみでの実装」と「現行のComponent等とECSを組み合わせたハイブリットと呼ばれる実装」の2種類があると言う認識です。
その為に便宜上、前者を「Pure ECS」後者を「Hybrid ECS」と呼び分けていこうと思います。

※とは言え、今回はPure ECSを主軸に置いた解説となるので後者の単語はあまり出ないかと思われますが...。
※ちなみに呼び方については公式のサンプルであるEntityComponentSystemSamplesの中にある「TwoStickShooter」に倣って呼んでおります。(これが正式な呼び方かどうかは不明...)

「Pure ECSのみでの実装」解説

ECSは大きく分けて4つの用語があり、必要な物を実装していく必要があります。

  • Entity
    • 「ComponentSystemが動作する為に要求するComponentData各種(Group)」を持つデータの単位
    • インスタンスに該当
  • ConponentSystem
    • Groupのデータが揃っている際に動作する処理の内容
  • ComponentData
    • 処理(ComponentSystem)で使われるデータ
  • Group
    • 処理(ComponentSystem)を動作させるために必要となるデータ(ComponentData)各種の定義

ECSは処理(ComponentSystem)とデータ(ComponentData)は別々に実装され、インスタンス化の際に処理に必要となるデータ各種(ComponentData一覧 == Group)を渡すことでインスタンス(Entity)が生成されます。

先ずは「ドカベンの回転処理を行う部分」のComponentSystemとComponentData各種の実装から載せていきます。
※あくまで回転処理の部分だけであり描画関連は別の実装となります。そちらについては後述。

ComponentDataの実装

  • こちらではロゴを回転させるのに必要な情報を持たせてます。
    • ※Positionに関しては正確に言うと回転処理そのものには使わないが生成時の座標保持用に確保。
  • ComponentDataを作る際には構造体且つIComponentDataを継承している必要があります。
    • ※IComponentData以外にもデータの共有に適しているISharedComponentDataと言った物もありますがここでは割愛。
  • 変数については従来のVector3では無くUnity.Mathematicsのを使うようにしております。

▼DokabenComponentData.cs

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

namespace MainContents.ECS
{
    /// <summary>
    /// ドカベンロゴの回転に必要なデータ
    /// </summary>
    public struct DokabenComponentData : IComponentData
    {
        /// <summary>
        /// アニメーションテーブル内に於ける再生位置
        /// </summary>
        public float AnimationHeader;
        /// <summary>
        /// 位置
        /// </summary>
        public float3 Position;
    }
}

Unity.Mathematicsについて

  • ※Unity.Mathematicsについてもう少し掘り下げると、特徴としてはシェーダー言語のような行列型やベクトル型が使えるようになったりします。その上でMatrix4x4と言った従来の型からの変換も容易な印象です。
  • 後はこちらを使うことでBurstCompilerで最適化されるとか何とか。
  • その他特徴として、ベクトル型に限り?スウィズル演算子が使えるみたいです。
    • スウィズル演算子自体は[EditorBrowsable(EditorBrowsableState.Never)]属性が指定されている為にIntelliSense等で表示されることはありません。恐らくは演算子の特性上、大量の候補が出てくることが予想されるためにIntelliSenseを汚さないための配慮かと思われます。
    • 見た感じだと行列型の演算子は実装されていない模様..?
float4x4 m = new float4x4();
float4 hoge = m.m0.xyzw;

ComponentSystemの実装

  • こちらがドカベンロゴの回転処理の実装部分となります。
  • ComponentSystemを作る際にはComponentSystemを継承したクラスを作る必要があります。
  • こちらの動作要件としては「DokabenComponentData」と「TransformMatrix」と言う2つのComponentDataを要求し、Entityがこの2点のComponentDataを持っていたら動作する形となってます。
    • ※TransformMatrixについて少し解説するとこちらはEntitiesライブラリに元から入っているComponentDataで、内容としてはfloat4x4の行列を一つだけ持っている物です。内部的には後述のMeshInstanceRendererSystemで使われてたりします。
  • 回転のロジックとしては前回の記事同様に任意点周りの回転移動で算出しております。
  • その他注意点としてはコメントにも記載しておりますが、Matrix4x4は列優先でfloat4x4は行優先っぽいので注意。。

▼DokabenComponentSystem.cs

using UnityEngine;

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.Collections;

namespace MainContents.ECS
{
    /// <summary>
    /// ドカベンロゴの回転(ComponentSystem)
    /// </summary>
    public class DokabenComponentSystem : ComponentSystem
    {
        // Systemで要求されるComponentData
        struct Group
        {
            public int Length;
            public ComponentDataArray<DokabenComponentData> Dokabens;
            public ComponentDataArray<TransformMatrix> Transforms;
        }

        [Inject] Group _group;

        protected override void OnUpdate()
        {
            float time = Time.time;
            int animLength = Constants.AnimationTable.Length;

            for (int i = 0; i < this._group.Length; i++)
            {
                var data = this._group.Dokabens[i];
                float4x4 m = float4x4.identity;

                // 時間の正弦を算出(再生位置を加算することで角度をずらせるように設定)
                float sinTime = math.sin(time * Constants.AnimationSpeed) + data.AnimationHeader;

                // _SinTime0~1に正規化→0~15(コマ数分)の範囲にスケールして要素数として扱う
                float normal = (sinTime + 1f) / 2f;

                // X軸に0~90度回転
                var index = (int)math.round(normal * (animLength - 1));
                float rot = Constants.AnimationTable[index] * math.radians(90f);

                // 任意の原点周りにX軸回転を行う(原点を-0.5ずらして下端に設定)
                // ※Matrix4x4は列優先でfloat4x4は行優先みたいなので注意
                float y = 0f, z = 0f;
                float halfY = y - 0.5f;
                float sin = math.sin(rot);
                float cos = math.cos(rot);
                m.m1.yz = new float2(cos, sin);
                m.m2.yz = new float2(-sin, cos);
                m.m3.yz = new float2(halfY - halfY * cos + z * sin, z - halfY * sin - z * cos);

                // 移動
                m.m3.xyz += data.Position.xyz;


                // 計算結果の保持
                this._group.Dokabens[i] = data;

                var trs = this._group.Transforms[i];
                trs.Value = m;
                this._group.Transforms[i] = trs;
            }
        }
    }
}

Entityの生成

やっている事を簡潔に纏めると...

  • MonoBehaviour.Start()内でEntityManagerを生成。
  • DokabenComponentSystemで動かすためのArchetypeを生成。
  • 最後にArchetypeを基にEntityを生成し初期値も設定。
    • 加えてMeshInstanceRendererSystem用のデータをAddSharedComponentDataで設定し描画されるようにする(※こちらについての解説は後述)

※後はECSでは専用の用語的な物(と言うよりは機能)が幾つか出てくるので、不明点などはドキュメントに目を通しつつ読み進めていくことをオススメします。以下に幾つか要点をピックアップして解説。

  • EntityManagerについて、こちらはEntityの生成/生存管理/データの追加/削除などを担当する物となります。
  • Archetypeについて、こちらはEntityを生成する際に参照する雛形的なものです。こちらに動かしたいシステムで必要となるデータをセットする事で生成時にデータを揃えたEntityを生成する事が出来ます。
    • ※今回の例で言うとDokabenComponentSystemは「DokabenComponentData」と「TransformMatrix」と言う2つのデータを要求するシステムとなるので、アーキタイプにはこちらの2点を登録してます。
  • Worldについて、こちらは(多分)EntityManagerとComponentSystemを管理する物で、デフォルトではプレイモードに入る際に1つのWorldが生成されプロジェクト内で使用可能な全てのComponentSystemが設定されるようです。
    • ドキュメント曰くデフォルトのWorldを無効化し、独自のものに置き換えることも可能みたいです。(未検証)

▼DokabenBootstrap.cs (コードの一部を抜粋)

    /// <summary>
    /// ドカベンロゴの回転(起動部)
    /// </summary>
    public class DokabenBootstrap : MonoBehaviour
    {
        // ------------------------------
        #region // Private Members(Editable)

        /// <summary>
        /// ドカベンロゴのスプライト
        /// </summary>
        [SerializeField] Sprite _DokabenSprite;

        /// <summary>
        /// ドカベンロゴの表示マテリアル
        /// </summary>
        [SerializeField] Material _DokabenMaterial;

        /// <summary>
        /// 表示領域のサイズ
        /// </summary>
        [SerializeField] Vector3 _BoundSize = new Vector3(256f, 256f, 256f);

        /// <summary>
        /// 最大オブジェクト数
        /// </summary>
        [SerializeField] int _MaxObjectNum = 100000;

        #endregion // Private Members(Editable)


        void Start()
        {
            var entityManager = World.Active.GetOrCreateManager<EntityManager>();

            // ドカベンロゴ用のアーキタイプ
            var archeType = entityManager.CreateArchetype(
                typeof(DokabenComponentData),
                typeof(TransformMatrix));

            var Look = this.CreateDokabenMeshInstanceRenderer();
            var halfX = this._BoundSize.x / 2;
            var halfY = this._BoundSize.y / 2;
            var halfZ = this._BoundSize.z / 2;
            var identity = new TransformMatrix { Value = float4x4.identity };
            for (int i = 0; i < this._MaxObjectNum; ++i)
            {
                var entity = entityManager.CreateEntity(archeType);
                entityManager.SetComponentData(entity,
                    new DokabenComponentData
                    {
                        AnimationHeader = 0f,
                        Position = new float3(
                            UnityRandom.Range(-halfX, halfX),
                            UnityRandom.Range(-halfY, halfY),
                            UnityRandom.Range(-halfZ, halfZ))
                    });

                entityManager.SetComponentData(entity, identity);

                // 描画用の情報としてMeshInstanceRendererを紐付ける
                // ※MeshInstanceRendererとTransformMatrixを紐付けることでMeshInstanceRendererSystemから呼ばれるようになる
                entityManager.AddSharedComponentData(entity, Look);
            }
        }
        .......
    }

結果

ecs_only.png

上記がプロファイリング結果です。
※Entity DebuggerはEntitiesパッケージを入れると追加されるウィンドウで、生成したEntity/ComponentSystemの負荷/ComponentDataの内容などが参照できます。

やはり10万個の一括計算はコストが高ためか、回転行列演算のシステム(DokabenComponentSystem)の負荷だけで言うと平均120ms近くは掛かっている印象です。

※補足 : 描画について

描画周りについて簡単に補足していくと、こちらはEntitiesパッケージ内にデフォルトで入っているMeshInstanceRendererSystemと言うシステムを用いて描画するようにしております。

こちらのシステム動かすのに必要なComponentDataとしては「TransformMatrix」と「MeshInstanceRenderer」と言う2点のデータが必要となり、後者のMeshInstanceRendererについては以下の様な構造体となっているみたいです。
※Entitiesパッケージのバージョンによっては内容が変わっている可能性があるので注意。

/// <summary>
/// Render Mesh with Material (must be instanced material) by object to world matrix.
/// Specified by TransformMatrix associated with Entity.
/// </summary>
[Serializable]
public struct MeshInstanceRenderer : ISharedComponentData
{
    public Mesh mesh;
    public Material material;
    public ShadowCastingMode castShadows;
    public bool receiveShadows;
}

その上でSystem側の挙動としては、内部でGraphics.DrawMeshInstancedで描画を行っているのでMeshInstanceRendererのコメントにもある通り、マテリアル/シェーダー自体はGPU Instancingに対応している必要があります。
今回の実装としてはドカベンロゴのSpriteを動的にMesh化し、GPU Instancingに対応させたUnlit Shaderを用いて描画させる形にしております。以下に生成時のコードを一部抜粋。

▼DokabenBootstrap.cs (コードの一部を抜粋)

        void Start()
        {
            var Look = this.CreateDokabenMeshInstanceRenderer();
            .........

            for (int i = 0; i < this._MaxObjectNum; ++i)
            {
                var entity = entityManager.CreateEntity(archeType);
                .......

                // 描画用の情報としてMeshInstanceRendererを紐付ける
                // ※MeshInstanceRendererとTransformMatrixを紐付けることでMeshInstanceRendererSystemから呼ばれるようになる
                entityManager.AddSharedComponentData(entity, Look);
            }
        }

        /// <summary>
        /// ドカベンロゴ表示用のMeshInstanceRendererの生成
        /// </summary>
        /// <returns>生成したMeshInstanceRenderer</returns>
        MeshInstanceRenderer CreateDokabenMeshInstanceRenderer()
        {
            // Sprite to Mesh
            var mesh = new Mesh();
            mesh.SetVertices(Array.ConvertAll(this._DokabenSprite.vertices, _ => (Vector3)_).ToList());
            mesh.SetUVs(0, this._DokabenSprite.uv.ToList());
            mesh.SetTriangles(Array.ConvertAll(this._DokabenSprite.triangles, _ => (int)_), 0);

            // 渡すマテリアルはGPU Instancingに対応させる必要がある
            var meshInstanceRenderer = new MeshInstanceRenderer();
            meshInstanceRenderer.mesh = mesh;
            meshInstanceRenderer.material = this._DokabenMaterial;
            return meshInstanceRenderer;
        }

※更に補足

MeshInstanceRendererSystemのソースを見た感じだとまだまだ最適化の余地がありそうな感じだったので、今後のバージョンでは場合によっては実装自体は変わっていくかもしれません。
現状の実装については取得したEntitiesパッケージから参照可能なので、気になる方は直接確認してみて下さい。

コードの確認方法について、PackageManagerから取得したパッケージについては自分の環境(Windows 10)だと以下の場所に保存されていることが確認できました。

/AppData/Local/Unity/cache/pachages/stagins-packages.unity.com

この中にある「com.unity.entities@(バージョン)」フォルダからESC関連のソースを参照することが可能です。
ちなみに、上記で紹介したMeshInstanceRendererについては「"/com.unity.entities@0.0.11/Unity.Rendering.Hybrid"」以下にありました。(※バージョンによっては変わっているかもしれないので注意)

「Pure ECSとC# JobSystemを合わせた実装」解説

次にJobSystemを合わせた並列処理による高速化の例を記載していきます。
変更点としてはComponentSystemだけとなるので、そちらのコード及び要点を纏めていきます。

ComponentSystemの実装

要点としては以下

  • ComponentSystemでは無くJobComponentSystemを継承したクラスを作成。
  • 回転行列の演算自体はJob側で行うようにした上でOnUpdate内ではパラメータの設定とJobの実行のみを行う。
  • UpdateAfterでMeshInstanceRendererSystemの後に実行するように指定
    • ※コメントにもある通り、プロファイリング結果を見た感じだとMeshInstanceRenderer周りで多分Job待ち?と思われる挙動が発生していたので。。
      • → この対応により特定のフレームは従来の様に5~6msで完了するが、それ以外は28~30ms?ぐらい掛かったりする。。
  • IJobProcessComponentDataに「ComputeJobOptimization」属性を付けることでBurstCompolerの恩恵を受けられるとのことですが、(エラーコードの内容から)「staticの参照不可」「C#の配列を使えない」と言った制限がある様に見受けられた上に今回の実装と少し相性が悪い所があったので一旦無視してます。。(どこかで試したい所...)

▼DokabenComponentJobSystem.cs

using UnityEngine;

using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;
using Unity.Jobs;
using Unity.Rendering;

namespace MainContents.ECS
{
    /// <summary>
    /// ドカベンロゴの回転(ComponentSystem) ※JobSystem併用版
    /// </summary>
    /// <remarks>JobComponentSystemを継承した実装</remarks>
    [UpdateAfter(typeof(MeshInstanceRendererSystem))]   // MeshInstanceRendererSystemでJob待ち?が発生するっぽいので後に実行。しかし毎フレーム解決されるわけではない...
    public class DokabenComponentJobSystem : JobComponentSystem
    {
        /// <summary>
        /// ドカベンロゴ回転計算用Job
        /// </summary>
        //[ComputeJobOptimization]  // TODO BurstCompolerについて、static、配列などが使えないので作り変える必要ありそう感
        struct DokabenJob : IJobProcessComponentData<DokabenComponentData, TransformMatrix>
        {
            public float Time;

            // 時間の設定
            public void SetTime(float time)
            {
                Time = time;
            }

            // Jobで実行されるコード
            public void Execute(ref DokabenComponentData data, ref TransformMatrix transform)
            {
                float4x4 m = float4x4.identity;

                // 時間の正弦を算出(再生位置を加算することで角度をずらせるように設定)
                float sinTime = math.sin(this.Time * Constants.AnimationSpeed) + data.AnimationHeader;

                // _SinTime0~1に正規化→0~15(コマ数分)の範囲にスケールして要素数として扱う
                float normal = (sinTime + 1f) / 2f;

                // X軸に0~90度回転
                var animIndex = (int)math.round(normal * (Constants.AnimationTable.Length - 1));
                float rot = Constants.AnimationTable[animIndex] * math.radians(90f);

                // 任意の原点周りにX軸回転を行う(原点を-0.5ずらして下端に設定)
                // ※Matrix4x4は列優先でfloat4x4は行優先みたいなので注意
                float y = 0f, z = 0f;
                float halfY = y - 0.5f;
                float sin = math.sin(rot);
                float cos = math.cos(rot);
                m.m1.yz = new float2(cos, sin);
                m.m2.yz = new float2(-sin, cos);
                m.m3.yz = new float2(halfY - halfY * cos + z * sin, z - halfY * sin - z * cos);

                // 移動
                m.m3.xyz += data.Position.xyz;

                // 計算結果の反映
                transform.Value = m;
            }
        }

        DokabenJob _job;

        protected override void OnCreateManager(int capacity)
        {
            base.OnCreateManager(capacity);
            this._job = new DokabenJob();
        }

        protected override JobHandle OnUpdate(JobHandle inputDeps)
        {
            // Jobの実行
            this._job.SetTime(Time.time);
            return this._job.Schedule(this, 7, inputDeps);
        }
    }
}

結果

ecs_job.png

こちらがJobSytemを合わせて並列処理による高速化を行った例のプロファイリング結果です。
回転行列演算のシステム(DokabenComponentJobSystem)の負荷としては平均0.05ms(高いときで0.20ms)以内には収まっていると言った印象です。

ただ「ComponentSystemの実装」の項目でも記載した通り、MeshInstanceRendererSystemによる(おそらくは)Job待ちと思われる現象が発生する影響で全体的に負荷が疎らな感じとなっております。。こちらについてはMeshInstanceRendererSystem側でJobの優先順位を付けるなどすれば解決できるか?等と色々予想しておりますが、現状まだ未検証です...。

最後に

従来のオブジェクト指向ベースの考えとは違った実装となるので、最初の方こそ戸惑った上に現時点に於いてもまだ完全には理解出来ていないかもしれませんが...大まかな考え方自体は理解できたかもしれません。(※完全に理解したとは言っていない)

現状はまだ機能的にも不足しているように思える所もあり、それこそPureで実装しようとすると色々と自作する必要が出てくるかもしれませんが、そこについては今後の進展に期待です。

ただ、ECS + C# JobSytemによる最適化は魅力的な所ではあるので、今後も色々と検証していけたらなと思っております。

参考/関連サイト

公式

JobSystem & ECS

JobSystem

ECS

講演資料

GitHub

JobSystem

ECS

その他

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした