simplestarの技術ブログ

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

Unity:VRMのMToonマテリアルをLightweightRPのShaderGraphで、その3で公開

# はじめに

内容は玄人向けですが、はじめての方にもわかりやすく説明します。

  • Unity とは、ゲーム開発するときに、共通で必要になるものをだいたい用意してくれるツールです。(基本無料です。)
  • VRM とは、Virtual Reality における3Dモデルデータを扱うためのファイルフォーマットです。(世界に先駆けて日本が作っているので応援してる)
  • MToon とは、VRM の標準の Toon シェーダーです。(イラスト調の見た目を作る陰影計算ロジック)
  • UniversalRP とは、本記事まで LightweightRP と呼んでましたが、Unity がこれからメインとして扱うシェーダを書く場所
  • ShaderGraph とは、UniversalRP のシェーダーをノード(要素)とエッジ(連結線)で描くデザイナ脳で作れる Unity 内のツール

# 公開しました

細かい事抜きにして、動くコードを公開しました。

github.com

README の手順に従って LightweightRP でも描画確認できた方は続けてお読みください。

置き換えたファイルが何しているかをイメージするための資料を以下に書き留めていきます。

# 差分をとって PBR シェーダーとの差異を確認する

今回移動してもらったこちらの C# ファイルは、シェーダーコードを構築する仕事を行います。
Packages\com.unity.render-pipelines.lightweight@5.16.1\Editor\ShaderGraph\LightWeightToonSubShader.cs

ベースとなる実装は最初から存在する物理ベースレンダリング(PBR)の実装です。
差分を Visual Studio Code のコマンドパレットの差分チェックなどで見てみてください。
基本的な部分は全部一致していて、Shade カラーとテクスチャ、Outline の幅の情報を渡す口だけが増えていることを確認できます。
ほんと、差異はこれだけです。

f:id:simplestar_tech:20190721101843p:plain
差分を取ると、スロットが追加されているだけ

シェーダーコードを構築するのが先程紹介した SubShader.cs ファイルの仕事ですが、これをもう少し具体的に解説すると
ある .template テキストファイルをベースに、文字列置換しながらシェーダーコードを完成させます。

具体的にどの .template ファイルを利用しているかはこちらのコードから確認できます。

f:id:simplestar_tech:20190721102148p:plain
.template ファイルのテキスト読み込み部分

ここまで理解が進めば、あとは .template で陰影計算ロジックを書いているんだろうと予想がつくでしょう。そのとおりです。

こちらも、PBR のものをベースに作成しています。
差分をとってみるととても納得できると思います。

差分を見るのは次のファイルです。
Packages\com.unity.render-pipelines.lightweight@5.16.1\Editor\ShaderGraph\lightweightToonForwardPass.template

f:id:simplestar_tech:20190721102618p:plain
.template を Toon(左)と PBR (右)で比較したときの様子

f:id:simplestar_tech:20190721102803p:plain
.template を Toon(左)と PBR (右)で比較したときの様子(色決定部分)

差異をもう少し注意深く追うと、Toon の方は outline pass と書かれたもう一つ、とても似たレンダリングパスの記述が確認できると思います。
こちらです。

//
// outline pass
// 

Pass
{
	// Material options generated by graph
${Tags}
${Blending}
Cull Front
ZTest Less
${ZWrite}

Unity シェーダーでこの記述は、ポリゴンの裏面を、表面よりちょっとだけ奥に配置するようにして正しい奥行きで描画という意味になります。(イメージできますか?)
もう一つ重要なのは、スクリーン座標における頂点位置の拡張です。(位置を法線方向にずらす処理)

        o.clipPos = TransformOutlineToHClipScreenSpace(v.vertex.xyz, v.normal.xyz, vd.OutlineWidth);

脳内でイメージを作って読んでいた方はこれで納得できると思いますが、この処理により、輪郭線が表現されています。

f:id:simplestar_tech:20190721103827p:plain
顔の輪郭線が表現されたときの様子

# 肝となるシェーダーロジック

ここまでの解説を要約すると PBR シェーダーをベースに、影色と輪郭線の太さパラメータを利用することと輪郭線を描くパスが追加されたというものでした。
気になるのは要所で出てきた次の2つの関数ですね。

  • LightweightFragmentToon
  • TransformOutlineToHClipScreenSpace

実装は次のファイルに書かれています。

Packages\com.unity.render-pipelines.lightweight@5.16.1\ShaderLibrary\Lighting.hlsl

コメントで // for Toon Shading と書かれている行より上は置き換える前のファイルとまったく同じコードです。手入れていません。
その下に続く関数については License 表記にあるとおり、こちらは
LiliumToonGraph をベースに Fragment 関数を簡略化したものです。
github.com
ToonyIntensity の計算はオリジナルの MToon と全く同じロジックを使いました。
github.com

あとは Normal と SphereAdd (MToon 特有のモデル周辺が黄色く光る表現)は次のとおり Shader Graph で表現しました。
こちらは simplestar の完全オリジナル版

f:id:simplestar_tech:20190721111531p:plain
MToon の SphereAdd の座標計算をグラフ表現してます

# カスタム Master Node

次のクラスが上記の Toon Master という名前のノードを定義しています。直感で理解します。
Packages\com.unity.shadergraph@5.16.1\Editor\Data\MasterNodes\ToonMasterNode.cs

# まとめ

解説は以上になります。
なんかすぐに Universal RP 対応が入りそうなのですぐに古い情報になってしまいそうですが
勘所としておさえ、新しい環境にも容易に移植できる技術者が増えることを期待します。

ここまで読んでくださりありがとうございました。

Unity:VRMのMToonマテリアルをLWRPのShaderGraphで、その2

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

Unity Shader Graph で VRM の MToon 表現を LWRP で実現してみます。
カスタム Master Node の作り方まで確認できたので、具体的な Toon シェーディングコードを
GitHub - you-ri/LiliumToonGraph: ShaderGraph が使える ToonShader (LWRP)
こちらの実装から写経・理解してみます。

Lighting.hlsl に追加するべきは、まずは LightingToon 関数で、これが4つの引数をとります。
そこでさらに独自関数が呼ばれていました。DirectToonBDRF コレの定義を追います。
実装は Minimalist CookTorrance BRDF とのこと

一旦これで Toon の関数が揃いました。

変更前と後で見た目を比較してみます。

f:id:simplestar_tech:20190715132515p:plain
PRB そのままの絵
f:id:simplestar_tech:20190715132840p:plain
LightingToon に置き換えた Shader の絵(実際はクックトランスシェーダー)

次は GlobalIlluminationToon に GlobalIllumination を置き換えます

それは GlossyEnvironmentReflectionToon, EnvironmentToon で構成されていたので、それらも定義します。
適用した後の絵がこちら

f:id:simplestar_tech:20190715133718p:plain
GlobalIlluminationToon の適用結果

続いて LightingToonyBased を導入します。

f:id:simplestar_tech:20190715134936p:plain
ToonyBased を入れたのですが、変わりました?なんか暗いですね。

コレで最後かな? ToonyIntensity を導入します。

f:id:simplestar_tech:20190715140602p:plain
ToonyIntensity を導入した結果

暗に型変換するエラーがいくつかありましたので解決しました。
例えば戻り値が half3 の関数なのに、利用時に half として扱うとか

あれ、アウトラインはどうやっているんだろう?

lightweightToonExtraPasses.template ファイルの差異を見たが、得なし
lightweightToonForwardPass.template ファイルの差異を見た

以下の変数定義が追加されている

		float ToonyLighting = 1;

		float3 Shade = float3(0.25, 0.25, 0.25);
		float ShadeShift = 0.5;
		float ShadeToony = 1;

ほか LightweightFragmentToon に必要とされる引数の数が変わったので、hlsl の方を修正する

また Pass が一つ追加されていて、こちらはアウトラインの追加となっていた。

一通り見てからのコンパイル

うわ、エラー 2つ
一つ潰して 3つに

OutlineWidth というものを外部から利用している模様

ここで ToonMasterNode クラスの方を修正することにした
以下のパラメータを追加

        public const string ShadeSlotName = "Shade";
        public const string ShadeShiftSlotName = "ShadeShift";
        public const string ShadeToonySlotName = "ShadeToony";
        public const string OutlineWidthSlotName = "OutlineWidth";
        public const string ToonyLightingSlotName = "ToonyLighting";

他にもスロットを追加したところ、Master Node のスロットだけ増えました。

f:id:simplestar_tech:20190715151909p:plain
Master Node のスロットだけ増えた。依然としてエラー

最後に直すべきは…class LightWeightToonSubShader : IToonSubShaderですね。
記述の足りていない OutlineWidth などを追加していきます。

            PixelShaderSlots = new List<int>
            {
                ToonMasterNode.AlbedoSlotId,
                ToonMasterNode.NormalSlotId,
                ToonMasterNode.EmissionSlotId,
                ToonMasterNode.MetallicSlotId,
                ToonMasterNode.SmoothnessSlotId,
                ToonMasterNode.OcclusionSlotId,
                ToonMasterNode.AlphaSlotId,
                ToonMasterNode.AlphaThresholdSlotId,
                ToonMasterNode.ShadeSlotId,
                ToonMasterNode.ShadeShiftSlotId,
                ToonMasterNode.ShadeToonySlotId,
                ToonMasterNode.ToonyLightingSlotId
            },
            VertexShaderSlots = new List<int>()
            {
                ToonMasterNode.PositionSlotId,
                ToonMasterNode.OutlineWidthSlotId
            }

コレなんだろう
subShader.Append("CustomEditor \"UnityEditor.ShaderGraph.ToonMasterGUI\"");
後で足さないとな…

ひとまずシェーダーエラーを片付けたら絵が出ました。

f:id:simplestar_tech:20190715155040p:plain
outline 全部入りのディフォルト値の絵

試験的に UnityChan に当てるとこんな感じ

f:id:simplestar_tech:20190715161553p:plain
上記のディフォルト値のシェーダを適用

思った絵にならないな

f:id:simplestar_tech:20190715165850p:plain
ライティングの設定に問題?

あ、Normal Sampler を Type Default にしたままだった、そりゃ法線がおかしくなるかな
修正後がこちら

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

f:id:simplestar_tech:20190715173407p:plain
カスタムノードから生やした Shader Graph はこちらです。

LWRP で MainColor, ShadeColor を指定できる Toon マテリアル表現ができるようになりました。
あとはこのシェーダを使って UniVRM で生成したキャラクターを確認できればよいのかな?

続く

ユニティちゃんライセンス

この作品はユニティちゃんライセンス条項の元に提供されています

Unity:VRMのMToonマテリアルをLWRPのShaderGraphで、その1

自分だけの3Dキャラクターをあらゆるゲームタイトルに登場させるというのは、ゲームが始まった頃からの全人類の悲願でした(二回目)
長い間、キャラクターエディットという、ゲーム開始時のアバター選びという機能の提供が続いていましたが、ついに一つ作れば、どのゲームにも入れる時代となりました。(なろうとしています)
https://vrm.dev/images/vrm/vrm_app.png
画像は 「VRM」って何?どんなことができる? - VRM より

Unity で LWRP の時代が到来しようとしているところ
VRM の Unity インポートまわりは MToon マテリアルを使用していて
そのまま使うとシェーダーエラーになります
simplestar-tech.hatenablog.com
簡易対応は絵を破壊してしまうので、本対応が待たれていますが…あれから三ヶ月
対応にはもう少し時間がかかりそうという雰囲気を感じたので、資料をもとに再現する Shader Graph を作って、作り方を記録します。

まずは Unity の LWRPについて理解しましょう。
simplestar-tech.hatenablog.com

…何が、Unity 最高!だよ(最高だけど)

Shader Graph について理解はこちら
simplestar-tech.hatenablog.com

…いったい、何に勝つんだか…

そして、MToon について理解を深めます。
github.com

具体的にはこちらを読み解きました
https://niconare.nicovideo.jp/watch/kn3485

ここで、これから作りたいものが形になり始めます

# 草案

パラメータ全ては移動できそうにないですが、いくつか代表的なものを引っ越ししたいと思います。

  • Lit & Alpha
  • Shade

のカラーとテクスチャの両方は必須で引っ越しするとして

輪郭線の太さとカラー
幅制御テクスチャは可能なら調べて対応してみたい

Shadeing Shift
Shading Toony
は考えどころ、Shader Graph についてもっと深い理解が要求されますね…

リムライトテクスチャは、機能を探して対応してあげたいが

そんなところですね。

Shader Graph で Toon レンダリング

まずは数万人の開発需要と数億人のユーザー需要がある中
新しい Shader システムに Toon が存在しているかどうかを確認してみましょう。

まさに MToon をリファレンスして LWRP で Toon 表現を行うカスタムマスターノードの例が見つかりました。
github.com

まずは Shader Graph でカスタムマスターノードを用意する方法について確認してみましょう。

# Shader Graph 入門

まずはこちらを読みます。
Home · you-ri/LiliumToonGraph Wiki · GitHub

なるほど、Packages フォルダ以下へ Cache 下にあった Shader Graph コード一式を移動し、勝手に初期化される問題を回避し
まずは CreatePBRShaderGraph.cs を真似て、CreateToonShaderGraph.cs を作る

using System.IO;
using UnityEditor.ProjectWindowCallback;

namespace UnityEditor.ShaderGraph
{
    class CreateToonShaderGraph : EndNameEditAction
    {
        [MenuItem("Assets/Create/Shader/Toon Graph", false, 208)]
        public static void CreateMaterialGraph()
        {
            ProjectWindowUtil.StartNameEditingIfProjectWindowExists(0, CreateInstance<CreateToonShaderGraph>(),
                string.Format("New Shader Graph.{0}", ShaderGraphImporter.Extension), null, null);
        }

        public override void Action(int instanceId, string pathName, string resourceFile)
        {
            var graph = new GraphData();
            graph.AddNode(new PBRMasterNode()); // ←ここをカスタムノードクラスに置き換える!!
            graph.path = "Shader Graphs";
            File.WriteAllText(pathName, EditorJsonUtility.ToJson(graph));
            AssetDatabase.Refresh();
        }
    }
}

まずはこんな感じの作業で、UI に Toon Graph の項目を作り、既存の PBRMasterNode が作られるところまで確認できました。

f:id:simplestar_tech:20190715030756j:plain
Toon Graph がメニューに追加されていることを確認

コメントで強調したとおり、PBRMasterNode クラスを真似て、ToonMasterNode クラスを作ってみます。

PBR を一括でドキュメント内 Toon リプレースしてみました。
IToonSubShader, ToonSettingsView クラスの2つを用意しろと怒られますね。

元の PBRのインタフェースと View クラスの方もリネームして用意しましょう。

IPBRSubShader は ISubShader しか継承してなかったので、元の IPBRSubShader を利用するように戻しました。
ToonSettingsView はクラス名だけ変えた引数を受け付けるだけなので、作る必要はあるけど、内部のロジックは PBRSettingsView.cs クラスをそのまま持ってきました。

ここをカスタムノードクラスに置き換える!!のコメントを ToonMasterNode に置き換えて
一旦ここでコンパイルが通る状態

試験的に Toon Graph を作ってみると…
あれ、エラーに…仕方なく IToonSubShader を IPBRSubShader に似せて作って再チャレンジ

f:id:simplestar_tech:20190715093106p:plain
ノットコンパチのエラーが出るけど、カスタムマスターノードとして UI 操作ができる状態に

ToonSubShader を用意する

参考にしているプロジェクトでは LightWeightToonSubShader : IToonSubShader クラスを用意している
このインタフェースが実装されると、Shader Graph で有効になるのかな?

Unity オリジナルを参照するにはここまで Shader Graph の中を見てきましたが、ここからは LightWeightRP の Package の中を見ていくことになります。
そこには確かに
LightWeightPBRSubShader.cs
lightweightPBRForwardPass.template
lightweightPBRExtraPasses.template
がありました。

仕方ないので、LightWeightRP もカスタムする目的で Library\PackageCache から com.unity.render-pipelines.lightweight@5.16.1 を Packages フォルダへ移動します。

上記3つのファイル名や中身について、あらゆる PBR を Toon に置き換えたものを同フォルダへ配置してみましょう。

こんな感じ

f:id:simplestar_tech:20190715102406p:plain
LWRP の Pacage 配下へ Toon リネーム系を配置

Shader Graph を Toon から作ってみると、ちょっとだけエラーメッセージが変わりました。

f:id:simplestar_tech:20190715103056p:plain
変わったエラー

該当箇所について周辺を読んでみます。
おそらく LightweightFragmentPBR 関数がどこか hlsl として記述されていて、これをインクルードして使えるようになっていて
Toon に置き換えたものがどこにも無いのだと思われる

grep で探すとヒット

ShaderLibrary/Lighting.hlsl

///////////////////////////////////////////////////////////////////////////////
//                      Fragment Functions                                   //
//       Used by ShaderGraph and others builtin renderers                    //
///////////////////////////////////////////////////////////////////////////////
half4 LightweightFragmentPBR(InputData inputData, half3 albedo, half metallic, half3 specular,
    half smoothness, half occlusion, half3 emission, half alpha)
{
    BRDFData brdfData;

そこに関数を複製して Toon サフィックス付けて配置するとどうでしょう?(以下のように endif の手前に挿入した)

}

half4 LightweightFragmentToon(InputData inputData, half3 albedo, half metallic, half3 specular,
    half smoothness, half occlusion, half3 emission, half alpha)
{
// 省略
}
#endif

これで Toon Graph を作成したら、うまくいきました。

f:id:simplestar_tech:20190715105715p:plain
カスタム Master Node で正しく LWRP シェーディングできている様子

f:id:simplestar_tech:20190715105937p:plain
見た目は PBR そのままです。そっくりそのままですもんね…

続いて Toon カスタムしていきます。
長くなったので2つに分けます。

続き
simplestar-tech.hatenablog.com

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