simplestarの技術ブログ

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

CubeWalkGameリファクタリングでわかりやすく

今回は CubeWalkGame シリーズの最終回ということで
カメラを DOTween でぐるりと軌道を描くように動かして様子を見ます。
あとはコードを見直しながら、直したいところ直していきます。

最終的にフレームレートを落とさずに、無駄なメッシュが残ること無く、近傍からメッシュが作成され続ける仕組みができました。

f:id:simplestar_tech:20190706163650j:plain
farChuncRadius = 6 の絵

まずはコードを見てコメントする

高速化
一番効いたのが、周辺チャンクをまたぐ参照の計算を、一個内側の場合はスキップするように書いたこと

f:id:simplestar_tech:20190702235206p:plain
5ms → 2.2ms 7ms → 2.8ms まで短縮

チャンクメッシュ作成をスキップするものが事前にわかるなら
これからメッシュを作らなければならないメッシュも明らかなわけで
常に最大メッシュ作成数までタスクリストから取り出したら、それを entity マーカーフラグに設定すればいい

そうすればスパイクなくなる

だいたいそうだった

今は GC Collect のスパイクが気になる
明示的に GC を呼ぶと…遅すぎたのでキャンセル

あと、過去のメッシュが残る問題を確認した。
期待とことなる動きの理由は?

…先程入れた、既存の meshFilter があるときのエンティティ作成スキップが原因
これからメッシュを作らなければならないメッシュだけを System で処理するようにしたら、起きるべきイベントまでスキップされていた…
これはもとに戻した。

スキップするものはメッシュ作成にカウントしないようにしたが…今度はスキップ処理が数万件となりスパイクが発生…
そこで、スキップ数にも限度 100 を設定したところ、バランス良く計算量をバラけさせることができた。

今度はカメラが大きく移動したときに、期待と異なるくらい遠方のメッシュ結合が走るようになっていた
意味がわからない

意味がわかってきた。
現在の仕組みをおさらいしよう。

この世界の最小構成要素はプリズムです。
プリズムが2つでキューブとなり
キューブが 16 x 16 x 16 のチャンクを一つの結合メッシュオブジェクトとして表現しています。

世界データを永続化するためにチャンク単位で int 配列をファイル保存し、実行時にこれを非同期処理で読み込みます。
読み込むチャンクの順序は初期フレームとプレイヤーチャンクの更新のタイミングにて、内側から順に余白分も含めて
1結合チャンクのために 125 チャンクの読み込みを farChunkRadius * 2 + 1 の3乗回、farChunkRadius が 5 なので
1,331 回分の計画を立てます。(固定長配列に)

計画が立った段階で、非同期処理が開始され、読み込み済みのチャンク位置についてはスキップしながら高速にループが回ります。
125 チャンクの読み込みが完了したタイミングで、コアと周面のデータロード完了のイベントが発行されます。

メッシュ作成を行うクラスはこのデータロード完了イベントを待っていて
ハンドルすると、周辺チャンクメッシュ作成と、コアによるメッシュ結合を計画します。(キューに詰めます)

あとはメインスレッドで環境の最大スレッド数 - 1 個の数だけメッシュ作成ジョブを実行する目的で、キューの内容を消化します。
ここが 100 件以上スキップするか、最大スレッド数 - 1 以上のメッシュ作成になるようなら、キューからの取り出しをやめて処理を回すというもの

問題をみつけるようになったのは、この処理の途中でカメラが動き続けて、プレイヤーチャンクが頻繁に更新されるようになった時

非同期処理は同期処理のことなんか見ていないので
データロードをものすごい勢いでスキップしながら、コア周辺のロードも終わったよとイベントを発行します

メインスレッド側はプレイヤーチャンク移動を検出したら、一度非同期処理を終わらせようとフラグを立てますが
そのフラグを非同期読み込み処理が気づいた頃には、ずっと遠方のデータロード完了のイベントを多数発火するキューが作られてしまっているわけ

で、期待はプレイヤー周辺の未作成のチャンクが作られる様子を想像するのだけど
気づけば、ものすごく遠方のチャンクが作られる様子を目の当たりにする。

これを直すには…

ということを考えて、ひらめくのは
世代番号の利用

プレイヤーチャンクの更新イベントは あるクラスが発火し
コアと周辺データロード完了のイベントも同じクラスが担当しています

プレイヤーチャンク更新のたびにインクリメントされる uint インデックスを考えます。
コアと周辺データロードを実行するタスクのローカル変数にその世代番号を持たせ
イベントにも第何世代によるロードイベントなのかを伝えます。

メッシュ作成側には常に、あるクラスの最新の世代番号というものを渡しておき
更新後に飛んできたロード完了イベントについて、世代番号が古い場合は、周辺チャンクメッシュ作成とコアによるメッシュ結合の計画を阻止します。

もう一つ、メッシュ作成とメッシュ結合の計画は、古い世代のものがすでに詰まっていることになるので
新しい世代になるまで、これを全部吐き出させます。

一旦整理後に見える動きは正しそうなので、これの動作を見ていきます。

実装の急所はこのへんかな

CreateChunkMeshBehaviour.cs

    void Update()
    {
        #region Queue から位置を取り出してMeshObjectをInstantiate
        this.createMeshObjectCount = 0;
        var skipCreateCount = 0;
        while (this.threadCount > this.createMeshObjectCount)
        {
            if (0 == this.createEntityQueue.Count)
            {
                break;
            }
            if (100 < skipCreateCount)
            {
                break;
            }
            var createChunkInfo = this.createEntityQueue.Dequeue();
            this.chunkWorld.ChunkInt3ToChunkKey(createChunkInfo.chunkInt3, out var chunkKeyXYZ);
            var byteMax = (byte.MaxValue + 1);
            int chunkIndex = chunkKeyXYZ.x * byteMax * byteMax + chunkKeyXYZ.z * byteMax + chunkKeyXYZ.y;
            var meshFilter = this.worldChunkMeshFilters[chunkIndex];
            this.CreateChunkObjectEntity(createChunkInfo, chunkKeyXYZ, chunkIndex, meshFilter);
            if (null == meshFilter)
            {
                this.createMeshObjectCount++;
            }
            else
            {
                skipCreateCount++;
            }
        }
        #endregion

        #region プレイヤーチャンクから一定の距離以上のチャンクを削除
        const int endSubtractPosition = ChunkWorld.farChunkRadius * (ChunkWorld.nearMergeRadius * 2 + 1) - 1;
        var destroyCount = 0;
        for (int meshFilterIndex = this.offsetWorldChunkMeshFilters; meshFilterIndex < this.offsetWorldChunkMeshFilters + this.limitWorldChunkMeshFilters; meshFilterIndex++)
        {
            if (this.worldChunkMeshFilters.Length <= meshFilterIndex)
            {
                this.offsetWorldChunkMeshFilters = 0;
                break;
            }
            if (10 < destroyCount)
            {
                break;
            }
            var meshFilter = this.worldChunkMeshFilters[meshFilterIndex];
            if (null != meshFilter)
            {
                var mergeChunkRefInfo = meshFilter.GetComponent<MergeChunkRefInfo>();
                var diff = mergeChunkRefInfo.chunkInt3 - playerChunkInt3;
                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);
                    destroyCount++;
                }
            }
        }
        if (0 < destroyCount)
        {
            this.offsetWorldChunkMeshFilters += destroyCount;
        }
        else
        {
            this.offsetWorldChunkMeshFilters += this.limitWorldChunkMeshFilters;
        }
        #endregion
    }

ChunkWorld.cs

    void Update()
    {
        // チャンクサイズ以上離れないなら更新は走らない
        var distancePlayerToChunk = Vector3.Distance(this.playerCamera.position, this.playerChunkCenter);
        if (this.minChunkSize < distancePlayerToChunk)
        {
            UpdatePlayerChunk();
        }
        // チャンクデータのロードタスクが初期化されているならば、既存のタスクを止めて新しい非同期ロードを開始
        if (this.loadTaskCancelFlag)
        {
            if (null == this.loadChunkTask || this.loadChunkTask.IsCompleted)
            {
                var mainContext = SynchronizationContext.Current;
                this.loadTaskCancelFlag = false;
                this.loadChunkTask = Task.Run(() => {
                    var myLoadTaskGeneration = this.loadTaskGeneration;
                    for (int taskIndex = 0; taskIndex < this.chunkLoadTasks.Length; taskIndex++)
                    {
                        if (this.loadTaskCancelFlag)
                        {
                            break;
                        }
                        var taskData = this.chunkLoadTasks[taskIndex];
                        this.LoadChunkData(taskData);
                        // combineCoreChunkIndex が 0 以外の場合はコアメッシュ作成イベント -1 はプレイや付近の非結合を意味する
                        if (0 != taskData.combineCoreChunkIndex)
                        {
                            mainContext.Post(_ => {
                                this.onLoadChunkCoreEvent?.Invoke(taskData.coreChunkInt3, taskData.combineCoreChunkIndex, taskData.playerCameraTransform, myLoadTaskGeneration);
                            }, null);
                        }
                    }
                });
            }
        }
    }

動作確認したときの映像がこちら

これで一通り、思い浮かべた処理が動いたところですね
サンプルはこちらに
github.com