LoginSignup
26
19

More than 3 years have passed since last update.

ECSで移動するビルボード風スプライトを同時に114514体表示する

Last updated at Posted at 2018-08-09

この記事はUnityゆるふわサマーアドベントカレンダー 2018の10日目の記事です。
前日の記事は@matsu_vrさんのUnityでGoogle Resonance Audioを使ってAmbisonics音源を鳴らすVRコンテンツでVRが有効にならないときは「Plugins/x86_64」フォルダを消せばいいGoogle Resonance Audio + Ambisonics音源を実装する時の罠でした!

サンプルプロジェクトのリポジトリ

Unityを初めてまだ2月ほどの初心者ですので、バッドノウハウも多いかと思います。ご指摘いただけると幸いです。

Unityで2Dの絵を簡単に出す方法といえば、Sprite Rendererのような2D機能を使用する方法と、uGUIのImage系の機能を使用する方法があります。(テラシュールブログさんの記事がわかりやすいですね。)
この記事ではスプライトを大量に表示し、かつ移動させようと思います(パーティクルシステムでOKと言ってはいけない)。
ECSを利用した弾幕シューティングの秀逸な解説記事が既にありますが、この記事では3次元空間上にビルボード風に常にカメラを向くようにスプライトを表示させてみたいと思います。

次の画像のようにクォータービューで大量のスプライトを表示するゲームを作る際にこの記事が役立つと思います。
タワーディフェンスゲームやリアルタイムストラテジーとかが候補でしょう。
祖国と銃とキャプチャ.PNG

実行画面

Monoバージョン画面.PNG

執筆者環境

  • Unity
    • Unity 2018.2.4f1 Personal
    • ECS version0.0.12-preview8
  • パソコンスペック
    • Windows 10 Home(x64)
    • Intel Core i5-5200U CPU @2.20GHz
    • RAM 8.00GB
    • Intel HD Graphics 5500
    • DirectXバージョン 12

サンプルについての説明

(ゲーム性は)ないです。
114514体のユニットが地面の範囲内を好き勝手に動き回ります。
WASDQEキーでカメラが前後左右上下に動きます。
パネルをクリックすることでそこにカメラを向けます。
スペースキーでユニットが移動するのを一時停止します。
エスケープキーで終了。
1キーでレンダリング方法1(Graphics.DrawMeshInstancedIndirect・ComputeBuffer1本使い)。
2キーでレンダリング方法2(Graphics.DrawMeshInstancedIndirect・ComputeBuffer2本使い)。
3キーでレンダリング方法3(Graphics.DrawMeshInstanced)。

設計について

GOクラス、いやGODクラスとしてManagerクラスを使います。
Mangerクラスの役割としては、

  • ComponentSystemの初期化
  • 入力の管理

です。
ECS HybridでComponentSystemのフィールドをInspectorから設定できれば万々歳なのですができないためしょうがなくMonoBehaviourから諸々(ShaderやMeshなどを)設定します。

Managerクラス

Manager.cs
[RequireComponent(typeof(Camera))]
public class Manager : MonoBehaviour
{

    // Unlitシェーダー、シェーダー内部でビルボードの挙動を取らせている。
    // 移動方向(Heading.x)に応じて左右を反転させる。
    // Meshが一部描画されないのはなぜなのか……
    // (A):DrawMeshInstancedIndirectを複数回同一フレームで呼び出す際に引数として渡したComputeBufferが共有されていたから!
    [SerializeField] Shader Unlit_Position;

    // Unlitシェーダー、シェーダー内部でビルボードの挙動を取らせている。
    // 移動方向(Heading.x)に応じて左右を反転させる。
    // HeadingとPositionをコピペしてGPUに渡すためComputeBufferを2本使用している。
    // Meshが一部描画されないのはなぜなのか……
    [SerializeField] Shader Unlit_Position_DoubleBuffer;

    // ごく普通のUnlitシェーダー。
    // ビルボードな挙動はしない。
    [SerializeField] Shader Unlit_MeshInstanceRenderer;

    // スプライトの種類数に制限は設けない。
    [SerializeField] Sprite[] UnitSprites;
    [SerializeField] [Range(0, 2)] int currentMode;

    Camera mainCamera;

    readonly Heading[] Angles = new Heading[360];
    // 描画方法を三通り試すため、Worldを3つ用意する。アクセスの利便性のため配列にする。
    // EntityManagerは各World固有の存在。
    // EntityManagerは内部にArchetypeManagerを一つ持つ。
    // EntityManager.CreateArchetypeでEntityArchetypeを作るならそれぞれのWorldで作る必要がある。
    // よってWorldとEntityArchetypeのタプルで管理する方がよいだろう。
    readonly (World world, EntityArchetype archetype)[] WorldArchetypeTupleArray = new(World, EntityArchetype)[3];

    // SpriteRendererSharedComponentとMeshInstanceRendererが両方共にクラスならボクシングとかを気にする必要がなく、Array[]とかの形にして変数の個数を減らせたのだが。
    SpriteRendererSharedComponent[] renderer1;
    SpriteRendererSharedComponent[] renderer2;
    MeshInstanceRenderer[] renderer3;

    void Awake()
    {
        var collection = World.AllWorlds;
        for (int i = 0; i < WorldArchetypeTupleArray.Length; i++)
            WorldArchetypeTupleArray[i].world = collection[i];
        WorldArchetypeTupleArray[0].archetype = WorldArchetypeTupleArray[0].world.GetExistingManager<EntityManager>().CreateArchetype(typeof(Position), typeof(Heading), typeof(MoveSpeed), typeof(MoveForward), typeof(SpriteRenderSystem.Tag), typeof(SpriteRendererSharedComponent));
        WorldArchetypeTupleArray[1].archetype = WorldArchetypeTupleArray[1].world.GetExistingManager<EntityManager>().CreateArchetype(typeof(Position), typeof(Heading), typeof(MoveSpeed), typeof(MoveForward), typeof(SpriteRenderSystem_DoubleBuffer.Tag), typeof(SpriteRendererSharedComponent));
        WorldArchetypeTupleArray[2].archetype = WorldArchetypeTupleArray[2].world.GetExistingManager<EntityManager>().CreateArchetype(typeof(Position), typeof(Heading), typeof(MoveSpeed), typeof(MoveForward), typeof(TransformMatrix), typeof(MeshInstanceRenderer));
        WorldArchetypeTupleArray[1].world.GetExistingManager<SpriteRenderSystem_DoubleBuffer>().Camera = WorldArchetypeTupleArray[0].world.GetExistingManager<SpriteRenderSystem>().Camera = mainCamera = GetComponent<Camera>();
        InitializeRenderer();
        InitializeAngle();
        const int count = 114514;
        // 114514体のEntityを生成する。
        SpawnEntities(count, ref WorldArchetypeTupleArray[0], renderer1);
        SpawnEntities(count, ref WorldArchetypeTupleArray[1], renderer2);
        SpawnEntities(count, ref WorldArchetypeTupleArray[2], renderer3);
        ChooseWorldToRun(currentMode);
    }

    private void ChooseWorldToRun(int mode)
    {
        currentMode = mode;
        // World.Activeに指定していないWorldは勝手に動作する。Enabled = falseにしてやらないと普通に動作してレンダリングとかする。
        for (int i = 0; i < WorldArchetypeTupleArray.Length; i++)
            if (i != currentMode)
                StopWorld(WorldArchetypeTupleArray[i].world);
        RunWorld(World.Active = WorldArchetypeTupleArray[currentMode].world);
    }

    void InitializeRenderer()
    {
        renderer1 = new SpriteRendererSharedComponent[UnitSprites.Length];
        for (int i = 0; i < UnitSprites.Length; i++)
            renderer1[i] = new SpriteRendererSharedComponent(Unlit_Position, UnitSprites[i]);
        renderer2 = new SpriteRendererSharedComponent[UnitSprites.Length];
        for (int i = 0; i < UnitSprites.Length; i++)
            renderer2[i] = new SpriteRendererSharedComponent(Unlit_Position_DoubleBuffer, UnitSprites[i]);
        renderer3 = new MeshInstanceRenderer[UnitSprites.Length];
        for (int i = 0; i < UnitSprites.Length; i++)
            renderer3[i] = CreateMeshInstanceRenderer(UnitSprites[i]);
    }

    void Update()
    {
        var deltaMove = Time.deltaTime * 10;
        var transform = this.transform;
        if (Input.GetKey(KeyCode.W))
            transform.position += deltaMove * Vector3.forward;
        if (Input.GetKey(KeyCode.A))
            transform.position += deltaMove * Vector3.left;
        if (Input.GetKey(KeyCode.S))
            transform.position += deltaMove * Vector3.back;
        if (Input.GetKey(KeyCode.D))
            transform.position += deltaMove * Vector3.right;
        if (Input.GetKey(KeyCode.Q))
            transform.position += deltaMove * Vector3.up;
        if (Input.GetKey(KeyCode.E))
            transform.position += deltaMove * Vector3.down;
        if (Input.GetMouseButtonDown(0) && Physics.Raycast(mainCamera.ScreenPointToRay(Input.mousePosition), out var hitInfo))
            transform.LookAt(hitInfo.point);
        if (Input.GetKeyDown(KeyCode.Space))
        {
            var MoveForwardSystem = World.Active.GetExistingManager<MoveForwardSystem>();
            MoveForwardSystem.Enabled = !MoveForwardSystem.Enabled;
        }
        if (Input.GetKey(KeyCode.Escape))
            Application.Quit();
        // 描画方法の切り替え。
        if (Input.GetKeyDown(KeyCode.Alpha0) && currentMode != 0)
            ChooseWorldToRun(0);
        else if (Input.GetKeyDown(KeyCode.Alpha1) && currentMode != 1)
            ChooseWorldToRun(1);
        else if (Input.GetKeyDown(KeyCode.Alpha2) && currentMode != 2)
            ChooseWorldToRun(2);
    }

    void SpawnEntities<T>(int count, ref (World world, EntityArchetype archetype) pair, T[] array) where T : struct, ISharedComponentData
    {
        var manager = pair.world.GetExistingManager<EntityManager>();
        var restLength = (count - UnitSprites.Length) / UnitSprites.Length;
        var srcEntities = new NativeArray<Entity>(UnitSprites.Length, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        var firstEntities = new NativeArray<Entity>(count - (restLength + 1) * UnitSprites.Length + restLength, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        var restEntities = new NativeArray<Entity>(restLength, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
        try
        {
            InitializeSourceEntities(manager, ref pair.archetype, ref srcEntities, array);
            InitializeEntities(manager, ref srcEntities, array, ref firstEntities, ref restEntities);
        }
        finally
        {
            srcEntities.Dispose();
            firstEntities.Dispose();
            restEntities.Dispose();
        }
    }


    // 1°ずつ変化するベクトルを設定する。

    void InitializeAngle()
    {
        for (int i = 0; i < Angles.Length; i++)
        {
            var angle = math.radians(i);
            Angles[i] = new Heading { Value = new float3(math.cos(angle), 0, math.sin(angle)) };
        }
    }

    // NativeArray<T>の構造体のサイズは8byte(void*)+4byte(int)+4byte(Allocator)=16byteであると思われる。
    // この程度ならref使わなくてもいいか? 呼び出し回数も少ないことであるし。
    void InitializeSourceEntities<T>(EntityManager manager, ref EntityArchetype archetype, ref NativeArray<Entity> src, T[] shared) where T : struct, ISharedComponentData
    {
        manager.CreateEntity(archetype, src);
        for (int i = 0; i < src.Length; i++)
        {
            var entity = src[i];
            // SetSharedComponentはChunk移動(創造)を起こすので最小限度にする。
            manager.SetSharedComponentData(entity, shared[i]);
            manager.SetComponentData(entity, new MoveSpeed { speed = UnityEngine.Random.Range(0.1f, 2f) });
            manager.SetComponentData(entity, Angles[(int)UnityEngine.Random.Range(0f, 360f)]);
        }
    }

    void InitializeEntities<T>(EntityManager manager, ref NativeArray<Entity> srcEntities, T[] array, ref NativeArray<Entity> firstEntities, ref NativeArray<Entity> restEntities) where T : struct, ISharedComponentData
    {
        if (array == null) throw new ArgumentNullException();
        // Instantiateした場合はISharedComponentDataが最初から適切に設定されているためChunkについて頭を悩ませなくて良い。
        manager.Instantiate(srcEntities[0], firstEntities);
        for (int i = 0; i < firstEntities.Length; i++)
        {
            var entity = firstEntities[i];
            manager.SetComponentData(entity, new MoveSpeed { speed = UnityEngine.Random.Range(0.1f, 2f) });
            manager.SetComponentData(entity, Angles[i % 360]);
        }
        for (int i = 1; i < srcEntities.Length; i++)
        {
            manager.Instantiate(srcEntities[i], restEntities);
            for (int j = 0; j < restEntities.Length; j++)
            {
                var entity = restEntities[j];
                manager.SetComponentData(entity, new MoveSpeed { speed = UnityEngine.Random.Range(0.1f, 2f) });
                manager.SetComponentData(entity, Angles[j % 360]);
            }
        }
    }

    void StopWorld(World world)
    {
        foreach (var item in world.BehaviourManagers)
        {
            var system = item as ComponentSystemBase;
            if (system == null) continue;
            system.Enabled = false;
        }
    }
    void RunWorld(World world)
    {
        foreach (var item in world.BehaviourManagers)
        {
            var system = item as ComponentSystemBase;
            if (system == null) continue;
            system.Enabled = true;
        }
    }


    // スプライトからMeshとMaterialを作成する。
    MeshInstanceRenderer CreateMeshInstanceRenderer(Sprite sprite)
    {
        var mesh = new Mesh();
        // LINQ使うのは遅いが、少量であるから手を抜いている。
        var vertices = Array.ConvertAll(sprite.vertices, _ => (Vector3)_).ToList();
        mesh.SetVertices(vertices);
        mesh.SetUVs(0, sprite.uv.ToList());
        mesh.SetTriangles(Array.ConvertAll(sprite.triangles, _ => (int)_), 0);

        return new MeshInstanceRenderer()
        {
            mesh = mesh,
            material = new Material(Unlit_MeshInstanceRenderer)
            {
                enableInstancing = true,
                mainTexture = sprite.texture
            }
        };
    }
}

要注意点としてはEntityをJob Systemを使って生成しようとした場合、Editorでは正常に動作しましたが、Stand AloneではNullReferenceExceptionを吐いてしまいます。
仮に非同期にSetComponentやらAddComponentしようとしても結局はEndFrameBarrierとかのBarrierSystem派生クラスがメインスレッドで作業するだけなので非同期にする必要はないですが。

複数のWorldを作成して運用する。

Note: We are currently working on multiplayer demos, that will show how to work in a setup with separate simulation & presentation Worlds. This is a work in progress, so right now have no clear guidelines and are likely missing features in ECS to enable it.

公式の説明がデッドリンクなので結構苦労しましたが、複数のWorldを作成することができました。
今回のサンプルではレンダリング方法別にWorldを3通り用意して、それぞれのWorldに対して最適化されたEntityを114514体生成しています。
その際、デフォルトのWorldが自動生成されないようにPlayer Settings>Other Settings>Configuration>Scripting Define Symbolsに「UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP」を設定しました。デフォルトWorldの自動生成について知りたい方はUnity.Entities.Hybrid/Injection/AutomaticWorldBootstrap.csを参照されるといいと思います。

ECSって内部的にunsafeとリフレクション祭りでうへぇってなりますね……
ジェネリック型のComponentSystemはデフォルトでは使用できなかったです。
公式のサンプルが未だ存在しないので非効率かもしれませんが、複数World作成方法の例にはなるでしょう。(ECSのバージョンが上がったら多分動かなくなると思います。だって必要なメソッドが無駄に隠蔽されていて発展途上感ありありとしていますもの。)
複数Worldの注意点は、World.Activeに指定されたWorld以外も普通に動作し続けるということです。World.Activeは単なる薄いプロパティでしかないです。

Bootstrap.cs
static class Bootstrap
{
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void Initialize()
    {
        var worlds = new World[3];

        // もしもPure ECSではなくGameObjectEntityなどを使用してHybrid ECSを使いたいと言うならば、Hookする必要がある。
        // ただ、その場合Unity.Entities.GameObjectArrayInjectionHookインスタンスが必要だが、internalなクラスであるのでリフレクションが必須となる。
        // 参考までにここに書いておく。

        // var assembly = Assembly.GetAssembly(typeof(GameObjectEntity));
        // InjectionHookSupport.RegisterHook(Activator.CreateInstance(assembly.GetType("Unity.Entities.GameObjectArrayInjectionHook")) as InjectionHook);
        // InjectionHookSupport.RegisterHook(Activator.CreateInstance(assembly.GetType("Unity.Entities.TransformAccessArrayInjectionHook")) as InjectionHook);
        // InjectionHookSupport.RegisterHook(Activator.CreateInstance(assembly.GetType("Unity.Entities.ComponentArrayInjectionHook")) as InjectionHook);


        // ComponentSystemPatchAttributeというクラスに付く属性が存在する。
        // 用途はイマイチ不明であるが、ComponentSystemの動作の上書きでもするのだろうか?
        // ScriptBehaviourUpdateOrder.csで言及されている。要研究。

        // var AddComponentSystemPatch = typeof(World).GetMethod("AddComponentSystemPatch", BindingFlags.NonPublic | BindingFlags.Instance);
        // AddComponentSystemPatch.Invoke(world, new object[] { SpriteRenderSystem_DoubleBufferType });

        PlayerLoopManager.RegisterDomainUnload(DomainUnloadShutdown, 10000);

        var Bound = new float4(-50, -50, 50, 50);
        var MoveForwardSystemType = typeof(MoveForwardSystem);

        // 最低限必要なSystemだけに絞るべし。

        worlds[0] = World.Active = new World("world0");
        // 最初にこうすることでEntityManagerがガリガリっとEntityを保存する領域を確保できるようにしてあげよう。
        worlds[0].SetDefaultCapacity(114514);
        worlds[0].CreateManager<BoundReflectSystem>().Region = Bound;
        // 内部実装上ジェネリクスはTypeオブジェクトを引数に取るメソッドのラッパーでしかない。
        worlds[0].CreateManager(MoveForwardSystemType);
        worlds[0].CreateManager(typeof(SpriteRenderSystem));

        worlds[1] = new World("world1");
        worlds[1].SetDefaultCapacity(114514);
        worlds[1].CreateManager<BoundReflectSystem>().Region = Bound;
        worlds[1].CreateManager(MoveForwardSystemType);
        worlds[1].CreateManager(typeof(SpriteRenderSystem_DoubleBuffer));

        worlds[2] = new World("world2");
        worlds[2].SetDefaultCapacity(114514);
        worlds[2].CreateManager<BoundReflectSystem>().Region = Bound;
        worlds[2].CreateManager(MoveForwardSystemType);
        worlds[2].CreateManager(typeof(TransformSystem));
        worlds[2].CreateManager(typeof(MeshInstanceRendererSystem));

        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(worlds);
    }
    static void DomainUnloadShutdown()
    {
        World.DisposeAllWorlds();
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop();
    }
}

EntityManagerはどこで作られるのかわからないのですが、World.CreateManagerで作成せずともよい(したら例外を吐く)ようです。 ComponentSystemBaseというabstractクラスのOnBeforeCreateManagerInternalメソッド内部でGetOrCreateManagerされていました。なんでもいいから一つComponentSysteをCreateManagerしさえすれば勝手に作られるというわけですね。

実はWorld.CreateManagerにはparams object[] constructorArgumnentsという形で対象のComponentSystemのコンストラクタに引数を渡せます。
今回のサンプルでは使い所がない&ボクシングが嫌でしたので使っていませんが素晴らしい機能ですね。
readonlyフィールドの初期化ができるので安全に運用できますよ!
なお、ComponentGroupはコンストラクタ内で初期化できないのでOnCreateManagerで初期化せねばなりません。
MonoBehavior派生クラスでコンストラクタが書けないとかいう不自由さとは大違いですよ!
ECSの美点はprotected overrideでUpdateメソッドが定義されていることですね。マジックメソッドではないからコード補完が効くっていうのは実に素晴らしい。
ちなみにWorld.CreateManagerは内部的にActivator.CreateInstanceを始めとするリフレクションを使っています(オイ)。

上記コードではRuntimeInitializeOnLoadMethodAttributeを使っていますが、Manger.csのAwake内にコピペして動かしてみてもちゃんと動きます。
肝となるのはScriptBehaviourUpdateOrder.UpdatePlayerLoopです。これを呼び出さないとPlayerLoopがまともに動きません。

追記(2018年8月23日)

CEDEC2018での講演【CEDEC2018】CPUを使い切れ! Entity Component System(通称ECS) が切り開く新しいプログラミングの86枚目のスライドにて、Entityが格納されるバッファは存在するEntityの個数がCapacityを超えるとバッファを倍の長さでmallocしてからmemcpyするので、起動時にEntityManager.EntityCapacityを想定最大値にすると良いと書いてあります。
EntityManager.EntityCapacityを書き換えるのはList.Capacity書き換えるのと同義です。new List(int capcity)の時に適切に設定してあげましょう。

自作System概説

  • SpriteRenderSystem
    • ユニット(影描画なし、回転なし)のスプライトをビルボード風に描画します。
    • ヴァーレントゥーガ風に移動方向によって左右反転して描画します。
    • ComputeBuffer1本に引き渡す情報をまとめます。
  • SpriteRenderSystem_DoubleBuffer
    • 性能比較のためにComputeBuffer2本を使用して情報をまとめずに素渡しします。
    • SpriteRenderSystemと殆どのコードが被ってます。
  • BoundReflectSystem
    • ユニットが移動する範囲を制限します。範囲外に出た場合移動方向を変更し、範囲内に戻します。

Systemを自作する際に絶対にすべきことは、sealedキーワードを付けることです。(ライブラリでも書くのでなければやらない理由が)ないです。

SpriteRenderSystem_DoubleBuffer

:SpriteRenderSystem_DoubleBuffer.cs
[UpdateAfter(typeof(UnityEngine.Experimental.PlayerLoop.PreLateUpdate.ParticleSystemBeginUpdateAll))]
sealed class SpriteRenderSystem_DoubleBuffer : ComponentSystem
{
    // IL2CPPでビルドする際には定数化したほうが圧倒的にいいらしい。
    // Marshal.SizeOf<Heading>の値を使う。
    const int Stride = sizeof(float) * 3;
    public Bounds Bounds = new Bounds(Vector3.zero, Vector3.one * 300);
    // 全カメラに対して描画するならnull
    // 特定1つに対して描画するなら非null
    public Camera Camera;
    uint[] args = new uint[5];

    // ECS標準の[Inject]で注入やらなんやらは実際ComponentGroupをやりくりすることで行っている。
    // [Inject]はリフレクション祭りなのでやめよう(提案)。
    ComponentGroup g;
    int ShaderProperty_PositionBuffer = Shader.PropertyToID("_PositionBuffer");
    int ShaderProperty_HeadingBuffer = Shader.PropertyToID("_HeadingBuffer");

    readonly List<SpriteRendererSharedComponent> sharedComponents = new List<SpriteRendererSharedComponent>();
    readonly List<int> sharedIndices = new List<int>();
    readonly Dictionary<int, (ComputeBuffer, ComputeBuffer)> buffers = new Dictionary<int, (ComputeBuffer, ComputeBuffer)>();
    readonly List<ComputeBuffer> argsList = new List<ComputeBuffer>();

    protected override void OnCreateManager(int capacity)
    {
        // ComponentType.Subtractive<T>()はTをArchetypeに持たないということを意味する。
        g = GetComponentGroup(ComponentType.ReadOnly<Position>(), ComponentType.ReadOnly<Heading>(), ComponentType.ReadOnly<SpriteRendererSharedComponent>());
    }
    // MeshInstanceRendererSystemを真似て書いている。
    // あちらではMatrix4x4[]を用意して1023個ずつEntityをGraphics.DrawMeshInstancedしている。
    // その際にTransformMatrixをむりくりMatrix4x4に読み替えたりコピーしたりしているようなので多分遅い。
    // あと、preview8のコメント文でDarwMeshInstancedの引数にNativeArray/Sliceが使えないことを嘆いている。
    protected override void OnUpdate()
    {
        sharedComponents.Clear();
        sharedIndices.Clear();
        // sharedIndicesはsharedComponentが同一である限り変化しないようだ。
        // Set(Add)SharedComponentした順にindexが増える。
        EntityManager.GetAllUniqueSharedComponentDatas(sharedComponents, sharedIndices);
        // 公式によるとfilterかけるのはかなり低コスト。毎フレーム2.4KbGCが発生するけどな!!!
        // NativeList<T>に対応せず、List<T>にしか対応していないのナンデ?
        // 後の研究の結果内部的にISharedComponentはList<object>に格納されているからだと判明。
        IncreaseArgsList(sharedComponents.Count);
        using (var filter = g.CreateForEachFilter(sharedComponents))
            for (int i = 0; i < sharedComponents.Count; i++)
            {
                // meshとmaterialいずれかがnullの場合は描画できないので飛ばす。
                var sprite = sharedComponents[i];
                // DestroyしないならSystem.Object.ReferenceEquals使ってもいいじゃない。
                if (object.ReferenceEquals(sprite.material, null) || object.ReferenceEquals(sprite.mesh, null))
                    continue;
                // ComponentDataArrayの実態はポインタ(+もろもろ)だそうだ。
                var positionDataArray = g.GetComponentDataArray<Position>(filter, i);
                var headingDataArray = g.GetComponentDataArray<Heading>(filter, i);
                // 本当ならC#7.2で導入されたin引数でin filterにすることでGetComponentDataArray<Heading>のタイミングを遅らせたい。
                Render(i, ref sprite, ref positionDataArray, ref headingDataArray);
            }
    }

    void IncreaseArgsList(int count)
    {
        if (argsList.Count >= count) return;
        if (argsList.Capacity < count)
            argsList.Capacity = count;
        while (argsList.Count < count)
            argsList.Add(new ComputeBuffer(args.Length, sizeof(uint), ComputeBufferType.IndirectArguments));
    }

    void Render(int i, ref SpriteRendererSharedComponent sprite, ref ComponentDataArray<Position> positionDataArray, ref ComponentDataArray<Heading> headingDataArray)
    {
        var length = positionDataArray.Length;
        if (length == 0)
            return;
        var index = sharedIndices[i];
        // Unity公式のGraphics.DrawMeshInstancedIndirectのサンプルでは
        // 描画する数が前フレームの数と一致しない場合には必ず毎フレームComputeBufferをReleaseして更にnewしていた。
        if (!buffers.TryGetValue(index, out var buffer))
        {
            buffers[index] = buffer = (new ComputeBuffer(length, Stride), new ComputeBuffer(length, Stride));
            sprite.material.SetBuffer(ShaderProperty_PositionBuffer, buffer.Item1);
            sprite.material.SetBuffer(ShaderProperty_HeadingBuffer, buffer.Item2);
        }
        // 短ければnewするのは当然だが必要量より長い分にはnewしないほうがいいのではなかろうか。
        if (buffer.Item1.count < length)
        {
            buffer.Item1.Release();
            buffer.Item2.Release();
            buffers[index] = buffer = (new ComputeBuffer(length, Stride), new ComputeBuffer(length, Stride));
            // MaterialPropertyBlockではなくMaterialに直に設定する。
            sprite.material.SetBuffer(ShaderProperty_PositionBuffer, buffer.Item1);
            sprite.material.SetBuffer(ShaderProperty_HeadingBuffer, buffer.Item2);
        }
        args[0] = sprite.mesh.GetIndexCount(0);
        // 何体描画するか
        args[1] = (uint)length;
        args[2] = sprite.mesh.GetIndexStart(0); // このプログラムの場合は0固定だが書いておく
        args[3] = sprite.mesh.GetBaseVertex(0); // このプログラムの場合は0固定だが書いておく
        argsList[i].SetData(args, 0, 0, 4);
        // 時代はSpan<T>、ただしUnityの場合はNativeSlice<T>
        // Allocator.Persistentにしてクラスフィールドにしたほうがいいのかどうなのかいまいちわからない。
        using (var position = new NativeArray<Position>(length, Allocator.Temp, NativeArrayOptions.UninitializedMemory))
        {
            positionDataArray.CopyTo(position);
            // なぜNativeSliceを扱えないのか(困惑)
            // lengthを指定することで必要量だけ書き換えよう。
            // GPUへの転送量がこれで抑えられるのかどうかは不明。
            // 抑えられないようならば描画数が変わる度にComputeBufferをnewしなくてはならないだろう。
            buffer.Item1.SetData(position, 0, 0, length);
            // 構造体レイアウトが同じであるからできる邪道。
            headingDataArray.CopyTo(position.Slice().SliceConvert<Heading>());
            buffer.Item2.SetData(position, 0, 0, length);
        }
        // 影を描画しないしさせない鉄の意志。
        // Unity内部でバッチングをするためargとして渡すComputeBufferはメソッド呼び出し毎に別々にしなくてはならない。
        Graphics.DrawMeshInstancedIndirect(sprite.mesh, 0, sprite.material, Bounds, argsList[i], 0, null, ShadowCastingMode.Off, false, 0, Camera, LightProbeUsage.Off, null);
    }

    // 描画しないなら解放する。
    protected override void OnStopRunning()
    {
        for (int i = 0; i < argsList.Count; i++)
            argsList[i].Dispose();
        argsList.Clear();
        using (var e = buffers.Values.GetEnumerator())
            while (e.MoveNext())
            {
                var buffer = e.Current;
                buffer.Item1.Release();
                buffer.Item2.Release();
            }
        buffers.Clear();
    }
    protected override void OnDestroyManager()
    {
        for (int i = 0; i < argsList.Count; i++)
            argsList[i].Dispose();
        argsList.Clear();
        using (var e = buffers.Values.GetEnumerator())
            while (e.MoveNext())
            {
                var buffer = e.Current;
                buffer.Item1.Release();
                buffer.Item2.Release();
            }
        buffers.Clear();
    }
}

PlayerLoopが未だExperimentalなのでこのコードは近い将来動かなくなります。

struct Group{
  public readonly int Length;
  public SharedComponentDataArray<T> sharedData;
}

[Inject]Group g;記法も利便性が高くていいですが、ISharedComponentDataを中心に動かすならばEntityManager.GetAllUniqueSharedComponentDatasを使うのが一番パフォーマンスがいいです。
C#7.2で導入された安全なstackallocでSpanをSetDataの引数にできればGCから更に解放されるでしょう。早く対応してほしいものです。

Graphics.DrawMeshInstancedIndirectの引数に渡したりMaterialに渡すComputeBufferは呼び出し毎に別々のインスタンスに必ずしてください。使い回したせいでメッシュの頂点数が足りなくて変な描画になったりしました。

BoundReflectSystem

BoundReflectSystem.cs
[UpdateBefore(typeof(MoveForwardSystem))]
sealed class BoundReflectSystem : JobComponentSystem
{
    public float4 Region;
    [BurstCompile]
    struct Job : IJobProcessComponentData<Position, MoveSpeed, Heading>
    {
        // x:最小X
        // y:最小Z
        // z:最大X
        // w:最大Z
        public float4 Region;
        public void Execute(ref Position data0, [ReadOnly]ref MoveSpeed data1, ref Heading data2)
        {
            // 範囲内にクランプする。
            data0.Value.xz = math.clamp(data0.Value.xz, Region.xy, Region.zw);
            // 範囲境界にあるならば
            var xzxz = math.equal(data0.Value.xzxz, Region);
            // 移動方向を反転させる
            data2.Value.x *= math.any(xzxz.xz) ? -1 : 1;
            data2.Value.z *= math.any(xzxz.yw) ? -1 : 1;
        }
    }
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        return new Job
        {
            Region = Region
        }.Schedule(this, 1024 * 16, inputDeps);
    }
}

ECS+Job SystemにおいてはIJobProcessComponentDataを継承したJobが最もメモリ効率がよいそうです。
しかし、この場合ISharedComponentDataを扱えない上に、3種類までのComponentDataの組み合わせしか表現できないため使い所はかなり限られます。
math aware libraryであるUnity.Mathematics.mathがまんまShaderLabの組み込み関数ですから、Executeメソッド内では色々読む限りシェーダーを書くノリで書くべきっぽいのかもしれません。実際の所はどうなのかよくわかりませんが。

シェーダー

Shader "Unlit/Indirect/Position-DoubleBuffer"
{
    Properties
    {
        _MainTex ("Base (RGB) Trans (A)", 2D) = "white" {}
    }

    SubShader
    {
        Tags
        {
            "IgnoreProjector"="True"
            "RenderType"="Opaque"
        }
        Cull Off
        Lighting Off
        ZWrite On
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct appdata_t
            {
                float4 vertex : POSITION;
                float2 texcoord : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f 
            {
                float4 vertex : SV_POSITION;
                float2 texcoord : TEXCOORD0;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            StructuredBuffer<float3> _PositionBuffer;
            StructuredBuffer<float3> _HeadingBuffer;

            v2f vert (appdata_t v, uint instanceID : SV_InstanceID)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                float4x4 matrix_ = UNITY_MATRIX_I_V;
                v.vertex.x *= lerp(1, -1, step(0, _HeadingBuffer[instanceID].x));
                matrix_._14_24_34 = _PositionBuffer[instanceID];
                o.vertex = UnityObjectToClipPos(mul(matrix_, v.vertex));
                o.texcoord = TRANSFORM_TEX(v.texcoord, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.texcoord);
            }
            ENDCG
        }
    }
}

この辺にぃ(UnityCG.cginc)、ビュー行列の逆行列(UNITY_MATRIX_I_V)が(定義して)あるらしいっすよ。
メッシュの頂点に逆ビュー行列かけてたら実際描画されるものはカメラに正対したメッシュになります。
ビルボードは常にカメラに対して正対しているからビルボード風に描画できますねえ!
_PositionBufferから引っ張ってきた座標をアフィン変換してあげましょう。

_HeadingBufferは移動方向を格納したComputeBufferです。左右反転させるために必要でした。TagsでCull Offしていることでスプライトの裏面も描画できるので楽ですね。

Graphics.DrawMeshInstancedとMeshInstnaceRendererSystemについての解説

ソース全文はcom.unity.entities/Unity.Rendering.Hybrid/MeshInstanceRendererSystem.csを参照してください。

MeshInstanceRendererSystem.cs(1)
// This is the ugly bit, necessary until Graphics.DrawMeshInstanced supports NativeArrays pulling the data in from a job.
public unsafe static void CopyMatrices(ComponentDataArray<TransformMatrix> transforms, int beginIndex, int length, Matrix4x4[] outMatrices)
{
    // @TODO: This is using unsafe code because the Unity DrawInstances API takes a Matrix4x4[] instead of NativeArray.
    // We want to use the ComponentDataArray.CopyTo method
    // because internally it uses memcpy to copy the data,
    // if the nativeslice layout matches the layout of the component data. It's very fast...
    fixed (Matrix4x4* matricesPtr = outMatrices)
    {
        Assert.AreEqual(sizeof(Matrix4x4), sizeof(TransformMatrix));
        var matricesSlice = NativeSliceUnsafeUtility.ConvertExistingDataToNativeSlice<TransformMatrix>(matricesPtr, sizeof(Matrix4x4), length);
        #if ENABLE_UNITY_COLLECTIONS_CHECKS
        NativeSliceUnsafeUtility.SetAtomicSafetyHandle(ref matricesSlice, AtomicSafetyHandle.GetTempUnsafePtrSliceHandle());
        #endif
        transforms.CopyTo(matricesSlice, beginIndex);
    }
}

protected override void OnUpdate()
{
    // We want to iterate over all unique MeshInstanceRenderer shared component data,
    // that are attached to any entities in the world
    EntityManager.GetAllUniqueSharedComponentDatas(m_CacheduniqueRendererTypes);
    var forEachFilter = m_InstanceRendererGroup.CreateForEachFilter(m_CacheduniqueRendererTypes);

    for (int i = 0;i != m_CacheduniqueRendererTypes.Count;i++)
    {
        // For each unique MeshInstanceRenderer data, we want to get all entities with a TransformMatrix
        // SharedComponentData gurantees that all those entities are packed togehter in a chunk with linear memory layout.
        // As a result the copy of the matrices out is internally done via memcpy.
        var renderer = m_CacheduniqueRendererTypes[i];
        var transforms = m_InstanceRendererGroup.GetComponentDataArray<TransformMatrix>(forEachFilter, i);

        // Graphics.DrawMeshInstanced has a set of limitations that are not optimal for working with ECS.
        // Specifically:
        // * No way to push the matrices from a job
        // * no NativeArray API, currently uses Matrix4x4[]
        // As a result this code is not yet jobified.
        // We are planning to adjust this API to make it more efficient for this use case.

        // For now, we have to copy our data into Matrix4x4[] with a specific upper limit of how many instances we can render in one batch.
        // So we just have a for loop here, representing each Graphics.DrawMeshInstanced batch
        int beginIndex = 0;
        while (beginIndex < transforms.Length)
        {
            int length = math.min(m_MatricesArray.Length, transforms.Length - beginIndex);
            CopyMatrices(transforms, beginIndex, length, m_MatricesArray);
            Graphics.DrawMeshInstanced(renderer.mesh, renderer.subMesh, renderer.material, m_MatricesArray, length, null, renderer.castShadows, renderer.receiveShadows);

            beginIndex += length;
        }
    }

    m_CacheduniqueRendererTypes.Clear();
    forEachFilter.Dispose();
}

やってることの基本はISharedComponentDataでフィルタリングしてバッチング効かせながら大量描画しているだけです。簡単ですね!
ECS関連で有名な@mao_さんが記事で書いていましたが、float4x4はTransformMatrixやMatrix4x4と構造を一致させるために列優先です。何かしら自作する場合はできる限り既存の型と構造を一致させたほうが性能上お得でしょうね。
Graphics.DrawMeshInstancedには引数に取れるMatrix4x4[]の長さが最大1023個までという制約があります。大量に描画しようとするとDrawCallが増えるという欠点があります。
1SetPass,1DrawCallで114514体描画できるGraphics.DrawMeshInstancedIndirectは神!

レンダリング性能比較

123プロファイラー.PNG
左からSpriteRenderSystem、SpriteRenderSystem_DoubleBuffer、MeshInstanceRendererSystemそれぞれのCPUプロファイル結果。
2番目が100FPS以上出していて最高に気持ちいい!

でも、GCのピンク色が見えますよね……:sob:
これNativeArrayをnewしたせいです。

追記(2018/08/13)

SpriteRenderSystem_DoubleBufferにおいてより効率的なデータコピーをすることで毎フレームのNativeArray確保を省くことに成功しました。しかし、それでもCreateForEachFilterに由来するGCが2.5kb発生します。このforeachfilterをキャッシュできればいいのですが、内部的にAllocator.Tempで確保されたNativeArrayを使用しているためキャッシュしてはならないでしょう。GC0は遠いですね……

追記(2018/08/23)

ECSのパッケージをPackagesフォルダからAssetsフォルダに移してソースコード本体に手を加えることにより、foreachフィルターを作成せずにレンダリングできるようになりました。GC0です。可読性は大分下がりました。
internal修飾子を全てpublicにするとかいう禁忌に手を出した甲斐はありましたね。
軽く解説するとComponentGroupからComponentChunkIteratorというunsafe structを得て、それを使って全チャンクを巡回し、各チャンクの持つISharedComponentDataを調べながらComputeBufferにデータを詰めていきました。無駄な一時バッファを作成せずに済むので気分的には最高です。

結論:Graphics.DrawMeshInstancedIndirectをできる限り使いましょう。

CPUからGPUへのデータ転送量をケチったらCPUサイクルを浪費することがわかります。
無駄情報が含まれているとしても、そのままComputeBufferにSetDataした方がいいですね。

反省

IL2CPPでWindows Stand Aloneビルドしたら動かないです:sob:
Mono(.NET 4.x互換)だと動くのですが…… どうしてなのかとんとわかりません。誰か助けてください。

操舵行動とかBoidsシミュレーション要素とか足したらリアルタイムストラテジーゲームっぽさが増すと思います。
Boidsシミュレーションをこの記事には書きませんが愚直に実装したら500体で30FPSと低速でした……

参考文献

26
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
19