DirectXの話 第180

Ray Binning

22/04/20 up

Ray Binningとは

Ray Binningレイトレーシングを行う際にある種の分類を行ったRayごとに処理を行うことで高速化を図る手法です。
Binningと言うのはWikipediaでは「対象物をある集合(「ビン」)に分配、集約する作業手順」とあります。Classificationと意味合い的には近いと思いますが、そこまで厳密ではないという感じでしょうか。

ハードウェアレイトレーシングではBVH上でレイをトレースし、ヒットしたポリゴンがあるとそのポリゴンに対してプログラマブルシェーダ(主にClosest Hit Shader)が起動します。
シェーダ部分の高速化は単純なシェーダの高速化だけではなく、マテリアルソートなどを利用してキャッシュやDivergenceを考慮して高速化することができます。
レイトレース自体の高速化はBVHの品質を高める方法が考えられます。インスタンスのオーバーラップを減らしたり、近い部分にあるインスタンスをまとめたりです。
なお、NVIDIAさんの発表によると、インスタンスを細かく分けるより全部1つのBLASにまとめた方が高速になりやすいそうですが、ゲームではそのような実装は難しいので、インスタンスを減らすなどの調整をする必要があります。

もう1つのレイトレース自体の高速化手法は、BVHの中で同一のインスタンスへの衝突判定がまとめて発生するようにすることです。
TLASをトレースした複数のレイが同じインスタンスに衝突しているのであれば、そのレイを同じWaveで処理するようにしたほうがBLASのキャッシュ効率が良くなるという寸法です。
この同じインスタンスに衝突しやすいレイを集約するのがRay Binningです。
以下の図はこの概念を簡単に図示したものです。

同色が同じビンに分配されたレイです。同色のレイはほぼ同じインスタンスに衝突していることがわかります。
この図ではレイは6本しかありませんが、実際にはもっと多くのレイがトレースされます。もちろん、ビンの数も3つと少ないわけではありません。

さて、ここで問題となるのはビンをどのような集合とするかということです。
Ray Binningについてのプレゼンテーションとして最初に思いつくのはEA DICEのBattlefield Vについての資料[1]です。
この資料によると、Battlefield Vではレイを飛ばすピクセルのスクリーンオフセットとレイの方向からキーを作成し、Binningしていたようです。

もう1つの資料はSIGGRAPH 2019でのUnityのプレゼンテーション資料[2]です。
こちらはレイの方向だけをビンとしていますが、Bininngはスクリーンのタイル単位で行うことでBFVのスクリーンオフセット代わりとしています。
資料を読むとわかりますが、レイの方向はOctahedral spaceに変換しています。
レイの方向は当然3次元ですが、3要素を利用してBinningするのはあまり効率的ではないので、Octahedral spaceに変換することで2次元の座標にしているようです。
実はUnreal EngineにもRay Binningは含まれていて、Unityと同じ手法を採用しています。
今回の私のサンプルもこの手法を用いています。

実装

サンプルはいつもどおりにGitHubに上がっています。

Monsho/D3D12Samples

今回はSample028です。

まずはRay BinningのCompute Shaderから見ていきます。

この関数はレイの方向をビンに変換する命令です。
正規化された方向をOctahedral spaceに変換し、Octahedral spaceがタイルサイズの2次元画像としたときにピクセル位置を求めています。
さらにこのピクセル位置をMorton code、いわゆるZオーダーで1つuintとします。
このuintの値がビンになります。

Compute Shaderの本体です。このコードはUnreal Engineを参考に作成しています。
共有メモリもだいぶ使っているので、所々でバリアが張られているのがわかるでしょう。

まずはビンのカウントを0クリアします。
スレッドグループのスレッド数はBinningするタイルサイズと同一ですので、各スレッドが0クリアすればすべてのビンがクリアされます。

次にスレッドIDなどからピクセルの位置、タイルの位置、タイル番号などを計算します。
これがわかればGBufferを読み込み、レイの方向を決定することができます。
今回のサンプルでは完全鏡面反射のみを行っていますが、実際にはラフネス値に合わせて重要度サンプリングを行う必要があります。
このとき、深度が書き込まれていなかったり、ピクセル位置が画面外だったりした場合はレイは無効とし、一番最後のビンに登録します。
有効なピクセルであれば法線情報から完全反射ベクトルを求め、その方向からビンの値を求めます。

ここでWITHOUT_BINNINGという定義がありますが、これが1の場合はBinningを行わずにそのままレイ情報を構造体のピクセル番号に書き込みます。
これはBinningの有無によってどの程度負荷が違うかを計測するための機能ですので、実際のエンジンにはこの部分のコードはいりません。

さて、各ピクセルのレイからビンは求まりましたが、これを同じビン同士で集約しなければなりません。
まずは各ビンのカウントをアップします。これでどのビンにいくつのレイが含まれるかがわかります。
あとは0番のビンから順番にレイを格納していけばいいのですが、愚直にループを回す方法では1スレッドだけが働くことになり、パフォーマンスに問題が出ます。

そこで登場するのがWave intrinsic。Wave単位で様々な情報をやり取りすることができる大変便利な命令群です。
最初に自分のスレッドが所属するWaveインデックスと、自身が実行されているLaneインデックスを求めます。
次にグループスレッド内でのIDをビンインデックスとして、そのインデックスのビンのカウントを取得します。
この値をWaveActiveSum命令の引数に渡すことで、このWaveに所属するビンインデックスの総数が求まります。
なお、ここで注意が必要なのは、グループスレッドの数はWaveのスレッド数より多いので、同じグループスレッド内で複数のWaveが存在するという点に注意が必要です。
そしてスレッドが所属するWaveインデックスの合計値(WaveSums)にビンのカウント総数を格納します。
ここで一旦バリアを張ります。

次に先のWaveSumsのPrefix Sumを求めます。
Prefix Sumは0番インデックスからその総数を順番に加算していく処理です。
WavePrefixSum命令を使うことでWave内のアクティブなLaneの順番に沿ってPrefix Sumを求めることができます。
これによってWaveSumsの配列には、各Waveがデータを格納する先頭アドレスが入っていることになります。
ここでまたバリアを張ります。

そして今度はグループスレッドIDをビンインデックスとして、そのビンのカウント配列(BinCounts)に、自身が所属するWaveのPrefix Sum(WaveSumsに格納されている値)と、ValueのPrefix Sum(WavePrefixSumを用いる)を加算したものを登録します。
これによって、ビンのカウント配列には、そのビンインデックスがデータを格納する先頭アドレスが含まれることになります。
これでやっとビンごとのデータ格納場所が求められたというわけです。
やはりここでバリアを張り、あとはレイを格納するインデックスを先のBinCountsとビンにおける自身のインデックスから求めて、レイ情報を構造体バッファに書き込みます。

ちなみに、Wave intrinsicを利用しない処理もコードに含まれていますが、こちらを利用するとかなり負荷が高くなるので、Wave intrinsicの強力さを体感するにはいいのではないかと思います。

レイトレーシングのコードについては特に面白いことはしていないので解説は省きます。
reflection_standard.lib.hlslはRay Binningしたレイを用いず、従来どおりにピクセルごとのレイをRGSで求めています。
reflection_binning.lib.hlslがRay Binningを利用しています。構造体バッファに含まれているレイを取得し、そのピクセルで処理を行います。

パフォーマンス

本サンプルではRay Binningを行わない従来手法のリフレクション、Ray Binningを行って構造体バッファのレイ情報を用いるリフレクション、そして構造体バッファにレイ情報は含めるがRay Binningは行わないリフレクションです。
デバッグメニューではEnable Pre Ray Generationがレイ情報を構造体バッファに含めるためのフラグ、Enable Ray Binningが構造体バッファに含めるレイ情報をBinningで分類するフラグです。
つまり、以下の3通りのリフレクションを試せます。

1.Enable Pre Ray Generation = False
2.Enable Pre Ray Generation = True、Enable Ray Binning = True
3.Enable Pre Ray Generation = True、Enable Ray Binning = False

それぞれのパターンでの全体処理時間は以下のようになりました。

なんと従来型のほうが圧倒的に速いという結果に!
なぜこのようなことになっているのが、Nsightでプロファイルしてみましょう。

まずは従来手法。1440pで約5msです。

そしてこちらがRay Binning有効。
まずはRay BinningのCSで0.6msかかっています。これはまあ必要経費なので致し方なし。
実際のレイトレーシングですが、こちらは5.73msと大幅増。なんでや!

詳しく見ていきましょう。
大きく違いがあるのはVRAM Throughputです。上の画像にもありますが、従来手法と比べて1.44倍です。
比較するとわかりますがVRAM Readも1.57倍。ここから、VRAM読み込みに問題があるということがわかります。
従来手法と比べて増えたリソースアクセスはレイ情報を格納したバッファです。こいつを読み込む処理が負荷となっているということでしょう。

しかし、従来手法のStall要因はLong ScoreboardとShort Scoreboardが拮抗しているのに対して、Ray Binningの方はLong Scoreboardが突出してShort Scoreboardが少し落ちています。
これはレイトレーシングが高速になったと考えることもできますが、レイ情報バッファのせいでLong Scoreboardが伸びただけの可能性もあります。
ただ、これについてはレイトレーシング自体の高速化に寄与しているのではないかと考えています。
というのも、Binningせずにレイ情報バッファを使った場合はレイトレーシング部分が6.03msでした。
Stall要因もShort ScoreboardがRay Binning版より大きくなっていますし、Ray Binningがレイトレーシングの高速化につながるということは確かなのでしょう。

なお、Unreal Engine 4のRay BinningはPC版ではデフォルトOffになっています。
つまり…お察しください。
いや、でも、ハードによっては高速化するかもしれませんよ!