simplestarの技術ブログ

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

CubeWalkGameプレイヤーカメラ移動によるチャンクの取捨選択

最後まで読むと得られる結果

このシリーズの続きです。
simplestar-tech.hatenablog.com

1.Unity ECS による動的頂点生成と面生成
2.チャンクをまたぐキューブデータ参照
3.キューブの回転表現とテクスチャの貼り付け
4.チャンクデータの永続化と描画負荷低減のための階層化

と階段を登ってきました。

今回は、プレイヤーカメラが移動すると、世界の描画範囲も合わせて移動する表現の実装を行っていきます。
イメージはスッと思い浮かぶのですが、それを具体的な手段に落とし込んで記録します。

# プレイヤーチャンクの特定

今まではとりあえず -12 ~ 12 の範囲のチャンクを対象にシーンにドンと配置する操作でした。
一応、カメラはデバッグ用に使いたいので、擬似的なプレイヤーオブジェクトをシーンに配置して、そのプレイヤーが移動すると
対象のプレイヤーチャンクが決定されるというコードを書いてみます。

シーンにあるプレイヤーオブジェクト
そろそろ何がプレイヤーの動きを監視するのでしょうか?

一度コード整理します。

アプリ全体に関わる ECS まわりの設定などはエントリポイント的な GameLogic に書きました。

そして、新しくクラスを用意し
そこで Update を構えて、その中でプレイヤーカメラの位置を追い続けるようにします。

クラス名は後で変えようと思うけど、プレイヤーカメラの位置によって世界を更新するものだから
そういう名前で

プレイヤーチャンクの決め方は簡単で、カメラ座標にチャンクの半分の長さを足して、チャンク中心までの距離を計算し
最も近い位置にあるチャンクがプレイヤーチャンクです。

走査する必要はなく、Round で丸めた値を int にすればチャンク位置であり
これをキーに変換する関数は前回作りましたので、これでチャンクを一意に特定できます。
どんな位置にいても

ロジックを組むと次の通り(動作確認済み)

using UnityEngine;

internal class ChunkWorld : MonoBehaviour
{
    #region Scene Components
    [SerializeField] Transform playerCamera;
    #endregion

    void Start()
    {
        
    }

    void Update()
    {
        var chunkSideOffset = ChunkConst.CubeSide * new Vector3(ChunkConst.ChunkSizeX, ChunkConst.ChunkSizeY, ChunkConst.ChunkSizeZ) / 2;
        chunkSideOffset = playerCamera.position - chunkSideOffset;
        var chunkInt3 = new Vector3Int(Mathf.RoundToInt(chunkSideOffset.x / ChunkConst.ChunkSizeX),
            Mathf.RoundToInt(chunkSideOffset.y / ChunkConst.ChunkSizeY),
            Mathf.RoundToInt(chunkSideOffset.z / ChunkConst.ChunkSizeZ));

        ChunkInt3ToChunkKey(chunkInt3, out var chunkKeyX, out var chunkKeyY, out var chunkKeyZ);
        Debug.Log($"{chunkKeyX}, {chunkKeyY}, {chunkKeyZ}");
    }

    internal static void ChunkInt3ToChunkKey(Vector3Int chunkInt3Position, out int chunkKeyX, out int chunkKeyY, out int chunkKeyZ)
    {
        var byteMax = (byte.MaxValue + 1);
        chunkKeyX = chunkInt3Position.x % byteMax;
        if (0 > chunkKeyX)
            chunkKeyX = (byte)chunkKeyX;
        chunkKeyY = chunkInt3Position.y % byteMax;
        if (0 > chunkKeyY)
            chunkKeyY = (byte)chunkKeyY;
        chunkKeyZ = chunkInt3Position.z % byteMax;
        if (0 > chunkKeyZ)
            chunkKeyZ = (byte)chunkKeyZ;
    }
}

internal class ChunkConst
{
    public const int ChunkSizeX = 16;
    public const int ChunkSizeY = 16;
    public const int ChunkSizeZ = 16;

    public const float CubeSide = 1f;
}

# プレイヤーチャンク移動タイミングの定義

毎回上記の更新作業をすると計算量がちょっとだけ勿体ないのと、境界でプレイヤーが振動したときに切り替わりが高速に走って
その後のチャンクメッシュの更新スピードに見合わないキューイングが発生しそうです。

一度プレイヤーチャンクのキー情報を特定したら、まずはそのチャンクから一定距離離れるまでは上記の更新作業を行わないようにすることをずっと考えていました。
実現してみます。

距離はチャンクの中心から、チャンクの最小外接球の半径としてみます。
キューブの中心から頂点までの距離は、立方体なら √3/2 (一辺が 1 なら)なので

次の通り(動作確認済み)

    void Start()
    {
        this.minChunkSize = Mathf.Sqrt(3f) / 2 * ChunkConst.CubeSide * Mathf.Min(ChunkConst.ChunkSizeX, ChunkConst.ChunkSizeY, ChunkConst.ChunkSizeZ) + 0.5f;
    }

    void Update()
    {
        this.UpdatePlayerChunk();
    }

    /// <summary>
    /// プレイヤーカメラの位置を使ってプレイヤーチャンクを更新
    /// </summary>
    void UpdatePlayerChunk()
    {
        var distancePlayerToChunk = Vector3.Distance(this.playerCamera.position, this.playerChunkCenter);
        if (this.minChunkSize < distancePlayerToChunk)
        {
            var chunkSideOffset = ChunkConst.CubeSide * new Vector3(ChunkConst.ChunkSizeX, ChunkConst.ChunkSizeY, ChunkConst.ChunkSizeZ) / 2;
            var playerOffsetPosition = this.playerCamera.position - chunkSideOffset;
            var chunkInt3 = new Vector3Int(Mathf.RoundToInt(playerOffsetPosition.x / ChunkConst.ChunkSizeX),
                Mathf.RoundToInt(playerOffsetPosition.y / ChunkConst.ChunkSizeY),
                Mathf.RoundToInt(playerOffsetPosition.z / ChunkConst.ChunkSizeZ));

            ChunkInt3ToChunkKey(chunkInt3, out var chunkKeyX, out var chunkKeyY, out var chunkKeyZ);
            this.playerChunkKey = new Vector3Int(chunkKeyX, chunkKeyY, chunkKeyZ);
            this.playerChunkCenter = new Vector3(chunkInt3.x * ChunkConst.ChunkSizeX, chunkInt3.y * ChunkConst.ChunkSizeY, chunkInt3.z * ChunkConst.ChunkSizeZ) + chunkSideOffset;

            Debug.Log($"{chunkKeyX}, {chunkKeyY}, {chunkKeyZ}");
        }
    }

    float minChunkSize = ChunkConst.ChunkSizeX;
    Vector3Int playerChunkKey = Vector3Int.zero;
    Vector3 playerChunkCenter = ChunkConst.CubeSide * new Vector3(ChunkConst.ChunkSizeX, ChunkConst.ChunkSizeY, ChunkConst.ChunkSizeZ) / 2;

# 既存のチャンクメッシュを利用する再結合処理

プレイヤーチャンクの切り替えタイミングまではっきりしましたが…
メッシュを作成するには周辺すべてのチャンクデータがロード済みでなければならず
さらに、必要なデータが全部揃ってからキューにチャンクメッシュ作成命令を積むべきものです。

近景はロード済みになっているはずなので、メッシュ更新は即時更新されるべきであり
遠景はロード完了を確認してからメッシュ更新を走らせるのが良さそうですが

実装コードになりますかね…

遠景はチャンクを複数結合しているため、その結合を解いて、作り直すという絵が見えます。

プレイヤーチャンクのキーから、次の情報が即時参照できますが、どうなることやら

    int** ppChunkData = null;                   // ワールドのチャンクデータ
    MeshFilter[] worldChunkMeshFilters = null;  // ワールドのチャンク MeshFilter

対象のチャンクオブジェクトのメッシュから、マージメッシュが取れたり
マージメッシュから、マージしているメッシュ一覧が効率的に取れると良さそう

事前の懸念はこれくらいにして、愚直に実装して壁にあたってみたいと思います。

プレイヤーチャンクの更新をイベントにして、そのイベントからコレまで通りのメッシュ更新を走らせてみましょう。

確認できた問題は2つ
1.現在のチャンクメッシュの更新は中央のチャンクが 000 固定…イベントの引数で切り替わるように作らなければならない
2.前回ロードしたチャンク情報を見ていないので、無駄な読み込みが走る

この問題は難しいので
・遅くても機能して無駄のない動きをすること
・原理的に絶対にフレームレートが落ちないこと
この2つに分けて、先に機能として十分で無駄なことをしない処理を目指します。

1.については機能の漏れなので、対応は想像しながらコーディングでいけそう
問題なく
こんな感じでいけました。

    /// <summary>
    /// ダウンロード完了イベントとキュー詰めは分けるべき @todo
    /// </summary>
    internal void DownloadWorld(Vector3Int chunkInt3)
    {
        var perChunkRadius = 1;
        for (var x = -perChunkRadius; x <= perChunkRadius; x++)
        {
            for (var z = -perChunkRadius; z <= perChunkRadius; z++)
            {
                for (var y = -perChunkRadius; y <= perChunkRadius; y++)
                {
                    this.EnqueueCreateEntity(chunkInt3.x + x, chunkInt3.y + y, chunkInt3.z + z, 0);
                }
            }
        }

2.は参照すべき情報があるのだから、それを見てロード済みだったらスキップでいけるはず

ファイル読み込みはこれでスキップできた

    void EnqueueCreateEntity(int x, int y, int z, int margeRadius)
    {
        int chunkKeyX, chunkKeyY, chunkKeyZ;
        ChunkWorld.ChunkInt3ToChunkKey(new Vector3Int(x, y, z), out chunkKeyX, out chunkKeyY, out chunkKeyZ);
        var byteMax = (byte.MaxValue + 1);
        int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;

        // 未読込の時にロード or 作成
        if (null == this.ppChunkData[chunkIndex])
        {
            LoadOrCreateChunkData(new Vector3Int(chunkKeyX, chunkKeyY, chunkKeyZ), new Vector3Int(x, y, z), out var pChunkData);
            this.ppChunkData[chunkIndex] = pChunkData;
        }

まだ、メッシュの無駄作成が走っているので、これもスキップする
動的にチャンクデータが更新されることを今後やったときに不具合になるけど、今回はチャンクデータ固定なので無視する、そんときは更新フラグを追加して更新されてたらメッシュを更新するとかにしよう

ということでメッシュ作成もスキップするコードはこちら
System からも world のチャンクデータを参照する必要がでてきたので static internal でプロジェクト内のどこからでも編集、参照できるようにしてしまった…

チャンクのメッシュ作成もすでにメッシュが作られている場合は処理をスキップするようにして、無駄なメッシュ作成は行われなくなった。
コードはこんな感じに

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

        #region エンティティごとにすでにメッシュが作成済の場合はスキップフラグを設定
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var meshData = meshDataArray[entityIndex];
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = meshData.chunkKeyX * byteMax * byteMax + meshData.chunkKeyZ * byteMax + meshData.chunkKeyY;
            meshData.skipFlag = null != ChunkWorld.worldChunkMeshFilters[chunkIndex] ? (byte)1 : (byte)0;
            meshDataArray[entityIndex] = meshData;
        }
        #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 に代入
        var entityMeshDataArray = new EntityMeshData[entities.Length];
        for (int entityIndex = 0; entityIndex < entities.Length; entityIndex++)
        {
            var meshData = meshDataArray[entityIndex];
            if (1 == meshData.skipFlag)
            {
                continue;
            }

結果から不思議に思ったことが…

f:id:simplestar_tech:20190623165746p:plain
拡張された領域はチャンク結合されない、なんでこうなる?

少し考えて、すぐにひらめきました。
核のイベントをスキップしている

核チャンクでキューイングされている場合はイベントを発火するようにします。

f:id:simplestar_tech:20190623170945p:plain
問題が一つ解決して、また問題が発覚 前のチャンク結合は残り、無用な側面も複製される

ここで明確な不具合が2つあります。
不具合1.無用な境界メッシュが作られている
不具合2.以前のチャンク結合が残っている

ひらめくアイディアに
不具合1は、データの読み込み領域を1チャンク広くして
データ読み込み完了をもってチャンクメッシュ作成をキューイング

不具合2は、全結合チャンクが作られてからクリア
クリアするときに、今回のキューイングで作成対象から外れているチャンクメッシュを一緒に削除

不具合1の解決方法を、イメージしながら手を動かして解決するか見ていきます。

まず、ダウンロードとキューイングは分けます。
分けた後で、ダウンロードの方の半径を大きく設定します。
あと、ちょうど階層化してループしているので
プレイヤーチャンクを中心とした 27 チャンク + 1 チャンク半径のダウンロード完了と
外周全部 + 1 チャンク半径のダウンロード完了時に異なるイベントを発行します

そのタイミングでチャンクメッシュ作成のハンドラが一つ内側のデータを使ってメッシュをカウントするようにしていきます。
だんだんアプリよりの実装になってきました。

書いてて気づいたことに、階層化した外周についても、ダウンロードも一括で行った後ではなく
外周のチャンク結合のコアの周辺が全てダウンロード完了したらイベントを発火して、キューイングするのが良さそう

そうすればダウンロードが非同期で行われた後も、イベントを頼りにメッシュを作っていけばいい

そうして実装したのがこちら

    /// <summary>
    /// チャンクデータのロード
    /// </summary>
    internal void DownloadWorld(Vector3Int centerChunkInt3)
    {
        var loadChunkRadius = 1 + 1;
        for (var x = -loadChunkRadius; x <= loadChunkRadius; x++)
        {
            for (var z = -loadChunkRadius; z <= loadChunkRadius; z++)
            {
                for (var y = -loadChunkRadius; y <= loadChunkRadius; y++)
                {
                    LoadChunkData(new Vector3Int(centerChunkInt3.x + x, centerChunkInt3.y + y, centerChunkInt3.z + z));
                }
            }
        }
        this.onDownloadChunkDataEvent?.Invoke(centerChunkInt3, 0);

        for (var level = loadChunkRadius; level <= 3; level++)
        {
            if (2 > level)
            {
                break;
            }
            var offset = 3;
            var geta = level * offset - 3;
            for (var x = -geta; x <= geta; x += offset)
            {
                for (var z = -geta; z <= geta; z += offset)
                {
                    for (var y = -geta; y <= geta; y += offset)
                    {
                        if (0 != geta - Mathf.Abs(x) && 0 != geta - Mathf.Abs(y) && 0 != geta - Mathf.Abs(z))
                        {
                            continue;
                        }
                        for (var radiusX = -loadChunkRadius; radiusX <= loadChunkRadius; radiusX++)
                        {
                            for (var radiusZ = -loadChunkRadius; radiusZ <= loadChunkRadius; radiusZ++)
                            {
                                for (var radiusY = -loadChunkRadius; radiusY <= loadChunkRadius; radiusY++)
                                {
                                    if (0 == radiusX && 0 == radiusY && 0 == radiusZ)
                                    {
                                        continue;
                                    }
                                    LoadChunkData(new Vector3Int(centerChunkInt3.x + x + radiusX, centerChunkInt3.y + y + radiusY, centerChunkInt3.z + z + radiusZ));
                                }
                            }
                        }
                        var coreChunkInt3 = new Vector3Int(centerChunkInt3.x + x, centerChunkInt3.y + y, centerChunkInt3.z + z);
                        LoadChunkData(coreChunkInt3);
                        this.onDownloadChunkDataEvent?.Invoke(coreChunkInt3, 1);
                    }
                }
            }
        }
    }

ダウンロード後のイベントハンドラがこちら

    void OnDownloadChunkData(Vector3Int coreChunkInt3, int margeRadius)
    {
        // コア周辺のチャンクのメッシュ作成情報を Enqueue
        var meshChunkRadius = 1;
        for (var radiusX = -meshChunkRadius; radiusX <= meshChunkRadius; radiusX++)
        {
            for (var radiusZ = -meshChunkRadius; radiusZ <= meshChunkRadius; radiusZ++)
            {
                for (var radiusY = -meshChunkRadius; radiusY <= meshChunkRadius; radiusY++)
                {
                    if (0 == radiusX && 0 == radiusY && 0 == radiusZ)
                    {
                        continue;
                    }
                    this.gameLogic.createEntityQueue.Enqueue(new CreateChunkInfo {
                        chunkInt3 = new Vector3Int(coreChunkInt3.x + radiusX, coreChunkInt3.y + radiusY, coreChunkInt3.z + radiusZ), mergeRadius = 0 });
                }
            }
        }
        // 最後にコアのチャンクメッシュ作成を Enqueue
        var createChunkInfo = new CreateChunkInfo { chunkInt3 = coreChunkInt3, mergeRadius = margeRadius };
        this.gameLogic.createEntityQueue.Enqueue(createChunkInfo);
    }

f:id:simplestar_tech:20190623181839p:plain
ダウンロードの半径を増やして、メッシュは不要な面を作らなくなった

期待通り動いている様子

f:id:simplestar_tech:20190623182206p:plain
次はこの、前に作ったチャンク結合メッシュを片付けます

ここでチャンクに参照カウンタを取り入れようと思います。
チャンク結合メッシュオブジェクトの削除のタイミングで、構成要素となっているチャンクオブジェクトの結合メッシュへの参照数を数えて
もし自身の 1 だったらそのまま Destroy するというもの

ここで見落としていた不具合が2つ

1.下図

f:id:simplestar_tech:20190624080113p:plain
プレイヤー周辺のチャンクメッシュのアクティブ化漏れ
移動後は 3x3x3 で周囲 27 チャンクがアクティブでなければならないのに

2.過去の結合チャンクの削除タイミング
先に次の世代の結合チャンクが作られていなければならない
また、近づいたときに残っていてはならないので、残し続けてもいけない
同時に消すというよりかは、次の世代の結合チャンクが作られるタイミングで順次消していってほしい

不具合に遭遇
これまで重複する場所に、メッシュオブジェクトを作成していることに気づきました。
すでに作成済みの gameObject を利用するように処理を書き換えたところ、EntityManager が null になっていて再利用できないことがわかりました。

どうも gameObject 自体が非アクティブだと、EntityManager は null 扱いになるらしいです。
そこで gameObject を非アクティブにするのではなく meshRenderer を非表示にするようにしました。

解決しました。

f:id:simplestar_tech:20190624215423p:plain
メッシュオブジェクトの再利用により、不具合1が解決

ふと思いついた絵に、結合チャンクの数は変わらないので、前回と同じインデックスに相当するチャンク結合は 1 チャンクだけずれて存在しているに違いない
そこで、前回と同じインデックスに属するチャンク結合を解き、そのときに結合したチャンクの描画をアクティブに戻す
ただし、参照カウンタを減らして 0 のものだけ、もし参照カウンタを減らしたのに 0 より大きい場合はすでに別のチャンク結合に所属しているので、描画の必要がない

これなら近くにある結合チャンクから先に削除されるので、描画用の結合チャンクにプレイヤーが混乱することは少なくなりそう
孤立したチャンクのうち、中心のチャンクから最大半径以上離れているものがあれば、これを削除する
で、削除するのはチャンクのメッシュだけではなく、チャンクのデータもなので
そのチャンクの周辺 27 個についても削除を走らせることにする

これで完璧に動きそうなので、実装して試してみます。

実装中困ったこと

結合チャンクコアはイベントで処理しているので、何番目のコアなのかのインデックスがわからない
イベントを発行する側でインデックスを作って渡せないか?

イベント発行元は ComponentSystem なので、順序はエンティティのオブジェクトコンポーネントに入っていると取り出せる
エンティティのマーカーコンポーネント追加場所を追ってみる
チャンクエンティティを作成する関数にたどりついたけど、ここにもそのようなインデックスを決定する処理はない
更に追うと、キューからデキューして作成のための情報を処理している
となると、ここでチャンクのインデックスやマージするコアのインデックスを渡せるか考えてみる
OnDownloadChunkData というイベントハンドラが呼び出し元、ここでもインデックス情報は参照できない
となると、このイベントを発行している元をさがしてみます。
DownloadWorld という関数で for ループを回してダウンロード処理を作っている ここですね。
ここならコアのインデックスを数えることができます。
このイベントの引数から情報を伝播させて、末端のマーカーコンポーネント追加処理まで情報を渡しましょう

実装することで、まず前回と同じインデックスに属するチャンク結合を解き、そのときに結合したチャンクの描画をアクティブに戻す
まで動作確認しました。

アクティブで孤立しているチャンクと遠く離れすぎた周辺データを削除するための情報が抜けているので、これにも対処します。

実行時の中心チャンクの位置int3情報と結合する chunk の半径がわかれば良さそうなので
これも同じようにイベント引数で伝播させてみます。

不要なチャンクの削除まで機能するところまで来ました。
チャンク結合を削除前に構成しているチャンクの表示を戻さないと更新処理中に穴が目立つ状態です。
これを解決するため、チャンクに参照カウンタを取り入れるアイディアを導入します。

導入して、更新中に穴が発生しなくなりました。
こんな感じで使います。

    /// <summary>
    /// マージ対象とその周囲メッシュが作成されたときに呼ばれる
    /// </summary>
    void OnToMergeMesh(Vector3Int chunkKeyXYZ, Vector3Int chunkInt3, int combineCoreChunkIndex, Vector3Int centerChunkInt3)
    {
        if (0 > combineCoreChunkIndex)
        {
            return;
        }
        #region 過去の結合メッシュの開放
        var oldCombineCoreChunks = this.combineCoreChunks[combineCoreChunkIndex];
        if (null != oldCombineCoreChunks)
        {
            const int endSubtractPosition = farChunkRadius * (nearMergeRadius * 2 + 1) - 1;
            foreach (var chunkIndex in oldCombineCoreChunks.chunkIndices)
            {
                // 中心から遠ければチャンクを削除
                var meshFilter = ChunkWorld.worldChunkMeshFilters[chunkIndex];
                if (null == meshFilter)
                {
                    continue;
                }
                var mergeChunkRefInfo = meshFilter.GetComponent<MergeChunkRefInfo>();
                mergeChunkRefInfo.mergeMeshObjectCount -= 1;
                var diff = mergeChunkRefInfo.chunkInt3 - centerChunkInt3;
                var maxSubtractPosition = Mathf.Max(Mathf.Abs(diff.x), Mathf.Abs(diff.y), Mathf.Abs(diff.z));
                // 中央から一定の距離以上のチャンクを削除
                if (endSubtractPosition <= maxSubtractPosition)
                {
                    meshFilter.sharedMesh.Clear();
                    Destroy(meshFilter.gameObject);
                }
                else if (0 == mergeChunkRefInfo.mergeMeshObjectCount)
                {
                    // 参照カウントが無ければ表示
                    meshFilter.GetComponent<MeshRenderer>().enabled = true;
                }
            }
            if (null != oldCombineCoreChunks.combineMeshObject)
            {
                // チャンク結合メッシュの削除
                var oldCombineMeshFilter = oldCombineCoreChunks.combineMeshObject.GetComponent<MeshFilter>();
                oldCombineMeshFilter.sharedMesh.Clear();
                Destroy(oldCombineCoreChunks.combineMeshObject);
            }
        }
        #endregion

        #region マージするメッシュを収集
        int mergeRadius = nearMergeRadius;
        var edgeCount = mergeRadius * 2 + 1;
        var combineCount = edgeCount * edgeCount * edgeCount;
        CombineInstance[] combineInstances = new CombineInstance[combineCount];
        int meshIndex = 0;
        var byteMax = (byte.MaxValue + 1);
        var combineChunks = new CombineChunks();
        for (var x = -mergeRadius; x <= mergeRadius; x++)
        {
            for (var z = -mergeRadius; z <= mergeRadius; z++)
            {
                for (var y = -mergeRadius; y <= mergeRadius; y++)
                {
                    int combineChunkIndex = (byte)(chunkKeyXYZ.x + x) * byteMax * byteMax + (byte)(chunkKeyXYZ.z + z) * byteMax + (byte)(chunkKeyXYZ.y + y);
                    var meshFilter = ChunkWorld.worldChunkMeshFilters[combineChunkIndex];
                    combineChunks.chunkIndices[meshIndex] = combineChunkIndex;

                    CombineInstance combineInstance = new CombineInstance();
                    combineInstance.transform = meshFilter.transform.localToWorldMatrix;
                    combineInstance.subMeshIndex = 0;
                    combineInstance.mesh = meshFilter.sharedMesh;
                    combineInstances[meshIndex++] = combineInstance;
                    // マージ対象のメッシュを非アクティブ化
                    meshFilter.GetComponent<MeshRenderer>().enabled = false;
                    meshFilter.GetComponent<MergeChunkRefInfo>().mergeMeshObjectCount += 1;
                }
            }
        }
        #endregion
        
        // マージオブジェクトの作成
        var meshObject = Instantiate(this.prefabMeshObject, Vector3.zero, Quaternion.identity);
        combineChunks.combineMeshObject = meshObject;
        var combineMeshFilter = meshObject.GetComponent<MeshFilter>();
        // メッシュのマージ
        var mesh = new Mesh();
        mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        mesh.CombineMeshes(combineInstances, mergeSubMeshes: true, useMatrices: true);
        // メッシュオブジェクトにメッシュを設定
        combineMeshFilter.sharedMesh = mesh;
        #region マテリアルの設定
        if (!this.materials.ContainsKey(this.meshShader.GetInstanceID()))
        {
            var material = new Material(this.meshShader);
            material.SetTexture("Texture2D_5418CE01", textureAlbedo);
            this.materials.Add(this.meshShader.GetInstanceID(), material);
        }
        var renderer = meshObject.GetComponent<MeshRenderer>();
        meshObject.GetComponent<MeshRenderer>().sharedMaterial = this.materials[this.meshShader.GetInstanceID()];
        #endregion

        // 結合メッシュの格納
        this.combineCoreChunks[combineCoreChunkIndex] = combineChunks;
    }

できた。
冒頭の動画になります。

チャンクデータの削除はしないことにした。
あまりにたまり過ぎたら、クリーンにする処理を走らせる方法を取り入れるのもありかも

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