26/05/31 up
今回はニュートン法を用いたスクリーン空間の屈折表現の実装についてです。
この技術の論文は以下となります。
https://jcgt.org/published/0015/01/03/
実装はこちら。
実行の際にはデフォルトでは水面描画を行っていませんので、Render Waterのフラグを有効にしてください。
スクリーン空間での屈折処理はゲームの世界ではよく使われています。
水面やガラス、もっとゲーム的なもので言えば光学迷彩の表現として用いられます。
よく用いられる方法は、屈折面の法線をスクリーン空間に変換し、そのXY軸方向に対して一定量のUV値をズラしてやる方法です。
この方法は厳密でなくてもいいが屈折していることがわかりやすい場合に有効で、しかも軽いです。
光学迷彩のように、パッと見ではわかりにくいけどよく見ると敵などが動いているのがわかりやすい状態になってほしいような場面で使いやすいです。
しかしこの方法はマテリアルごとに一律でUVがズレてしまうので、屈折面が平面だったときなどにズレ方がおかしくなります。
例えば湯船などの箱型の容器内に水が張られている場合、以下の図のように屈折面とオブジェクトの位置が近いか遠いかでズレ幅は変わってきます。
PとQは屈折なしであれば透明オブジェクトの先に見えるポイント、P'とQ'は屈折によって実際に見える位置と考えてください。
この図はスクリーン空間を示していないので若干間違いはあるのですが、ズレの大きさが違うことはわかってもらえるでしょう。
この問題を解決する手段の1つはレイマーチを用いる方法です。
屈折面の位置、法線、視線ベクトル、屈折率比が分かれば屈折ベクトルが求められるので、このベクトルをレイとしてレイマーチを行っていけば比較的正しい屈折面を得ることができます。
しかしレイマーチはクオリティを上げようと思うとループ回数が増え、パフォーマンスも下がりがちです。
今回紹介するニュートン法は反復的に行う求根アルゴリズムです。
このアルゴリズムについてはWikipediaにあるGIF動画像がわかりやすいです。
https://ja.wikipedia.org/wiki/ニュートン法
これを非屈折オブジェクトに適用しようというのが今回の手法です。
アルゴリズムの基本は以下のようになります。
1.視線ベクトルと非屈折オブジェクトとの交差点を求める(上図のPやQ)
2.交差点の接平面を求める
3.接平面と屈折ベクトルの交差点を求める
4.3の交差点と視点からベクトルを求め、非屈折オブジェクトとの交差点を求める
5.4の交差点が屈折面からの屈折ベクトルに近いならループを終了、そうでなければこの点を1の点と同様に扱い2から再開する
言葉で説明するとちょっとわかりにくいですが、本論文の図2を見ればわかりやすいです。
この方法もレイマーチと同様にループを行うことにはなるのですが、レイマーチと違って比較的早い段階で収束しやすいという利点があります。
論文のテストシーンではほとんどの場合で2~6回のループで収束しているそうです。
また、今回はかなりナイーブな実装をしていますが、より良い実装としてはミップマップを利用するのが良いようです。
この手法は局所的な連続性と滑らかさが必要になるのですが、これはどのようなシーンに対しても保証できるものではありません。
そのため、深度や法線のミップマップを生成することである程度の連続性や滑らかさを得ることができます。
どのミップレベルを利用するかは屈折ベクトルと交差点へのベクトルの差によって決定するのが良いようです。
今回の実装ではまだそこまでやっていないのですが、そのうちやってみる予定です。
この手法の欠点は明確な失敗ケースが有ることです。
接平面と屈折ベクトルが交差しない場合、遮蔽物によって正確な交差点が取得できない場合、画面外に出てしまった場合がありえます。
スクリーン空間技術なので画面外については割とどうしようもなく、これはレイマーチやユニフォームな屈折処理でも同様の問題が発生します。
このような場合は画面端が引き伸ばされてしまうようになるのはある程度致し方ないかとは思います。
しかし、それ以外で交差点が正常に求められない問題は何らかの解説作を見出す必要があります。
残念ながら、ニュートン法ではこの方法が解決できません。
そのため何らかのフォールバック手法を用いる必要があります。
今回のわたしの実装ではレイマーチ手法にフォールバックしています。
実際にフォールバックされるピクセル数はそんなに多くないはずなのでパフォーマンスもそんなに悪くはならないはず、と思って実装したのですが、実際にはどうなったか?
それは後ほど解説します。
今回の実装ではミップマップを利用しないナイーブな実装になっています。
また、接平面を求めるために法線が必要なのですが、当初はGBufferを利用していましたが深度バッファから再構築するようにしました。
ミップマップを利用すれば良いかもしれないですが、ミップマップを利用しない場合は凹凸のある場所で滑らかさが足りず、おかしな結果を得ることがあったためです。
わかりやすいのは石畳のこの部分。
水面は平面としているのでこのようなさざなみがあるような見た目にはならないはずなのですが、石畳の凹凸によってこのような見た目になってしまいました。
深度バッファから再構築する手段ではいかのような結果が得られています。
ただし、この問題はオブジェクトの非連続性に対しても発生します。
上の画像だと道路脇のポールの下部に注目してください。
石畳とポールの境界ではそれぞれの深度から法線を生成してしまっているため、正常な結果が得られていません。
そのため、屈折前のポールの境界部分が薄っすらと出てしまっています。
ニュートン法に関してはどうしても非連続的なエッジ部で良い結果が得られないことがわかっています。
今回のシーンですと、例えばスクーターの辺りでは良い結果を得られていません。
レイマーチ手法も良いとは言えないのでなんともではありますが、レイマーチの実装はあまり良くない手法を用いているので、もうちょっときちんと作ってパラメータ調整すればマシになる可能性はあります。
もちろんニュートン法もまだまだ改良の余地がありますので、もっと良い結果を得られる可能性はあります。
しかし、非連続的な部分では技術的にうまくいかない可能性が高そうではあります。
パフォーマンスについては、ループ回数やその他の条件にもよるのですが、レイマーチのループ回数がそれなりになると条件次第でニュートン法(+フォールバック)の方が速度が上がります。
レイマーチのループ回数を32回、ニュートン法のループ回数を4回とし、ニュートン法のフォールバック時のレイマーチもレイマーチオンリーと同じ条件として計測した結果、ニュートン法は0.33msに対してレイマーチは0.60msとなりました。
Ultrafastとは言わないまでも、結構良い結果を得られているように思います。
とはいえ、レイマーチはスクリーン空間のピクセル距離にすることでより良い結果を得られると思いますし、カメラからの距離に応じてステップ数を調整するなどすればもっと高速化する可能性はありそうです。
しかし問題は屈折面が平面ではなくそれなりの法線を持っている場合です。
水面のようにゲーム中で常に動いているようなものであれば、正直なところ正確な屈折結果が必要にならないと思っています。
法線マップを水面に適用するオプションも追加してみたのですが、法線マップがあるだけでどの手法でもそんなに気にならないなと。
動かしていなくてもそんな感じなので、動かしたら一律屈折でも気にならないのではないでしょうか。
まあ、ゲーム内容とか屈折面と視点の距離とか、様々な要因があるので一概には言えないのですが。
今後の課題としては、ニュートン法の深度バッファのミップマップ化でしょうか。
擬似的に連続性と滑らかさを上げることでより良い結果が得られるかもしれません。
ミップマップ化の処理が含まれてしまうのでパフォーマンスは悪くなる可能性はありますが、より良い結果が得られるのであれば悪くないかも。
また、レイマーチの実装はちゃんと作るべきです。
現在の実装はあまりよろしくないので、パフォーマンス比較をするにしてもフェアじゃないように感じます。
ただまあ、ちゃんと作ったらレイマーチはかなり遅くなりそうな予感はあります。
他にも手法ごとにシェーダを分けたほうがいいかなとかですね。
関係あるのかどうかわからないのですが、if~elseで[branch]属性入れていた場合に思った結果にならない場合があったので、不要な分岐は避けるべきでしょう。
これらの更新を行って結果に差が出てくるようでしたらこの記事に追記していきます。
深度バッファ、及び法線のGBufferをミップマップ化する手法を取り入れてみました。
該当ソースコードはこちらです。
結果としてはパフォーマンスが微妙に悪化、品質はそこまで大きな変化なしという感じです。
まず、ミップマップ化を行う必要があり、その分の処理時間が追加されています。
PIXでの計測では0.1msちょっとくらいですが、Computeキューを利用することで隠蔽は可能なのでまあ良しとします。
しかし、水面描画部分ですらパフォーマンスが微妙に悪くなっていました。
最適化不足な部分はあるものの、Ultrafastとは言いづらいですね。
今回の変更でデバッグ表示として、フォールバックしているピクセルの可視化を行いました。
可視化してみるとミップマップバッファを利用するほうが面法線を再構築する方法より明らかにフォールバックするピクセルが減っています。
左がミップマップ利用、右が以前からの面法線手法です。赤いピクセルがレイマーチにフォールバックしているピクセルです。
このようにレイマーチにフォールバックするピクセルは大幅に減っているのですが、それでも面法線のほうがパフォーマンスは良好です。
なお、レイマーチのみの手法と比較すると、レイマーチの回数や表示角度などによって差が出ます。
NsightやPIXでプロファイルしてみると、どうもミップマップバージョンは面法線バージョンやレイマーチバージョンと比べて占有率が少し低いようです。
レジスタの使用数がレイマーチ<面法線<ミップマップとなっているようで、占有率はこれに影響されています。
また、L1TEXとL2のキャッシュヒット率が微妙に低いようです。
ミップマップバージョンでは深度と法線のテクスチャをサンプリングしていますが、残りの2つの手法は深度のみをサンプリングしています。
多分これが原因でしょう。
プロファイラでのキャプチャ結果としては差があると言っても0.03msとかの差なので、正直どれを選んでも似たりよったりです。
ですが、RTX4080でこのような明確な差が出ていますので、普及帯のハードではもっと差が出るかもしれません。
ただ、レイマーチバージョンは結構調整が難しいと感じました。
水面と床面の高さの差やループ回数などをうまく調整しないとレイのヒットがうまく取れず、安定しない結果になってしまうことがあります。
もっとループの回数を増やせば安定感は増すのですが、当然負荷も上がっていきます。
安定性という点ではニュートン法とミップマップ化した深度・法線バッファの組み合わせはなかなか良いと感じたので、パフォーマンスより安定性重視で選んでもいいかもしれません。