DirectXの話 第120回

High Definition Ambient Occlusion

今回は Screen Space Ambient Occlusion (SSAO) の技術の1つ、High Definition Ambient Occlusion (HDAO) をやりました。

この技術は ATI (今はAMDって言った方がいいのかな?) が開発したもので、理論的には簡単ですが、名前の通り高精細な AO を実現することが出来ます。

法線マップを貼ってあるオブジェクトに対しても効果があるため、マシンスペックに余裕があるなら実装しても悪くない代物だと思います。

上記の画像は SSAO には必須とも言えるブラーパスを用いていません。それでここまで綺麗に出るわけです。

通常のレイマーチング法(複数のレイを飛ばして衝突判定を取る方法)ではブラーパスなしは厳しいでしょうが、HDAO はこのように綺麗に出ます。

その分重めではあります。Xbox360やPS3で実装する場合、少々粗く処理してブラーパスを用いた方がいいかもしれません。

技術的には非常に簡単です。

処理したいピクセルP に対してあるピクセルA を取得します。

このとき、ピクセルP に対する点対称にあるピクセルA' も取得します。

この A と A' それぞれと P を比較し、P が谷となっている場合は AO を有効に、そうでなければ AO を無効にします。

これを P を中心とした円上のピクセルに対して処理を行い、AO バッファを作成するというものです。

AO の強さは予め定められたウェイト値によって決定していて、ピクセルの高さの差を AO の強さとしては用いません。

法線を用いる場合はこれに加えて、元の深度に対してカメラ座標上の法線のZ成分を加算し、深度と同様の処理を行います。

深度の差はある一定以上、且つある一定以下の場合のみ AO 有効とします。

かなり平面に近い場合や、そもそも離れすぎていて遮蔽にならないと見なされる場合を棄却しています。

このように、図にする必要すらない程度に簡単です。

以前にやった『Uncharted 2』に実装されている AO と似ているとも言えますね。

詳しい実装はシェーダコードを読んでみてください。

基本的には ATI の実装を用いていますが、どの程度の距離まで対応するかという部分について独自の実装を行っています。

ATI の実装では処理するピクセルのオフセットは全て一定でした。

しかし、かなり距離が離れていれば処理する必要もありませんし、処理しなければならないピクセル数も減らせるはずです。

そのため、"Check Radius" のパラメータでテストを行うピクセルのワールドでの範囲を指定しています。

これをスクリーン空間に変換した際にどの程度のピクセルをテストすればいいかを計算し、ループの回数を設定しています。

カメラに近いピクセルほどループの回数は増え、遠ければループの回数は減ります。

シェーダコード "render_hdao.hlsl" の CalcRings() 関数がそれに当たります。

最初にも書いたとおり、この技術はそのまま実装するにはやや重めです。

実際にはブラーパスなしでここまで綺麗に描画しようとする場合、多分レイマーチングの実装の方が重くなると思います。

そのため、この技術についても高速化手法は他の SSAO と同じ方法が使用できると思います。

すなわち、粗く処理してブラーパスを用いる、Temporal Coherence を用いる、縮小バッファで処理するなどです。

粗く処理するのはピクセルを間引けばいいだけですが、ある程度のランダム性を持たせた方がいいかもしれません。

レイマーチングでも同様の方法が用いられますが、十分有効な手段と考えます。

Temporal Coherence はいわゆる前回の情報を用いる手法です。

この方法は SSAO 以外にも用いられますが、カメラが大きく動かなければかなり有効な手法です。

CryEngine でも当たり前のように使われていますし、『Gears of War 2』の SSAO も前回情報を用いています。

縮小バッファの使用もかなり効果的です。フル解像度で処理するには重い処理を縮小バッファで処理してしまえばかなり速くなります。

SSAO、Light Pre-Pass のライト計算、パーティクル描画などにかなり効いてくるはずです。

ただし、縮小バッファで処理したものをそのままフル解像度にバイリニアで貼り付けてはいけません。これだとかなり汚いです。

そのために Bilateral Upsampling を用います。

これは Bilateral フィルタの要領で、エッジを残しつつバイリニアっぽく引き伸ばします。

エッジ部分の検出は深度、および法線を用います。

上図を見てください。

この図において、緑の枠がフル解像度のピクセル、赤の枠が縦横を半分にした半解像度のピクセルです。

最終的に求めたいのは緑の数字で指定された各ピクセルです。そのために、その各ピクセルに対応した半解像度のピクセル0~3が必要になります。

取得する必要のあるピクセルは以下の3種類です。

1.フル解像度にて処理を行うピクセル(緑の0~3の内のいずれか)の深度、および法線

2.半解像度のピクセル0~3の深度、および法線

3.半解像度のピクセル0~3のカラー、SSAOの値など、フィルタリングしたいパラメータ

最終的には上記3の値に、1と2から計算したウェイト値を掛けて足すだけです。

ウェイト値は法線、深度、ピクセルの位置から求めます。

ピクセルの位置は上図緑の0~3において一意に決まっています。

const float4 kBilinearWeights[4] =

{

// 0 1 2 3

float4( 9.0/16.0, 3.0/16.0, 3.0/16.0, 1.0/16.0 ), // 0

float4( 3.0/16.0, 9.0/16.0, 1.0/16.0, 3.0/16.0 ), // 1

float4( 3.0/16.0, 1.0/16.0, 9.0/16.0, 3.0/16.0 ), // 2

float4( 1.0/16.0, 3.0/16.0, 3.0/16.0, 9.0/16.0 ) // 3

};

法線のウェイト値は以下の方法で求めます。

float normalWeight = dot( lowResND[i].xyz, hiResND.xyz );

normalWeight = pow( saturate(normalWeight), 32.0 );

lowResND は半解像度の法線、深度情報を格納しています。hiResND はフル解像度の法線、深度情報です。

float depthDiff = hiResND.w - lowResND[i].w;

float depthWeight = 1.0 / (kEpsilon + abs(depthDiff));

こちらが深度のウェイト値の求め方です。

これら全てを計算し、半解像度の各ピクセルに対して積算するだけです。簡単でしょ?

実際、こんな簡単な手法でかなり綺麗になります。

もちろん、フル解像度での処理と比較すると厳しい面もありますが、速度の面を考えるならかなり実用的でしょう。

これと Temporal Coherence を用いるのが現在の主流のようです。次世代になったとしてもその方針はあまり変わらないのではないかと考えています。

と言うわけでサンプルは下から。

自由にカメラは回せませんが、Pキーを押すと回転を始めます。

あとはパラメータを色々いじって試してみてください。