25/10/13 up
5年以上前にVariable Rate Shading (以下VRS) についての記事を書きました。
このときはD3D12でハードウェアに実装された機能を紹介しましたが、パフォーマンスが向上するかというと割と疑問でした。
確かに、VRSを利用したパスについてはある程度向上するのですが、Coarse Pixelのためのデータを生成するのに赤字になっている状況でした。
当時は機能解説をメインに記事を書いていたためパフォーマンスや実践的な利用方法ついてはあまりきちんと対応していなかったのですが、どうにもHardware VRSは扱いづらいという印象でした。
しかし、レイトレやCompute Shaderを利用した様々なグラフィクス技術が普及した昨今では、それらの処理でVRSを利用できる場面が増えてきました。
例えばレイトレによる処理はパフォーマンス向上のために解像度を下げることもありますが、オブジェクトのエッジなどではアーティファクトが目立つこともあります。
そのような場合にVRSが使えればエッジ部分はフル解像度、そうでない場面は半解像度などにしてパフォーマンスとクオリティのバランスを取ることもできます。
ただし、Hardware VRSはピクセルシェーダでしか利用できず、Compute Shaderやレイトレでは利用できません。
そこで利用されるのがSoftware VRSです。
その名の通りソフトウェア実装、というより、自前でVRSの実装をしましょうというものです。
自前ですので計算量の減少もバッファへの書き込みも自前でシェーダを書く必要がありますが、その分自由度は高いです。
Software VRSの記事としては古いものではCall of Dutyで使われたものが2020年に発表されています。
今回はそんなSoftware VRSの実装について解説します。
サンプルはこちら。
Software VRSはCompute Shaderやレイトレで利用するため、Hardware VRSのようなプリミティブ単位での処理ではなくCoarse Pixel、粗いピクセルでの処理となります。
粗いピクセルということはピクセル単位で処理され、ピクセルごとに処理するか、隣接ピクセルで1つの処理にするかを選択することになります。
このためにはどのピクセルをどの単位で処理するかを決定するためのバッファが必要になり、今回はこれをVRS Maskバッファと呼ぶようにします。
VRS Maskバッファの生成には当然時間がかかりますので、パフォーマンス向上のために気をつけることがあります。
VRS Maskバッファ生成に利用する情報はすでに存在しているデータを利用するようにしましょう
VRS Maskを生成するために今までにないバッファを作成するなどするとパフォーマンスに悪影響を与えます
可能であればGraphics Queueの裏のCompute Queueで実行するようにしましょう
今回、自分の実装ではGraphics Queueでやっていますが、これはうまくCompute Queueで並列化できる場所がなかったためです
今回の自分の実装ではVRS Maskは2種のデータから生成しています。
1つはトーンマップ前の画像のLuminance、もう1つはVisibility Bufferから取得できるマテリアルIDです。
マテリアルIDについてはわかりやすいですね。隣接IDが同一なら粗いピクセル扱いになります。
トーンマップ前の画像については本来はあまり良くありませんが、トーンマップパスがスワップチェインに直接描画している関係でトーンマップ前の画像を利用しています。
VRSは人間の目で見てほとんど差別できないピクセルを粗く処理することが目的なので、トーンマップ前ではなくトーンマップ後の画像を利用するべきです。
この辺は、ちゃんとした実装をサボったなと思っていただければ。
VRS Maskバッファの生成シェーダはvrs.c.hlslのGenerateVrsCS関数になります。
トーンマップ前画像のIntensityから求める部分は2x2のクアッドではなく、自身のピクセルを中心に上下左右のピクセルから求めるようにしています。
この差がしきい値未満であればVRSを適用するようにします。
2x2のクアッドでIntensity差を求めた場合はどうにも結果がよろしくなかったのと、先に紹介したCoDの事例でも3x3ピクセルで判定していたためです。
上下左右の4ピクセルと3x3のピクセルではVRS Maskの結果に差がありましたが、最終結果としてはそこまで大きな問題がなかったので上下左右にしていますが、問題が出ることが多いのであれば3x3にしても良いと思います。
コミット済みコードでは3x3のSobelフィルタバージョンをコメントアウトしています。
Visibility BufferのマテリアルIDについても同様に上下左右のピクセルを取得し、自身のピクセルと比較して同一かどうかをチェックしています。
同一であれば粗いピクセルとし、違いがあれば詳細ピクセルとして記録します。
最終的には両結果の最小値を選ぶことになります。
このCompute Shaderはフル改造度で処理されますが、書き込み先のバッファは縦横半分の解像度です。
これはVRSの最大を2x2としているためです。
Hardware VRSの場合は4x4まで指定することができますが、ほぼ使うことのない4x4のために色々制限されてもあれなので、2x2を最大としています。
さて、トーンマップ前の画像を利用しているため、VRS Maskの生成はフレームの最後となります。
これでは次のフレームにそのまま利用できませんので、次のフレームでReprojectionします。
こちらは同じくvrs.c.hlslのReprojectionCS関数です。
ReprojectionするタイミングはVisibility Buffer描画後となりますので、現在フレームの深度バッファが利用できます。
現在の深度バッファから得られる座標を前フレームに変換、前フレームの深度バッファと比較してしきい値以内ならReprojection成功、そうでなければ1x1として処理します。
今回VRS Maskを利用する部分はVisibility BufferからGBufferを生成する場面です。
GIやAOでも使える部分はあると思いますが、今回はここだけです。
しかし、単純に処理しないピクセルをreturnするだけではGPUスレッドが無駄になってしまいます。
そこでピクセルビニングのタイミングで処理するピクセルのみ積むようにします。
material_binning.c.hlslのCountCS関数とBinningCS関数では各ピクセルが有効であれば処理を行うようにしていますが、ここで深度を見るだけではなくIsValidPixelByVRS関数でのチェックも入れています。
この関数では指定したピクセル位置からVRS Maskの結果を取得、その結果とピクセル位置が2x2のどの場所になるかでそのピクセルが有効かどうかを判定しています。
また、ここで取得したVRSの結果はピクセル位置としてエンコードされます。
ビニングの段階で計算されるピクセル座標は選別されているので、処理されるスレッド数は十分に減少するはずです。
あとは実際に処理している段階でVRS結果によって出力するピクセルを変更します。
material_gbuffer.c.hlslのWriteGBuffer関数で最大2x2のクアッドに対して出力を行っています。
実行するにはタイミングやリソースビューを行おうとした際に表示される文字のクリック可能な部分(例えば、"Click here to start analysis"と書かれた文字列の"Click here"部分)をクリックするか、もしくはF5キーを押します。
タイミングキャプチャを行うにはOverviewタブの下半分にあるタイミングペインでクリックする必要があります。(上図の下半分)
Analysisを実行し、タイミングキャプチャを行うと以下のような画面になります。
また、頂点属性を計算する際にも粗いピクセルの場合は2x2のクアッド中心座標をもとに計算します。
私のVisibility BufferサンプルではGBuffer生成の流れを4種類で実装しています。
Pixel Shaderによる深度ハックを用いたタイル描画手法
Compute Shaderによるピクセルビニングを用いたピクセル単位の手法
Compute Shaderによるタイル単位の手法
Work Graphによる手法
この内、Software VRSに対応しているのはピクセルビニング手法のみです。
Pixel Shaderを利用するものについてはSoftware VRSは利用できません。
タイル単位手法では処理がタイル単位のために粗いピクセルの処理は意味をなしません。
Work Graphについては対応できそうですが、今回は対応しませんでした。余裕があるときに対応するかもしれません。
パフォーマンスについてはNsight GraphicsのGPU Traceで計測を行いました。
こちらはVisibility BufferからGBufferを生成するイベントの比較です。
1stがVRSなし、2ndがVRSありです。
時間では0.06msほどVRSを使ったほうが軽くなります。
Compute Shaderの起動したWarpの数もVRSありのほうが大きく下がっていることがわかりますね。
ただし、VRS Maskの生成には時間がかかっています。
生成には0.08ms、Reprojectionには0.02msで、全体的な収支としては赤字になってしまいました。
しかしながら、実際のゲーム開発ではGBuffer生成時に複雑なマテリアル計算を行うことも多く、0.28ms程度で終了することは稀です。
GBuffer生成のパフォーマンスが1.2倍になると考えるなら十分黒字になる可能性はあります。
また、VRS Maskは他の用途にも利用できそうですし、それらにも利用できれば悪くない選択肢ではないかと思います。
あとはVRS生成のシェーダコードを最適化するのもいいかもしれません。
実際に高速化するかは実装してみないとなんともですが、共有メモリを利用することでマシになるかもしれませんね。
ちなみに、計測時のVRS Maskは以下のようになっています。
黒が詳細ピクセル、赤は1x2、緑は2x1、青が2x2です。
かなり青の範囲が広いですが、最終結果の差はそこまで大きくありません。
ただ、若干の不具合があるようで、カメラを止めていてもVRS Maskが安定しない部分があります。
これのおかげでVRSを利用するとちょっと安定しない部分が出てきています。
今回のサンプルではVRS Maskの生成をVisibility Bufferの結果とトーンマップ前の画像から求めましたが、先にも書いた通りトーンマップ後の画像から生成すべきです。
また、速度バッファを利用して速度が大きい場所で詳細ピクセルを利用するようにするもの良いでしょう。
他にもアップスケーリングを利用している場合は次のフレームでネイティブ解像度まで下げる必要があります。
だいたい微妙なサイズでスケーリングされるので、ネイティブ解像度への変換は気を使う必要がありそうです。
まだまだ考えなければならないことも多いですが、十分な可能性のある技術ですね。