simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

Unity:ECSでメッシュオブジェクトを生成する方法

まえがき

Unity で動的にメッシュオブジェクトを作成して、キャラクターと衝突判定を行えるようにコライダーを設定する…となると?

Unity の書式により
1.シーン内に GameObject インスタンスを作り、追加コンポーネントは MeshFilter と Renderer, MeshCollider が必須となります。
2.Vector3や int, Vector2 のマネージド配列を vertices, indices(submeshごとに), uvs(submeshごとに) を作成し MeshFilter に設定、normals のために CalcurateNormals を行う必要があります。
3.その後、MeshCollider に mesh を渡してコライダーを再構築します。
4.マテリアルを設定します。(submeshごとに)

この内容であれば、過去に二回記事を書いてきましたし、そこからさらにプリミティブな技術に関する記事へ飛ぶことができます。
以下の通り。
simplestar-tech.hatenablog.com

今回は、さらに ECS を使ってステップ2のvertices や indices をマルチスレッドで作成する最小サンプルの動作確認をしていきます。

ECS についてわからない人はこちらを参照して理解してくる必要があります。
qiita.com
自分は上記の Tips 集を経て、コードが書けるようになってから、こちらの解説を読めるようになるのが一番簡単なルートかなと感じてます。
www.f-sp.com

Unity ECS の理解は難易度が高いです。
数ヶ月勉強しても、よくわからない状態が続いても、あきらめないことが大事です。
筆者も一ヶ月近くサンプルやドキュメント読みながら、よくわからない状態が続きましたが、思ったとおりに動いてしまえばこっちのものです。

そんなわけで、Unity ECS と動的メッシュ作成を見ていきましょう!

Unity のCPU利用順序

ゲーム開発は多次元のイメージに様々な角度からライトを当てて、一次元のコードとして影をはっきり描く、高い集中力と体力を必要とする知的活動です。
影として描く一次元のコードというのがミソで、かならず真っ直ぐなラインで順序を数えることができます。
まずは Unity でコードを書いたら、どういう順番で処理されるのかを正確に把握するところから始めます。
simplestar-tech.hatenablog.com

もう一つ重要なこととして、ユーザーさんは、画面がカクつく(描画タイミングが一瞬遅れる)ことに強いストレスを感じます。
メッシュは数百万頂点から構成されているので、どうしても計算リソースが描画レートを上回ります。
そのため、画面がカクつく前に計算量を調整する仕組みを用意しなければなりません。

その仕組みも今回のサンプルに盛り込んでみましょう。

まずは一つ ECS を使わずにメッシュを作る

ざっとおさらい
CreateMeshWithoutECS.cs

using UnityEngine;

/// <summary>
/// ECS を使わずに動的に Mesh 作成をする
/// </summary>
public class CreateMeshWithoutECS : MonoBehaviour
{
    #region Assets
    [SerializeField] GameObject prefabMeshObject;
    [SerializeField] Shader meshShader;
    #endregion

    const float root3 = 1.732051f;

    void Start()
    {
        var gameObject = Instantiate(this.prefabMeshObject);
        var mesh = new Mesh();
        Vector3[] positions = new Vector3[] {
            // top
            new Vector3 (0f, 1f, 0f),
            new Vector3 (1f, 1f, -root3),
            new Vector3 (-1f, 1f, -root3),
            // bottom
            new Vector3 (0f, 0f, 0f),
            new Vector3 (-1f, 0f, -root3),
            new Vector3 (1f, 0f, -root3),
        };
        int[] vertIndices = new int[]
        {
            0, 1, 2,
            3, 4, 5,
            0, 2, 4,
            4, 3, 0,
            2, 1, 5,
            5, 4, 2,
            1, 0, 3,
            3, 5, 1
        };
        Vector3[] vertices = new Vector3[vertIndices.Length];
        for (int i = 0; i < vertIndices.Length; i++)
        {
            vertices[i] = positions[vertIndices[i]];
        }
        mesh.vertices = vertices;

        int[] triangles = new int[mesh.vertices.Length];
        for (int i = 0; i < mesh.vertices.Length; i++)
        {
            triangles[i] = i;
        }
        mesh.triangles = triangles;

        mesh.RecalculateNormals();

        var filter = gameObject.GetComponent<MeshFilter>();
        filter.sharedMesh = mesh;

        var renderer = gameObject.GetComponent<MeshRenderer>();
        renderer.material = new Material(this.meshShader);
    }
}

結果よし

f:id:simplestar_tech:20190609102828p:plain
上記コードの実行結果

ECS でメッシュを作る

Hybrid ECS と呼ばれる GameObject に予め GameObjectEntity コンポーネントを設定する方法で攻めます。
次の MonoBehaviour をシーンに配置すると entity が一つ作成されます。
ここで書かれているチャンク情報は今回の記事では活躍しないので無視してください。

CreateMeshWithECS.cs

using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using UnityEngine;

public class CreateMeshWithECS : MonoBehaviour
{
    #region Assets
    [SerializeField] GameObject prefabMeshObject;
    [SerializeField] Shader meshShader;
    #endregion

    void Awake()
    {
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(World.Active); // 明示的に ECS デフォルトワールド使用を宣言
    }

    unsafe void Start()
    {
        #region メッシュオブジェクト Entity の作成
        var meshObject = Instantiate(this.prefabMeshObject);
        var meshEntity = meshObject.GetComponent<GameObjectEntity>();
        meshEntity.EntityManager.AddComponent(meshEntity.Entity, ComponentType.ReadOnly<CreateMeshMarker>());
        meshEntity.EntityManager.AddComponent(meshEntity.Entity, ComponentType.ReadWrite<ChunkMeshData>());
        #endregion
        #region チャンク情報の定義
        this.nativeChunkData = new NativeArray<int>(
            new int[] {
                1
            }, Allocator.Persistent);
        #endregion
        #region チャンク情報の設定
        meshEntity.EntityManager.SetComponentData<ChunkMeshData>(meshEntity.Entity, new ChunkMeshData
        {
            pChunkData = (int*)this.nativeChunkData.GetUnsafePtr(),
            chunkDataLength = this.nativeChunkData.Length
        });
        #endregion

        var renderer = meshObject.GetComponent<MeshRenderer>();
        renderer.material = new Material(this.meshShader);
    }

    void OnDestroy()
    {
        #region メッシュ情報破棄
        this.nativeChunkData.Dispose();
        #endregion
        #region ECS 終了処理
        World.DisposeAllWorlds();
        WordStorage.Instance.Dispose();
        WordStorage.Instance = null;
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
        #endregion
    }

    NativeArray<int> nativeChunkData;
}

出てきた登場クラス紹介

CreateMeshComponents.cs

using Unity.Entities;

internal unsafe struct ChunkMeshData : IComponentData
{
    internal int* pChunkData;
    internal int chunkDataLength;
    internal float* pVerticesVector3;
    internal int vertexCount;   
}

MarkerInterfaces.cs

using Unity.Entities;
public struct CreateMeshMarker : IComponentData { }

次の ComponentSystem を実装しておけば、ECS が entity マーカーである CreateMeshMarker を持つ entity 配列に対して処理を実行するようになります。
初期化 OnCreate 関数内では頂点データのベースを定義し、OnDestroy 関数内で、確保した領域を開放しています。

並列ジョブを entity 単位で実行するように実装しました。
キューブどころか三角柱のメッシュデータを利用していますので、これ以上小さい ECS 動的メッシュ生成サンプルは作れない気がしています。
Job も頂点数カウントとバッファ書き込みの2つを直列で実行するように、一つの System で2つの Job を記述するようにまとめました。

CreateMeshSystem.cs

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

internal unsafe class CreateMeshSystem : ComponentSystem
{
    /// <summary>
    /// チャンク情報から頂点数をカウント
    /// </summary>
    [BurstCompile]    
    unsafe struct CountVerticesJob : IJobParallelFor
    {
        internal NativeArray<ChunkMeshData> meshDataArray;
        internal int verticesSourceCount;

        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            meshData.vertexCount = 0;
            for (int dataIndex = 0; dataIndex < meshData.chunkDataLength; dataIndex++)
            {
                var data = meshData.pChunkData[dataIndex];
                if (1 == data)
                {
                    meshData.vertexCount += this.verticesSourceCount;
                }
            }
            this.meshDataArray[entityIndex] = meshData;
        }
    }

    /// <summary>
    /// 頂点バッファに頂点データを塗る
    /// </summary>
    [BurstCompile] 
    unsafe struct FillVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSourceVector3;

        internal NativeArray<ChunkMeshData> meshDataArray;
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            for (int dataIndex = 0; dataIndex < meshData.chunkDataLength; dataIndex++)
            {
                var data = meshData.pChunkData[dataIndex];
                if (1 == data)
                {
                    UnsafeUtility.MemCpy(meshData.pVerticesVector3, this.pVerticesSourceVector3, size: meshData.vertexCount * 3 * sizeof(float));
                }
            }
        }
    }

    protected override unsafe void OnCreate()
    {
        base.OnCreate();

        this.query = GetEntityQuery(new EntityQueryDesc
        {
            All = new[] { ComponentType.ReadOnly<CreateMeshMarker>(),
                ComponentType.ReadWrite<ChunkMeshData>() },
        });

        #region モデルの形状を定義→NativeArray確保
        int[] indices = new int[]
        {
            0, 1, 2,
            3, 4, 5,
            0, 2, 4,
            4, 3, 0,
            2, 1, 5,
            5, 4, 2,
            1, 0, 3,
            3, 5, 1
        };
        const float root3 = 1.732051f;
        var vertices = new Vector3[] {
            new Vector3(0f, 1f, 0f),
            new Vector3(1f, 1f, -root3),
            new Vector3(-1f, 1f, -root3),
            new Vector3(0f, 0f, 0f),
            new Vector3(-1f, 0f, -root3),
            new Vector3(1f, 0f, -root3),
        };
        Vector3[] verticesSourceVector3 = new Vector3[indices.Length];
        for (int indicesIndex = 0; indicesIndex < indices.Length; indicesIndex++)
        {
            verticesSourceVector3[indicesIndex] = vertices[indices[indicesIndex]];
        }
        this.pVerticesSourceVector3 = (float*)(this.nativeVerticesSource = new NativeArray<Vector3>(verticesSourceVector3, Allocator.Persistent)).GetUnsafePtr();
        #endregion
    }

    protected override void OnDestroy()
    {
        this.nativeVerticesSource.Dispose(); // NativeArray 開放

        base.OnDestroy();
    }

    protected unsafe override void OnUpdate()
    {
        #region NativeArray 確保
        var entities = this.query.ToEntityArray(Allocator.TempJob);
        var meshDataArray = this.query.ToComponentDataArray<ChunkMeshData>(Allocator.TempJob);
        #endregion

        #region メッシュの頂点数をカウント
        var countVerticesJob = new CountVerticesJob
        {
            verticesSourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var jobHandle = countVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        jobHandle.Complete();
        #endregion

        #region カウント数→頂点バッファを確保→バッファポインタを ComponentData に代入
        this.nativeEntityVerticesList.Clear();
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var meshData = meshDataArray[entityIndex];
            var nativeVertices = new NativeArray<Vector3>(meshData.vertexCount, Allocator.TempJob);
            this.nativeEntityVerticesList.Add(nativeVertices);
            meshData.pVerticesVector3 = (float*)nativeVertices.GetUnsafePtr();
            meshDataArray[entityIndex] = meshData;
        }
        #endregion

        #region 頂点バッファに頂点データを塗る
        var fillVerticesJob = new FillVerticesJob
        {
            pVerticesSourceVector3 = this.pVerticesSourceVector3,
            meshDataArray = meshDataArray
        };
        var fillJobHandle = fillVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        fillJobHandle.Complete();
        #endregion

        #region 頂点バッファ→マネージド配列→メッシュ作成→メッシュ法線・接線の計算
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var entity = entities[entityIndex];
            var vertices = this.nativeEntityVerticesList[entityIndex].ToArray();

            var mesh = new Mesh();
            mesh.vertices = vertices;
            int[] triangles = new int[vertices.Length];
            for (int vertexIndex = 0; vertexIndex < vertices.Length; vertexIndex++)
            {
                triangles[vertexIndex] = vertexIndex;
            }
            mesh.SetIndices(triangles, MeshTopology.Triangles, submesh: 0, calculateBounds: true);
            mesh.RecalculateNormals();
            mesh.RecalculateTangents();

            var meshFilter = EntityManager.GetComponentObject<MeshFilter>(entity);
            meshFilter.mesh = mesh;

            this.nativeEntityVerticesList[entityIndex].Dispose();
        }
        this.nativeEntityVerticesList.Clear();
        #endregion

        #region entity から marker の除去
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var entity = entities[entityIndex];
            EntityManager.RemoveComponent(entity, ComponentType.ReadOnly<CreateMeshMarker>());
        }
        #endregion

        #region NativeArray 開放
        meshDataArray.Dispose();
        entities.Dispose();
        #endregion
    }

    EntityQuery query;
    
    NativeArray<Vector3> nativeVerticesSource;
    float* pVerticesSourceVector3;

    List<NativeArray<Vector3>> nativeEntityVerticesList = new List<NativeArray<Vector3>>();
}

with ECS で同じ結果を得られることを確認しました。

f:id:simplestar_tech:20190609183536p:plain
上記サンプルコードを実行したときの様子

カクつかない仕組みを作る

まずはカクつかせるようにメッシュを複雑にして entity の数を増やしてみましょう。

上記のコードを次の通り変更します。

public class ChunkManager
{ 
    public const int ChunkSizeX = 16;
    public const int ChunkSizeY = 16;
    public const int ChunkSizeZ = 16;

    public const float CubeSide = 2f;
}

        #region チャンク情報の定義
        var chunkData = new int[ChunkManager.ChunkSizeX * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY];
        for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
        {
            for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
            {
                for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                {
                    var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                    chunkData[dataIndex] = 1;
                }
            }
        }
        this.nativeChunkData = new NativeArray<int>(chunkData, Allocator.Persistent);
        #endregion
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];

            for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
            {
                for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
                {
                    for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                    {
                        var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                        var data = meshData.pChunkData[dataIndex];
                        if (1 == data)
                        {
                            var verticesOffset = dataIndex * this.verticesSourceCount;
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset * 3, this.pVerticesSourceVector3, size: this.verticesSourceCount * 3 * sizeof(float));

                            for (int vertexIndex = 0; vertexIndex < this.verticesSourceCount; vertexIndex++)
                            {
                                var positionXIndex = (verticesOffset + vertexIndex) * 3;
                                meshData.pVerticesVector3[positionXIndex + 0] += x * ChunkManager.CubeSide;
                                meshData.pVerticesVector3[positionXIndex + 1] += y * ChunkManager.CubeSide;
                                meshData.pVerticesVector3[positionXIndex + 2] += z * ChunkManager.CubeSide;
                            }
                        }
                    }
                }
            }
        }

f:id:simplestar_tech:20190609225400p:plain
16x16x16 のチャンクにしてみる

さらに entity を増やします。

コードはこんな変更をします。

    private unsafe void CreateMeshObject(Vector3 position)
    {
        #region メッシュオブジェクト Entity の作成
        var meshObject = Instantiate(this.prefabMeshObject, position, Quaternion.identity);
        var meshEntity = meshObject.GetComponent<GameObjectEntity>();
        meshEntity.EntityManager.AddComponent(meshEntity.Entity, ComponentType.ReadOnly<CreateMeshMarker>());
        meshEntity.EntityManager.AddComponent(meshEntity.Entity, ComponentType.ReadWrite<ChunkMeshData>());
        #endregion
        
        #region チャンク情報の設定
        meshEntity.EntityManager.SetComponentData<ChunkMeshData>(meshEntity.Entity, new ChunkMeshData
        {
            pChunkData = (int*)this.nativeChunkData.GetUnsafePtr(),
            chunkDataLength = this.nativeChunkData.Length
        });
        #endregion

        var renderer = meshObject.GetComponent<MeshRenderer>();
        renderer.material = new Material(this.meshShader);
    }
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
            {
                for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
                {
                    for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                    {
                        var position = new Vector3(ChunkManager.ChunkSizeX * x * 2, ChunkManager.ChunkSizeY * y, ChunkManager.ChunkSizeZ * z * 1.732f);
                        CreateMeshObject(position);
                    }
                }
            }
            
        }
    }

f:id:simplestar_tech:20190610213701p:plain
8x8x8 のチャンクを 8x8x8 個作成した様子

プロファイラを確認すると大変なスパイクが発生していることが確認できました。

f:id:simplestar_tech:20190610214042p:plain
スパイク時の Timeline (1132.64ms)

normal と tangent の計算に時間を要しているようです。
normal と tangent をあらかじめ与えてコピーするようにしてみたらどうなるでしょうか?

f:id:simplestar_tech:20190611212548p:plain
normals と tangents の計算をしなかった場合Timeline (1202.04ms)

若干遅くなってしまいました。参考までにソースコードを示します。

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
using UnityEngine;

internal unsafe class CreateMeshSystem : ComponentSystem
{
    /// <summary>
    /// チャンク情報から頂点数をカウント
    /// </summary>
    [BurstCompile]
    unsafe struct CountVerticesJob : IJobParallelFor
    {
        internal NativeArray<ChunkMeshData> meshDataArray;
        internal int verticesSourceCount;

        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            meshData.vertexCount = 0;
            for (int dataIndex = 0; dataIndex < meshData.chunkDataLength; dataIndex++)
            {
                var data = meshData.pChunkData[dataIndex];
                if (1 == data)
                {
                    meshData.vertexCount += this.verticesSourceCount;
                }
            }
            this.meshDataArray[entityIndex] = meshData;
        }
    }

    /// <summary>
    /// 頂点バッファに頂点データを塗る
    /// </summary>
    [BurstCompile]
    unsafe struct FillVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSource;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pNormalsSource;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pTangentsSource;
        [ReadOnly] internal int sourceCount;
        internal NativeArray<ChunkMeshData> meshDataArray;

        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];

            for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
            {
                for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
                {
                    for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                    {
                        var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                        var data = meshData.pChunkData[dataIndex];
                        if (1 == data)
                        {
                            var verticesOffset = dataIndex * this.sourceCount;
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset, this.pVerticesSource, size: this.sourceCount * sizeof(float));
                            UnsafeUtility.MemCpy(meshData.pNormalsVector3 + verticesOffset, this.pNormalsSource, size: this.sourceCount * sizeof(float));
                            UnsafeUtility.MemCpy(meshData.pTangentsVector4 + verticesOffset * 4 / 3, this.pTangentsSource, size: this.sourceCount * 4 / 3 * sizeof(float));

                            for (int vertexIndex = 0; vertexIndex < this.sourceCount; vertexIndex+=3)
                            {
                                var positionXIndex = verticesOffset + vertexIndex;
                                meshData.pVerticesVector3[positionXIndex + 0] += x * ChunkManager.CubeSide;
                                meshData.pVerticesVector3[positionXIndex + 1] += y * ChunkManager.CubeSide / 2;
                                meshData.pVerticesVector3[positionXIndex + 2] += z * ChunkManager.CubeSide;
                            }
                        }
                    }
                }
            }
        }
    }

    protected override unsafe void OnCreate()
    {
        base.OnCreate();

        this.query = GetEntityQuery(new EntityQueryDesc
        {
            All = new[] { ComponentType.ReadOnly<CreateMeshMarker>(),
                ComponentType.ReadWrite<ChunkMeshData>() },
        });

        #region モデルの形状を定義→NativeArray確保
        var vertices = BinaryUtility.Deserialize<float[]>(System.IO.Path.Combine(Application.dataPath, "Project/Resources/vertices.bytes"));
        this.pVerticesSource = (float*)(this.nativeVerticesSource = new NativeArray<float>(vertices, Allocator.Persistent)).GetUnsafePtr();
        var normals = BinaryUtility.Deserialize<float[]>(System.IO.Path.Combine(Application.dataPath, "Project/Resources/normals.bytes"));
        this.pNormalsSource = (float*)(this.nativeNormalsSource = new NativeArray<float>(normals, Allocator.Persistent)).GetUnsafePtr();
        var tangents = BinaryUtility.Deserialize<float[]>(System.IO.Path.Combine(Application.dataPath, "Project/Resources/tangents.bytes"));
        this.pTangentsSource = (float*)(this.nativeTangentsSource = new NativeArray<float>(tangents, Allocator.Persistent)).GetUnsafePtr();
        #endregion
    }

    protected override void OnDestroy()
    {
        this.nativeVerticesSource.Dispose();
        this.nativeNormalsSource.Dispose();
        this.nativeTangentsSource.Dispose();

        base.OnDestroy();
    }

    protected unsafe override void OnUpdate()
    {
        #region NativeArray 確保
        var entities = this.query.ToEntityArray(Allocator.TempJob);
        var meshDataArray = this.query.ToComponentDataArray<ChunkMeshData>(Allocator.TempJob);
        #endregion

        #region メッシュの頂点数をカウント
        var countVerticesJob = new CountVerticesJob
        {
            verticesSourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var jobHandle = countVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        jobHandle.Complete();
        #endregion

        #region カウント数→頂点バッファを確保→バッファポインタを ComponentData に代入
        this.entityMeshDataList.Clear();
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var meshData = meshDataArray[entityIndex];
            var countVector3 = meshData.vertexCount / 3;
            var entityMeshData = new EntityMeshData {
                nativeVertices = new NativeArray<Vector3>(countVector3, Allocator.TempJob),
                nativeNormals = new NativeArray<Vector3>(countVector3, Allocator.TempJob),
                nativeTangents = new NativeArray<Vector4>(countVector3, Allocator.TempJob),
            };
            this.entityMeshDataList.Add(entityMeshData);
            meshData.pVerticesVector3 = (float*)entityMeshData.nativeVertices.GetUnsafePtr();
            meshData.pNormalsVector3 = (float*)entityMeshData.nativeNormals.GetUnsafePtr();
            meshData.pTangentsVector4 = (float*)entityMeshData.nativeTangents.GetUnsafePtr();
            meshDataArray[entityIndex] = meshData;
        }
        #endregion

        #region 頂点バッファに頂点データを塗る
        var fillVerticesJob = new FillVerticesJob
        {
            pVerticesSource = this.pVerticesSource,
            pNormalsSource = this.pNormalsSource,
            pTangentsSource = this.pTangentsSource,
            sourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var fillJobHandle = fillVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        fillJobHandle.Complete();
        #endregion

        #region 頂点バッファ→マネージド配列→メッシュ作成→メッシュ法線・接線の計算
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var entity = entities[entityIndex];
            var entityMeshData = this.entityMeshDataList[entityIndex];
            var vertices = entityMeshData.nativeVertices.ToArray();
            var normals = entityMeshData.nativeNormals.ToArray();
            var tangents = entityMeshData.nativeTangents.ToArray();

            var mesh = new Mesh();
            mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
            mesh.vertices = vertices;
            mesh.normals = normals;
            mesh.tangents = tangents;
            int[] triangles = new int[vertices.Length];
            for (int vertexIndex = 0; vertexIndex < vertices.Length; vertexIndex++)
            {
                triangles[vertexIndex] = vertexIndex;
            }
            mesh.SetIndices(triangles, MeshTopology.Triangles, submesh: 0, calculateBounds: true);
            var meshFilter = EntityManager.GetComponentObject<MeshFilter>(entity);
            meshFilter.mesh = mesh;

            entityMeshData.nativeVertices.Dispose();
            entityMeshData.nativeNormals.Dispose();
            entityMeshData.nativeTangents.Dispose();
        }
        this.entityMeshDataList.Clear();
        #endregion

        #region entity から marker の除去
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var entity = entities[entityIndex];
            EntityManager.RemoveComponent(entity, ComponentType.ReadOnly<CreateMeshMarker>());
        }
        #endregion

        #region NativeArray 開放
        meshDataArray.Dispose();
        entities.Dispose();
        #endregion
    }

    EntityQuery query;
    
    NativeArray<float> nativeVerticesSource;
    NativeArray<float> nativeNormalsSource;
    NativeArray<float> nativeTangentsSource;
    float* pVerticesSource;
    float* pNormalsSource;
    float* pTangentsSource;

    List<EntityMeshData> entityMeshDataList = new List<EntityMeshData>();

    class EntityMeshData
    {
        public NativeArray<Vector3> nativeVertices;
        public NativeArray<Vector3> nativeNormals;
        public NativeArray<Vector4> nativeTangents;
    }
}

いったん、この状態で負荷を分散するように作ってみましょう。

考えてみて難しいと思う部分は、処理をしながらフレームレートを上回るときに手を止めるという操作ができないということ
Instantiate に 200 ms と無視できない時間を要し
CreateMeshSystem に 1218 ms と最も大きな時間を要しています。

エンティティの数は 8x8x8 個ですので、概算で entity 一個あたり 2.76 ms 要しています。

一つ考えたのは、作成に使う値をキューに詰めて、必ず 1frame 1 entity 以上は作成するようポップして
最初は 1 entity の時間を計測し
次からは処理時間 + 余った時間を見て、それを全部埋める entity 数を算出し、次の frame ではその entity 数を処理
を繰り返す、つまりは次に処理するべき entity 数を endOfFrame で決定するというもの

簡単に作ってみます。

まずはキューに詰めてフレームに分散する処理

    IEnumerator OnStartOfFrame()
    {
        while (true)
        {
            yield return null;
            this.startFrame = Time.realtimeSinceStartup;

            #region 作成対象の MeshObject 位置を Enqueue
            if (Input.GetKeyDown(KeyCode.Space))
            {
                for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
                {
                    for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
                    {
                        for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                        {
                            var position = new Vector3(ChunkManager.ChunkSizeX * x * 2, ChunkManager.ChunkSizeY * y, ChunkManager.ChunkSizeZ * z * 1.732f);
                            this.createMeshObjectQueue.Enqueue(position);
                        }
                    }
                }
            }
            #endregion

            #region Queue から位置を取り出してMeshObjectをInstantiate
            this.createMeshObjectCount = 0;
            for (int dequeueIndex = 0; dequeueIndex < this.dequeueCount; dequeueIndex++)
            {
                if (0 == this.createMeshObjectQueue.Count) {
                    break;
                }
                CreateMeshObject(this.createMeshObjectQueue.Dequeue());
                this.createMeshObjectCount = dequeueIndex + 1;
            }
            #endregion
        }
    }

これで 1frame 1個
1秒 60 entity が作成されていく様子が確認できました。

続いて処理時間を entity 数で割って、 1 entity あたりの処理時間を出し
その1処理時間で、全処理時間 + 余っている時間 を割って、次のフレームでの dequeueCount を決定します。

    IEnumerator OnEndOfFrame()
    {
        while (true)
        {
            yield return new WaitForEndOfFrame();
            this.endFrame = Time.realtimeSinceStartup;

            #region 処理時間を見て次のフレームの dequeueCount を決定
            if (0 < this.createMeshObjectCount)
            {
                var updateTime = (this.endFrame - this.startFrame);
                var entityTime = updateTime / this.createMeshObjectCount;
                var targetFrameSecond = 1f / Application.targetFrameRate;
                this.dequeueCount = Mathf.FloorToInt(Mathf.Lerp(this.dequeueCount, targetFrameSecond / entityTime, 0.7f));
            }
            if (0 == this.dequeueCount)
            {
                this.dequeueCount = 1;
            }
            #endregion
        }
    }

GCがあるないでギザギザしていますが…巨大なスパイクだけは回避できています。

f:id:simplestar_tech:20190612081957p:plain
周辺フレームに分散して CreateMesh が 60fps をターゲットに行えている様子

動作確認する

こんな処理になりました。

動くサンプルはこちら
github.com

同じこと考えていた人はご参考までに~