simplestarの技術ブログ

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

Unity: Behavior Designer のノードの勉強

基本的なことはこちら
simplestar-tech.hatenablog.com

ビヘイビアツリーを構成しているノードは三つに分けられる、それぞれ Task, Composite, Decorator である。

・Task はツリーのリーフ要素で、1フレームに許された計算時間で条件判定やアクションを実行し、戻り値として1)Running(まだタスクが残っている), 2)Failure(条件に合わない、または失敗), 3)Success(条件一致、または成功) の三つのいずれかを返す。
・Composite は子ノードの実行を制御するノードで、シーケンスとセレクターの二種類が存在する。
シーケンスは最初の子ノードが実行完了するまで待機し、成功を返した場合に、次の子ノードを実行し、この操作を繰し、最後の子ノードが成功を返したら、シーケンスノードも成功を返す。一度でも子ノードが失敗を返したら、残りの子ノードは無視して失敗を返す。子ノードが実行中を返すようなら、シーケンスも実行中を返す。
セレクターは優先度順に子ノードの選択を行い、選択したノードを実行する。シーケンスと違う点は、どれか一つの子ノードが成功を返した時点で、まだ実行していない子ノードがあっても、成功を返す点である。
セレクターには指定した割合の確率で選択する Probability Selector や、1フレームにすべての子ノードを実行する Parallel がある。
・Decorator ノードは特質系で、子ノードを一つしかとることができない。ノードのタイプによって振る舞いはさまざまで、たとえば n 回子ノードを繰り返し実行してから成功を返したり、子ノードがn秒以上実行中だったら成功失敗に関わらず強制的に失敗を返したりする。

これから Behavior Tree を活用していくので、使い方に慣れるため、各ノードの動きを確認しながら、道具として使えるようになるため、覚えていこうと思います。

## Parallel

子タスクを一斉に並列実行し、いずれかが失敗を返したらすべての子タスクを停止して失敗を返す
実行中の子タスクが残るなら、実行中を返し
すべての子タスクが成功したとき、成功を返す
イメージとしては並列実行の And 条件 if 文って感じですかね

f:id:simplestar_tech:20210703130241p:plain
Parallel

## Parallel Complete

子タスクを一斉に並列実行し、最も早く答えを出した子タスクの答えを返す
全部成功していて、残る実行中のものがあったとしても、最初に成功を返した子タスクを見つけた時点で成功を返す
イメージとしては並列実行の Or 条件 if 文って感じですかね、False を返すのが先だと False が返るところが全然違う

f:id:simplestar_tech:20210703131331p:plain
Parallel Complete

## Parallel Selector

子タスクを一斉に並列実行し、すべて失敗となるまで待つが、いずれかが成功を返した瞬間に子タスクをすべて停止して成功を返す
イメージとしては並列実行の Or 条件 if 文って感じですね
f:id:simplestar_tech:20210703131954p:plain

## Priority Selector

float GetPriority(); を Action Task 側で実装して返す必要がある
この値が大きい順で子タスクを順番に実行して、先に成功を返したタスクが生まれたら成功を返すもの

f:id:simplestar_tech:20210703142958p:plain
Task class

## Random Selector

ランダムに子タスクの順番を決めて Selector として、成功を見つけるまで続け、成功を返す子タスクによって、続く子タスクの実行を停止して、成功を返す
イメージとしては順番実行の Or 条件 if 文って感じで、評価順がランダムというものですね

f:id:simplestar_tech:20210703143258p:plain
Random Selector

## Random Sequence

ランダムに子タスクの順番を決めて Sequence として、すべてが成功するまで続け、失敗を返す子タスクによって、中断して子タスクの実行を停止して、失敗を返す
最後の子タスクが成功したときにやっと成功を返すという
イメージとしては順番実行の And 条件 if 文って感じで、評価順がランダムというものですね

f:id:simplestar_tech:20210703150525p:plain
Random Sequence

## Selector

いずれかが成功したら成功を返し、残りの実行を中断する
イメージとしては順番実行の Or 条件 if 文って感じ

f:id:simplestar_tech:20210703150847p:plain
Selector

## Selector Evaluator

実行中を返す Action が子タスクの優先度の低いものであったなら、それより高い優先度の Action の評価をもう一度行う様子
実行中のタスクより低い子タスクの実行はせず
優先度が高いタスクは常にチェックしたいといったギミックに良いのかも たとえばプレイヤーを視認していること という条件に使って、続くアクションに追いかけるといった running ステータスを返す何かが挟まるなど
イメージとしては順番実行の Or 条件 if 文って感じだが、どういうわけかさっき調べた条件をもう一度しらべいにく
いくつもの Task のうち、最初に成功を返した Task の結果をもって成功を返す

f:id:simplestar_tech:20210703151759p:plain
Selector Evaluator

## Sequence

順次実行して、失敗が返ると即子タスクを停止して失敗を返す
イメージとしては順番実行の And 条件 if 文って感じ

f:id:simplestar_tech:20210703153116p:plain
Sequence

## Utility Selector

float GetUtility の値が大きいものを常に選んで実行する
子タスクすべてを見ていて、Utility 値が実行中に小さくなったなら、他の Utility 値が大きいタスクの実行へと移る
いずれかが成功を返したら、他のタスクを停止して成功を返す でも、いずれの子タスクも Running を返すようにする使い方がメインなのかな
Priority Selector との違いは、それまで最高Utility だったタスクが Running 中でも、他の Utility が高まったら中断するところかな

f:id:simplestar_tech:20210703153501p:plain
GetUtility
f:id:simplestar_tech:20210703154804p:plain
Utility Selector

CubeWalkGame:AIが使う脳内コンテキスト

前書き

自作ゲームの開発ネタです。
考えていることを書くと、頭が整理されてコードに落とし込める気がして

最近の進捗絵はこんな感じ

f:id:simplestar_tech:20200616212308p:plain
木のぼり

動画はこちら

作りたいのはワールドロジックとその共用

世界がどうなるかの先行きを小さなロジックを使って取り扱いたくて
キューブのデータをある場所を中心に切り取ってコピーを受け取れる方法がこちら

        /// <summary>
        /// 対象のチャンク、チャンク内のキューブ位置インデックスと格納先配列を渡すと、データを埋める
        /// </summary>
        /// <param name="chunkInt3">ベースとなるチャンクの位置インデックス</param>
        /// <param name="chunkCubeInt3">ベースチャンク内での中心となるキューブの位置インデックス</param>
        /// <param name="radius">ppCubeデータの半径キューブ数</param>
        /// <param name="ppCubeData">埋めてもらうキューブデータ配列</param>
        static unsafe void FillAroundCubeData(Vector3Int chunkInt3, Vector3Int chunkCubeInt3, int radius, byte** ppCubeData)

radius * 2 + 1 の 3乗要素数の byte x 4 配列を確保して渡すと、指定した位置を中心にローカル最新データを埋めてくれるもの

うまくいきまして、AIが周囲のキューブの状態から未来予測を行い、木が育つことをサーバーに報告すると
サーバーが検算して合格したら、本当に木が育つようになりました!

最近の様子を記録したので載せておきますね

Unity:CubeWalkゲームの起動方法

Magic Onion やその他さまざまなパッケージに頼っているため、インストールの仕方を間違えると起動しないという問題があります。
エラーもすごくわかりづらい…
いつまでも記憶しておくことはできない、ゆえにコストを割いてここに記録しておきます。
Unity 環境を変えるとかしたときは絶対参照して、無駄時間を省きましょう

1.リポジトリをチェックアウト
自身の GitHub プライベートリポジトリ に CubeWalk 2019.3 があるのでチェックアウトします。
https://github.com/simplestargame

2.Assets/00 フォルダを削除
いきなり Unity として開くとエラー連発なので、いろいろとアセットがそろうまで、Git 管理ファイルをローカルから取り除きます。
Unity 2019.3 以降で、Assets フォルダの親フォルダを開きます。

勝手に以下の通りパッケージがインストールされることになります。

f:id:simplestar_tech:20191021142716p:plain
パッケージ一覧
言及するなら
Burst
Chinemachine
Entities
Input System
Shader Graph
TextMesh Pro
Timeline
Universal RP
は必須、使っているのでないとビルドは通りません

3.PlayFab Editor 拡張と SDK 最新取得
パッケージ取得は公式ドキュメントの案内に従います
Documentation Home > getting-started > Unity Getting Started
Editor 拡張で Login して、SDK をインストールして、タイトルIDを記入して完了です

4.VRoid Hub SDK をインストール
開発者登録して SDK を運営から受け取ります。
パッケージインストールしたのち VRoidSDK > Plugins > SDKConfigurations の SDKConfiguration のインスペクタにアプリシークレット情報を書き込みます。
アプリケーション管理方法は README にリンクと説明があります。

5.Magic Onion のインストール
https://github.com/Cysharp/MagicOnion/releases
最新リリースパッケージと moc というコード生成ツールを取得します。
Magic Onion の導入についてもドキュメントを参照することになりますが Ver 2.6.3 のときは別途 Official gRPC Releases から最新の grpc も取ってきて入れてとあります。
grpc_unity_package.2.25.0-dev.zip を今日はダウンロードして Plugins の下に grpc_unity フォルダを作って配置しました。

6.Message Pack のインストール
https://github.com/neuecc/MessagePack-CSharp/releases
こちらにファイルがあるので zip の中にある src\MessagePack を Scripts/MagicOnion と同列に MessagePack とならぶように配置
同フォルダにある MessagePack.UnityClient に示されるファイル構成となるように不要ファイルを除去していく
具体的には MessagePackSerializer.Typeless.cs
と MessagePack.csproj は取り除く
LZ4MessagePackSerializer.Typeless.cs
Shims フォルダは追加
Unityフォルダを作り
Formatters.cs
UnityResolver.cs
を MessagePack.UnityShims フォルダから配置
MessagePack.UnityShims\Extension から配置

6.Package の移動
Library\PackageCache にある
com.unity.collections
com.unity.render-pipelines.universal
com.unity.shadergraph
を移動、うち collections については System.Runtime.CompilerServices.Unsafe.dll ファイルを除去(grpc のやつと重複しているので)
これやっておかないと直接的でない変なエラー連発なので忘れずに

7.UniUnsafeIO をインストール
https://github.com/pCYSl5EDgo/UniUnsafeIO
こちらの Plugins フォルダをそっくり UniUnsafeIO というフォルダ名にして Plugins フォルダに配置

8.依存アセットの購入とインストール
ビルドエラーが起きない状況を確認しつつ Asset Store を開いて
Basic RPG Cursors
Massive Clouds - Screen Space Volumetric Clouds
Third Person Controller - Basic Locomotion Template → プロジェクト設定上書きなので、インポート後に戻す

8.SharpZipLib のアセットの導入
次の最新リリースを取得して net45 フォルダの ICSharpCode.SharpZipLib.dll ファイルを Plugins 以下に配置して使えるようにする
https://github.com/icsharpcode/SharpZipLib


9.1.で退避していた 00 フォルダを元に戻す
Project Settings > Graphics にて SRP Settings に CubeWalkRenderPipelineAsset を設定
00 > Scenes > SampleScene を開いて Import Text Mesh Pro をする

この時点で初めてエラーなく実行可能に

f:id:simplestar_tech:20191021160219p:plain
PlayMode

10.VRM-Toon-for-Universal-RP の導入
[https://github.com/simplestargame/VRM-Toon-for-Universal-RP
ReadMe にならって一部 VRM の書き換えも行います。
00 > Resources にある ToonShaderGraph を開いて Save Asset ボタンでビルド確認

11.実機用ビルド
Project Settings > Graphics の Preloaded Shaders の Size を 0 -> 1 にして MassiveCloudShaderVariants をセット
Development Build にチェックをいれてビルド

実行すると次の通り(なんだぁこのエラー)

f:id:simplestar_tech:20191021163519p:plain
実行画面

追記:問題の切り分けとして Toon Shader 単体のゲームで再現しなかったので、それ以外のアセットが邪魔してこのエラーが起きているとのこと
誰が犯人なんだろ

12.サーバーの実行
dotnet コマンドが打てる PowerShellとかで Server フォルダをカレントに
dotnet publish -c Release -o ../out

docker 実行環境を用意して
out フォルダをカレントに
docker build . -t cubewalkroom:1.0
して image 作成

shell 実行環境を用意して
docker/run_local_server.sh
でローカルサーバーが 12000 ポートで立ち上がります

run_local_server.sh の内容は次の通り

 docker run -d --rm -p 12000:12000 --name cubewalkroom cubewalkroom:1.0 dotnet CubeWalkServer.dll 12000

13.サーバー通信
 11.でキャラクターが動けるならサーバーと通信成功している

以上

CubeWalkGame昼と夜

LWRP アセットがお気に入りの今日このごろ
ゲーム内に時間を設定して、これに対して昼と夜の見た目にプロシージャルに変化する様子を確認してみたいと思います。

あ、できちゃった

www.youtube.com

詳細はツィートにあり

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

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

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

できました。


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

動くサンプルはここに
github.com

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