【WebGPU】Deferred RenderingとShadow Mappingと

2023-11-09
WebGPUを触ってみて、一応Computeシェーダーは使えるようになった。

「次のステップ」としてチュートリアルに書かれていたWebGPU Samplesの中で、Deferred Renderingが気になったので、ソースを読んでみた。

デモページを開くソース

概要

いわゆる古典的なリアルタイム3Dグラフィックスではモデル描画時に光源処理(シェーディング)をしていた。 その場合扱える光源の数に制限があった。

Deferred Renderingという手法ではその制限を緩和できるのと、出力される中間結果をポストエフェクトに利用したりで豪華な画面が描けるため、最近はほとんどこの手法が使われているっぽい。 デメリットとしては、アンチエリアシングや半透明と相性が悪いというはなしである。

正直なところどういう仕組みなのかよく理解していなかったので、ソースを読んでみることにした。 で影を適用したかったのでシャドウマッピングを組み込んでみた。

Deferred Renderingのソースを読む

WebGPU SamplesのdeferredRenderingのソースはGitHubで公開されている。

モデル描画時にシェーディング計算したカラーを直接出力するのではなく「Gバッファ」と呼ばれる中間バッファ(テクスチャ)を構築し、 最後に画面全体を塗りつぶす際に、Gバッファに従って光源計算を行って最終出力を作成する。 光源処理をジオメトリ処理と分離できるのが利点となっている。

件のソースではGバッファとして 法線(XYZ)アルベド(RGB) 、そして 深度(Z) を出力する。

Gバッファの構築(マルチレンダーターゲット)

Gバッファ構築時にはモデルに対してdrawコールをかけて、法線・アルベド・深度を出力する。 出力先は複数のテクスチャとなるため、マルチレンダーターゲットを使う必要がある。

WebGPUでどうやるかというと、

深度バッファは通常のデプステストに使用するZバッファに出力されたものを後でそのまま利用できるのでレンダーターゲットとして指定するんではなく、 同じくデスクリプタのdepthStencilAttachmentに指定する。

画面塗りつぶしの際の光源計算(Deferred Lighting)

光源情報を格納するバッファはcreateBufferでストレージとして作成する。 コンピュートシェーダーで位置を更新した後、レンダーパイプラインに渡す。

画面全体を塗りつぶす際のフラグメントシェーダーで、Gバッファ光源情報を受け取り、 光源の数だけループでシェーディング結果を加算して最終出力を計算する。

(このサンプルでは単純なランバート・拡散反射しか考慮してないけど、他にRoughnessとか他のマテリアル情報もGバッファに書き込んでおけば豪華なライティングが可能だと思う。)

ワールド座標の復元

ひとつ面白いのはフラグメントシェーダー内で各フラグメントに対応するワールド座標を求めるところ。 件のサンプルでは光源は点光源のようなものになっていてシェーディングには光源までの距離や向きが必要で、それらを求めるためには描画しているフラグメントのワールド座標が必要になる。 ワールド座標を保持するGバッファを作成すれば直接利用できるが、バッファを削減するためか違う方法で算出している。

フラグメントに対応する2Dスクリーン座標系XYがそれぞれ-1〜+1で渡ってくる。 それで深度バッファからtextureLoadでそのフラグメントに対応する深度を取得する。 そしてカメラのビュープロジェクションの逆行列を使って算出している。

影を適用したい:シャドウマッピングを組み込む

最大1024個もの光源を使えるのはすごいけど、そういえば遮蔽判定してないので影が出てないなと思った。 できれば影も出してみたい…どうやるんだろうと考えたところshadowMappingもサンプルに含まれていた。 いっちょこれを組み込んでみることにした。

実装は紆余曲折、いろいろ苦労した。

光源ごとにシェーディング計算を分割(加算合成)

元のdeferredRenderingでは複数の光源のシェーディングを一括で行うのが利点だったが、光源の数の分のシャドウマップテクスチャを一度にすべて扱うのはちょっと難しいので、光源ごとに計算するように修正する。

画面の塗りつぶしを光源の数だけ繰り返し、結果を加算合成でフレームバッファに加えていくようにする。 WebGPUで半透明を行うには、createRenderPipelinefragments.targetsblend.colorにあれこれ指定する。 加算合成なら {srcFactor: 'one', dstFactor: 'src-alpha', operation: 'add'} でよい。

  • dstFactor'one'とかでもいいんだけど、src-alphaでいじれる道を残してみた
  • 最初の光源に対する塗りつぶしの際だけカラーバッファのクリアが必要なので、デスクリプタを別途用意するという細かいことも必要だった

シャドウマップの構築

シャドウマップを構築するには、光源からの視点でシーンを描画してデプスバッファを更新することでできる。 サンプルコードではshadowPassで行っていて、それを使ってレンダーパスを動かしている。

これを組み込む際には、光源が複数あるのでループで繰り返す。 光源計算を光源1つずつに変更したので、シャドウパス→画面塗りつぶし、という処理を光源の回数分繰り返してやる。 シャドウマップは1枚を使いまわせる。

  • シーンの描画を光源の回数分繰り返すのはなかなかヘビーだと思う

ライティングにシャドウマップを適用

シャドウマップサンプルでは、モデル描画の頂点シェーダーでワールド座標を光源のビュープロジェクション行列で光源座標系に変換して、 フラグメントシェーダーでtextureSampleCompareを使ってシャドウマップの値とZ値−オフセットを比較して、遮蔽されてない場合のみ光源の影響を与えている。 シャドウマップ作成時に通常のZテストで一番近い点のZ値が生き残るので、それより奥の位置は遮蔽されて影になっているということが判定できる、というわけ (ハードエッジ回避で近傍3x3領域の割合を考慮している)。

組み込みには、塗りつぶしのフラグメントシェーダーでワールド座標を逆算できるので、同様に光源座標系に変換してシャドウマップを適用する。

スポットライト(点光源)に変更したい

シャドウマップサンプルでは、光源視点用のプロジェクション行列にmat4.orthoを使用しているので、平行投影=ディレクショナルライトになっている。 これをスポットライトに変更したいと思った。 本当は点光源にしたかったが、全方向に光源を飛ばすにはキューブマップのように6方向に対して行う必要がありそうなので、ひとまず角度を絞ってスポットライトで1方向だけで済むように。

単にプロジェクション行列にmat4.perspectiveを使えばよい…と甘く考えていたが、それだけではダメだった。 ググったところ、wで割る必要があるとか。 でもそれだけではまだダメだった… 元の平行投影の場合でもz値に-0.007というヒューリスティックなオフセットを加えているくらいなので、さらに小手先の対処が必要なのかもしれない。

シャドウマップに描かれたデプス値と、フラグメントシェーダー内で光源座標系に変換したZ値がどうなってるかを調べることにした。

パースペクティブ射影行列・同次座標系

これは射影行列を理解する必要があると思いググって、【Unity】プロジェクション行列は掛けるだけじゃなくてw除算しなきゃダメだよという話 - LIGHT11からたどってその70 完全ホワイトボックスなパースペクティブ射影変換行列を読んでみた。

理解したところだとパースペクティブ射影変換後の座標は

となって、で割ると、

となる。最終的なデプス値は意図通りにnearZの時0.0、farZの時に1.0となる。 この計算はシャドウマップ作成時とDeferred Lighting時が同じ計算なので数値的には問題ないが、 平行投影の場合は元Zに対して線型だったのに対して、パースペクティブ投影の場合には元Zに反比例した値となっている。 そうすると遠ざかるに従ってデプス値の精度が落ちる(=数値の幅が狭くなる)ことになる。 てことで-0.007のオフセットがそのままではうまくいってない可能性がある。

場当たり的に数値をいじってみた結果、nearZを小さくしてしまうと反比例度合いがきつくなってしまうので、なるだけ大きくすることで影が表示されるようになった(付け焼き刃…)。

  • シャドウマップに保持するデプス値を線型にできればもっとよさげだが、シャドウマップは通常のデプスバッファ書き込みで行っていてちょっとそのままでは手が出せなそうなので、保留…

テクスチャ配列を使用する

シェーダーでテクスチャを配列として受け取る方法を知らなかったので光源ごとに分割したがよくよく調べるとできるとわかったので、書き直すのは大変だけど利用するよう変更してみた。

JavaScript側:デプステクスチャ作成時のcreateTexturesizeの3番目にレイヤー数を指定、 テクスチャビューはcreateView{baseArrayLayer: i, arrayLayerCount: 1}を与えて取得する。 そしてテクスチャのバインドグループレイアウトにviewDimension: '2d-array'を指定する。

フラグメントシェーダー:デプステクスチャの型をtexture_depth_2d_arrayに変更(array<texture_depth_2d>ではなかった)、 textureSampleCompareなどテクスチャを参照する関数に追加でレイヤー番号を渡す。

テクスチャ配列にインデクスでアクセスできるようになるので、光源に対してそれぞれのシャドウマップを使ったシェーディングが複数光源をまとめて1回のdrawコールで実行できる。 フラグメント内で光源の数でループして光源の影響を足し合わせていく。 ただし今まではdiscardで抜けられていたのができなくなる。 それに対応してifで影響を分岐させようにも、textureSampleCompareなどのテクスチャフェッチする関数は分岐内で利用するのはダメとのことで、0.0の乗算で無効にするなどの処理が必要になる。

こうしてディファードの光源計算はまとめられるが、その代わりに今まで1つのシャドウマップテクスチャを使いまわしてたのが、光源ごとに用意してやる必要がある。

  • パイプラインでのブレンド処理は不要になったのでコメントアウトする
  • シャドウマップの構築は変わらず光源ごとに行う必要があると思う

そんなこんなで一旦完成〜。

あとがき

苦労した点

  • 座標系を取り違えないこと:扱ってる座標がワールド座標系なのかまたは光源座標系なのかなど、 取り違えると全然計算が合わずに悩むことになってしまう
  • 同次座標系:w=1を追加した4次元ベクトルで扱い、パース変換した後にw’で割った値がどうのとか、 テクスチャのパース補正でやったような気がするがもうすっかり頭から抜け落ちていた…
  • シェーダーの出力結果がおかしい時の対処法:大量に並列に動いて、また変数の値をログを出したりできないのでなにが間違っているのかを探すのが大変
  • WebGPU関連:
    • レンダーターゲットやテクスチャを指定するのがデスクリプタで、それを事前に用意しておかなきゃいけないのは辛い、バーテックスバッファを与えるようにその場でできるようにして欲しい
      • デスクリプタは単なるJavaScriptのオブジェクトなので事前に作成しておく必要はないといえばない
    • 同様にユニフォームバッファやストレージを指定するバインドグループもあらかじめ生成しておかなきゃいけない
    • ブラウザのウィンドウがリサイズされた時に対処するにはテクスチャを作り直す必要があって、そうするとデスクリプタもバインドグループも作り直さなきゃならなくなり、諦めた…

今後の課題・疑問

  • 点光源で全方位にライティングしたい → 次記事
  • 光源を複数あてると結果がおかしい気がする、ガンマ補正をしてないから? ガンマ補正をしてみたら気持ち影がわかりやすくなったかも(微妙)
  • Deferred Renderingで光源を多数扱えるといっても、シーンを複数回描画するのは厳しくないか?
    • 複数のシャドウマップを同時に構築できる方法はあるのか?
    • 広範囲に影響を及ぼす平行光源となる太陽は1つ、あとは影響範囲を限った点光源にするとか
    • 最新のゲームエンジンではどうしてるのか、謎は深まった…

リンク