simplestarの技術ブログ

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

CubeWalkGame非同期読み込みと近傍優先処理

CubeWalk シリーズです。
1.Unity ECS による動的頂点生成と面生成
2.チャンクをまたぐキューブデータ参照
3.キューブの回転表現とテクスチャの貼り付け
4.チャンクデータの永続化と描画負荷低減のための階層化
5.プレイヤーカメラが移動するタイミングでメッシュをアップデート
と進めてきました。

前回はこちら
simplestar-tech.hatenablog.com

今回は同期で一度に読み込んでいたチャンクデータを非同期処理で読み込み、ロード完了のイベントでメッシュ作成のキューイングを行うようにします。
プレイヤーチャンクの移動がロード中に走った場合は、ロードをキャンセルしてチャンクデータの読み込みをプレイヤーを中心に割り込ませます。

ワールドの更新のための機能はこれが最後になる予定です。
期待通り動く絵を作るため、具体的な実装を考えていきましょう。

f:id:simplestar_tech:20190701224336j:plain
実装した結果、無限に続く世界の非同期ロードによる更新が確認できました

# 同期処理を非同期処理へ

ファイル読み込み部分を確認すると…

        if (File.Exists(filePath))
        {
            UnsafeFileUtility.ReadData(filePath, out var fileData);
            pChunkData = (int*)fileData.Buffer;
        }

この関数内は?

    /// <summary>
    /// 呼び出し元は fileData.Buffer に対し ReleaseReadData で開放する責任あり
    /// </summary>
    public static void ReadData(string filePath, out ReadCommand fileData)
    {
        var fileInfo = new System.IO.FileInfo(filePath);
        long fileSize = fileInfo.Length;

        var readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
        readCommand[0] = new ReadCommand
        {
            Offset = 0,
            Size = fileSize,
            Buffer = UnsafeUtility.Malloc(fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent),
        };

        var readHandle = AsyncReadManager.Read(filePath, (ReadCommand*)readCommand.GetUnsafePtr(), 1);
        readHandle.JobHandle.Complete();
        fileData = readCommand[0];
        
        readHandle.Dispose();
        readCommand.Dispose();
    }

もともと非同期処理を、handle の Complete でブロックして完了を待っています。
ここを 非同期関数にしてみます。

    /// <summary>
    /// 呼び出し元は fileData に対し ReleaseReadData で開放する責任あり
    /// さらに readHandle.IsValid() && readHandle.Status != ReadStatus.InProgress になるまで監視して readHandle.Dispose() で開放する責任あり
    /// </summary>
    public static void ReadData(string filePath, out ReadHandle readHandle , out ReadCommand fileData)
    {
        var fileInfo = new System.IO.FileInfo(filePath);
        long fileSize = fileInfo.Length;

        var readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
        readCommand[0] = new ReadCommand
        {
            Offset = 0,
            Size = fileSize,
            Buffer = UnsafeUtility.Malloc(fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent),
        };

        readHandle = AsyncReadManager.Read(filePath, (ReadCommand*)readCommand.GetUnsafePtr(), 1);
        fileData = readCommand[0];
        
        readCommand.Dispose();
    }

コメントの通り、呼び出し元で現在のステータスをチェックして、プロセスが完了していたら正しくデータが格納されていることを確認できました。

で、これを今の呼び出しだと、次の通り
3x3x3 の 27 チャンクのさらに外周についてもロードを for 文で回し 5 x 5 x 5 の 125 チャンクデータのロードを走らせ
成功したときに、コアのチャンクのインデックス情報と共に、メッシュ作成とチャンク結合のイベントを投げるようにしています。

    /// <summary>
    /// チャンクデータのロード
    /// </summary>
    internal void DownloadWorld(Vector3Int centerChunkInt3)
    {
        // 近景チャンク半径1 + 1(無用メッシュ境界を作らないため)でチャンクデータをロード
        var loadChunkRadius = nearMergeRadius + 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, -1, centerChunkInt3);
        // 遠景チャンクを 1~ max 半径まで周回しながらチャンクデータをロード
        int combineCoreChunkIndex = 0;
        for (var coreChunkLevel = 1; coreChunkLevel < farChunkRadius; coreChunkLevel++)
        {
            var offset = nearMergeRadius * 2 + 1;
            var geta = coreChunkLevel * offset;
            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, combineCoreChunkIndex++, centerChunkInt3);
                    }
                }
            }
        }
    }

現在はそのイベントの先にて、以下の通りチャンクメッシュ作成の順序を守りながら Enqueue していますが…

    void OnLoadCoreChunkData(Vector3Int coreChunkInt3, int mergeCoreChunkIndex, Vector3Int centerChunkInt3)
    {
        // コア周辺のチャンクのメッシュ作成情報を Enqueue
        var meshChunkRadius = nearMergeRadius;
        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),
                        mergeCoreChunkIndex = -1,
                        centerChunkInt3 = centerChunkInt3
                    });
                }
            }
        }
        // 最後にコアのチャンクメッシュ作成を Enqueue
        var createChunkInfo = new CreateChunkInfo {
            chunkInt3 = coreChunkInt3,
            mergeCoreChunkIndex = mergeCoreChunkIndex,
            centerChunkInt3 = centerChunkInt3
        };
        this.gameLogic.createEntityQueue.Enqueue(createChunkInfo);
    }

愚直に、ダウンロードの順序を守りながら非同期処理を行うとして、ダウンロードもキューイングして
非同期処理完了後に、ダウンロード情報をキューからデキューして再度非同期処理を走らせると良いと思います。

非同期によるフレーム分散の処理の先で、さらにフレーム分散のためのキューイングを行う感じですね。
キューに詰める情報として、コアとしてのメッシュ作成のためのキューイングのための情報も詰める形です。

キューイングの先でキューイングという…
ファイルロード、メッシュ結合、2つの重たい処理を負荷分散する形です。

これが完成したら、2つのキューをクリアする機能を用意して
これをプレイヤーチャンク更新のタイミングで走らせてから、再度キューに詰め直すという流れをイメージして完璧な動きを想像できています。

さぁ、あとは自分がどれだけ正しい未来を予見しているかの証明ですね。
作ってみて結果を報告します。

実装した結果は次の通り

実装中に困ったこと
1.チャンクデータがファイルとして無い場合は作っているが、その関数を非同期化したい
処理が読み込みと作成で分岐するけど、それを非同期で動かそうとしているので、可能なら作成も非同期化したいが…

タスクの情報はキューには詰めているし
非同期の Task は使えるし、コールバックは走らせられる
メインスレッドでコールバックもできる

ということは、キューに詰める情報には開放必要なものは置かず
メインスレッドは非同期処理の内容を気にせず好きなタイミングでキューをクリアしたり情報を詰めたりできる
非同期処理がキックされたら、引数情報をもとに非同期処理をして、完了をメインスレッドでコールバック
コールバック内で、キューからデキューした引数情報を再帰的に非同期処理に乗せる

ん、非同期処理のキックが大量に行われると困ったことに…
一つの非同期処理が走っている間は、多重キックしないようにしないといけない

Task 実行中ってわかるには?
docs.microsoft.com

タスクの最終状態としては、RanToCompletion、Canceled、Faultedのいずれかになります。利用可能性の高いこれらの値をより便利に利用できるよう、TaskクラスにIsCompleted、IsCanceled、IsFaultedプロパティが提供されています。IsCompletedはStatusプロパティがRanToCompletion、Canceled、Faultedのいずれかのときにtrueを返すので注意が必要です。

となると、次のコードで目的の動作が実現できるか

    Task LoacChunkDataAsync(ChunkLoadInfo chunkLoadTask)
    {
        return Task.Run(() => {
            // ここでロード or 作成
        });
    }

タスク内でメインスレッド Invoke するには?
こうしました。(動作確認済み)

        var mainContext = SynchronizationContext.Current;
        return Task.Run(() => {
            // ここでロード or 作成
            LoadChunkData(chunkLoadTask.coreChunkInt3, chunkIndex, chunkKeyXYZ, filePath);
            if (0 != chunkLoadTask.combineCoreChunkIndex)
            {
                mainContext.Post(_ => {
                    this.onDownloadChunkDataEvent?.Invoke(chunkLoadTask.coreChunkInt3, chunkLoadTask.combineCoreChunkIndex, chunkLoadTask.centerChunkInt3);
                }, null);
            }
        });

一応上記の対応で負荷が上がることなく、非同期読み込みの結果のチャンク生成までできました…が
一番小さい 3x3x3 の結合の外周半径1で、全メッシュ作成に 60000 ms かかってしまい、遅すぎると…
目標としては 10000 ms 以内としたいので、あと 6倍以上は高速化したいところ

少し実装内容を変えていきます。
具体的には、非同期処理内でスレッドセーフキューをループして取り出す仕組みです。

すると 3000 ms まで高速化しました。20倍の高速化です。
軽く動作確認してみます。

プレイヤーが高速移動して、既存の処理を飛び越えるようなことすると null 参照エラーで
ppChunk のポインタが無効と System 側から怒られる

原因の把握と対策をしてみます

途中から非同期処理のために Task 内で Random.Range 使うことになってた…

これがまずかった模様

次の通り書き換えることで解決しました。

            var r = new Random(seed: (uint)System.DateTime.Now.Ticks);
                        pData[1] = (byte)r.NextInt((int)CubeRotationType.Top000, (int)CubeRotationType.Max);

あとは、プレイヤーチャンクの更新でキューに積んだ処理を律儀にこなし続けるので、プレイヤーチャンクを更新したらスレッドセーフなキューをクリアするようにします。

できました。