simplestarの技術ブログ

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

Unity:CubeWalkGameのキューブのテクスチャ座標と回転

# まえがき

これらの記事シリーズの続きです。
今回は作業メモになっているので、開発者以外が読むと辛い文章かも…

simplestar-tech.hatenablog.com
simplestar-tech.hatenablog.com


シリーズ名は CubeWalkGame として、前回はチャンクをまたぐキューブ情報の取得を確認しました。
前回はマクロ視点でしたが、今回はミクロ視点ということで、キューブ単体のテクスチャ座標と回転表現をやります。

## 無回転でテクスチャ貼り

テクスチャ座標はこれなんだけど、どうやって貼り付け?

new Vector2[]{
            // Top
            new Vector2 (0, 1),
            new Vector2 (1, 1),
            new Vector2 (1, 0),
            // Bottom
            new Vector2 (1, 0),
            new Vector2 (0, 0),
            new Vector2 (0, 1),
            // Right up
            new Vector2 (1, 1),
            new Vector2 (2, 1),
            new Vector2 (2, 0),
            // Right down
            new Vector2 (2, 0),
            new Vector2 (1, 0),
            new Vector2 (1, 1),
            // Forward up
            new Vector2 (2, 1),
            new Vector2 (3, 1),
            new Vector2 (3, 0),
            // Forward down
            new Vector2 (3, 0),
            new Vector2 (2, 0),
            new Vector2 (2, 1),
            // Cross up
            new Vector2 (3, 1),
            new Vector2 (4.4142f, 1),
            new Vector2 (4.4142f, 0),
            // Cross down
            new Vector2 (4.4142f, 0),
            new Vector2 (3, 0),
            new Vector2 (3, 1),
        }

ひとまず頂点位置と同じようにセットしてみます。

マジックコードを発見しましたが、コレなんでしょう?

            const float mtf = 232f / 4096f;
            float mtoffu = col / 4.0f;
            float mtoffv = mtf * (16 - row % 17) % 17 + 0.0371f;


mesh.uv[vertCount * 2 + 0] = uvSrc[i * 2 + 0] * mtf + mtoffu;
mesh.uv[vertCount * 2 + 1] = uvSrc[i * 2 + 1] * mtf + mtoffv;

こちらの level 4 との対となるマジックコードでした。
github.com

f:id:simplestar_tech:20190616123849p:plain
コードを復元するように書き込むとこの通り

ロジックの肝心な箇所はこちら

                            int row = 10;
                            int col = 0;
                            const float mtf = 232f / 4096f;
                            float mtoffu = col / 4.0f;
                            float mtoffv = mtf * (16 - row % 17) % 17 + 0.0371f;
                            for (int vertexIndex = 0; vertexIndex < vertexCount * 2; vertexIndex += 2)
                            {
                                var uvXIndex = uvOffset + vertexIndex;
                                meshData.pUVVector2[uvXIndex + 0] *= mtf;
                                meshData.pUVVector2[uvXIndex + 0] += mtoffu;
                                meshData.pUVVector2[uvXIndex + 1] *= mtf;
                                meshData.pUVVector2[uvXIndex + 1] += mtoffv;
                            }
                            verticesOffset += vertexCount * 3;
                            uvOffset += vertexCount * 2;

いずれテクスチャの row, col の値は ChunkData から取らないといけませんね。

## 頂点を回転

この記事で試しておかなければならないこと、それが mathematics でその場で頂点回転できないか?というもの
キューブの頂点座標は原点中心で定義しているので、90度または パイ/2 ラジアンで回転する quaternion とかあれば良いのですが

ECS で使う Mathematics を確認します。

こういうときは過去記事を引っ張ってきて、関連リンクを読み漁ります。
simplestar-tech.hatenablog.com

そして発見!

using static Unity.Mathematics.math;

        /// <summary>Returns the result of rotating a vector by a unit quaternion.</summary>
        [MethodImpl(MethodImplOptions.AggressiveInlining)]
        public static float3 rotate(quaternion q, float3 v)
        {
            float3 t = 2 * cross(q.value.xyz, v);
            return v + q.value.w * t + cross(q.value.xyz, t);
        }

仮で、すべての頂点を X 軸で 90 度回転させてみます。

コードはこんな感じかな?

                            for (int vertexIndex = 0; vertexIndex < vertexCount * 3; vertexIndex += 3)
                            {
                                var positionXIndex = verticesOffset + vertexIndex;
                                var pPosition = meshData.pVerticesVector3 + positionXIndex;

                                var q = Unity.Mathematics.quaternion.RotateX(PI / 2);
                                var p = float3(pPosition[0], pPosition[1], pPosition[2]);
                                var r = rotate(q, p);

                                pPosition[0] = r.x + x * ChunkManager.CubeSide;
                                pPosition[1] = r.y + y * ChunkManager.CubeSide;
                                pPosition[2] = r.z + z * ChunkManager.CubeSide;
                            }

変化は?

f:id:simplestar_tech:20190616140407p:plain
x軸で時計回りに90度回転しました

期待通りですね!

回転ごとに周辺チャンクの参照先を変えなければならないので、ここが頑張りどころです。

## 回転タイプを byte 値で判別

ここは強引にこう定義してみました。

internal enum CubeRotationType : byte
{
    Top000 = 0,
    Top090,
    Top180,
    Top270,

    Bottom000,
    Bottom090,
    Bottom180,
    Bottom270,

    Forward000,
    Forward090,
    Forward180,
    Forward270,

    Back000,
    Back090,
    Back180,
    Back270,

    Right000,
    Right090,
    Right180,
    Right270,

    Left000,
    Left090,
    Left180,
    Left270,

    Max
}

以前の回転結果図を参考に決めてみたけど、これでうまくいくか

   /// <summary>
    /// byte の回転タイプから Quaternion を作って返す
    /// </summary>
    static Unity.Mathematics.quaternion GetRotationQuaternion(CubeRotationType rotationType)
    {
        // @TODO: インデックスアクセスに
        var rotationQuaternion = Unity.Mathematics.quaternion.identity;
        switch (rotationType)
        {
            case CubeRotationType.Top000:
                break;
            case CubeRotationType.Top090:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI / 2);
                break;
            case CubeRotationType.Top180:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI);
                break;
            case CubeRotationType.Top270:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI * 3 / 2);
                break;
            case CubeRotationType.Bottom000:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateX(PI);
                break;
            case CubeRotationType.Bottom090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Bottom180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Bottom270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Forward000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Forward090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Forward180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(0));
                break;
            case CubeRotationType.Forward270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Back000:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateX(-PI / 2);
                break;
            case CubeRotationType.Back090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Back180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Back270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Right000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Right090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(0));
                break;
            case CubeRotationType.Right180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Right270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Left000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Left090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Left180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Left270:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateZ(PI / 2);
                break;
            case CubeRotationType.Max:
                break;
            default:
                break;
        }
        return rotationQuaternion;
    }

次に周辺ブロックを見るためのインデックスを回転タイプから求めます。

いっぱい switch 文を書いていて気づく、enum が巨大な連番だったら switch 文の分岐書くより
配列のインデックスアクセスにした方が、結果取得早いしプログラミングも楽です。(読解し辛いのでコメント必須ですが)

前回のポインターポインターを活用することにして、次の通り定義してみました。

        #region 回転タイプと面タイプごとの方向
        var top = int3(0, 1, 0);
        var bottom = int3(0, -1, 0);
        var right = int3(1, 0, 0);
        var left = int3(-1, 0, 0);
        var forward = int3(0, 0, 1);
        var back = int3(0, 0, -1);
        this.ppRotationFaceDirections = (int3**)(UnsafeUtility.Malloc(sizeof(int3*) * (int)CubeRotationType.Max, sizeof(int3*), Allocator.Persistent));
        #region Top
        this.ppRotationFaceDirections[(int)CubeRotationType.Top000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Top000] = new NativeArray<int3>(
            new int3[] {
                top,
                bottom,
                right,
                forward,
                bottom,
                top,
                left,
                back }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Top090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Top090] = new NativeArray<int3>(
            new int3[] {
                top,
                bottom,
                back,
                right,
                bottom,
                top,
                forward,
                left }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Top180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Top180] = new NativeArray<int3>(
            new int3[] {
                top,
                bottom,
                left,
                back,
                bottom,
                top,
                right,
                forward }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Top270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Top270] = new NativeArray<int3>(
            new int3[] {
                top,
                bottom,
                forward,
                left,
                bottom,
                top,
                back,
                right }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #region Bottom
        this.ppRotationFaceDirections[(int)CubeRotationType.Bottom000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Bottom000] = new NativeArray<int3>(
            new int3[] {
                bottom,
                top,
                right,
                back,
                top,
                bottom,
                left,
                forward }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Bottom090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Bottom090] = new NativeArray<int3>(
            new int3[] {
                bottom,
                top,
                forward,
                right,
                top,
                bottom,
                back,
                left }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Bottom180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Bottom180] = new NativeArray<int3>(
            new int3[] {
                bottom,
                top,
                left,
                forward,
                top,
                bottom,
                right,
                back }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Bottom270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Bottom270] = new NativeArray<int3>(
            new int3[] {
                bottom,
                top,
                back,
                left,
                top,
                bottom,
                forward,
                right }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #region Forward
        this.ppRotationFaceDirections[(int)CubeRotationType.Forward000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Forward000] = new NativeArray<int3>(
            new int3[] {
                forward,
                back,
                left,
                top,
                back,
                forward,
                right,
                bottom }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Forward090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Forward090] = new NativeArray<int3>(
            new int3[] {
                forward,
                back,
                bottom,
                left,
                back,
                forward,
                top,
                right }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Forward180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Forward180] = new NativeArray<int3>(
            new int3[] {
                forward,
                back,
                right,
                bottom,
                back,
                forward,
                left,
                top }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Forward270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Forward270] = new NativeArray<int3>(
            new int3[] {
                forward,
                back,
                top,
                right,
                back,
                forward,
                bottom,
                left }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #region Back
        this.ppRotationFaceDirections[(int)CubeRotationType.Back000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Back000] = new NativeArray<int3>(
            new int3[] {
                back,
                forward,
                right,
                top,
                forward,
                back,
                left,
                bottom }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Back090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Back090] = new NativeArray<int3>(
            new int3[] {
                back,
                forward,
                bottom,
                right,
                forward,
                back,
                top,
                left }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Back180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Back180] = new NativeArray<int3>(
            new int3[] {
                back,
                forward,
                left,
                bottom,
                forward,
                back,
                right,
                top }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Back270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Back270] = new NativeArray<int3>(
            new int3[] {
                back,
                forward,
                top,
                left,
                forward,
                back,
                bottom,
                right }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #region Right
        this.ppRotationFaceDirections[(int)CubeRotationType.Right000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Right000] = new NativeArray<int3>(
            new int3[] {
                right,
                left,
                forward,
                top,
                left,
                right,
                back,
                bottom }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Right090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Right090] = new NativeArray<int3>(
            new int3[] {
                right,
                left,
                bottom,
                forward,
                left,
                right,
                top,
                back }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Right180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Right180] = new NativeArray<int3>(
            new int3[] {
                right,
                left,
                back,
                bottom,
                left,
                right,
                forward,
                top }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Right270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Right270] = new NativeArray<int3>(
            new int3[] {
                right,
                left,
                top,
                back,
                left,
                right,
                bottom,
                forward }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #region Left
        this.ppRotationFaceDirections[(int)CubeRotationType.Left000] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Left000] = new NativeArray<int3>(
            new int3[] {
                left,
                right,
                back,
                top,
                right,
                left,
                forward,
                bottom }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Left090] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Left090] = new NativeArray<int3>(
            new int3[] {
                left,
                right,
                bottom,
                back,
                right,
                left,
                top,
                forward }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Left180] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Left180] = new NativeArray<int3>(
            new int3[] {
                left,
                right,
                forward,
                bottom,
                right,
                left,
                back,
                top }, Allocator.Persistent)).GetUnsafePtr();
        this.ppRotationFaceDirections[(int)CubeRotationType.Left270] = (int3*)(this.rotationFaceDirectionArray[(int)CubeRotationType.Left270] = new NativeArray<int3>(
            new int3[] {
                left,
                right,
                top,
                forward,
                right,
                left,
                bottom,
                back }, Allocator.Persistent)).GetUnsafePtr();
        #endregion
        #endregion
    }

    protected override void OnDestroy()
    {
        for (int cubeRotationIndex = 0; cubeRotationIndex < (int)CubeRotationType.Max; cubeRotationIndex++)
        {
            this.rotationFaceDirectionArray[cubeRotationIndex].Dispose();
        }
        UnsafeUtility.Free(this.ppRotationFaceDirections, Allocator.Persistent);

こちらのポインターポインターを参照することで、キューブの回転タイプから正しい方向の周囲のキューブ情報の取得が行えることが確認できました。

頂点情報を作成するコードをリファクタリングして、同じようなコードをまとめた結果がこちら

    /// <summary>
    /// 頂点バッファに頂点データをコピー書き込み
    /// </summary>
    unsafe struct CopyWriteVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal int3** ppRotationFaceDirection;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSource;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pUVSource;
        [ReadOnly] internal int sourceCount;
        internal NativeArray<ChunkMeshData> meshDataArray;

        [BurstCompile]
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            this.verticesOffset = 0;
            this.uvOffset = 0;
            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 pData = GetDataPtr(meshData, x, y, z);
                        if (1 == pData[(int)ChunkDataType.Category])
                        {
                            var rotationType = (CubeRotationType)pData[(int)ChunkDataType.Rotation];

                            this.sourceOffset = 0;
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.TopA);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.BottomA);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.RightA);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.ForwardA);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.CrossA);

                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.BottomB);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.TopB);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.LeftB);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.BackB);
                            this.MakeFace(meshData, x, y, z, rotationType, CubeFaceType.CrossB);
                        }
                    }
                }
            }
        }

        private int MakeFace(ChunkMeshData meshData, int x, int y, int z,
            CubeRotationType rotationType, CubeFaceType faceType)
        {
            int faceCount = 0;
            int dataSideIndex = -1;
            int crossFlag = 0;
            #region 面タイプごとの変数を決定
            switch (faceType)
            {
                case CubeFaceType.TopA:                    
                case CubeFaceType.BottomA:
                    {
                        dataSideIndex = (int)ChunkDataType.SideA;
                        faceCount = 1;
                    }
                    break;
                case CubeFaceType.BottomB:
                case CubeFaceType.TopB:
                    {
                        dataSideIndex = (int)ChunkDataType.SideB;
                        faceCount = 1;
                    }
                    break;
                case CubeFaceType.RightA:
                case CubeFaceType.ForwardA:
                    {
                        dataSideIndex = (int)ChunkDataType.SideA;
                        faceCount = 2;
                    }
                    break;
                case CubeFaceType.LeftB:
                case CubeFaceType.BackB:
                    {
                        dataSideIndex = (int)ChunkDataType.SideB;
                        faceCount = 2;
                    }
                    break;
                case CubeFaceType.CrossA:
                    {
                        dataSideIndex = (int)ChunkDataType.SideA;
                        faceCount = 2;
                        crossFlag = 1;
                    }
                    break;
                case CubeFaceType.CrossB:
                    {
                        dataSideIndex = (int)ChunkDataType.SideB;
                        faceCount = 2;
                        crossFlag = 1;
                    }
                    break;
                default:
                    break;
            }
            #endregion

            var vertexCount = 0;
            #region 周囲チャンクデータをチェックして、面作成が必要なときのみコピー
            var sideCubeOffset = crossFlag == 0 ? this.ppRotationFaceDirection[(int)rotationType][(int)faceType] : int3(0, 0, 0);
            var pData = GetDataPtr(meshData, x + sideCubeOffset.x, y + sideCubeOffset.y, z + sideCubeOffset.z);
            if (!(null != pData && 1 == pData[(int)ChunkDataType.Category] && 1 == pData[dataSideIndex]))
            {
                UnsafeUtility.MemCpy(meshData.pVerticesVector3 + this.verticesOffset, this.pVerticesSource + this.sourceOffset * 9, size: 9 * sizeof(float) * faceCount);
                UnsafeUtility.MemCpy(meshData.pUVVector2 + this.uvOffset, this.pUVSource + this.sourceOffset * 6, size: 6 * sizeof(float) * faceCount);
                vertexCount = faceCount * 3;
            }
            this.sourceOffset += faceCount;
            #endregion

            #region コピーしたものを回転タイプとチャンク内インデックスから、正しい頂点位置へ移動
            for (int vertexIndex = 0; vertexIndex < vertexCount * 3; vertexIndex += 3)
            {
                // コピーした頂点情報を取得
                var pPosition = meshData.pVerticesVector3 + this.verticesOffset + vertexIndex;
                // 回転タイプによる回転
                var quaternion = GetRotationQuaternion(rotationType);
                var position = float3(pPosition[0], pPosition[1], pPosition[2]);
                var resultPosition = rotate(quaternion, position);
                // チャンク内オフセットを足して格納
                pPosition[0] = resultPosition.x + x * ChunkManager.CubeSide;
                pPosition[1] = resultPosition.y + y * ChunkManager.CubeSide;
                pPosition[2] = resultPosition.z + z * ChunkManager.CubeSide;
            }
            this.verticesOffset += vertexCount * 3;
            #endregion

            #region UV 設定
            int row = 10; // データから決めるべき
            int col = 0;
            const float mtf = 232f / 4096f;
            float mtoffu = col / 4.0f;
            float mtoffv = mtf * (16 - row % 17) % 17 + 0.0371f;
            for (int vertexIndex = 0; vertexIndex < vertexCount * 2; vertexIndex += 2)
            {
                var pUV = meshData.pUVVector2 + this.uvOffset + vertexIndex;
                pUV[0] = pUV[0] * mtf + mtoffu;
                pUV[1] = pUV[1] * mtf + mtoffv;
            }
            this.uvOffset += vertexCount * 2;
            #endregion

            return vertexCount;
        }

        int sourceOffset;
        int verticesOffset;
        int uvOffset;
    }

このジョブの関数の面を描くべきかどうかの判定は再利用して頂点カウントのジョブ側でも使いたいところ
切り出し方を考えてみると

具体的には vertexCount を計算する部分を共通化できるといい

できた。

言葉にし難いけど、System は以下の通り、だいぶコンパクトにたたむことができた

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

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

        [BurstCompile]
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            meshData.vertexCount = 0;
            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 pData = GetDataPtr(meshData, x, y, z);
                        if (1 == pData[(int)ChunkDataType.Category]) // @debug 1 とは?
                        {
                            var rotationType = (CubeRotationType)pData[(int)ChunkDataType.Rotation];
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.TopA).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BottomA).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.RightA).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.ForwardA).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.CrossA).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BottomB).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.TopB).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.LeftB).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BackB).activeVertexCount;
                            meshData.vertexCount += CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.CrossB).activeVertexCount;
                        }
                    }
                }
            }
            this.meshDataArray[entityIndex] = meshData;
        }
    }

    /// <summary>
    /// 頂点バッファに頂点データをコピー書き込み
    /// </summary>
    unsafe struct CopyWriteVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal int3** ppRotationFaceDirection;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSource;
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pUVSource;
        internal NativeArray<ChunkMeshData> meshDataArray;

        [BurstCompile]
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            this.verticesOffset = 0;
            this.uvOffset = 0;
            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 pData = GetDataPtr(meshData, x, y, z);
                        if (1 == pData[(int)ChunkDataType.Category]) // @debug 1 とは?
                        {
                            var rotationType = (CubeRotationType)pData[(int)ChunkDataType.Rotation];

                            this.sourceFaceOffset = 0;
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.TopA));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BottomA));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.RightA));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.ForwardA));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.CrossA));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BottomB));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.TopB));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.LeftB));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.BackB));
                            this.CalculateMeshData(meshData, x, y, z, rotationType, CountFace(meshData, x, y, z, rotationType, this.ppRotationFaceDirection, CubeFaceType.CrossB));
                        }
                    }
                }
            }
        }

        /// <summary>
        /// メッシュ情報をコピー&計算
        /// </summary>
        private void CalculateMeshData(ChunkMeshData meshData, int x, int y, int z, CubeRotationType rotationType, CountData countData)
        {
            if (0 < countData.activeVertexCount)
            {
                // 作成対象の頂点データをコピー
                UnsafeUtility.MemCpy(meshData.pVerticesVector3 + this.verticesOffset, this.pVerticesSource + 9 * this.sourceFaceOffset, size: 3 * sizeof(float) * countData.activeVertexCount);
                UnsafeUtility.MemCpy(meshData.pUVVector2 + this.uvOffset, this.pUVSource + 6 * this.sourceFaceOffset, size: 2 * sizeof(float) * countData.activeVertexCount);

                #region コピーしたものを回転タイプとチャンク内インデックスから、正しい頂点位置へ移動
                for (int vertexIndex = 0; vertexIndex < countData.activeVertexCount * 3; vertexIndex += 3)
                {
                    // コピーした頂点情報を取得
                    var pPosition = meshData.pVerticesVector3 + this.verticesOffset + vertexIndex;
                    // 回転タイプによる回転
                    var quaternion = GetRotationQuaternion(rotationType);
                    var position = float3(pPosition[0], pPosition[1], pPosition[2]);
                    var resultPosition = rotate(quaternion, position);
                    // チャンク内オフセットを足して格納
                    pPosition[0] = resultPosition.x + x * ChunkManager.CubeSide;
                    pPosition[1] = resultPosition.y + y * ChunkManager.CubeSide;
                    pPosition[2] = resultPosition.z + z * ChunkManager.CubeSide;
                }
                this.verticesOffset += countData.activeVertexCount * 3;
                #endregion

                #region UV 設定
                int row = 10; // @debug データから決めるべき あと変数名キモ
                int col = 0;
                const float mtf = 232f / 4096f;
                float mtoffu = col / 4.0f;
                float mtoffv = mtf * (16 - row % 17) % 17 + 0.0371f;
                for (int vertexIndex = 0; vertexIndex < countData.activeVertexCount * 2; vertexIndex += 2)
                {
                    var pUV = meshData.pUVVector2 + this.uvOffset + vertexIndex;
                    pUV[0] = pUV[0] * mtf + mtoffu;
                    pUV[1] = pUV[1] * mtf + mtoffv;
                }
                this.uvOffset += countData.activeVertexCount * 2;
                #endregion
            }
            // 作成しなくても面タイプごとの面数オフセットを加算
            this.sourceFaceOffset += countData.reservedFaceOffset;
        }

        int sourceFaceOffset;
        int verticesOffset;
        int uvOffset;
    }

    /// <summary>
    /// 回転タイプと面タイプとチャンク内の座標から、作成するべき頂点数をカウント
    /// </summary>
    static CountData CountFace(ChunkMeshData meshData, int x, int y, int z,
            CubeRotationType rotationType, int3** ppRotationFaceDirection, CubeFaceType faceType)
    {
        int faceCount = 0;
        int dataSideIndex = -1;
        int crossFlag = 0;
        #region 面タイプごとの変数を決定
        switch (faceType)
        {
            case CubeFaceType.TopA:
            case CubeFaceType.BottomA:
                {
                    dataSideIndex = (int)ChunkDataType.SideA;
                    faceCount = 1;
                }
                break;
            case CubeFaceType.BottomB:
            case CubeFaceType.TopB:
                {
                    dataSideIndex = (int)ChunkDataType.SideB;
                    faceCount = 1;
                }
                break;
            case CubeFaceType.RightA:
            case CubeFaceType.ForwardA:
                {
                    dataSideIndex = (int)ChunkDataType.SideA;
                    faceCount = 2;
                }
                break;
            case CubeFaceType.LeftB:
            case CubeFaceType.BackB:
                {
                    dataSideIndex = (int)ChunkDataType.SideB;
                    faceCount = 2;
                }
                break;
            case CubeFaceType.CrossA:
                {
                    dataSideIndex = (int)ChunkDataType.SideB;
                    faceCount = 2;
                    crossFlag = 1;
                }
                break;
            case CubeFaceType.CrossB:
                {
                    dataSideIndex = (int)ChunkDataType.SideA;
                    faceCount = 2;
                    crossFlag = 1;
                }
                break;
            default:
                break;
        }
        #endregion

        #region 周囲チャンクデータをチェックして、面作成が必要なときのみコピー
        var vertexCount = 0;
        var sideCubeOffset = crossFlag == 0 ? ppRotationFaceDirection[(int)rotationType][(int)faceType] : int3(0, 0, 0);
        var pData = GetDataPtr(meshData, x + sideCubeOffset.x, y + sideCubeOffset.y, z + sideCubeOffset.z);
        if (!(null != pData && 1 == pData[(int)ChunkDataType.Category] && 1 == pData[dataSideIndex])) // @debug 1 とは? 相手の回転も見ないと駄目なのでは?
        {
            vertexCount = faceCount * 3;
        }
        #endregion
        return new CountData { activeVertexCount = vertexCount, reservedFaceOffset = faceCount };
    }

    static byte* GetDataPtr(ChunkMeshData meshData, int x, int y, int z)
    {
        if (0 <= x && ChunkManager.ChunkSizeX > x
            && 0 <= y && ChunkManager.ChunkSizeY > y
            && 0 <= z && ChunkManager.ChunkSizeZ > z)
        {
            #region チャンク内のデータを返す
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = meshData.chunkKeyX * byteMax * byteMax + meshData.chunkKeyZ * byteMax + meshData.chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
            return (byte*)(pChunkData + dataIndex);
            #endregion
        }
        else
        {
            #region チャンク越えカウント
            var overChunkCountX = x / ChunkManager.ChunkSizeX;
            if (0 > x) overChunkCountX -= 1;
            var overChunkCountY = y / ChunkManager.ChunkSizeY;
            if (0 > y) overChunkCountY -= 1;
            var overChunkCountZ = z / ChunkManager.ChunkSizeZ;
            if (0 > z) overChunkCountZ -= 1;
            #endregion
            #region byte オーバーフローによる値ループ
            var chunkKeyX = (byte)(meshData.chunkKeyX + overChunkCountX);
            var chunkKeyY = (byte)(meshData.chunkKeyY + overChunkCountY);
            var chunkKeyZ = (byte)(meshData.chunkKeyZ + overChunkCountZ);
            #endregion
            #region チャンクの特定
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            #endregion
            if (null != pChunkData)
            {
                #region チャンク内のデータインデックスへ変換
                x -= overChunkCountX * ChunkManager.ChunkSizeX;
                y -= overChunkCountY * ChunkManager.ChunkSizeY;
                z -= overChunkCountZ * ChunkManager.ChunkSizeZ;
                var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
                #endregion
                return (byte*)(pChunkData + dataIndex);
            }

        }
        return null;
    }

   /// <summary>
    /// byte の回転タイプから Quaternion を作って返す
    /// </summary>
    static Unity.Mathematics.quaternion GetRotationQuaternion(CubeRotationType rotationType)
    {
        // @TODO: インデックスアクセスに
        var rotationQuaternion = Unity.Mathematics.quaternion.identity;
        switch (rotationType)
        {
            case CubeRotationType.Top000:
                break;
            case CubeRotationType.Top090:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI / 2);
                break;
            case CubeRotationType.Top180:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI);
                break;
            case CubeRotationType.Top270:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateY(PI * 3 / 2);
                break;
            case CubeRotationType.Bottom000:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateX(PI);
                break;
            case CubeRotationType.Bottom090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Bottom180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Bottom270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Forward000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Forward090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Forward180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(0));
                break;
            case CubeRotationType.Forward270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Back000:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateX(-PI / 2);
                break;
            case CubeRotationType.Back090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Back180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Back270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateX(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Right000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Right090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(0));
                break;
            case CubeRotationType.Right180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Right270:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(-PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Left000:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI / 2));
                break;
            case CubeRotationType.Left090:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI));
                break;
            case CubeRotationType.Left180:
                rotationQuaternion = mul(Unity.Mathematics.quaternion.RotateZ(PI / 2), Unity.Mathematics.quaternion.RotateY(PI * 3 / 2));
                break;
            case CubeRotationType.Left270:
                rotationQuaternion = Unity.Mathematics.quaternion.RotateZ(PI / 2);
                break;
            case CubeRotationType.Max:
                break;
            default:
                break;
        }
        return rotationQuaternion;
    }

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

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

        #region モデルの形状を定義→NativeArray確保
        // 省略
        #endregion
    }

    protected override void OnDestroy()
    {
        for (int cubeRotationIndex = 0; cubeRotationIndex < (int)CubeRotationType.Max; cubeRotationIndex++)
        {
            this.rotationFaceDirectionArray[cubeRotationIndex].Dispose();
        }
        UnsafeUtility.Free(this.ppRotationFaceDirection, Allocator.Persistent);

        this.nativeUVSource.Dispose();
        this.nativeVerticesSource.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
        {
            ppRotationFaceDirection = this.ppRotationFaceDirection,
            sourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var countJobHandle = countVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        countJobHandle.Complete();
        #endregion

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

        #region 頂点バッファに頂点データをコピー
        var copyVerticesJob = new CopyWriteVerticesJob
        {
            ppRotationFaceDirection = this.ppRotationFaceDirection,
            pVerticesSource = this.pVerticesSource,
            pUVSource = this.pUVSource,
            meshDataArray = meshDataArray
        };
        var copyJobHandle = copyVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        copyJobHandle.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 uv = entityMeshData.nativeUV.ToArray();

            var mesh = new Mesh();
            mesh.Clear();
            mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
            mesh.vertices = vertices;
            mesh.uv = uv;
            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;

            entityMeshData.nativeUV.Dispose();
            entityMeshData.nativeVertices.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;
    float* pVerticesSource;
    NativeArray<float> nativeUVSource;
    float* pUVSource;

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

    NativeArray<int3>[] rotationFaceDirectionArray = new NativeArray<int3>[(int)CubeRotationType.Max];
    int3** ppRotationFaceDirection;


    class EntityMeshData
    {
        public NativeArray<Vector3> nativeVertices;
        public NativeArray<Vector2> nativeUV;
    }

    struct CountData
    {
        public int activeVertexCount;
        public int reservedFaceOffset;
    }
}

internal enum ChunkDataType : int
{
    Category = 0,
    Rotation,
    SideA,
    SideB,
    Max
}

internal enum CubeFaceType : byte
{
    TopA = 0,
    BottomA,
    RightA,
    ForwardA,
    BottomB,
    TopB,
    LeftB,
    BackB,
    CrossA,
    CrossB,
    Max,
}

internal enum CubeRotationType : byte
{
    Top000 = 0,
    Top090,
    Top180,
    Top270,

    Bottom000,
    Bottom090,
    Bottom180,
    Bottom270,

    Forward000,
    Forward090,
    Forward180,
    Forward270,

    Back000,
    Back090,
    Back180,
    Back270,

    Right000,
    Right090,
    Right180,
    Right270,

    Left000,
    Left090,
    Left180,
    Left270,

    Max
}

## 動作確認

回転対応ができたので random に CubeRotationType を ChunkData に与えて、結果のメッシュを確認します。
余計な面が大きなキューブの内側に入っていなければ OK

結果はどうか?

残念 回転タイプ 0~7 までは問題なかったが
8から問題あり

11 まで修正(180度回転ずらせばよかった)

15 まではすでに正しかった。
16は、こちらも不具合あったが 180度回転ずらして修正を確認

19までOK
20から、すでに正しかった。

これでもう一度ランダムに回転を与えて結果を確認します。

f:id:simplestar_tech:20190616223015p:plain
正しく設定できました。

上記コードの方、正しいものに修正しておきました。

Unity:ポインターのポインターを入力にECS

# まえがき

Unity でキューブを敷き詰めた世界で、VRMオンラインチャットできることを目指して仕事の合間に技術調査してます。(かれこれ2年くらい)
Unity も 2017.1 から気づけば 2019.1 ですね。

キューブ一つひとつをメッシュオブジェクトにすると、下記を参考にわずか 20 x 20 x 20 個のキューブをシーンに配置するだけで、描画レートは 20 fps を下回ります。
Unity に限った話ではありませんが、参考までにこちら2年前の記事ですが…
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20171009/20171009170254.jpg
六角柱を敷き詰めたマイクロワールドの構築(メッシュの結合とマルチマテリアル処理) - simplestarの技術ブログ

当時はメッシュを結合すれば爆速になることを確認して、とにかくメッシュを結合することを決めました。
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20171016/20171016231940.jpg
結合してしまえば 215 x 215 x 215 個のキューブをシーンに配置しても 1087 fps 出た。

一旦その方向で技術調査をすすめていきましたが…
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20180114/20180114104815.jpg

キューブまたは構成要素一つひとつのロジック計算量の見積もりが甘く…人間が面白いと感じる速度で世界は変化していかないわけです…
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20180219/20180219001525.jpg
そこで目をつけたのが PC のメニーコア化と Unity ECS です。

(画像はイメージです)
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20180204/20180204231816.gif

モンスター計算マシンと ECS なら、面白いと感じる速度で世界が変化するかも…と、そこで結果を出すために技術調査を続ける毎日です。

話を元に戻しますが、私達の生きているこの世界に果てはないそうで、地平線の向こう側まで直進し続けると元の場所に戻ってくるそうじゃないですか。
宇宙すら、不可能とされる光速を越えて直進し続けると出発点に戻ってくるとか

そこで 1 byte の 255 + 1 = 0 のオーバーフローを利用して、世界を 256 x 256 x 256 結合メッシュとする世界を考えます。

そろそろ本題ですが、結合メッシュの世界の最小構成要素(キューブ)配列を 256 x 256 x 256 (16,777,216) 要素の配列にして表現したく…
プログラマーの手段でこれを扱うと int** とポインターポインターになるわけです。(キューブの情報を int とするとね)
ECS はその原理からマネージド配列の操作を許しませんので(NativeArray または ポインター)、どうしてもこのポインターポインターを渡して仕事をさせたい訳なのです。

実はもうできそうなことわかっているので、その動作確認というものをこの記事で行ってみたいと思います。
できなかったらこの記事は完成しません。(恥ずかしい…)

# ECS によるメッシュ生成でキューブの配置

六角を廃して、最近作り直したキューブの世界…
https://cdn-ak.f.st-hatena.com/images/fotolife/s/simplestar_tech/20190321/20190321221355.jpg
動作確認できてよかったので、コードを移植していきます。
キューブ一つに int 32 bit が割り振られており
byte 8 bit ごとに
1.キューブカテゴリ
2.キューブ回転
3.A面タイプ
4.B面タイプ
の合計4つの意味を持つ値が入っています。
例えばキューブ回転はこんな感じ、値は時計回りの回転角度

public enum CubeRotation : byte
{
    Top000 = 0,
    Top090,
    Top180,
    Top270,

    Bottom000,
    Bottom090,
    Bottom180,
    Bottom270,

回転角度の適用は下の図の通りです。

f:id:simplestar_tech:20190614064059p:plain
左から Top000, 090, 180, 270
f:id:simplestar_tech:20190614063744p:plain
手前から Top 行、Bottom 行
f:id:simplestar_tech:20190614063651p:plain
そのほかの回転をわかりやすくする角度

関連ツィートはこちらから

このとき手打ちした頂点位置配列がこちら

        new Vector3[]{
            // Top
            new Vector3(-halfSide, +halfSide, +halfSide),
            new Vector3 (+halfSide, +halfSide, +halfSide),
            new Vector3 (+halfSide, +halfSide, -halfSide),
            // Bottom
            new Vector3 (+halfSide, -halfSide, -halfSide),
            new Vector3 (+halfSide, -halfSide, +halfSide),
            new Vector3 (-halfSide, -halfSide, +halfSide),
            // Right up
            new Vector3 (+halfSide, +halfSide, -halfSide),
            new Vector3 (+halfSide, +halfSide, +halfSide),
            new Vector3 (+halfSide, -halfSide, +halfSide),
            // Right down
            new Vector3 (+halfSide, -halfSide, +halfSide),
            new Vector3 (+halfSide, -halfSide, -halfSide),
            new Vector3 (+halfSide, +halfSide, -halfSide),
            // Forward up
            new Vector3 (+halfSide, +halfSide, +halfSide),
            new Vector3 (-halfSide, +halfSide, +halfSide),
            new Vector3 (-halfSide, -halfSide, +halfSide),
            // Forward down
            new Vector3 (-halfSide, -halfSide, +halfSide),
            new Vector3 (+halfSide, -halfSide, +halfSide),
            new Vector3 (+halfSide, +halfSide, +halfSide),
            // Cross up
            new Vector3 (-halfSide, +halfSide, +halfSide),
            new Vector3 (+halfSide, +halfSide, -halfSide),
            new Vector3 (+halfSide, -halfSide, -halfSide),
            // Cross down
            new Vector3 (+halfSide, -halfSide, -halfSide),
            new Vector3 (-halfSide, -halfSide, +halfSide),
            new Vector3 (-halfSide, +halfSide, +halfSide),

キューブを敷き詰めるときに重要なのが、周囲のキューブの情報の取得です。
処理を結合メッシュチャンクごとに分けた以上、チャンクをまたぐインデックスアクセスだと範囲外エラーとなります。

実はこの問題を解決するために、ポインターポインターが必要になります。

このまま解説のみで形にすることは難しいので、一度無回転のキューブだけを敷き詰め
周囲のチャンクの情報を取得しようとして困った状況というものを作ってみます。

頂点情報は上で作ったもので交換して、三角柱をキューブにして確認していきましょう。

f:id:simplestar_tech:20190614075221p:plain
キューブ化

頂点情報はこちらに交換しました。

        #region モデルの形状を定義→NativeArray確保
        // var vertices = BinaryUtility.Deserialize<float[]>(System.IO.Path.Combine(Application.dataPath, "Project/Resources/vertices.bytes"));
        const float halfSide = ChunkManager.CubeSide / 2.0f;
        var vertices = new float[]{
            // A面
            -halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            // B面
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
        };
        this.pVerticesSource = (float*)(this.nativeVerticesSource = new NativeArray<float>(vertices, Allocator.Persistent)).GetUnsafePtr();
        #endregion

面と頂点数はとても勿体ない状況であることを確認します。

f:id:simplestar_tech:20190614075600p:plain
8x8x8のキューブメッシュ結合体 この頂点数は多すぎ!

メッシュ用の頂点数をカウントする、頂点情報を埋める処理において、周囲のキューブ情報を参照して、描画されることのない面を削る処理を追加してみます。

作る前に頭で想像すると
1.面ごとに頂点カウント、頂点情報作成
2.面ごとに描画対象かどうか
3.面ごとに、面の法線方向のキューブの値の取得、判定
4.汎用的に、キューブからの相対位置のキューブの値の取得
5.面の法線方向のキューブの相対位置の計算
が浮かびました。

1.キューブは切断面含め、三角形が 16 面あります。
頂点数はその3倍の48頂点
現在は 48 x 4 byte の MemCpy を一括で行っています。
これが 16 個に小分けになることになります。

つまりはこれが

            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));

                            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;
                                meshData.pVerticesVector3[positionXIndex + 2] += z * ChunkManager.CubeSide;
                            }
                        }
                    }
                }
            }

こうってことです。

            var verticesOffset = 0;
            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 faceCount = 0;
                            // TopFaceA
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float));
                            faceCount++;
                            // BottomFaceA
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float));
                            faceCount++;
                            // SideFaceA1
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;
                            // SideFaceA2
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;
                            // CrossFaceA
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;

                            // TopFaceB
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float));
                            faceCount++;
                            // BottomFaceB
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float));
                            faceCount++;
                            // SideFaceB1
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;
                            // SideFaceB2
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;
                            // CrossFaceB
                            UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float) * 2);
                            faceCount += 2;


                            var vector3Count = faceCount * 3;
                            var floatCount = vector3Count * 3;

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

2.上記の処理を書いた後 16 面ごとに描画対象なのか判定を行うことになります。
yes/no を返す関数化しておきたいですが、引数はキューブ位置、キューブの情報、面の向きで解決できそうです。
3.面ごとに、面の法線方向のキューブの値の取得、判定
4.汎用的に、キューブからの相対位置のキューブの値の取得
5.面の法線方向のキューブの相対位置の計算
これらもクリアするロジックがこちら

                            // TopFaceA
                            var sideDataIndex = this.GetDataIndex(dataIndex, 0, 1, 0);
                            var sideData = this.GetData(meshData, sideDataIndex);


        /// <summary>
        /// データインデックスから相対的な座標のデータインデックスを取得
        /// </summary>
        int GetDataIndex(int dataIndex, int x, int y, int z)
        {
            dataIndex += x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY;
            dataIndex += z * ChunkManager.ChunkSizeY;
            dataIndex += y;
            return dataIndex;
        }

        int GetData(ChunkMeshData meshData, int dataIndex)
        {
            if (0 <= dataIndex && meshData.chunkDataLength > dataIndex)
            {
                return meshData.pChunkData[dataIndex];
            }
            else
            {
                // 困った
                return 0;
            }
        }

ここで、上記コメントの通り困ることに気が付きます。
GetDataIndex で得られる値が pChunkData の範囲外となった場合です。

ひとまず困った状態のまま、面どなりのデータを参照して、描く必要のない面をカリングしていきます。
ロジックはこんな感じ

                    // TopFaceB
                    {
                        var sideDataIndex = GetDataIndex(dataIndex, 0, 1, 0); // 一個上を見る
                        var pSideData = GetDataPtr(meshData, sideDataIndex);
                        if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                        {
                            // UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + faceCount * 9, size: 9 * sizeof(float));
                            faceCount++;
                        }
                    }

16面全部に適用してみるとひとまずはカリングされます。

f:id:simplestar_tech:20190615144955p:plain
16面を上記関数でカリングした結果
この結果を正しいと確信できる人は、私と同じこと考えているに違いない

# ポインターポインターで世界を 8 byte アドレスに
今回は 8 x 8 x 8 要素のチャンクを作り、これを使い回す形で 8 x8x8 個のエンティティを作成しています。
チャンクにインデックスを振るなら byte にする

現在はエンティティを個別に識別するための要素は Vector3 の位置だけ
その x, y, z 要素はそれぞれ Chunk の SideX, Y, Z の整数倍となっているので、単純に割ってから 256 で余りを得るとインデックスになる
ただ、ランタイムで割り算は行いたくないので、ここはエンティティ作成時に byte index を設定することにする

まずは byte インデックスの立体行列(テンソル)を作成して、開放するコードを書きます。

    unsafe void Start()
    {
        #region チャンクテンソルの作成
        this.ppChunkData= (int**)(UnsafeUtility.Malloc(sizeof(int*) * (byte.MaxValue + 1) * (byte.MaxValue + 1) * (byte.MaxValue + 1), sizeof(int*), Allocator.Persistent));
        #endregion



    void OnDestroy()
    {
        #region チャンクテンソルの破棄
        UnsafeUtility.Free((void*)this.ppChunkData, Allocator.Persistent);
        #endregion

チャンク(=エンティティ)インデックスの値はこのように決めることにしました。

        int chunkIndex = entityKey.x * (byte.MaxValue + 1) * (byte.MaxValue + 1) + entityKey.y * (byte.MaxValue + 1) + entityKey.z;
        this.ppChunkData[chunkIndex] = (int*)this.nativeChunkData.GetUnsafePtr();

ひとまず小ゴールとして ポインターポインターで世界を 8 byte アドレスにして動作確認できました。

    static byte* GetDataPtr(ChunkMeshData meshData, int dataIndex)
    {
        if (0 <= dataIndex && meshData.chunkDataLength > dataIndex)
        {
            var pChunkData = meshData.ppChunkData[meshData.chunkIndex];
            return (byte*)(pChunkData + dataIndex);
        }
        else
        {
            int* pData = meshData.ppChunkData[meshData.chunkIndex]; // @TODO どうやって周囲アクセス!?
            // 困った
            return null;
        }
    }

f:id:simplestar_tech:20190615175846p:plain
上記コードの実行時の様子

# 周囲の結合メッシュの情報を参照

周囲の結合メッシュ…つまりは entity です。
ただ、entity から entity にアクセスするのではなく、ポインターポインターの 8byte で世界の構成要素の情報を取得、書き換えできるようにここまで準備してきました。

具体的には ppChunkData から必要なチャンクを特定し、そこから値を取り出すようにしましょう。

コードを書く前に検算ですが、3次元を一次元配列にしている現在
x 方向に一つ足すと (byte.MaxValue + 1) * (byte.MaxValue + 1) 足すことを意味する
困ったことに x 方向に一つ足すべきなのかを判別する計算がわからない

今は chunkData 内の index でしかなく、もし境界の要素だったとして、そこから一つ足した場合でも chunkData 内に納まるインデックスだったら?
ね?
判別不可能でしょう。
そこで、chunkData 内の index を受け取るのをやめて、もっと情報量のボリュームがある三次元 index x, y, z を受け取るようにするのはどうか?
こうすると例えば y を一つたして chunk の y 最大を n 回オーバーするときに ppChunk 内でいくつ移動すればよいかがわかる
これにしましょう。
リファクタリングします。

f:id:simplestar_tech:20190615192908p:plain
リファクタリング後 まだチャンクをまたぐ参照ができていないカリング状態

このときはまだこのように、チャンクをまたぐ部分で困った状態となっています。

    static byte* GetDataPtr(ChunkMeshData meshData, int x, int y, int z)
    {
        if (0 <= x && ChunkManager.ChunkSizeX > x
            && 0 <= y && ChunkManager.ChunkSizeY > y
            && 0 <= z && ChunkManager.ChunkSizeZ > z)
        {
            var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
            var pChunkData = meshData.ppChunkData[meshData.chunkIndex];
            return (byte*)(pChunkData + dataIndex);
        }

        // over chunk count x, y, z
        // else
        {
            // 困った
            return null;
        }
    }

コメントにあるとおり、範囲外となったときに何個チャンクを跨いでいるのかを計算し、その分のチャンク移動を行ってみます。

コード修正中の様子がこちら、うすうす勘付いていたのですが…

    static byte* GetDataPtr(ChunkMeshData meshData, int x, int y, int z)
    {
        if (0 <= x && ChunkManager.ChunkSizeX > x
            && 0 <= y && ChunkManager.ChunkSizeY > y
            && 0 <= z && ChunkManager.ChunkSizeZ > z)
        {
            var pChunkData = meshData.ppChunkData[meshData.chunkIndex];
            var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
            return (byte*)(pChunkData + dataIndex);
        }

        var overChunkCountX = x / ChunkManager.ChunkSizeX;
        if (0 > x)
        {
            overChunkCountX -= 1;
        }
        x -= overChunkCountX * ChunkManager.ChunkSizeX;
        var overChunkCountY = y / ChunkManager.ChunkSizeY;
        if (0 > y)
        {
            overChunkCountY -= 1;
        }
        y -= overChunkCountY * ChunkManager.ChunkSizeY;
        var overChunkCountZ = z / ChunkManager.ChunkSizeZ;
        if (0 > z)
        {
            overChunkCountZ -= 1;
        }
        z -= overChunkCountZ * ChunkManager.ChunkSizeZ;

        // over chunk count x, y, z
        // else
        {
            // var chunkIndex = meshData.chunkIndex + 
            var pChunkData = meshData.ppChunkData[meshData.chunkIndex];
            var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
            return (byte*)(pChunkData + dataIndex);
        }
    }

やっぱり chunkIndex を計算する際、チャンクのインデックス x, y, z が必要です。
これをエンティティから取り出せるように修正します。

修正後

        #region 位置情報からチャンクキーに変換
        var byteMax = (byte.MaxValue + 1);
        var chunkKeyX = chunkPosition.x % byteMax;
        if (0 > chunkKeyX) chunkKeyX += byteMax;
        var chunkKeyY = chunkPosition.y % byteMax;
        if (0 > chunkKeyY) chunkKeyY += byteMax;
        var chunkKeyZ = chunkPosition.z % byteMax;
        if (0 > chunkKeyZ) chunkKeyZ += byteMax;
        #endregion

        #region チャンク情報の設定
        int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;
        this.ppChunkData[chunkIndex] = (int*)this.nativeChunkData.GetUnsafePtr();
        meshEntity.EntityManager.SetComponentData(meshEntity.Entity, new ChunkMeshData
        {
            chunkKeyX = (byte)chunkKeyX,
            chunkKeyY = (byte)chunkKeyY,
            chunkKeyZ = (byte)chunkKeyZ,
            ppChunkData = this.ppChunkData,
        });
        #endregion

現在の meshData から x, y, z を指定してチャンクデータを取得するコード、修正後がこちら

    static byte* GetDataPtr(ChunkMeshData meshData, int x, int y, int z)
    {
        if (0 <= x && ChunkManager.ChunkSizeX > x
            && 0 <= y && ChunkManager.ChunkSizeY > y
            && 0 <= z && ChunkManager.ChunkSizeZ > z)
        {
            #region チャンク内のデータを返す
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = meshData.chunkKeyX * byteMax * byteMax + meshData.chunkKeyZ * byteMax + meshData.chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
            return (byte*)(pChunkData + dataIndex);
            #endregion
        }
        else
        {
            #region チャンク越えカウント
            var overChunkCountX = x / ChunkManager.ChunkSizeX;
            if (0 > x) overChunkCountX -= 1;
            var overChunkCountY = y / ChunkManager.ChunkSizeY;
            if (0 > y) overChunkCountY -= 1;
            var overChunkCountZ = z / ChunkManager.ChunkSizeZ;
            if (0 > z) overChunkCountZ -= 1;
            #endregion
            #region byte オーバーフローによる値ループ
            var chunkKeyX = (byte)(meshData.chunkKeyX + overChunkCountX);
            var chunkKeyY = (byte)(meshData.chunkKeyY + overChunkCountY);
            var chunkKeyZ = (byte)(meshData.chunkKeyZ + overChunkCountZ);
            #endregion
            #region チャンクの特定
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            #endregion
            if (null != pChunkData)
            {
                #region チャンク内のデータインデックスへ変換
                x -= overChunkCountX * ChunkManager.ChunkSizeX;
                y -= overChunkCountY * ChunkManager.ChunkSizeY;
                z -= overChunkCountZ * ChunkManager.ChunkSizeZ;
                var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
                #endregion
                return (byte*)(pChunkData + dataIndex);
            }

        }
        return null;
    }

一見して成功したかと思ったけど、X 方向のチャンク参照がすべて失敗している様子

f:id:simplestar_tech:20190615234026p:plain
不具合が見た目から確認した様子…期待ではボックスの内側はすべて空であるべき

動作確認していきます。

# 動作確認

overChunkCountX が 1 になるときの処理を追ってみます。
あれ、本来ポインターポインターに設定されているべきインデックスの値が空…

なるほど、そういえば前回の記事で前後のフレームに計算を分散させてましたね。
simplestar-tech.hatenablog.com
すっかり忘れてた…

以下の通り、全初期化を最初のフレームで行うことで解決!

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

            #region 作成対象のエンティティを 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++)
                        {
                            this.EnqueueCreateEntity(x, z, y);
                        }
                    }
                }
            }
            #endregion

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

    void EnqueueCreateEntity(int x, int z, int y)
    {
        #region ポインターのポインターにチャンクをセット
        var byteMax = (byte.MaxValue + 1);
        int chunkIndex = x * byteMax * byteMax + z * byteMax + y;
        this.ppChunkData[chunkIndex] = (int*)this.nativeChunkData.GetUnsafePtr(); // 本来なら Amazon S3 からダウンロード完了後にこれしてから Enqueue とか?
        #endregion

        #region キーとなる位置情報を Enqueue
        var chunkPosition = new Vector3Int(x, y, z);
        this.createEntityQueue.Enqueue(chunkPosition);
        #endregion
    }

周辺チャンクへのアクセスが正しく行えるようになり、メッシュも期待通り削れました。

f:id:simplestar_tech:20190616071217p:plain
8x8x8 の結合メッシュ、倍数にして頂点数と三角形数が 0.047 倍まで削減された!

動作している絵

# まとめ

キューブの結合メッシュの元となるチャンクデータは int 配列でしたが
そんなチャンクデータもまた3次元テンソルになってまして…これを Unity ECS に渡すにはポインターポインターしかない!
と思ったので、動くかなーと手を動かしてみた記録が上記です。

ちょっと試してみよう…という発想と作業内容が全然釣り合わない感じですが、正しく動きましたので
ゼロから作るより、こういう作業でこういう結果が得られるのかと、参考にしていただけたらと思います。

最後に具体的な手段を載せます。

肝心の Entity 作成側の MonoBehaviour と ComponentSystem クラスの実装です。
CreateMeshWithECS.cs

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

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

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

    unsafe void Start()
    {
        #region チャンクテンソルの作成
        this.ppChunkData = (int**)(UnsafeUtility.Malloc(sizeof(int*) * (byte.MaxValue + 1) * (byte.MaxValue + 1) * (byte.MaxValue + 1), sizeof(int*), Allocator.Persistent));
        #endregion

        #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] = 16843009;
                }
            }
        }
        this.nativeChunkData = new NativeArray<int>(chunkData, Allocator.Persistent);
        #endregion

        StartCoroutine(this.OnStartOfFrame());
        StartCoroutine(this.OnEndOfFrame());
    }

    /// <summary>
    /// チャンクエンティティを作成
    /// </summary>
    void CreateChunkObjectEntity(Vector3Int chunkPosition)
    {
        #region メッシュオブジェクト Entity の作成
        var position = new Vector3(ChunkManager.ChunkSizeX * chunkPosition.x, ChunkManager.ChunkSizeY * chunkPosition.y, ChunkManager.ChunkSizeZ * chunkPosition.z);
        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 位置からチャンクキーに変換
        var byteMax = (byte.MaxValue + 1);
        var chunkKeyX = chunkPosition.x % byteMax;
        if (0 > chunkKeyX) chunkKeyX += byteMax;
        var chunkKeyY = chunkPosition.y % byteMax;
        if (0 > chunkKeyY) chunkKeyY += byteMax;
        var chunkKeyZ = chunkPosition.z % byteMax;
        if (0 > chunkKeyZ) chunkKeyZ += byteMax;
        #endregion

        #region チャンクキーとポインターのポインターを設定
        meshEntity.EntityManager.SetComponentData(meshEntity.Entity, new ChunkMeshData
        {
            chunkKeyX = (byte)chunkKeyX,
            chunkKeyY = (byte)chunkKeyY,
            chunkKeyZ = (byte)chunkKeyZ,
            ppChunkData = this.ppChunkData,
        });
        #endregion

        #region マテリアルの設定
        var renderer = meshObject.GetComponent<MeshRenderer>();
        renderer.material = new Material(this.meshShader);
        #endregion
    }

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

            #region 作成対象のエンティティを 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++)
                        {
                            this.EnqueueCreateEntity(x, z, y);
                        }
                    }
                }
            }
            #endregion

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

    void EnqueueCreateEntity(int x, int z, int y)
    {
        #region ポインターのポインターにチャンクをセット
        var byteMax = (byte.MaxValue + 1);
        int chunkIndex = x * byteMax * byteMax + z * byteMax + y;
        this.ppChunkData[chunkIndex] = (int*)this.nativeChunkData.GetUnsafePtr(); // 本来なら Amazon S3 からダウンロード完了後にこれしてから Enqueue とか?
        #endregion

        #region キーとなる位置情報を Enqueue
        var chunkPosition = new Vector3Int(x, y, z);
        this.createEntityQueue.Enqueue(chunkPosition);
        #endregion
    }

    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
        }
    }

    void OnDestroy()
    {
        #region メッシュ情報破棄
        this.nativeChunkData.Dispose();
        #endregion
        #region チャンクテンソルの破棄
        UnsafeUtility.Free((void*)this.ppChunkData, Allocator.Persistent);
        #endregion
        #region ECS 終了処理
        World.DisposeAllWorlds();
        WordStorage.Instance.Dispose();
        WordStorage.Instance = null;
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
        #endregion
    }

    NativeArray<int> nativeChunkData;
    float startFrame;
    float endFrame;
    Queue<Vector3Int> createEntityQueue = new Queue<Vector3Int>();
    int dequeueCount = 1;
    int createMeshObjectCount = 0;
    int** ppChunkData = null;
}

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>
    unsafe struct CountVerticesJob : IJobParallelFor
    {
        internal NativeArray<ChunkMeshData> meshDataArray;
        internal int sourceCount;

        [BurstCompile]
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            meshData.vertexCount = 0;
            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 pData = GetDataPtr(meshData, x, y, z);
                        if (1 == pData[0])
                        {
                            var faceCount = 0;
                            // TopFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y + 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    faceCount++;
                                }
                            }
                            // BottomFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y - 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    faceCount++;
                                }
                            }
                            // RightSideFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x + 1, y, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    faceCount += 2;
                                }
                            }
                            // ForwardSideFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y, z + 1);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    faceCount += 2;
                                }
                            }
                            // CrossFaceA
                            {
                                if (!(1 == pData[3]))
                                {
                                    faceCount += 2;
                                }
                            }

                            // BottomFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y - 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    faceCount++;
                                }
                            }
                            // TopFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y + 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    faceCount++;
                                }
                            }
                            // LeftSideFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x - 1, y, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    faceCount += 2;
                                }
                            }
                            // BackSideFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y, z - 1);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    faceCount += 2;
                                }
                            }
                            // CrossFaceB
                            {
                                if (!(1 == pData[2]))
                                {
                                    faceCount += 2;
                                }
                            }

                            var vertexCount = faceCount * 3;
                            var floatCount = vertexCount * 3;
                            meshData.vertexCount += floatCount;
                        }
                    }
                }
            }
            this.meshDataArray[entityIndex] = meshData;
        }
    }

    /// <summary>
    /// 頂点バッファに頂点データをコピー書き込み
    /// </summary>
    unsafe struct CopyWriteVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSource;
        [ReadOnly] internal int sourceCount;
        internal NativeArray<ChunkMeshData> meshDataArray;

        [BurstCompile]
        public void Execute(int entityIndex)
        {
            var meshData = this.meshDataArray[entityIndex];
            var verticesOffset = 0;
            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 pData = GetDataPtr(meshData, x, y, z);
                        if (1 == pData[0])
                        {
                            var faceCount = 0;
                            // TopFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y + 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 0 * 9, size: 9 * sizeof(float));
                                    faceCount++;
                                }
                            }
                            // BottomFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y -1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 1 * 9, size: 9 * sizeof(float));
                                    faceCount++;
                                }
                            }
                            // RightSideFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x + 1, y, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 2 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }
                            // ForwardSideFaceA
                            {
                                var pSideData = GetDataPtr(meshData, x, y, z + 1);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[2]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 4 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }
                            // CrossFaceA
                            {
                                if (!(1 == pData[3]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 6 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }

                            // BottomFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y - 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 8 * 9, size: 9 * sizeof(float));
                                    faceCount++;
                                }
                            }
                            // TopFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y + 1, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 9 * 9, size: 9 * sizeof(float));
                                    faceCount++;
                                }
                            }
                            // LeftSideFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x - 1, y, z);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 10 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }
                            // BackSideFaceB
                            {
                                var pSideData = GetDataPtr(meshData, x, y, z - 1);
                                if (!(null != pSideData && 1 == pSideData[0] && 1 == pSideData[1] && 1 == pSideData[3]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 12 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }
                            // CrossFaceB
                            {
                                if (!(1 == pData[2]))
                                {
                                    UnsafeUtility.MemCpy(meshData.pVerticesVector3 + verticesOffset + faceCount * 9, this.pVerticesSource + 14 * 9, size: 9 * sizeof(float) * 2);
                                    faceCount += 2;
                                }
                            }

                            var vertexCount = faceCount * 3;
                            var floatCount = vertexCount * 3;

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

    static byte* GetDataPtr(ChunkMeshData meshData, int x, int y, int z)
    {
        if (0 <= x && ChunkManager.ChunkSizeX > x
            && 0 <= y && ChunkManager.ChunkSizeY > y
            && 0 <= z && ChunkManager.ChunkSizeZ > z)
        {
            #region チャンク内のデータを返す
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = meshData.chunkKeyX * byteMax * byteMax + meshData.chunkKeyZ * byteMax + meshData.chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
            return (byte*)(pChunkData + dataIndex);
            #endregion
        }
        else
        {
            #region チャンク越えカウント
            var overChunkCountX = x / ChunkManager.ChunkSizeX;
            if (0 > x) overChunkCountX -= 1;
            var overChunkCountY = y / ChunkManager.ChunkSizeY;
            if (0 > y) overChunkCountY -= 1;
            var overChunkCountZ = z / ChunkManager.ChunkSizeZ;
            if (0 > z) overChunkCountZ -= 1;
            #endregion
            #region byte オーバーフローによる値ループ
            var chunkKeyX = (byte)(meshData.chunkKeyX + overChunkCountX);
            var chunkKeyY = (byte)(meshData.chunkKeyY + overChunkCountY);
            var chunkKeyZ = (byte)(meshData.chunkKeyZ + overChunkCountZ);
            #endregion
            #region チャンクの特定
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;
            var pChunkData = meshData.ppChunkData[chunkIndex];
            #endregion
            if (null != pChunkData)
            {
                #region チャンク内のデータインデックスへ変換
                x -= overChunkCountX * ChunkManager.ChunkSizeX;
                y -= overChunkCountY * ChunkManager.ChunkSizeY;
                z -= overChunkCountZ * ChunkManager.ChunkSizeZ;
                var dataIndex = x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y;
                #endregion
                return (byte*)(pChunkData + dataIndex);
            }

        }
        return null;
    }

    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"));
        const float halfSide = ChunkManager.CubeSide / 2.0f;
        var vertices = new float[]{
            // A面
            -halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, +halfSide,
            +halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            // B面
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, -halfSide, -halfSide,
            -halfSide, -halfSide, -halfSide,
            -halfSide, +halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
            -halfSide, +halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            -halfSide, -halfSide, +halfSide,
            +halfSide, -halfSide, -halfSide,
            +halfSide, +halfSide, -halfSide,
        };
        this.pVerticesSource = (float*)(this.nativeVerticesSource = new NativeArray<float>(vertices, Allocator.Persistent)).GetUnsafePtr();
        #endregion
    }

    protected override void OnDestroy()
    {
        this.nativeVerticesSource.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
        {
            sourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var countJobHandle = countVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        countJobHandle.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),
            };
            this.entityMeshDataList.Add(entityMeshData);
            meshData.pVerticesVector3 = (float*)entityMeshData.nativeVertices.GetUnsafePtr();
            meshDataArray[entityIndex] = meshData;
        }
        #endregion

        #region 頂点バッファに頂点データをコピー
        var copyVerticesJob = new CopyWriteVerticesJob
        {
            pVerticesSource = this.pVerticesSource,
            sourceCount = this.nativeVerticesSource.Length,
            meshDataArray = meshDataArray
        };
        var copyJobHandle = copyVerticesJob.Schedule(arrayLength: meshDataArray.Length, innerloopBatchCount: 1);
        copyJobHandle.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 mesh = new Mesh();
            mesh.Clear();
            mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
            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;

            entityMeshData.nativeVertices.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;
    float* pVerticesSource;

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

    class EntityMeshData
    {
        public NativeArray<Vector3> nativeVertices;
    }
}

最後まで読んでくれてありがとう!

続きはこちら
simplestar-tech.hatenablog.com

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>
    unsafe struct CountVerticesJob : IJobParallelFor
    {
        internal NativeArray<ChunkMeshData> meshDataArray;
        internal int verticesSourceCount;

        [BurstCompile]
        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>
    unsafe struct FillVerticesJob : IJobParallelFor
    {
        [NativeDisableUnsafePtrRestriction] [ReadOnly] internal float* pVerticesSourceVector3;

        internal NativeArray<ChunkMeshData> meshDataArray;

        [BurstCompile]
        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>
    unsafe struct CountVerticesJob : IJobParallelFor
    {
        internal NativeArray<ChunkMeshData> meshDataArray;
        internal int verticesSourceCount;

        [BurstCompile]
        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>
    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;

        [BurstCompile]
        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 をターゲットに行えている様子

動作確認する

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

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

Unity:Amazon DynamoDB を C# から利用する

高速読み書きがスケーラブルなデータベース Dynamo DB を Unity から利用する

現在のルームメンバー一覧を取得したいとか、これからジョインするルームを決めるときに、ルームに何人入っているのかなどを確認したいときがあるのです。
S3 にファイルとして情報を置く?…いえいえ、ここはデータベースの出番だと思います。

今回は DynamoDB というキーと値のセットでデータを格納するタイプのデータベースを Unity から利用してデータを取得する例を示します。

前知識

前回の記事では、Amazon S3 というオンラインストレージを Unity から利用して
ファイルパスを渡してS3の指定バケットにアップロードするとオブジェクトキーを取得する機能
S3バケットのオブジェクトキーを指定するとダウンロードしたbyte配列を受け取る機能
いずれも非同期で実行する方法を示し、動作確認も行いました。
simplestar-tech.hatenablog.com

ここからは Unity から AWS を利用するための Cognito による認証・認可のハードルを越えている読者に向けて説明を続けます。
理解できずに振り落とされてしまった場合は、前回の記事を参照していただければと思います。

AWS SDK DynamoDB を使うための準備

Assets 以下に次の URL から net45 フォルダの dll を配置して、DynamoDB 関係の実装を利用できるようにします。
AWSSDK.DynamoDBv2

そして S3 のクライアントのときと同じく、Cognito の SignIn イベントにハンドラを登録して DynamoDBContext を作成します。
あとは以下のロジックを組みます。

AmazonDynamoDBClient.cs

using Amazon;
using Amazon.CognitoIdentity;
using Amazon.DynamoDBv2;
using Amazon.DynamoDBv2.DataModel;
using Amazon.DynamoDBv2.DocumentModel;
using System.Collections.Generic;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

namespace AWS
{
    public class AmazonDynamoDBClient : MonoBehaviour
    {
        #region Scene Components
        [SerializeField] AmazonCognitoSignInGUI cognitoSignInGUI;
        #endregion        

        void Start()
        {
            this.cognitoSignInGUI.onSignInSuccess += OnSignIn;            
        }

        void OnSignIn(CognitoAWSCredentials credentials)
        {
            this.dynamoDBClient = new Amazon.DynamoDBv2.AmazonDynamoDBClient(credentials, this.resourceRegion);
            this.dynamoDBContext = new DynamoDBContext(this.dynamoDBClient);
        }

        public async Task QueryAsync<T>(string partitionKey, QueryOperator queryOperator, IEnumerable<string> sortKeys, UnityAction<List<T>> onQuerySuccess)
        {
            var appQuery = dynamoDBContext.QueryAsync<T>(partitionKey, queryOperator, sortKeys);
            onQuerySuccess?.Invoke(await appQuery.GetRemainingAsync());
        }

        public async Task LoadAttributes<T>(string partitionKey, string sortKey, UnityAction<T> onLoadSuccess)
        {
            onLoadSuccess?.Invoke(await this.dynamoDBContext.LoadAsync<T>(partitionKey, sortKey));
        }

        public async Task SaveAttributes<T>(T attributes, UnityAction onSaveSuccess)
        {
            await this.dynamoDBContext.SaveAsync<T>(attributes);
            onSaveSuccess?.Invoke();
        }

        int bookID = 1001;

        RegionEndpoint resourceRegion = RegionEndpoint.APNortheast1;
        IAmazonDynamoDB dynamoDBClient = null;
        IDynamoDBContext dynamoDBContext = null;
    }
}

AmazonDynamoDBAttributes.cs

using Amazon.DynamoDBv2.DataModel;
using System.Collections.Generic;

namespace AWS.DynamoDBAttributes
{
    [DynamoDBTable("CubeWalkC1")]
    public class Rooms
    {
        [DynamoDBHashKey] public string PartitionKey { get; set; } = "Rooms";
        [DynamoDBRangeKey] public string SortKey { get; set; } = "XXX#XXX#XXXXXXX";
        [DynamoDBProperty("Players")] public List<string> DatePlayerIDs { get; set; } = new List<string>();
    }

    [DynamoDBTable("CubeWalkC1")]
    public class Players
    {
        [DynamoDBHashKey] public string PartitionKey { get; set; } = "Players";
        [DynamoDBRangeKey] public string SortKey { get; set; } = "20XX-XX-XXTXX:XX:XX#xxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
        [DynamoDBProperty] public string PlayerName { get; set; }
    }
}

Scene Component との接続は次の通り

f:id:simplestar_tech:20190602164129p:plain
Scene Component 接続

認証済みユーザーに割り当てる IAM ロールにて DynamoDB の GetItem x 特定テーブル ARN の指定を加えます。
以下のロール編集画面の通り

これけっこう重要なナレッジかな?DynamoDBContext には DescribeTable は必須みたい(ドキュメントのどこかに書かれているかな?)

f:id:simplestar_tech:20190602165421j:plain
DynamoDB のインラインポリシーの設定例

これで認証されたユーザーから CubeWalkC1 テーブルからアイテム情報を受け取ったり、配置できたりするようになります。

テスト

テストに使ったコードがこちら
TestAWSGUI.cs

using UnityEngine;
using UnityEngine.UI;
using AWS;
using AWS.DynamoDBAttributes;
using System.IO;
using Amazon.DynamoDBv2.DocumentModel;

public class TestAWSGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] Button buttonPut;
    [SerializeField] Button buttonGet;
    #endregion

    #region Scene Components
    [SerializeField] AmazonS3Client s3Client;
    [SerializeField] AmazonDynamoDBClient dynamoDBClient;
    #endregion

    void Start()
    {
        this.buttonPut.onClick.AddListener(this.OnPut);
        this.buttonGet.onClick.AddListener(this.OnGet);
    }

    async void OnGet()
    {
        await this.s3Client.GetObjectDataAsync("ap-northeast-1:xxxxxxxxx-xxxxxx-xxxxxxx-xxxxx-xxxxxxxxxxxxxxx/Miraikomachi.vrm", (bytes) =>
        {
            File.WriteAllBytes(@"C:\Users\simpl\Downloads\VRM\MiraikomachiVRM-master\Miraikomachi2.vrm", bytes);
        });

        await this.dynamoDBClient.QueryAsync<Rooms>("Rooms", QueryOperator.BeginsWith, new string[] { "XXX" }, (list) => {
            foreach (var item in list)
            {
                foreach (var playerID in item.DatePlayerIDs)
                {
                    Debug.Log(playerID);
                }
                Debug.Log(item.SortKey);
            }
        });
    }

    async void OnPut()
    {
        await this.s3Client.PutObjectFileAsync(@"C:\Users\simpl\Downloads\VRM\MiraikomachiVRM-master\Miraikomachi.vrm", (objectKey) =>
        {
            Debug.Log(objectKey);
        });

        await this.dynamoDBClient.SaveAttributes(new Rooms { DatePlayerIDs = new System.Collections.Generic.List<string> { "player1", "player2" } }, () =>
        {
            Debug.Log("Save Rooms");
        });

        await this.dynamoDBClient.SaveAttributes(new Players { PlayerName = "player1" }, () =>
        {
            Debug.Log("Save Players");
        });
    }
}

実行すると以下のログが流れました。
期待通り、DynamoDB への書き込み、読み取りが機能している様子(クライアントからルーム情報いじれるのはセキュリティ的にどうかと考えてみたけど、ウソのジョインをいっぱい送ることも可能なのでどこで更新しても壊せるかな)

f:id:simplestar_tech:20190602170652j:plain
上記テストコードの実行結果

まとめ

DynamoDB を Unity から操作できました!

Unity:オンラインVRMチャットサンプルの作り方4

数十MBある大きいVRM データのリアルタイム送受信

f:id:simplestar_tech:20190601193357j:plain
最後まで読むとできるようになる絵

この記事の続きです。
simplestar-tech.hatenablog.com

Magic Onion を Unity 間のリアルタイム通信に使いましたが、送受信するデータが 1MB より大きくなる場合は別の手段を構築するべきであることがわかります。

Protocol Buffers are not designed to handle large messages.

developers.google.com

VRMチャットの命ともいえるキャラクターデータは 8~40MB ほどありますので、前回の Magic Onion の実装を使って受け渡しはできません。(試したところ 3MB 以上でエラーを返した)

今回は VRM データを Unity 間で送受信し合うサンプルを示し、動作確認まで見ていきたいと思います。

Amazon S3 を Unity から利用する

具体的な解決策として Amazon Web Services の一つ Simple Storage Service(S3) を Unity から直接利用してデータの送受信を行います。
考えは単純で、VRM ファイルデータを暗号化して S3 に配置し、その時のダウンロードURLを相手のクライアントに送り、Unity クライアントが直接 S3 からダウンロードを行い、復号化して利用します。

さっそく S3 にファイルを配置する and S3 からファイルデータをダウンロードする実装を見てみましょう。
参考コードはこちら
docs.aws.amazon.com

その前に AWS を利用できるように dll 群を Unity プロジェクトの Assets 以下に配置します。

手順は以下の Qiita 記事の AWS SDK のインストール という項目の通りです。

qiita.com

今回は S3 も使うので、追加でこちらもダウンロードして Assets 以下に配置します。

AWSSDK.S3

ライブラリの準備が完了したら、以下の実装を行って Unity シーンに配置します。
内容はファイルパスを渡してS3の指定バケットにアップロードするとオブジェクトキーを取得する機能、S3指定バケットのオブジェクトキーを指定するとダウンロードしたbyte配列を受け取る機能、いずれも非同期です。
S3 のバケット名は世界でたった一つなので、もし後述の Cognito で認証いらずのチェックを入れていると、全世界公開ということになるので注意です。
AmazonS3Client.cs

using Amazon;
using Amazon.CognitoIdentity;
using Amazon.S3;
using Amazon.S3.Model;
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;

namespace AWS
{
    public class AmazonS3Client : MonoBehaviour
    {
        #region Scene Components
        [SerializeField] AmazonCognitoSignInGUI signInGUI;
        #endregion

        void Start()
        {
            this.signInGUI.onSignInSuccess += OnSignIn;
        }

        void OnSignIn(CognitoAWSCredentials credentials)
        {
            this.identityId = credentials.GetIdentityId();
            this.s3Client = new Amazon.S3.AmazonS3Client(credentials, this.resourceRegion);
        }

        public async Task PutObjectFileAsync(string filePath, UnityAction<string> onSuccess)
        {
            await this.PutObjectFileAsync(this.identityId + "/" + Path.GetFileName(filePath), filePath, onSuccess);
        }

        public async Task GetObjectDataAsync(string keyName, UnityAction<byte[]> onSuccess)
        {
            try
            {
                var request = new GetObjectRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName
                };
                using (var response = await this.s3Client.GetObjectAsync(request))
                {
                    using (var responseStream = response.ResponseStream)
                    {
                        long partSize = 5 * (long)Math.Pow(2, 12); // 5 MB
                        var tmpBuffer = new byte[partSize];
                        using (var ms = new MemoryStream())
                        {
                            while (true)
                            {
                                var read = responseStream.Read(tmpBuffer, 0, tmpBuffer.Length);

                                if (read > 0)
                                {
                                    ms.Write(tmpBuffer, 0, read);
                                }
                                else
                                {
                                    break;
                                }
                            }
                            onSuccess?.Invoke(ms.ToArray());
                        }
                    }
                }
            }
            catch (AmazonS3Exception e)
            {
                Debug.LogError($"Error encountered on S3 Get Object. Message:'{e.Message}'");
            }
            catch (Exception e)
            {
                Debug.LogError($"Unknown encountered on Read Object. Message:'{e.Message}'");
            }
        }

        async Task PutObjectFileAsync(string keyName, string filePath, UnityAction<string> onSuccess)
        {
            var uploadResponses = new List<UploadPartResponse>();

            var initiateRequest = new InitiateMultipartUploadRequest
            {
                BucketName = this.bucketName,
                Key = keyName
            };

            var initResponse = await this.s3Client.InitiateMultipartUploadAsync(initiateRequest);

            long contentLength = new FileInfo(filePath).Length;
            long partSize = 5 * (long)Math.Pow(2, 20); // 5 MB

            try
            {
                long filePosition = 0;
                for (int partNumber = 1; filePosition < contentLength; partNumber++)
                {
                    var uploadRequest = new UploadPartRequest
                    {
                        BucketName = this.bucketName,
                        Key = keyName,
                        UploadId = initResponse.UploadId,
                        PartNumber = partNumber,
                        PartSize = partSize,
                        FilePosition = filePosition,
                        FilePath = filePath
                    };
                    uploadResponses.Add(await this.s3Client.UploadPartAsync(uploadRequest));
                    filePosition += partSize;
                }

                var completeRequest = new CompleteMultipartUploadRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName,
                    UploadId = initResponse.UploadId
                };
                completeRequest.AddPartETags(uploadResponses);
                var completeUploadResponse = await s3Client.CompleteMultipartUploadAsync(completeRequest);

                onSuccess?.Invoke(keyName);
            }
            catch (Exception exception)
            {
                Debug.LogError($"An Exception was thrown on S3 Upload : {exception.Message}");

                var abortMPURequest = new AbortMultipartUploadRequest
                {
                    BucketName = this.bucketName,
                    Key = keyName,
                    UploadId = initResponse.UploadId
                };
                await this.s3Client.AbortMultipartUploadAsync(abortMPURequest);
            }
        }

        RegionEndpoint resourceRegion = RegionEndpoint.APNortheast1;
        string bucketName = "your-bucket-name";
        string identityId = "invalid value.";
        IAmazonS3 s3Client = null;
    }
}

これを読んで気になる点を挙げるなら AmazonCognitoSignInGUI の実装ですね。
こちらも以下に示します。

AmazonCognitoSignInGUI.cs

using System;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.Extensions.CognitoAuthentication;
using TMPro;
using UnityEngine.Events;
using UnityEngine.UI;
using CodeStage.AntiCheat.ObscuredTypes;
using Amazon.CognitoIdentity;

public class AmazonCognitoSignInGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldPassword;
    [SerializeField] Button buttonSignIn;
    [SerializeField] TMP_Text textStatus;
    [SerializeField] GameObject panelSignUpIn;
    #endregion

    internal UnityAction<CognitoAWSCredentials> onSignInSuccess;

    void Start()
    {
        this.buttonSignIn.onClick.AddListener(this.OnSignIn);

        this.InitializeUi();
    }

    void InitializeUi()
    {
        var emailAddress = ObscuredPrefs.GetString(AmazonCognitoPlayerPrefs.EmailAddress);
        this.inputFieldEmail.text = emailAddress;

        if (0 == inputFieldEmail.text.Length)
        {
            this.textStatus.text = "Input Email, Password and Press SignUp Button.\r\nAlready have an account? Press SignIn Button.";
        }
        else
        {
            this.textStatus.text = "Input Email and Password.\r\nPress SignIn Button.";
        }
    }

    public void OnSignIn()
    {
        try
        {
            AuthenticateWithSrpAsync();
        }
        catch (Exception ex)
        {
            Debug.LogError(ex);
        }
    }

    public async void AuthenticateWithSrpAsync()
    {
        var provider = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var userPool = new CognitoUserPool(
            AmazonCognitoIDs.UserPoolId,
            AmazonCognitoIDs.UserPoolAppClientId,
            provider
        );
        var user = new CognitoUser(
            this.inputFieldEmail.text,
            AmazonCognitoIDs.UserPoolAppClientId,
            userPool,
            provider
        );

        try
        {
            await user.StartWithSrpAuthAsync(
                new InitiateSrpAuthRequest { 
                    Password = this.inputFieldPassword.text
                }).ConfigureAwait(true);
            this.textStatus.text = "SignIn Success.";
            var credentials = new CognitoAWSCredentials(AmazonCognitoIDs.IdentityPoolId, AmazonCognitoIDs.CognitoPoolRegion);
            credentials.AddLogin($"cognito-idp.{AmazonCognitoIDs.CognitoPoolRegion.SystemName}.amazonaws.com/{AmazonCognitoIDs.UserPoolId}", user.SessionTokens.IdToken);
            ObscuredPrefs.SetString(AmazonCognitoPlayerPrefs.EmailAddress, this.inputFieldEmail.text);
            this.panelSignUpIn.SetActive(false);

            this.onSignInSuccess?.Invoke(credentials);
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

SignInに関する UI との接続は次の通り

f:id:simplestar_tech:20190601142721p:plain
Cognito Sign In の UI 接続

用意した UI の見た目は以下の通りです。

f:id:simplestar_tech:20190601142813p:plain
サインイン・アップの UI の見た目

そしてまたまた気になる要素が次の固定値ですね。

AmazonCognitoIDs.CognitoPoolRegion
AmazonCognitoIDs.UserPoolId
AmazonCognitoIDs.UserPoolAppClientId
AmazonCognitoIDs.IdentityPoolId

一応リージョンは東京(RegionEndpoint.APNortheast1)だと予測できますが、それ以外はアプリ固有のユニークな情報となっています。基本秘匿されるべき情報になるのでコード埋め込みというよりは、暗号化して外部ファイルとして管理するなど必要かもしれません。

対策なく公開すると Amazon S3 のファイルアップロードやダウンロードを全世界に公開することになるわけで、続いてアプリで認証されたユーザーからのみ操作できるようにします。

Amazon Cognito を使えば認証機構を用意することができます。具体的には上記の4つの値を作成できます。
手順は公式ドキュメントを参照します。次のステップの1,2まで行うと UserPoolId と UserPoolAppClientId が作成されます。公式ドキュメントですが、わかりやすかったです。(ここ書いた人好き)
docs.aws.amazon.com

続いて、次の手順を公式ドキュメントに沿って行います。「ID プールを設定するには」 1~10 のステップを踏むと IdentityPoolId が作成されます。(Unity のログインサンプルコードだけ無いのが惜しい!)
docs.aws.amazon.com

ということで、Unity のログインサンプルコードがこちら

            var credentials = new CognitoAWSCredentials(AmazonCognitoIDs.IdentityPoolId, AmazonCognitoIDs.CognitoPoolRegion);
            credentials.AddLogin($"cognito-idp.{AmazonCognitoIDs.CognitoPoolRegion.SystemName}.amazonaws.com/{AmazonCognitoIDs.UserPoolId}", user.SessionTokens.IdToken);

この credentials を各種 Amazon Client のコンストラクタに渡せば、認証されたユーザーとしてクライアントAPIを利用することができるようになります!

email アドレスとパスワードを秘匿通信して、Unity ユーザーを認証し、そのユーザーに与えられた権限の範囲で AWS のデータ編集を許可します。

お気づきになられたと思いますが、権限の範囲の設定の説明が抜けていますね。
「ID プールを設定するには」 1~10 のステップを踏んでいる途中で確認できる画面に次のものがあります。

f:id:simplestar_tech:20190601155252j:plain
IDプールの編集で権限範囲のロールとの接続を行っている様子

公式ドキュメント作成者にお願いしたいこととして、どこの設定で何と接続されているかを具体的に強調してほしい。。。
つまりは、ここで設定された Cognito_*_Auth_Role (*印は ID プール名)という名前の権限設定が認証されたユーザーに与えられることになります!

続いて IAM ("あいあむ"と読む)のサービスにて Cognito_*_Auth_Role の内容を確認して、S3 の Get, Put の操作を許可する権限を付与しましょう。
現在のままではサインインした後、AmazonS3Client 操作を行っても Status 400 Bad Request が返って来てしまいますので。。

IAM で認証されたユーザーに最低限必要な範囲の権限を付与する

認証されたユーザーのためのロールに権限を付与しなければならないのはアプリを便利に使うために必要なことですが、ここでもし、フルアクセス権限を与えると
悪意あるユーザーがクライアントを改ざんした場合に、すべての情報が抜き取られ、データは破壊され、場合により利用料金が跳ね上がりかねないので、特に権限付与の行為は注意が必要です。

権限設定の具体的な手順を説明します。
AWS Console にログインして、IAM の先程確認した名前のロールを選択して「インラインポリシーの追加」ボタンを押します。
ビジュアルエディタのボタンを押すと
どのサービスの?と聞かれるところがあるので S3 となるように選択します。
S3 のどんな操作を許可するの?と聞かれるので、まずは PutObject だけを選択します。

まだ危険な設定です。書き込める範囲を制限するためリソースの指定の欄にて次の ARN の設定を追加します。

f:id:simplestar_tech:20190601161155j:plain
ユーザーIDのディレクトリ以下しか操作させない設定

解説すると、${cognito-identity.amazonaws.com:sub} に当たる文字列は上記サンプルコードで出てくる credentials.GetIdentityId() で得た値と一致するので、要するに認証したユーザーに紐づくディレクトリ以下だけしかファイルをアップロードできないように制限しています。
ここの設定で参考になる公式ドキュメントはこちら
docs.aws.amazon.com

ほかにも自宅IPアドレスからの操作のみ受け付けるように制限も加えます。(これで試験中はかなり安心してテストできる)

最後に追加するインラインポリシーの名前を "S3PutObjectPlayerVRM" などにセットして保存します。
同様にして S3GetObjectPlayerVRM といった名前で S3 の GetObject で特定のバケットの特定のディレクトリ以下のみからしかデータを受け取れないようにする制約を与えます。

ここまで制限すれば、もしすべてのアプリ固有IDがゲームコードから割れても
編集できるのはユーザーのディレクトリの下だけとなり
読み取れるのは特定のバケットディレクトリの下だけとなります。

あとは暗号化されたファイルが S3 に配置され、一定時間が経つと S3 上からファイルが消えるように設定しておけば
問題が起きた時、被害の範囲が予想でき、その後の対応も行いやすいのではないかと考えています。

Amazon S3バケット作成と暗号化設定は、新規作成の設定画面で説明不要なくらいわかりやすい操作なので、ここまで Cognito などの使い勝手がわかってきていれば
迷うことなくバケットを作れると思います。

ここまでのまとめ

Unity から S3 クライアントを使ってファイルのアップロード、ダウンロードを行う動作確認済みのサンプルコードを示し
Unity から Amazon Cognito によるサインイン操作を提供する動作確認済みサンプルコードを示し
サインインしたユーザーに、必要な権限を付与するための手順と、一般的な影響範囲の制限の例を示しました。

まだ説明してほしいところが以下の2つあります。

  • ユーザーアカウントの作成方法
  • 前回までのチャットサンプルへの VRM データやり取りの機能追加

順に説明していきます。

Unity でユーザーアカウントの作成機構を作る

サインインするための Email と Password を入力して、初回はサインアップボタンを押してもらうことにし、検証用コードがメールアドレスに届き
この検証コードを一定時間以内に回答するとアカウントの作成が行える Unity UI を作ります。

具体的には次のロジックを記述する
AmazonCognitoSignUpGUI.cs

using System;
using System.Collections.Generic;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;
using TMPro;
using UnityEngine.UI;
using UnityEngine.Events;

public class AmazonCognitoSignUpGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldPassword;
    [SerializeField] Button buttonSignup;
    [SerializeField] TMP_Text textStatus;
    #endregion

    internal UnityAction onSingUpSuccess;

    void Start()
    {
        this.buttonSignup.onClick.AddListener(this.OnSignup);
    }

    void OnSignup()
    {
        var client = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var signupRequest = new SignUpRequest
        {
            ClientId = AmazonCognitoIDs.UserPoolAppClientId,
            Username = this.inputFieldEmail.text,
            Password = this.inputFieldPassword.text,
            UserAttributes = new List<AttributeType> {
                new AttributeType {
                    Name = "email",
                    Value = this.inputFieldEmail.text
                }
            }
        };

        try
        {
            var result = client.SignUp(signupRequest);
            this.textStatus.text = $"We have sent a code by email to {result.CodeDeliveryDetails.Destination}.\r\nEnter it above to confirm your account.";
            this.onSingUpSuccess?.Invoke();
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

UIつなぎ込みは以下の通り

f:id:simplestar_tech:20190601142639p:plain
Cognito Sign Up の UI 接続

ここまでの実装で有効な mail アドレスと password を記入して SignUp ボタンを押してもらうと、指定した mail アドレスに検証用コードが記入された確認メールが届きます(たぶんスパムフォルダ)

その検証用コードを記入してアカウントを作成するロジックが次の通り
AmazonCognitoConfirmGUI.cs

using System;
using UnityEngine;
using Amazon.CognitoIdentityProvider;
using Amazon.CognitoIdentityProvider.Model;
using UnityEngine.UI;
using TMPro;
using UnityEngine.Events;
using CodeStage.AntiCheat.ObscuredTypes;

public class AmazonCognitoConfirmGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] InputField inputFieldEmail;
    [SerializeField] InputField inputFieldVerificationCode;
    [SerializeField] Button buttonConfirmAccount;
    [SerializeField] TMP_Text textStatus;
    #endregion

    #region Scene Components
    [SerializeField] AmazonCognitoSignUpGUI cognitoSingUp;
    #endregion

    internal UnityAction onSingUpConformed;

    void Start()
    {
        this.buttonConfirmAccount.onClick.AddListener(this.OnConfirmAccount);
        this.cognitoSingUp.onSingUpSuccess += this.OnSignUp;
    }

    void OnSignUp()
    {
        this.inputFieldVerificationCode.gameObject.SetActive(true);
        this.buttonConfirmAccount.gameObject.SetActive(true);
    }

    public void OnConfirmAccount()
    {
        var client = new AmazonCognitoIdentityProviderClient(null, AmazonCognitoIDs.CognitoPoolRegion);
        var confirmSignUpRequest = new ConfirmSignUpRequest();

        confirmSignUpRequest.Username = this.inputFieldEmail.text;
        confirmSignUpRequest.ConfirmationCode = this.inputFieldVerificationCode.text;
        confirmSignUpRequest.ClientId = AmazonCognitoIDs.UserPoolAppClientId;

        try
        {
            var confirmSignUpResult = client.ConfirmSignUp(confirmSignUpRequest);
            this.textStatus.text = $"SignUp Confirmed {confirmSignUpResult.HttpStatusCode}.\r\nRecord Your Password and Press SignIn Button.";
            ObscuredPrefs.SetString(AmazonCognitoPlayerPrefs.EmailAddress, this.inputFieldEmail.text);

            this.inputFieldVerificationCode.gameObject.SetActive(false);
            this.buttonConfirmAccount.gameObject.SetActive(false);
            onSingUpConformed?.Invoke();
        }
        catch (Exception ex)
        {
            this.textStatus.text = ex.Message;
        }
    }
}

UI接続は次の通り

f:id:simplestar_tech:20190601165411p:plain
Confirm の UI 接続の様子

サインアップ成功のイベントでコード入力欄とコード確認ボタンが現れ、これを押すとサインインボタン押してという作業を促します。

そこから先は、記事前半で説明してきたので、気になる方は読み返してみてください。

前回までのチャットサンプルへの VRM データやり取りの機能追加

ここまでがユーザー認証とオンラインストレージ(S3)とのファイルデータのやりとりの汎用的な機能紹介でした。
ここから本題の VRM データのやりとりをオンラインチャットアプリに組み込みます。

前回の記事でコメントをこのように残していたと思います。

    string myVRMObjectKey = "dummy_AWS_S3_URL"; // 本来は null で初期化しておく

ということで、こう

    string myVRMObjectKey = null;

前回の記事でコメントをこのように残していたと思います。2

            // 本当は S3 にファイルをアップロードしてダウンロード用 URL を送信たい…
            //AWS_S3.Instance.FileUpload(playerVRMPath, objectKey =>
            //{
            //    this.myVRMObjectKey = objectKey;
            //    this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, VRMContentKey);
            //});
            await this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);

ということで、こう

            // S3 に VRM ファイルをアップロードしてダウンロード用 URL を送信
            var playerVRMPath = ObscuredPrefs.GetString(VRMPlayerPrefs.PlayerVRMPath);
            await this.amazonS3Client.PutObjectFileAsync(playerVRMPath, async objectKey =>
            {
                this.myVRMObjectKey = objectKey;
                await this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
            });

前回の記事でコメントをこのように残していたと思います。3

                // var vrmData = AWS_S3.Instance.GetObjectData(response.URL); 本当は S3 などから URL でデータを落して来たい…
                // プレイヤー VRM データを読み込んでインスタンス化し、ユーザーユニークIDで辞書に追加
                var playerVRMPath = ObscuredPrefs.GetString(VRMPlayerPrefs.PlayerVRMPath);
                var vrmData = File.ReadAllBytes(playerVRMPath);
                var vrmRoot = this.vrmLoader.LoadVRMinMemory(vrmData, false);

ということで、こう

                // S3 から URL でデータを落して VRM キャラクターを生成
                await this.amazonS3Client.GetObjectDataAsync(response.URL, vrmData => {
                    var vrmRoot = this.vrmLoader.LoadVRMinMemory(vrmData, false);
                    var player = new VRMPlayerCache
                    {
                        playerName = response.PlayerName,
                        vrmRoot = vrmRoot,
                        animator = vrmRoot.GetComponent<Animator>(),
                        interpolation = vrmRoot.AddComponent<TransformInterpolation>()
                    };

                    // 再チェックして辞書に追加
                    if (!this.userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
                    {
                        this.userUniqueIdToVRMPlayer.Add(response.UserUniqueId, player);
                        this.onJoin?.Invoke(response.UserUniqueId, player);
                        this.streamingClient.SendEventAsync(EventId.VRMLoaded);
                    }
                    else
                    {
                        Destroy(vrmRoot);
                    }
                });

動作確認したところ、このように、お互いの VRM キャラデータを交換してオンラインチャットできました。
www.youtube.com

次はテキスト内容によってリップ・シンクする部分を見ていきます。

Unity:オンラインVRMチャットサンプルの作り方3

リアルタイム通信でオンラインチャット

f:id:simplestar_tech:20190526232641p:plain
最後まで読むとできるようになる絵

こちらの記事の続きです。
simplestar-tech.hatenablog.com

Unity 同士でリアルタイム通信するためのアセットはいくつかある様子ですが、全部試してられません。
C# を書く感覚でリアルタイム通信コード書けないかな?と思ってたところ、次の技術ブログ記事がタイムリーに刺さりました。
tech.cygames.co.jp

次の GitHub リポジトリで公開されていますね。

github.com

MagicOnion は Unity に導入する方法が難しいためサンプルが用意されています。
推奨される方法かはわかりませんが git clone して
MagicOnion\samples\ChatApp\ChatApp.Unity\Assets\Scripts
にあるフォルダのうち
MagicOnionとMessagePackをプロジェクトにコピーします。
また必要となる以下のパッケージ類も移動します。
MagicOnion\samples\ChatApp\ChatApp.Unity\Assets\Plugins\dll
(楽しすぎかな?)

コレを使えるようにするため、以下の通り .NET 4.x となるようにプロジェクト設定を更新しておきます。

f:id:simplestar_tech:20190526165519p:plain
Project Settings > Configuration > Api Compatibility Level

ServerShared を設計

サンプルを参考に以下の Shared クラスを定義してみました。

Requests.cs

using MessagePack;

namespace CubeWalk.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct JoinRequest
    {
        [Key(0)] public string RoomName { get; set; }
        [Key(1)] public string PlayerName { get; set; }
    }

    [MessagePackObject]
    public struct AnimFloatParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public float Value { get; set; }
    }

    [MessagePackObject]
    public struct AnimIntegerParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public int Value { get; set; }
    }

    [MessagePackObject]
    public struct AnimBoolParamRequest
    {
        [Key(0)] public int NameHash { get; set; }
        [Key(1)] public bool Value { get; set; }
    }
}

Responses.cs

using CubeWalk.Shared.Hubs;
using MessagePack;

namespace CubeWalk.Shared.MessagePackObjects
{
    [MessagePackObject]
    public struct MessageResponse
    {
        [Key(0)] public string PlayerName { get; set; }
        [Key(1)] public int UserUniqueId { get; set; }
        [Key(2)] public string Message { get; set; }
    }

    [MessagePackObject]
    public struct EventResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public EventId EventId { get; set; }
    }

    [MessagePackObject]
    public struct Vector3Response
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public float X { get; set; }
        [Key(2)] public float Y { get; set; }
        [Key(3)] public float Z { get; set; }
    }

    [MessagePackObject]
    public struct QuaternionResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public float X { get; set; }
        [Key(2)] public float Y { get; set; }
        [Key(3)] public float Z { get; set; }
        [Key(4)] public float W { get; set; }
    }

    [MessagePackObject]
    public struct ContentUrlResponse
    {
        [Key(0)] public int UserUniqueId { get; set; }
        [Key(1)] public string URL { get; set; }
        [Key(2)] public string ContentType { get; set; }
        [Key(3)] public string PlayerName { get;  set; }
    }
}

ICubeWalkHub.cs

using CubeWalk.Shared.MessagePackObjects;
using MagicOnion;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CubeWalk.Shared.Hubs
{
    /// <summary>
    /// Client -> Server API (Streaming)
    /// </summary>
    public interface ICubeWalkHub : IStreamingHub<ICubeWalkHub, ICubeWalkHubReceiver>
    {
        Task<int> JoinAsync(JoinRequest request);
        Task LeaveAsync();
        Task SendMessageAsync(string message);
        Task SendEventAsync(EventId eventId);
        Task SendContentUrlAsync(string url, string type);
        Task SendPositionAsync(float x, float y, float z);
        Task SendRotationAsync(float x, float y, float z, float w);
        Task SendAnimFloatAsync(List<AnimFloatParamRequest> animParams);
        Task SendAnimIntegerAsync(List<AnimIntegerParamRequest> animParams);
        Task SendAnimBoolAsync(List<AnimBoolParamRequest> animParams);
        Task SendAnimTriggerAsync(List<AnimBoolParamRequest> animParams);
        Task SendAnimStateAsync(int shortNameHash);
    }

    public enum EventId
    {
        Default = 0,
        VRMLoaded,

        Max
    }
}

ICubeWalkHubReceiver.cs

using CubeWalk.Shared.MessagePackObjects;
using System.Collections.Generic;

namespace CubeWalk.Shared.Hubs
{
    /// <summary>
    /// Server -> Client API
    /// </summary>
    public interface ICubeWalkHubReceiver
    {
        void OnJoin(int userUniqueId, string playerName);
        void OnLeave(int userUniqueId, string playerName);
        void OnSendMessage(MessageResponse response);
        void OnSendEvent(EventResponse response);
        void OnSendContentUrl(ContentUrlResponse response);
        void OnSendPosition(Vector3Response response);
        void OnSendRotation(QuaternionResponse response);
        void OnSendAnimFloat(int userUniqueId, List<AnimFloatParamRequest> animParams);
        void OnSendAnimInteger(int userUniqueId, List<AnimIntegerParamRequest> animParams);
        void OnSendAnimBool(int userUniqueId, List<AnimBoolParamRequest> animParams);
        void OnSendAnimTrigger(int userUniqueId, List<AnimBoolParamRequest> animParams);
        void OnSendAnimState(int userUniqueId, int shortNameHash);
    }
}

サーバープロジェクトの作成

Shared インタフェースが決まったら次はサーバープロジェクトの作成と実装です。

ここまでの Unity プロジェクトフォルダを CubeWalkC1 としましょう。
それと同列の階層に CubeWalkC1Server フォルダを作成し、その中に CubeWalkC1Server.csproj テキストファイルを作成します。
ServerShared フォルダパスはみなさんのお好みでどうぞ

内容は以下の通り
CubeWalkC1Server.csproj

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
    <LangVersion>7.3</LangVersion>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="..\CubeWalkC1\Assets\Project\Scripts\MagicOnion\ServerShared\**\*.cs" LinkBase="LinkFromUnity" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="MagicOnion.Hosting" Version="2.1.0" />
  </ItemGroup>

  <ItemGroup>
    <Folder Include="LinkFromUnity\" />
  </ItemGroup>

</Project>

.csproj と同じ階層のフォルダに次のエントリポイントファイルを作成します。
サンプルそのままですが localhost → 0.0.0.0 と書き換えています。

Program.cs

using Grpc.Core;
using MagicOnion.Hosting;
using MagicOnion.Server;
using Microsoft.Extensions.Hosting;
using System.Threading.Tasks;

namespace ChatApp.Server
{
    class Program
    {
        static async Task Main(string[] args)
        {
            GrpcEnvironment.SetLogger(new Grpc.Core.Logging.ConsoleLogger());

            await MagicOnionHost.CreateDefaultBuilder()
                .UseMagicOnion(
                    new MagicOnionOptions(isReturnExceptionStackTraceInErrorDetail: true),
                    new ServerPort("0.0.0.0", 12345, ServerCredentials.Insecure))
                .RunConsoleAsync();
        }
    }
}

サーバー側のインタフェース実装は次の通り
こちらも .csproj と同じ階層のフォルダに配置します。

CubeWalkHub.cs

using CubeWalk.Shared.Hubs;
using CubeWalk.Shared.MessagePackObjects;
using MagicOnion.Server.Hubs;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

namespace CubeWalk.Server
{
    /// <summary>
    /// 接続ごとにクラスインスタンスが作成されます
    /// </summary>
    public class CubeWalkHub : StreamingHubBase<ICubeWalkHub, ICubeWalkHubReceiver>, ICubeWalkHub
    {
        public async Task<int> JoinAsync(JoinRequest request)
        {
            this.room = await this.Group.AddAsync(request.RoomName);
            // ユニークなユーザーIDを作成
            this.userUniqueId = Convert.ToInt32(Guid.NewGuid().ToString("N").Substring(0, 8), 16);
            this.playerName = request.PlayerName;
            this.BroadcastExceptSelf(this.room).OnJoin(this.userUniqueId, this.playerName);
            await Task.CompletedTask;
            return this.userUniqueId;
        }

        public async Task LeaveAsync()
        {
            if (null != this.room)
            {
                await this.room.RemoveAsync(this.Context);
                this.BroadcastExceptSelf(this.room).OnLeave(this.userUniqueId, this.playerName);
                await Task.CompletedTask;
            }
        }

        public async Task SendMessageAsync(string message)
        {
            if (null != this.room)
            {
                this.Broadcast(this.room).OnSendMessage(new MessageResponse
                {
                    PlayerName = this.playerName,
                    UserUniqueId = this.userUniqueId,
                    Message = message
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendEventAsync(EventId eventId)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendEvent(new EventResponse
                {
                    UserUniqueId = this.userUniqueId,
                    EventId = eventId
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendContentUrlAsync(string url, string type)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendContentUrl(new ContentUrlResponse
                {
                    UserUniqueId = this.userUniqueId,
                    URL = url,
                    ContentType = type
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendPositionAsync(float x, float y, float z)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendPosition(new Vector3Response
                {
                    UserUniqueId = this.userUniqueId,
                    X = x, Y = y, Z = z
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendRotationAsync(float x, float y, float z, float w)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendRotation(new QuaternionResponse
                {
                    UserUniqueId = this.userUniqueId,
                    X = x, Y = y, Z = z, W = w
                });
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimFloatAsync(List<AnimFloatParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimFloat(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimIntegerAsync(List<AnimIntegerParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimInteger(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimBoolAsync(List<AnimBoolParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimBool(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimTriggerAsync(List<AnimBoolParamRequest> animParams)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimTrigger(this.userUniqueId, animParams);
                await Task.CompletedTask;
            }
        }

        public async Task SendAnimStateAsync(int shortNameHash)
        {
            if (null != this.room)
            {
                this.BroadcastExceptSelf(this.room).OnSendAnimState(this.userUniqueId, shortNameHash);
                await Task.CompletedTask;
            }
        }

        IGroup room = null;
        string playerName;
        int userUniqueId;
    }
}

.csproj を Visual Studio で開いて実行すれば確かに Server として機能してくれる筈です。(動作確認済み)

f:id:simplestar_tech:20190526173523p:plain
サーバープログラム実行時の様子

Unity クライアント用のコード生成

Magic Onion の次のサンプルフォルダにコード生成ツールが配置されています。(もう何でもサンプルに頼る形ですね…)
MagicOnion\samples\ChatApp\GeneratorTools

コマンドラインツールの一般的なコード生成コマンドは以下の通り(動作確認済み)

GeneratorTools/MagicOnionCodeGenerator/win-x64/moc.exe -i "CubeWalkC1Server\CubeWalkC1Server.csproj" -o "CubeWalkC1\Assets\Project\Scripts\MagicOnion\Generated\MagicOnion.Generated.cs"
GeneratorTools/MessagePackUniversalCodeGenerator/win-x64/mpc.exe -i "CubeWalkC1Server\CubeWalkC1Server.csproj" -o "CubeWalkC1\Assets\Project\Scripts\MagicOnion\Generated\MessagePack.Generated.cs"

これで Unity クライアント側にサーバーと通信するための下準備が整いました。

Unity クライアントコードを書く

先程作った Hub インタフェースを以下の通り実装します。

MagicOnionClientGUI.cs

using CubeWalk.Shared.Hubs;
using CubeWalk.Shared.MessagePackObjects;
using Grpc.Core;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using MagicOnion.Client;
using MessagePack.Resolvers;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

public class MagicOnionClientGUI : MonoBehaviour, ICubeWalkHubReceiver
{
    #region UI Connection
    [SerializeField] GameObject panelJoinRoom;
    [SerializeField] Button buttonJointRoom;
    [SerializeField] InputField inputFieldPlayerName;
    [SerializeField] TMP_Text textStatus;
    #endregion

    #region Scene Components
    [SerializeField] TextChatGUI textChatGUI;
    [SerializeField] VRMLoader vrmLoader;
    [SerializeField] Transform triggerActions;
    #endregion

    internal UnityAction<int /*userUniqueId*/, VRMPlayerCache /*player*/> onJoin;
    internal UnityAction<int /*userUniqueId*/, VRMPlayerCache /*player*/> onLeave;
    internal UnityAction<int /*userUniqueId*/, string /*playerNaame*/, string /*chatText*/> onReceiveMessage;

    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
    static void RegisterResolvers()
    {
        CompositeResolver.RegisterAndSetAsDefault
        (
            MagicOnion.Resolvers.MagicOnionResolver.Instance,
            GeneratedResolver.Instance,
            BuiltinResolver.Instance,
            PrimitiveObjectResolver.Instance
        );
    }

    void Start()
    {
        this.textChatGUI.onEndEditMessage += this.SendChatText;
        this.buttonJointRoom.onClick.AddListener(this.OnButtonJoinRoom);
        this.InitializeMagicOnion();
    }

    bool WantsToQuit()
    {
        // アプリ終了前処理
        CleanUpMagicOnion();
        // アプリを終了しないフラグを返しておく
        return false;
    }

    void InitializeMagicOnion()
    {
        // サーバー情報を指定して接続
        this.channel = new Channel("localhost", 12345, ChannelCredentials.Insecure);
        this.streamingClient = StreamingHubClient.Connect<ICubeWalkHub, ICubeWalkHubReceiver>(this.channel, this);
        Application.wantsToQuit += WantsToQuit;
    }

    async void CleanUpMagicOnion()
    {
        // Join しているルームから Leave
        this.Leave();
        // MagicOnion のお片付け
        await this.streamingClient.DisposeAsync();
        await this.channel.ShutdownAsync();
        // アプリ終了
        Application.wantsToQuit -= WantsToQuit;
        Application.Quit();
    }

    public void OnTriggerEventJoin(GameObject vrmRoot)
    {
        // プレイヤー名を覚えていれば設定
        var playerName = PlayerPrefs.GetString(MagicOnionClientGUI.PlayerName);
        if (null != playerName)
        {
            this.inputFieldPlayerName.text = playerName;
        }

        // パネルを表示
        this.panelJoinRoom.SetActive(true);

        // テキスト入力欄にキャレットを配置
        this.inputFieldPlayerName.ActivateInputField();

        // プレイヤー入力のロック
        this.LockPlayerInput(vrmRoot, lockInput: true);
        // プレイヤーを記憶
        this.myVrmRoot = vrmRoot;
    }

    void LockPlayerInput(GameObject vrmRoot, bool lockInput)
    {
        var vInput = vrmRoot.GetComponentInChildren<vThirdPersonInput>();
        if (null != vInput)
        {
            // プレイヤー入力のロック・ロック解除
            vInput.lockInput = lockInput;
            foreach (var vTriggerActions in this.triggerActions.GetComponentsInChildren<vTriggerGenericAction>())
            {
                vTriggerActions.actionInput.useInput = !vInput.lockInput;
            }
            // カーソル再表示とカーソルロック解除
            vInput.ShowCursor(vInput.lockInput);
            vInput.LockCursor(vInput.lockInput);
        }
    }

    #region Client -> Server (Streaming)

    async void OnButtonJoinRoom()
    {
        if (this.isJoin)
            return;

        if (0 == this.inputFieldPlayerName.text.Length)
        {
            this.textStatus.text = "Input Player Name.";
            return;
        }

        try
        {
            // Room への Join リクエストを作成して Join
            var request = new JoinRequest { PlayerName = this.inputFieldPlayerName.text, RoomName = "RoomA" };
            this.myUserUniqueId = await this.streamingClient.JoinAsync(request);
            this.isJoin = true;
            PlayerPrefs.SetString(MagicOnionClientGUI.PlayerName, request.PlayerName);
            this.panelJoinRoom.SetActive(false);
            this.LockPlayerInput(this.myVrmRoot, lockInput: false);
        }
        catch (Exception ex)
        {
            textStatus.text = ex.Message;
        }

        if (this.isJoin)
        {
            // プレイヤー情報を作成
            var player = new VRMPlayerCache
            {
                playerName = this.inputFieldPlayerName.text,
                vrmRoot = this.myVrmRoot,
                animator = this.myVrmRoot.GetComponent<Animator>(),
                interpolation = null
            };
            // 辞書に追加
            if (!this.userUniqueIdToVRMPlayer.ContainsKey(this.myUserUniqueId))
            {
                this.userUniqueIdToVRMPlayer.Add(this.myUserUniqueId, player);
                this.onJoin?.Invoke(this.myUserUniqueId, player);
            }

            // Join を希望した VRM にアニメーション同期させるためのClientを渡す
            var animSync = this.myVrmRoot.GetComponent<AnimationSync>();
            animSync.streamingClient = this.streamingClient;

            // 本当は S3 にファイルをアップロードしてダウンロード用 URL を送信たい…
            //AWS_S3.Instance.FileUpload(playerVRMPath, objectKey =>
            //{
            //    this.myVRMObjectKey = objectKey;
            //    this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, VRMContentKey);
            //});
            await this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
        }
    }

    async void Leave()
    {
        if (this.isJoin)
        {
            await this.streamingClient.LeaveAsync();
            this.isJoin = false;
        }
    }

    void SendChatText(string chatText)
    {
        if (!this.isJoin)
            return;
        this.streamingClient.SendMessageAsync(chatText);
    }

    #endregion


    #region Server -> Client (Streaming)

    public void OnJoin(int userUniqueId, string playerName)
    {
        // 後から参加してきたユーザーにモデルデータのキーを送信
        StartCoroutine(CoSendMyVRMObjectKey());
    }

    IEnumerator CoSendMyVRMObjectKey()
    {
        // 自身の VRM のキーが未定の場合は待機してから、有効なキーを参加してきたプレイヤーへ送信
        while (null == this.myVRMObjectKey)
        {
            yield return new WaitForSeconds(1.0f);
        }
        this.streamingClient.SendContentUrlAsync(this.myVRMObjectKey, MagicOnionClientGUI.VRMContentKey);
    }

    public void OnLeave(int userUniqueId, string playerName)
    {
        // Leave するユーザーの VRM インスタンスを削除して、辞書からも削除
        if (this.userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = this.userUniqueIdToVRMPlayer[userUniqueId];
            this.userUniqueIdToVRMPlayer.Remove(userUniqueId);
            this.onLeave?.Invoke(userUniqueId, player);
            Destroy(player.vrmRoot);
        }
    }

    public void OnSendMessage(MessageResponse response)
    {
        this.onReceiveMessage?.Invoke(response.UserUniqueId, response.PlayerName, response.Message);
    }

    public void OnSendEvent(EventResponse response)
    {
        switch (response.EventId)
        {
            // 外からVRLロード完了を受け取ったら、現在のプレイヤー VRM の位置を渡して初期配置してもらう
            case EventId.VRMLoaded:
                {
                    var position = this.myVrmRoot.transform.position;
                    this.streamingClient.SendPositionAsync(position.x, position.y, position.z);
                    var rotation = this.myVrmRoot.transform.rotation;
                    this.streamingClient.SendRotationAsync(rotation.x, rotation.y, rotation.z, rotation.w);
                }
                break;
            default:
                break;
        }
    }

    public void OnSendContentUrl(ContentUrlResponse response)
    {
        if (0 == string.Compare(MagicOnionClientGUI.VRMContentKey, response.ContentType))
        {
            if (!this.userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
            {
                // var vrmData = AWS_S3.Instance.GetObjectData(response.URL); 本当は S3 などから URL でデータを落して来たい…
                // プレイヤー VRM データを読み込んでインスタンス化し、ユーザーユニークIDで辞書に追加
                var playerVRMPath = PlayerPrefs.GetString(MagicOnionClientGUI.PlayerVRMPath);
                var vrmData = File.ReadAllBytes(playerVRMPath);
                var vrmRoot = this.vrmLoader.LoadVRMinMemory(vrmData, false);
                var player = new VRMPlayerCache
                {
                    playerName = response.PlayerName,
                    vrmRoot = vrmRoot,
                    animator = vrmRoot.GetComponent<Animator>(),
                    interpolation = vrmRoot.AddComponent<TransformInterpolation>()
                };

                // 再チェックして辞書に追加
                if (!this.userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
                {
                    this.userUniqueIdToVRMPlayer.Add(response.UserUniqueId, player);
                    this.onJoin?.Invoke(response.UserUniqueId, player);
                    this.streamingClient.SendEventAsync(EventId.VRMLoaded);
                }
                else
                {
                    Destroy(vrmRoot);
                }
            }
        }
    }

    public void OnSendPosition(Vector3Response response)
    {
        // 指定されたVRMの位置を補正
        if (userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[response.UserUniqueId];
            if (null != player.interpolation)
            {
                player.interpolation.position = new Vector3(response.X, response.Y, response.Z);
            }
        }
    }

    public void OnSendRotation(QuaternionResponse response)
    {
        // 指定されたVRMの回転を補正
        if (userUniqueIdToVRMPlayer.ContainsKey(response.UserUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[response.UserUniqueId];
            if (null != player.interpolation)
            {
                player.interpolation.rotation = new Quaternion(response.X, response.Y, response.Z, response.W);
            }
        }
    }

    public void OnSendAnimFloat(int userUniqueId, List<AnimFloatParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetFloat(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimInteger(int userUniqueId, List<AnimIntegerParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetInteger(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimBool(int userUniqueId, List<AnimBoolParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetBool(param.NameHash, param.Value);
                }
            }
        }
    }

    public void OnSendAnimTrigger(int userUniqueId, List<AnimBoolParamRequest> animParams)
    {
        // 指定されたVRMのアニメーションパラメータを更新
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                foreach (var param in animParams)
                {
                    player.animator.SetTrigger(param.NameHash);
                }
            }
        }
    }

    public void OnSendAnimState(int userUniqueId, int shortNameHash)
    {
        // 指定されたVRMのアニメーションステートの遷移
        if (userUniqueIdToVRMPlayer.ContainsKey(userUniqueId))
        {
            var player = userUniqueIdToVRMPlayer[userUniqueId];
            if (null != player.animator)
            {
                player.animator.CrossFadeInFixedTime(shortNameHash, 0.1f);
            }
        }
    }

    #endregion

    #region MagicOnion
    Channel channel;
    ICubeWalkHub streamingClient;
    #endregion

    Dictionary<int, VRMPlayerCache> userUniqueIdToVRMPlayer = new Dictionary<int, VRMPlayerCache>();
    bool isJoin = false;
    GameObject myVrmRoot = null;
    string myVRMObjectKey = "dummy_AWS_S3_URL"; // 本来は null で初期化しておく
    int myUserUniqueId = 0;

    const string PlayerName = "PlayerName";
    const string PlayerVRMPath = "PlayerVRMPath";
    const string VRMContentKey = "application/vrm";
}

UI はこんな感じにしてます。(VRM選択画面とほとんど同じ要素タイプ)

f:id:simplestar_tech:20190526231800p:plain
JoinRoomPanel

これらの UI との接続は次の通り

f:id:simplestar_tech:20190526232132p:plain
UI との接続

前回の記事の実装とイベントで接続するように書き換えることで、以下のツィートのような結果になりました。
正しく、VRM 同士で通信してチャットできていますね。


オンライン化

ちょっとUnityユーザーを振り落としてしまうかもしれないけど、気にせず読んでほしい。

サーバーは .NET Core ランタイムで動きますので、これをコンテナとして使用できるように準備します。
オンライン化のため Amazon ECR というコンテナイメージを簡単に保存できるサービスを利用し
その保存したコンテナをサーバー管理せずに使用できる AWS Fargate を利用します。
公開 IP アドレスに自宅の PC から接続してオンラインでチャットができるまでの具体的な手順は Qiita 記事として一月ほど前に確認しておきました。
qiita.com

これでローカル通信テストをそのままオンライン化できます。

Tips

Project Settings で Visible In Background にチェック入れると、Build したゲーム非アクティブにしていても、通信内容を正しく受信できることがわかりました。

続きはこちら
simplestar-tech.hatenablog.com

Unity:オンラインVRMチャットサンプルの作り方2

チャット入力画面とチャットテキストの表示

f:id:simplestar_tech:20190526084537p:plain
最後まで読むとできるようになる絵

プラス、バルーンはこんな動きをする予定

こちらの記事の続きです。
simplestar-tech.hatenablog.com
今回はチャット入力画面を作ってみましょう

UI を新たに作るので Canvas 以下を次のように配置

f:id:simplestar_tech:20190526082741p:plain
チャットテキスト入力欄

UI はこんな感じの見た目にします

f:id:simplestar_tech:20190526082845p:plain
チャットテキスト入力欄の見た目

この UI と接続するロジックを以下の通り記述します。
今回はコードの目的ごとにコメントを記入したよ。気になる処理は参考にどうぞ

using System.Collections.Generic;
using Invector.vCharacterController;
using Invector.vCharacterController.vActions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

/// <summary>
/// ゲーム内に InputField を出現させ、テキスト入力後に吹き出しGUIに配置
/// </summary>
public class TextChatGUI : MonoBehaviour
{
    #region UI Connection
    [SerializeField] GameObject panelTextChat;
    [SerializeField] InputField inputFieldTextChat;
    [SerializeField] Transform panelTextChatBalloons;
    #endregion

    #region Scene Components
    [SerializeField] VRMLoader vrmLoader;
    [SerializeField] Transform triggerActions;
    [SerializeField] Transform vrmCharacters;
    #endregion

    #region Assets
    [SerializeField] GameObject chatBalloonPrefab;
    #endregion

    UnityAction<string /*userUniqueId*/, string /*chatText*/> onEndEditMessage;

    void Start()
    {
        this.vrmLoader.onLoadVRM += this.OnLoadVRM;
        this.inputFieldTextChat.onEndEdit.AddListener(this.OnEndEditChatText);
        this.onEndEditMessage += this.OnSendMessage; // @debug 横着、オンライン通知イベントに OnSendMessage を接続予定
    }

    void Update()
    {
        // panel が非表示のときにキーボードの T を押すと panel が出現
        if (Input.GetKeyUp(KeyCode.T) && !this.panelTextChat.activeSelf)
        {
            var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
            if (null != vInput)
            {
                SwitchTextChatMode(vInput, showInputFieldTextChat : true);
            }
        }

        // 重なりが自然になるよう z 座標の小さい順にレンダリングするための処理
        if (this.panelTextChatBalloons.gameObject.activeSelf)
        {
            // 吹き出し UI の z 座標の大きい順に並べ替え
            var chatBalloons = new List<Transform>();
            foreach (var chatBalloon in this.panelTextChatBalloons)
            {
                chatBalloons.Add(chatBalloon as Transform);
            }
            chatBalloons.Sort((a, b) => b.localPosition.z > a.localPosition.z ? 1 : -1);
            // 並べ替えた順序でヒエラルキーの順序を変更
            int sibIndex = 0;
            foreach (var chatBallon in chatBalloons)
            {
                chatBallon.SetSiblingIndex(sibIndex++);
            }
        }
    }

    void OnLoadVRM(GameObject vrmRoot)
    {
        if (null == vrmRoot)
        {
            return;
        }

        // VRM インスタンスを指定 Transform 配下へ移動
        vrmRoot.transform.SetParent(this.vrmCharacters);
        // Balloon の 3D 位置用の空オブジェクトを作成し VRM インスタンス配下へ移動
        var textChatUIAnchor = new GameObject("TextChatUIAnchor", new System.Type[] { typeof(ChatBalloonLocater)});
        textChatUIAnchor.transform.SetParent(vrmRoot.transform);

        // メッシュデータから身長(m)を計測
        var heightMeasure = new MeshHeightMeasure();
        var vrmHeight = heightMeasure.GetMeshHeight(vrmRoot);
        // 身長 + オフセット上に来るように配置
        var heightOffset = 0.1f;
        textChatUIAnchor.transform.localPosition = new Vector3(0, vrmHeight + heightOffset, 0);
        // BalloonLocater が制御すべき Balloon を作成 + Locater に設定
        var chatBalloonLocater = textChatUIAnchor.GetComponent<ChatBalloonLocater>();
        var chatBalloon = Instantiate(this.chatBalloonPrefab, this.panelTextChatBalloons).GetComponent<RectTransform>();
        chatBalloonLocater.balloonRectTransform = chatBalloon.GetComponent<RectTransform>();

        // プレイヤー用の Balloon 名を 0 に設定
        chatBalloon.name = "0";
        this.playerTextChatBalloonName = chatBalloon.name;
        // 空文字列を初期テキストに設定してクリア
        UpdateBalloonText("", chatBalloon);
    }

    void OnEndEditChatText(string chatText)
    {
        var vInput = vrmCharacters.GetComponentInChildren<vThirdPersonInput>();
        if (null != vInput)
        {
            SwitchTextChatMode(vInput, showInputFieldTextChat: false);
        }
        this.onEndEditMessage?.Invoke(this.playerTextChatBalloonName, chatText);
    }

    void SwitchTextChatMode(vThirdPersonInput vInput, bool showInputFieldTextChat)
    {
        panelTextChat.SetActive(showInputFieldTextChat);

        if (showInputFieldTextChat)
        {
            // テキスト入力欄にキャレットを配置
            inputFieldTextChat.ActivateInputField();
        }
        else
        {
            // 非表示後にテキストをクリア
            inputFieldTextChat.text = "";
        }
        // プレイヤー入力のロック・ロック解除
        vInput.lockInput = showInputFieldTextChat;
        foreach (var vTriggerActions in triggerActions.GetComponentsInChildren<vTriggerGenericAction>())
        {
            vTriggerActions.actionInput.useInput = !vInput.lockInput;
        }
        // カーソル再表示とカーソルロック解除(なくてもチャットできる)
        // vInput.ShowCursor(vInput.lockInput);
        // vInput.LockCursor(vInput.lockInput);
    }

    void OnSendMessage(string userUniqueId, string chatText)
    {
        var chatBalloon = this.panelTextChatBalloons.Find($"{userUniqueId}")?.GetComponent<RectTransform>();
        UpdateBalloonText(chatText, chatBalloon);
    }

    static void UpdateBalloonText(string chatText, RectTransform chatBalloon)
    {
        if (null == chatBalloon)
        {
            return;
        }
        // 空文字列の時だけ非表示
        chatBalloon.GetComponent<Image>().enabled = 0 != chatText.Length;
        // 入力テキストが収まるよう表示幅を変更
        var chatBallonText = chatBalloon.GetComponentInChildren<Text>();
        chatBallonText.text = chatText;
        float margin = 20;
        chatBalloon.sizeDelta = new Vector2(chatBallonText.preferredWidth + margin, chatBalloon.sizeDelta.y);
    }

    string playerTextChatBalloonName = "";
}

インスペクタービューでは以下の通り UI と接続します

f:id:simplestar_tech:20190526083136p:plain
TextChatGUIのインスペクタービュー

prefab はなんの変哲もない Text です。

f:id:simplestar_tech:20190526083356p:plain
ChatBalloonPrefab
強いて言うならアンカーが左下という点
f:id:simplestar_tech:20190526083457p:plain
Anchorタイプ

Balloon の配置は以下のクラスに担当させています。(ロジックの作成の様子は過去記事で既出)

using UnityEngine;

/// <summary>
/// キャラの頭の上の吹き出し位置を毎フレーム更新
/// </summary>
public class ChatBalloonLocater : MonoBehaviour
{
    #region UI Connection
    internal RectTransform balloonRectTransform;
    #endregion

    #region Scene Components
    new Camera camera;
    #endregion

    void Start()
    {
        this.camera = Camera.main;
    }

    void Update()
    {
        if (null == this.balloonRectTransform || !this.balloonRectTransform.gameObject.activeSelf)
        {
            return;
        }

        // 遠くなるほど小さく
        var distance = Vector3.Distance(this.transform.position, this.camera.transform.position);
        this.balloonRectTransform.localScale = Vector3.one * Mathf.Clamp01(3 / distance);

        // スクリーン座標が画面外に出る時は、画面内に納まるようにクランプ
        Vector3 screenPos = this.camera.WorldToScreenPoint(this.transform.position);
        var scaleOffset = 5.0f;
        var marginX = this.balloonRectTransform.rect.width / 2 * this.balloonRectTransform.localScale.x + scaleOffset;
        var marginY = this.balloonRectTransform.rect.height / 2 * this.balloonRectTransform.localScale.y + scaleOffset;
        var x = Mathf.Clamp(screenPos.x, marginX, Screen.width - marginX);
        var y = Mathf.Clamp(screenPos.y, marginY, Screen.height - marginY);
        var z = screenPos.z;
        this.balloonRectTransform.rotation = Quaternion.identity;

        // 3Dアンカーのカメラの前後判定
        var dot = Vector3.Dot(this.camera.transform.forward, this.transform.position - this.camera.transform.position);
        var flag = Mathf.Sign(dot);

        // 3Dアンカーが背後にあるなら左右反転
        if (0 > flag)
        {
            x = Screen.width - x;
            y = Screen.height - y;
            this.balloonRectTransform.rotation = Quaternion.Euler(0, 180, 0);
        }
        // ここまでの計算を RectTransform に反映
        this.balloonRectTransform.position = new Vector3(x, y, z);
    }
}

コレを動かすと次の通り

次の記事では、オンライン通信部分を作ります。
simplestar-tech.hatenablog.com