24/10/20 up
前回は仮想化ジオメトリのLODリソースを作成する部分を解説しました。
その段階で以下のリソースが作成されています。
・各LODレベルのメッシュレット情報
・メッシュレットの親子関係
・メッシュレットが所属するグループのエラー値、及び親グループのエラー値
これらの情報を利用して描画すべきメッシュレットを決定します。
描画されるメッシュレットはすべて同じLODレベルというわけではなく、場所によってLODレベルが異なります。
また、親子関係のあるメッシュレットが同時に描画されては困るので、そのような問題も解決できるような手法で決定しなければなりません。
本記事では安定してLODレベルの選択を行うための手法について解説します。
以下の図は今回のサンプルプログラムのスクリーンショットです。
RenderLODのチェックボックスをONにするとLODレベルをパーツに合わせて変更するようになります。
OFFの場合はLOD0のメッシュレットのみが描画されます。
WASDキーでカメラ位置をOrbitで回転、IKキーでカメラの距離を変更できます。
GroupColorがONの場合はメッシュレットがそれぞれ別々の色で描画され、OFFの場合はメッシュ全体が白で描画されます。
描画されているスタンフォードバニーはLOD0のメッシュレットが1162個、全LODのメッシュレットが2651、最大LODレベルが11となっています。
LODの決定はどのようにすればよいでしょうか?
メッシュLODの場合、LODの決定には距離、もしくは面積を用いるのが一般的です。
距離は単純に境界オブジェクト(箱、もしくは球)の中心とカメラの距離です。面積はやはり境界オブジェクトの画面対面積比です。
よく使われるのは面積の方ではないでしょうか。
しかし、メッシュレットLODについてはこの手法は使えません。
というのも、メッシュレットごとに面積が大きく異なるからです。
理想的にはメッシュレットの境界オブジェクトはLODレベル間で同等程度であるべきですが、現実には難しいです。
メッシュレットごとの面積が異なると、隣接メッシュレットであるにも関わらずLODレベルが大きく異なってきてしまう可能性があります。
これは許されません。
以下の図は簡単ですが実際にあり得るメッシュレットLODのツリー構造です。
例えば M / N のメッシュレットを考えます。
Mは妙に大きく、Nは妙に小さくなってしまったとしましょう。どちらも中心とカメラの距離はほぼ同じと考えます。
このとき、M は大きいのでより小さなLOD 0を選択したくなります。逆に N は小さいので、LOD 2をそのまま描画したくなります。
となると、M の子である I に移動し、そこからまた A / B / C / D が描画されることになってしまいます。
しかし N の子は J であり、孫はやはり A / B / C / D になってしまいます。
これでは重なった部分が描画されてしまうことになるのでよろしくありません。
もし M から I まで移動したとしても、ここで止まってくれるのであれば N が描画されていても問題ありません。
このように見ていくと、隣接するメッシュレットのLODレベルは、通常±1の範囲に収まらなければならないということがわかります。
これを明確にするには境界オブジェクトの面積比では対応できませんし、距離でも難しいです。
そこで利用するのが、メッシュレットのシンプル化時に利用しているQuadric Errorです。
前回解説したように、Quadric Errorはシンプル化したポリゴンがシンプル化する前のポリゴンとどの程度乖離しているかを示す値です。
この値はメッシュソース(つまりLOD 0)では0.0となり、LODレベルが高くなればなるほど大きくなります。
meshoptimizerのシンプル化処理はシンプル化したメッシュのインデックスとともにシンプル化した際のQuadric Errorも出力してくれます。
この値は相対的なエラーである点に注意が必要です。
絶対的なエラーとするにはいくつかの処理が必要になります。
まずはエラーのスケーリングファクターを求めます。
このスケーリングファクターもmeshoptimizerの命令を利用します。meshopt_simplifyScale 関数がこれになります。
コードはmesh_grouping.cpp の529行目くらいからです。
これだけではあくまでもLOD nからLOD n+1の間でのエラーに過ぎません。
必要となるのはLOD 0からのエラー値ですので、これは自身の子となるメッシュレットのエラーに加算することで対応します。
コードは600行目からです。
シンプル化したメッシュは4つのメッシュレットから生成されますので、子メッシュレットのエラー値から最大値を求め、そこに先程のエラー値を加算します。
このようにして求めたエラー値は同一LODレベルの範囲内では完全一致しないまでもかなり近しい値になります。
これを利用することでLODレベルの調整ができそうです。
実際の選択処理を見ていきましょう。
この処理は sample_application.cpp の562行目、EnumerateVisibleMeshlets 関数になります。
この関数内で使用されているラムダ関数は2つで、IsMeshletVisible 関数が対象のメッシュレットが表示されるかどうかを判定する関数、ErrorProjection 関数がメッシュレットの可視判定に使用されるエラー値の計算関数です。
IsMeshletVisible 関数では各メッシュレットに保存されているグループのエラー値と親のエラー値を設定しているしきい値と比較します。
親のエラー値がしきい値より大きく、グループのエラー値がしきい値以下である場合、このメッシュレットを表示すると判断します。
グループのエラー値はシンプル化したメッシュレットグループから計算されたものです。
このエラー値を求める場合、境界オブジェクトはグループのものを利用します。
親のエラー値はこのメッシュレットを含んでシンプル化したグループのエラー値です。
境界オブジェクトもこの親グループのものを利用します。
こうすることで同一グループに属する2つのメッシュレットは常に同じエラー値を得ることが出来ます。
ErrorProjection 関数でエラー値を距離に応じて変化させます。
特にこういう計算でなければならない、ということはないはずですが、カメラから境界オブジェクトの距離が長くなった場合にエラー値が小さくなるような計算を行う必要はあります。
今回はスクリーンの高さと画角を考慮しています。つまりピクセルサイズが考慮されています。
また、距離の2乗に反比例するようにしています。
計算式によってはLODの切り替え範囲が変わってきますので、色々な計算式を試してみてもいいかもしれません。
これらのラムダ関数を利用してLODを選択しているのは608行目からです。
選択方法は2つ用意してあり、1つはリニアにツリーをトラバースする方法、もう1つは並列にすべてのメッシュレットを調べる方法です。
ツリーのトラバースは以下のようになっています。
queue にルートのメッシュレットIDを入れてからループに入れば、そのメッシュレットが表示扱いでなければ子がすべて queue に追加されます。
メッシュレットが表示すると判断されるとそのツリーの検索はそこで終了です。
幅優先探索で queue がなくなるまでループします。
メッシュインスタンスが遠距離にあれば早い段階で検索が打ち切られるので、距離に応じてパフォーマンスが変化します。
注意点として、ツリー構造は1つのメッシュレットに対して複数の子があるのは当然として、複数の親が存在します。
前図を見ればわかるかと思いますが、例えばメッシュレットKは N / O が親となります。
普通にツリーをトラバースしてしまうと1つのメッシュレットに対して複数回の検索が行われてしまいます。
これを避けるため、事前にトラバース用のツリーを用意してあります。
このツリーを用意しているのは mesh_grouping.cpp の654行目からです。
並列処理のコードは以下のようになります。
STLの並列forを利用しています。
1つのスレッドで64個のメッシュレットをループしています。
1メッシュレットを1スレッドでループするより効率が良いのではないかと思います。
GPUであれば1メッシュレットを1スレッドで処理する方が良いでしょう。
すべてのワーカーの処理が完了した後に出力IDをマージして完了です。
こうすればミューテックスロックも不要なので、一応それなりに高速なはずです。
RenderLOD フラグを有効にした場合に Traverse Time が表示されるようになりますが、これがCPUでのトラバース処理の時間となります。
デバッグビルドではリニアのほうが圧倒的に高速ですが、最適化の入るリリースビルドでは最悪パターンで並列のほうが若干高速です。
距離が離れた場合はリニアのほうが高速なので、どちらが良いかはどうとも言えません。
最終的にOutMeshletIDsに保存されたメッシュレットの頂点インデックスを利用して描画します。
描画処理は単純にメッシュレットの数だけDraw命令を発行するだけです。
このようなLOD選択がどのように動作するかを以下の図を用いて例示してみます。
各グループに書かれているエラー値は ErrorProjection の結果と考えてください。
LOD 0のエラー値はどの距離にあっても常に 0.0 となります。
例えばしきい値が 1.5 の場合は M と I の間でしきい値をまたぎますので I が描画対象となります。
同様に N / J、N / K、P / Lでしきい値をまたぐので J / K / L が描画対象です。
最終的には I / J / K / L が描画されます。
他にもいくつかのしきい値で描画されるパターンを表にまとめてみます。
それぞれの値で表示される各部のLODが、境界を含めて問題がないことが確認できるはずです。
今回は境界球の距離のみを考慮してLODを決定していますが、法線とカメラベクトルの方向も考慮するようにしてもよいでしょう。
つまり、スクリーン面に対して垂直の場合はポリゴンが粗くても問題ないという感じです。
ただ、隣接LODが妥当になるように計算する必要があるため、計算式の設計には注意が必要でしょう。
今回はLODの選択方法について解説しました。
しかし、今回の実装ではCPUでしかLODの選択ができません。
1つのスタンフォードバニーを描画するだけであればCPUで処理してもよいでしょうが、大量のメッシュインスタンスとなるとそうも行きません。
次回はその問題に対応するためのGPUでのLOD選択について解説します。
やっと実戦投入が出来るWork Graphさんの登場です。