← グラフィックスに戻る

GLSL シェーダー・パストレーサー

MIS と BVH を備えた GLSL/OpenGL ベースの GPU パストレーサー

日付 2026年3月
タグ
glslopenglc++

概要

GLSL で書いた GPU パストレーサーです。インテグレータ全体がフラグメントシェーダーの中で動いています:毎フレーム各ピクセルにつき 1 本のパスをトレースし、その結果を累積テクスチャに平均していくので、ビューポート上で画像がリアルタイムに収束していきます。MIS 付きの next event estimation、ディフューズ・スペキュラ・マイクロファセット・導体マテリアル、BVH 付きのテクスチャ付き三角形メッシュ、薄レンズの被写界深度、HDR 環境マップ、Intel OpenImageDenoise に対応しています。

カバー画像

128spp、1303x2000、デノイズなし、2 分 19 秒。ACES トーンマッピング

カバー画像

キャラクターモデルは YYB-Era 氏、シーンのモデル・構図・ライティングは自作

インテグレータ

インテグレータは最大 10 段のバウンスのループで、2 つの値を持ち回ります:throughput、つまりここまでのパス上の BSDF・コサイン・1/pdf すべての積と、カメラへ返す放射輝度 accumLight です。交差のたびに:

  1. 表面が発光している場合、その Le に throughput を掛けて加算します。ただしカメラレイのとき、または直前のバウンスがスペキュラだったときだけです。そして終了します。
  2. 表面がディフューズかマイクロファセットなら、この点での直接光を MIS で計算し、throughput を掛けて加算します。スペキュラ表面はこのステップを飛ばします。
  3. 新しい 2 次元乱数で BSDF をサンプリングして次の方向を得て、throughput に bsdf * |cos| / pdf を掛け、交点から次のレイを出します。
  4. レイがシーンの外へ抜けたら、環境の色に throughput を掛けて加算し、終了します。

ステップ 1 が推定を不偏に保つ要です。ディフューズ表面での直接光はステップ 2 の NEE ですでに数えているので、バウンスレイが再び光源に当たっても Le を二重に足してはいけません。例外がスペキュラ表面です:BSDF がデルタなので光源サンプリングが鏡面反射方向を選ぶことはありえず、鏡の中の光源を見る唯一の手段がバウンスレイになります。prevSpecular フラグがその判断を次の反復へ持ち越します。

MIS による直接光

直接光の関数は 2 つの推定量を power heuristic で組み合わせます:

  • ライト戦略。 光源を 1 つ一様に選び(環境マップも 1 つの光源として数える)、その上の点または方向をサンプリングし、シャドウレイを飛ばして、その方向への BSDF を評価します。寄与は pdf_light² / (pdf_light² + pdf_bsdf²) で重み付けされ、pdf_bsdf は同じ方向に対する BSDF の pdf です。
  • BSDF 戦略。 BSDF をサンプリングしてレイをトレースし、最初のステップで選んだ光源に当たったときだけ数えます。重みにはその方向に対する光源の pdf を使い、それを計算するのが Pdf_Li です:矩形の面積 pdf を立体角に変換したもの、または球の円錐 pdf です。

点光源とスポットライトはライト戦略だけを使います。BSDF サンプリングしたレイが点に当たる確率はゼロなので、2 つ目の推定量はノイズを増やすだけです。

ロシアンルーレット

3 回目のバウンス以降、パスは q = max(throughput.rgb)(下限 0.05)の確率で生き残り、生き残ったパスは不偏性を保つために q で割ります。実装はしましたが、正直なところ目に見える違いを感じられているかは分かりません。

光源サンプリング

光源の種類ごとにサンプラーと pdf があります:

  • 矩形エリアライト。 単位正方形上の一様な点をワールド空間に変換します。面積 pdf 1/area を掛け、光源側のコサインで割って立体角に変換します。裏面のサンプルはゼロを返します。
  • 球エリアライト。 球が張る方向の円錐内を一様にサンプリングします。cosThetaMax は中心までの距離と半径から求めるので、裏側にサンプルを無駄にしません。
  • 点光源。 逆二乗の減衰で、pdf は単に 1/numLights です。
  • スポットライト。 点光源と同じで、強度は外側と内側のコーン角の間の smoothstep で決まります。
  • 環境光。 法線まわりのコサイン重み付き半球サンプリングです。シャドウレイがシーンから完全に抜けたときだけサンプルが有効になり、マップは方向を正距円筒 UV に変換して参照します。

マテリアル

ディフューズと Oren-Nayar

Lambert は albedo / pi です。Oren-Nayar はそこに 2 つの角度と方位角差から求めるラフネス項を加え、AB の係数は sigma から標準の式で求めます。

512spp、1000x1000、デノイズあり。ACES トーンマッピング。Oren-Nayar モデルは sigma = 0.8。

LambertianOren-Nayar(エネルギー保存の補正あり)Oren-Nayar
LambertianOren-Nayar エネルギー保存の補正Oren-Nayar

通常の簡略化 Oren-Nayar が Lambertian より目に見えて暗いのが分かります。これは、最もよく使われる簡略化 Oren-Nayar モデルが実はエネルギー保存していないからです:sigma > 0 なら常に A < 1 になり、光のエネルギーが失われます。真ん中のレンダーは小さなエネルギー補正係数 1 / (1 - 0.33 sigma² / (sigma² + 0.09)) を掛けたあとのもので、ローブを Lambert が反射するはずの量までスケールし直しています。

個人的には、違いがより目立つのは石と竜のあごの部分だと思います。こうした斜入射の角度で Oren-Nayar のほうがずっと光のエネルギーを保っています。とはいえ差があまりに小さいので、どこかで間違えているのではないかと少し心配です。

モデルは Benedikt Bitterli のレンダリングリソース より

スペキュラ反射・透過・ガラス

完全な鏡面反射と完全な透過はデルタ BSDF なので、Sample_f は唯一の有効な方向を pdf 1 で返し、f() はそれ以外のどの方向に対してもゼロを返します。ガラスは入射角に対して誘電体のフレネル項を評価し(レイが媒質の内側にあるときは屈折率を入れ替え、全反射のときは反射率 1 を返す)、そのフレネル値に等しい確率で反射か屈折かを選びます。両方の分岐をトレースする代わりに確率的に選ぶことで、ピクセルあたり毎フレーム 1 本のパスのままにしています。

Trowbridge-Reitz マイクロファセットのスペキュラ反射と透過

反射は Trowbridge-Reitz 分布からマイクロファセット法線 wh をサンプリングし、wo をそれについて反射させます。透過は代わりにサンプリングした wh を通して wo を屈折させ、評価用のハーフベクトルは normalize(wo + eta * wi) になります。etawo が表面のどちら側にあるかで反転します。pdf には透過のヤコビアン |wi·wh| eta² / (wo·wh + eta wi·wh)² が掛かり、BTDF には対応する分母と (1 - F) の因子が付きます。

マイクロファセットガラスはこの 2 つを組み合わせます。まず wh をサンプリングし、マクロ法線ではなくそのマイクロファセット法線に対してフレネルを評価してから、確率 F で反射、1 - F で屈折を選び、それぞれの pdf に同じ確率を掛けて f / pdf が正しいままになるようにします。サンプリングしたマイクロファセットを通る屈折が全反射になった場合、そのサンプルは反射にフォールバックします。

1024spp、1000x1000、デノイズなし。Reinhard トーンマッピング

ラフネス 0.01ラフネス 0.05ラフネス 0.25
ラフネス 0.01ラフネス 0.05ラフネス 0.25
ラフネス 0.50ラフネス 0.75ラフネス 0.95
ラフネス 0.50ラフネス 0.75ラフネス 0.95

導体マテリアルのフレネル反射率

導体は RGB チャンネルごとの etak を使った複素屈折率の完全なフレネル方程式を、完全鏡面反射として評価します。下の 3 つの金属は金・銅・アルミニウムの表の値です。

金:eta [0.183, 0.421, 1.373]、k [3.424, 2.346, 1.770]

銅:eta [0.271, 0.677, 1.316]、k [3.609, 2.625, 2.292]

アルミニウム:eta [1.655, 0.879, 0.520]、k [9.224, 6.270, 4.837]

導体マテリアル

テクスチャ

マテリアルはアルベドマップ(サンプリング後に sRGB からリニアへ変換)、幾何法線の接空間で適用する法線マップ、マテリアルのスカラーのラフネスを上書きするラフネスマップを参照できます。3 つともサンプラー配列へのインデックスなので、メッシュはどの組み合わせでも使えます。

メッシュと BVH

三角形メッシュには CPU 上で表面積ヒューリスティック(SAH)による BVH を構築します:

  1. 現在の三角形範囲のバウンディングボックスと、その重心のバウンディングボックスを計算する。
  2. 重心のバウンディングボックスを最長軸に沿って 12 個のバケットに分け、各三角形を重心でバケットに振り分ける。
  3. 11 通りの分割それぞれについて、コスト = 0.125(トラバーサル)+ (count_left * area_left + count_right * area_right) / area_parent。
  4. 最も安い分割を採用する。退化した場合(すべてが片側)は範囲を半分に分けるフォールバックにする。

リーフは三角形を 1 つだけ持ちます。ツリーはノードごとに 2 つの vec4 に平坦化されます:バウンディングボックスの最小点と二次インデックス、バウンディングボックスの最大点と三角形数です。左の子は常に配列の次のノードなので、右の子のインデックスだけを保存すれば済みます。平坦化した配列は SSBO として GPU に送り、三角形自体はテクスチャに置いてインデックスで取り出します。

シェーダー内のトラバーサルは 64 個のノードインデックスを持つ明示的なスタックです。各ノードでスラブテストを行い、外れたボックスや完全にレイの後方にあるボックスを飛ばし、さらに入口距離がすでに現在の最近接ヒットより遠いボックスも飛ばします。リーフでは Möller-Trumbore を実行し、重心座標で法線と UV を補間します。

目立つバグにひとつぶつかりました:補間したメッシュ法線がオブジェクト空間のまま返ってきていて、回転やスケールの付いたメッシュはすべてシェーディングが狂っていました。その結果なかなか面白いグラデーションの色になったので、ここに載せておこうと思いました。修正はメッシュ変換の逆転置をきちんと掛けるだけでした。

マット、法線バグありガラス、法線バグあり修正後
メッシュ法線が壊れたマットのミクメッシュ法線が壊れたガラスのミクメッシュ法線を修正したミク

被写界深度

カメラは薄レンズです。各ピクセルについて、ピンホールレイを焦点面まで延ばし(t = focalDist / (dir · forward))、同心円ディスクサンプリングでレイの原点をレンズディスク上のランダムな点へ移し、新しい方向はそのレンズ上の点から焦点へ向けます。アナモルフィックなボケは 1 行追加するだけです:レンズサンプルの x 座標を、絞りでスケールする前にスクイーズ係数で割ると、錯乱円が縦長の楕円になります。

128spp、1303x2000、デノイズなし。ACES トーンマッピング

被写界深度なし被写界深度あり
被写界深度なし被写界深度あり
球面アナモルフィック
球面アナモルフィック

環境マップとプロシージャルスカイ

抜けたレイと環境光は同じ関数を参照します。HDR マップが読み込まれているときは、方向を atanasin で正距円筒 UV に変換します。カバー画像はその代わりにプロシージャルスカイを使っています:地平線の暗いティールから天頂の白へのグラデーションで、光源というより補助光として読めるよう全体を暗めにしています。

1024spp、1000x1000、2 秒。Reinhard トーンマッピング

コーネルボックスガラス球
コーネルボックスガラス球

プロシージャルスカイは冒頭のカバー画像を参照。

Intel OpenImageDenoise

パストレーサーは毎フレーム 3 つのレンダーターゲットを書き出します:カラー、アルベド、法線です。アルベドと法線は最初のヒットではなく最初の非スペキュラなヒットで取得するので、ガラス球越しでも AOV はガラス表面ではなくその奥にあるものを表します。3 つともカラーと同じように累積します。OIDN の RT フィルターを HDR モードで、アルベドと法線のバッファをガイドにして実行します。

12spp、1303x2000。生とデノイズ後:ACES トーンマッピング。アルベド:リニアな反射率で、ガンマのみ。法線:ワールド空間、[-1, 1] から [0, 1] に再マップ、トーンカーブなし。どちらのガイドも最初の非スペキュラなヒットで取得するので、ガラスの「17th」の文字にはその奥の階段が映り、反射が空に抜ける部分は黒になります。

デノイズ後
生デノイズ後
アルベド法線
アルベド法線

オフライン CLI レンダリング

Linux で Qt がクラッシュしたりファイルダイアログが動かなかったりするのに本当にうんざりして、これを作りました。--scene を渡すと UI を完全にスキップします:レンダラーは指定した解像度のオフスクリーンフレームバッファに固定サンプル数をレンダリングし、必要ならデノイズし、ファイルを書き出し、レンダリング時間を表示して終了します。

./ShaderPathtracer --scene <file.json>   ヘッドレスレンダリング(CLI モードに必須)
  --samples <n>        ピクセルあたりのサンプル数(デフォルト 256)
  --width / --height   解像度(デフォルト 800x600)
  --output <dir>       path_tracer/render/ 以下の出力ディレクトリ
  --oidn               最終画像をデノイズする
  --aovs               アルベドと法線のパスも書き出す
  --png / --hdr        出力形式(どちらも指定しなければ PNG)
  --envmap <file.hdr>  環境マップ
  --reinhard           ACES の代わりに Reinhard トーンマッピングを使う
  --sky                プロシージャルスカイ(--envmap が優先)

トレーサーには本物の OpenGL コンテキストが必要なので、offscreen プラットフォームは使えません。DISPLAY が設定されていないとき、ランチャーは動作中の Wayland ソケットを探して接続します。これで SSH 越しにレンダリングできます。

NEE デモレンダー

1024spp、1000x1000

エリアライトスポットライト球ライト
エリアライトスポットライト球ライト
点光源ラフミラーVeach シーン
点光源ラフミラーVeach シーン

使用ツール

C++、GLSL、OpenGL、Qt、Intel OpenImageDenoise