レイトレーシング三部作の最終回です。実用に足る速度をどう出すかを扱います。
発散が最大の敵
GPU は SIMD グループ単位で実行します。Apple GPU では 1 simdgroup が 32 スレッドです。グループ内の全スレッドが一つのプログラムカウンタを共有するため、分岐では両方の経路が直列に実行され、その経路を取らないスレッドはマスクされます。
レイトレーシングは本質的に発散し、しかも二つの層で発散します。
実行の発散。 同一 simdgroup 内のレイが異なるマテリアルに当たり、まったく別のシェーディング分岐に入ります。最悪の場合、32 スレッドが 32 種類のコードを実行し、実効利用率は 1/32 まで落ちます。
メモリの発散。 数回バウンスすると隣接ピクセル由来のレイはまったく別方向を向き、メモリ上で遠く離れた BVH ノードに触れます。キャッシュヒット率は崩壊し、レイテンシが隠せなくなります。
この二つゆえに、レイトレーシングの最適化はラスタライズとはまるで異なります。目標は命令数を減らすことではなく、スレッド群に似た仕事をさせることです。
ウェーブフロントパストレーシング
従来の「一つのカーネルが経路全体を走る」方式(メガカーネル)には二つの問題があります。レジスタ使用量が全分岐の最大値になって占有率を潰すこと、そしてバウンスが進むにつれてアクティブなスレッドが減り(経路の終了深度がばらつく)、SIMD 利用率が下がり続けることです。
ウェーブフロント方式はパストレーシングを小さなカーネルに分割し、キューでつなぎます。
Plain Textレイ生成 ─> [レイキュー] ─> 交差判定 ─> [ヒットキュー] ─> マテリアル別に仕分け ─> シェーディング A
─> シェーディング B
─> ...
└─> ミス ─> 環境カーネル
利点は次のとおり。
- 各カーネルのレジスタ需要がはるかに小さくなり、占有率が上がる。
- キューを圧縮できる。終了した経路を除き、生き残りを詰め直せば SIMD 利用率がほぼ 100% に戻る。
- 同じマテリアルのヒットを同じカーネルへ振り分けられ、シェーディング時の実行発散がほぼ消える。
代償は、すべての中間状態——レイ、throughput、乱数シード——をデバイスメモリへ書く必要があることで、帯域を大きく消費します。したがってウェーブフロントは経路が長くマテリアルが複雑な場面で効き、バウンスが 1〜2 回のハイブリッドレンダリングではメガカーネルに負けることも少なくありません。
Metal の intersector は intersection_query を備え、カーネル内から走査を手動で制御できます。ウェーブフロント実装に必要な柔軟性はここから得られます。
加速構造のトレードオフ
BVH 構築には重要なフラグ群があります。
Swiftdescriptor.usage = [.preferFastIntersection] // あるいは .preferFastBuild, .refit
.preferFastIntersection— SAH(表面積ヒューリスティック)で高品質な木を作ります。走査は速く、構築は遅い。静的ジオメトリにはこれを使います。.preferFastBuild— LBVH のような高速構築法。木の品質は落ち、走査は 10〜30% 遅くなります。毎フレーム再構築するジオメトリ向けです。.refit— 後から refit できるようにします。
インスタンス数について。TLAS の走査コストはインスタンス数とともに増えます。独立した小さな物体が一万個あるなら、少数の BLAS にまとめたほうが得です。逆にシーン全体を一つの巨大な BLAS に入れるのも良くありません。どこか一箇所の変化で全体の再構築が必要になります。経験的には、空間的な局所性に沿って数十から数百の BLAS に分けるのが健全です。
カリングはレイトレーシングにも適用できます。 カメラから遠い小さな物体は TLAS から丸ごと外すか、ローポリの BLAS に差し替えられます。視錐台カリングはできません(反射は視錐台の外を映します)が、距離とサイズに基づくカリングは有効です。
サンプリング列
モンテカルロの収束はサンプル数だけでなく、サンプル分布の質にも依存します。
純粋なホワイトノイズの収束は O(1/√N) です。低差異列(Sobol、Halton)はより均等に分布し、低次元では O(1/N) に近づきます。リアルタイムの 1 spp では、この差は劇的です。
さらに重要なのはノイズが画面上でどう分布するかです。同じ 1 spp でも、ブルーノイズとして分布した誤差はホワイトノイズよりはるかに良く見えます。人間の目は高周波ノイズより低周波ノイズ(大きなまだら)にずっと敏感であり、またブルーノイズは後段の空間フィルタや TAA で除去しやすいからです。
実務でもっとも効果的な組み合わせは、ピクセルごとのブルーノイズオフセットと低差異列です。
MSLfloat2 sample2D(uint2 pixel, uint frameIndex, uint dimension) {
float2 sobol = sobolSequence(frameIndex, dimension);
float2 offset = blueNoiseTexture.read(pixel % 128).rg; // ピクセルごとに固定のオフセット
return fract(sobol + offset); // Cranley-Patterson 回転
}
すべてのピクセルが同じ Sobol 列に異なるオフセットを加えるので、各ピクセル単体では低差異(収束が速く)、ピクセル間ではブルーノイズ(見た目がよく、デノイズしやすい)になります。コストはほぼゼロで、効果は非常に目立ちます。
ボトルネックを特定する
Xcode の GPU フレームキャプチャにはレイトレーシング専用の支援があり、真面目に使う価値があります。
Shader Profiler は行ごとのコストを表示します。レイトレーシングカーネルで、時間が走査(BVH のメモリアクセス)にあるのかシェーディング(ALU)にあるのかは、最適化の方向をまったく違うものにします。
Acceleration Structure Viewer は BVH の階層と各ノードのバウンディングボックスを可視化します。木の品質の問題——重なりすぎたボックス、不均衡な深さ——はここで一目でわかります。
占有率 は実際に常駐しているスレッド数を教えてくれます。レイトレーシングカーネルはたいていレジスタ律速で、レジスタを使いすぎたカーネルはハードウェアが本来収容できる半分しか走らないこともあります。生存変数を減らす、大きな構造体を分割する、float の代わりに half を使う、いずれも効きます。
よくある発見として、レイトレーシングは交差判定が遅いのだと皆思っていますが、プロファイルすると本当のボトルネックはシェーディング時のテクスチャサンプリングだった、というものがあります。各レイが別々の面に当たるためテクスチャアクセスは事実上ランダムで、キャッシュが役に立たないのです。この場合の正しい対処は BVH の調整ではなく、テクスチャ解像度を下げることと、より積極的なミップバイアスです。
どこから導入するか
実務的な導入順は次のとおりです。
- 影 — トレースした影は、品質でも実装の複雑さでもカスケードシャドウマップの仕組みに勝ります。最初に置き換える価値があるのは通常ここです。
- 反射 — SSR の破綻(画面外、遮蔽)は目立ちます。トレースした反射はそれを直接解決し、滑らかな面だけに限定して有効化できます。
- アンビエントオクルージョン — トレースした AO は SSAO よりずっと正しいですが、SSAO はずっと安価です。価値はシーン次第。
- 大域照明 — 見返りは最大、コストも最大。完全なデノイズパイプラインと ReSTIR のようなアルゴリズムがなければ、リアルタイム予算で使える結果にはなりません。
これで Metal シリーズの本線は終わりです。付録の二本、#21 コンピュートシェーダー と #22 SwiftUI における Metal は、この本線の外にありながら同じくらい頻繁に必要になる内容を扱っています。