DirectXの話 第138回

Particle Shadow Mapping  13/04/27 up

前回の”Fourier Opacity Mapping”に続いてパーティクルに対するシャドウマッピングの話題です。

今回紹介するのはGDC2013でNVIDIAが発表していた”Particle Shadow Mapping”です。

FOMと同様にパーティクルに対してシャドウマッピングを施す技術ですが、FOMは前回のサンプルにもあるとおりモデルにも適用は可能でした(クオリティはともかく)。

今回のPSMは残念ながらモデルに対しては対応が難しい技術です。故にこのような名前がついているのだと思います。

もう少しいい名前がないのかとも思いはしますが、技術の特徴や制限を端的に伝えている、とも言えますね。

では、以下技術解説。

詳しい部分についてはGDC2013のスライドをお読みいただくのがいいかと思いますが、英語がわからん!という人のために簡単に解説します。

FOMは各ピクセルにおいて、深度とα値の関連性をフーリエ級数によって表現するというものでした。

この方法は係数の数に速度とクオリティが依存しますが、それらを上手く利用すればスケーラビリティを確保出来ます。

しかし、広範囲の描画を必要とする昨今のゲームでは難しい部分もあります。

フーリエ級数による表現は係数が少ないと誤差も大きく、クオリティと言っても単純に影のクオリティだけでなく、影を落とすはずがないオブジェクトに影が落ちるという問題が出てきてしまいます。

実際、今回のサンプルでも前回のサンプルでもライトに対してて前側にあるオブジェクトに煙の影が落ちるというあり得ない状態は発生しています。

影を薄くすることで目立ちにくくすることは可能ですが、動いていると目立ってしまう場面も出てきてしまうかもしれません。

今回のPSMでは、少なくともそのような目立つアーティファクトは出てきません。

というのも、元にしている技術がFOMではなく、それ以前に存在していた”Opacity Shadow Mapping”だからです。

OSMは複数枚のレンダリングターゲットを深度ごとに配置して、パーティクルをそれらのレンダリングターゲットにそれぞれ描画していきます。

こうすることで、それぞれの深度に対してパーティクル群のα値が求められ、光の減衰率がわかるという手法です。

もちろん、パーティクルよりも手前に配置されているレンダリングターゲットは完全透明とします。

難点としては、MRTにパーティクルを描画しなければならないため重い、という点と、MRTの数が制限されているために深度をその数以上に分割出来ないのです。

PSMではMRTを使用せずこの問題を解決しています。

まず、描画するテクスチャは3Dテクスチャです。もしくは2Dテクスチャ配列でもかまわないでしょう。今回のサンプルでは3Dテクスチャを使用しています。

スライドにあるSTEP 1ではまずこのテクスチャを1.0の値でクリアします。つまり真っ白なテクスチャにするわけです。

STEP 2でパーティクルシャドウの描画を行います。

このとき、パーティクルはそれぞれの深度に見合ったスライスに描画されます。

3Dテクスチャ、もしくは2Dテクスチャ配列の場合、指定スライスに描画するにはSV_RenderTargetArrayIndexを利用します。

ここで少し工夫を行います。

例えば、3Dテクスチャの深度を256分割しようと考えます。

通常であれば3Dテクスチャ作成時に深度を256にすれば済むのですが、それはそれでもったいないのでRGBAそれぞれを別の深度として取り扱うことで、深度を1/4にすることが出来ます。

つまり、256分割したいなら深度が64あればいいと言うことになります。

こうするとより細かく分割数を上げたい時でも割とすんなり上げることが出来るようになるでしょう。

まあ、速度面でもある程度恩恵があるのかもしれませんが、普通に考えればバッファサイズ的な恩恵はないでしょうね。

つまり、64mの距離を256分割したい場合、1mにつき3Dテクスチャのスライスが1枚存在していることになります。

そして、RGBAがそれぞれ0.25mずつを分け合う状態になるという感じです。

パーティクルを描画する際には1.0-α をバッファに描き込みます。

この際、積算合成にしておくことで、RGBAの内関係ないカラーに1.0を入れて数値が変化しないようにすることが出来ます。

上の例でパーティクルの深度が1.4mだったとします。

この場合、スライス1のGチャンネルに対して1.0-α が積算され、それ以外のカラーは1.0 が積算されるようになります。

こうすることで、パーティクル1枚に対して1スライスへの描画、つまり、レンダリングターゲット1枚分の描画とほぼ同じと考えられます。

そして、この技術が”Particle”と主張されているのもこの部分に起因します。

モデルの場合はポリゴン1枚1枚に深度の幅が存在してしまうため、スライス1枚に絞り込むことが出来ません。

しかしパーティクルであれば深度方向の厚みは存在しないので、スライス1枚に絞り込むことが出来るというわけです。

OSMの場合はMRT全てに描画が回され、PixelShaderで深度チェックが行われるため、モデルに対してもやろうと思えば実装が可能です。

PSMは残念ながらパーティクル以外には使えないでしょうね。

しかしこれだけでは特定深度に描画されたパーティクルの減衰率しか求まりません。

そこで出てくるのがSTEP 3で、ComputeShaderを用いて各ピクセルの深度値を全て積算して、それぞれの深度の減衰率の合計を求めます。

深度0から開始して、テクスチャをフェッチ、RGBAをそれぞれ順番に積算し、その結果を出力バッファに出力します。

ここでまたちょっと高速化のアイデアがあります。

ComputeShaderで全ての深度の積算値を求めることにはなるのですが、パーティクルが完全に無関係なピクセルというのも少なからず存在してるはずです。

広範囲でPSMをやろうとすればなおさらそういうピクセルが多くなるはずです。

そこで、スライス0をマスクとして利用して無駄なピクセルの処理を減らします。

まあ、スライス0にいきなりパーティクルが描画される状況というのはほとんど無いでしょうから、これをマスクとして利用するのは十分ありだと思います。

また、スライドによると、フル解像度でマスクするとあまり効率が良くないようです。

多分フル解像度の場合はSTEP 2の処理速度の増加がSTEP 3での処理速度の減少を上回るのではないかと思います。

しかしまあ、これに関してはハードによっても差が出るかもしれないので、フル解像度で描画する方が高速ならそちらを利用した方が良いのではないかと思います。

解像度を下げる場合はマルチビューポートを用います。スライドでは8*8のビューポートへ描画しているようですね。

こちらのサンプルでは8*8を1ブロックとするようにしています。

で、実際に試した際に微妙にズレが生じてしまっているような感じでしたので、対応するブロックのマスクチェックだけでなく、上下左右のブロックのマスクチェックも行っています。

…これならフル解像度の方が良いかもしれないなぁ…

最後にSTEP 4でパーティクルの影を落とします。

この際の注意点としては、深度方向にリニアサンプリングしてはいけないという点です。

そのためにも2Dテクスチャ配列にしておいた方が有利なんじゃないかと思います。

RGBAの各チャンネルがそれぞれ深度を表しているため、スライス間で補間されては困るのです。

前述の1.4mの例の場合、補間されるべきはスライス1のGとBの値です。スライス1と2のGではありません。

ただ、スライスnのAとスライスn+1のRで補間しなければならない場面も存在します。

この場合は2回のテクスチャフェッチが必要になる点に注意しなければなりません。

サンプルでは思いっきり分岐で回しちゃっていますが、もっと最適な方法があるかもしれませんね。

また、パーティクルのセルフシャドウを行う際には、テクスチャをフェッチする深度を1スライス分奥に移動してフェッチするようにしています。

理由としては、パーティクル1枚単位で見た場合、まさに自分自身が自分自身に影を落とす結果になってしまうためです。

1スライス分も奥に進める理由があったのか、と言われるとちょっと難しいところですが、まあまあクオリティが上がったのでよしとしました。

技術的にはそれほど難しいものではないですが、調整するとなるとそれなりに苦労しそうだな、というのは作ってみて感じました。

まずはサンプルをDLしてみてください。

Download:Sample129.zip

ライトの角度を色々と変えてみていただけると気付くかもしれませんが、影の段差が微妙に気になる部分が出てくるものと思われます。

静止画では非常にわかりづらいのですが動いてると結構わかってしまうんですね、これ。

パーティクルの量が多めなら比較的気付きにくいのですが、少ないとわかりやすくなります。

もうちょっとマシな回避方法を探さないとちょっと厳しいかもしれません。

ただ、k=3のFOMは本来そんなに暗くならないはずの部分が結構暗くなってしまったりと、精度面でかなり問題があるのは事実です。

速度面ではk=3のFOMより1msほどPSMの方が遅いです。

より高速化させるためのアイデアとしては、STEP 2の描画バッファとSTEP 3のUAVを同一にしてしまえば、処理する必要の無いピクセルで1.0を出力する必要がなくなるという点があります。

多分NVIDIAのスライドはそれを前提にしているのでしょう。

今回のサンプルでそうしなかった理由ですが…まあ、お察しください。

また、パーティクルが描画されはじめるスライスは、今回のサンプルだと30番目くらいからでした。

なので、1ピクセル以上描画された最も浅いスライスを求めることが出来ると、それ以前のスライスをまるっと無視することが出来るかもしれません。

何となくアイデアはありますが、それでどこまで高速化されるかは難しいところですかね。

1.0-α が1.0の間はバッファへの書き込みは行わない、というのもありかもしれませんが、バッファへの書き込みと分岐のどちらが速いかの問題になりそうです。

とりあえず、このコード間違ってるよ!ってのが見つかったらお知らせ願えると助かります。

なんか間違えてるんじゃないかって気がするんですよね…