DirectXの話 第109回

Compute Shaderによる簡易SSAO

今回は前回と同様に Compute Shader を使用します。

前回は Compute Shader の使い道の1つであるGPGPUとして使用しましたが、今回はやっぱりシェーダだからと言うことでグラフィックの補助として使用してみます。

実装してみるのは以前にもやってみた Screen Space Ambient Occlusion(SSAO) の簡易バージョンです。

簡易バージョンではありますが、リッチに作ってあるため割と綺麗だったりします。

実際に使用するにはもっと工夫する必要がありますが、それについては後述します。

さて、どんな実装かというと、PS3の『アンチャーテッド2』の実装と似たような感じです。

通常のSSAOは各ピクセルの法線方向を中心とした半球状にレイを飛ばし、衝突判定を取るといった方法を用います。

今回は深度のみを用い、スクリーンに対して平行なボックス状の範囲で深度の差だけを用いて影になっているか否かを決定します。

以下の図をご覧ください。

手描きの図で申し訳ないのですが、この図に描いてあることほぼそのままです。

つまり、あるピクセルについて周囲 n*n 範囲の矩形との深度差を求め、これを遮蔽率として用いる、と言うわけです。

もちろん正しい実装とは言いにくいのですが、画面上のアクセントとして用いるのであればこれで十分なのではないかと。『アンチャーテッド2』はこう実装しているわけですし。

正確にはこちらの実装はあちらの実装とは別ですが、似たようなものではあります。

さて、SSAOは重めのポストエフェクトとして有名ですが、その理由として1ピクセル当たりのテクスチャフェッチ回数が多いというのが上げられます。

計算そのものも重いのですが、綺麗なAOを実現するにはレイを飛ばす回数を多めにしなければいけません。

レイの数が多くなる、と言うことはつまりテクスチャフェッチの回数が増えるということになります。

テクスチャフェッチの回数は減らしたいけど、Pixel Shader は各ピクセルで独立したスレッドが存在し、別のスレッドとは無関係に処理が走るので減らすことが出来ません。

しかし、ここで Compute Shader の出番です。

Compute Shader も1つ1つの計算処理が別々のスレッドで走ることになりますが、いくつかのスレッドをまとめたスレッドグループという概念があります。

スレッドグループは内部のスレッドが共有することが可能な共有メモリを持つことが出来ます。サイズは32768バイトが最大です。

これをどう利用するのか?

各スレッドでフェッチされるテクスチャを予め共有メモリにおいておけばいいんです!

1画面の中の32*32ピクセルのブロックを想像してみてください。

この範囲でSSAOに使用されるピクセルは、どんなに多くても32*32ブロックを中心とした64*64ブロックくらいではないでしょうか?

このブロックの1ピクセルを1スレッドとして見立てると、1スレッドでフェッチするテクセルはわずか4テクセルです。 1ピクセルごとに16テクセルをフェッチするより1/4も少ないわけです。

しかも、SSAOを綺麗にするためレイを32本、64本と増やしても、Pixel Shaderの実装と違ってフェッチするテクセルの数は変わりません。

つまり、レイの数が増えても計算処理以外に負荷がかからないということになるわけです。

今回の実装ではレイを飛ばしているわけではないのですが、やっていることは同じです。

なお、サンプルでは1ピクセルにつき 17*17 の矩形範囲で深度をチェックしています。

自分自身のテクセルをフェッチすることも考えると、1ピクセル中290テクセルをフェッチしていることになります。これが4テクセルになるのですから、効果は十分高いでしょう。

以下が今回の Compute Shader の抜粋です。

// 入力テクスチャ

Texture2D rInputTex : register( t0 );

// 出力テクスチャ

RWTexture2D<float4> rwOutputTex : register( u0 );

// 共有メモリ

groupshared float shRectPixel[kWidth * kWidth * 4];

[numthreads(kWidth, kWidth, 1)]

void DispatchCS( uint3 did : SV_DispatchThreadID, uint3 gtd : SV_GroupThreadID, uint3 gid : SV_GroupID )

{

int2 basePos = gid.xy * kWidth - kHalfWidth;

uint index0 = gtd.y * kDoubleWidth + gtd.x;

uint index1 = (gtd.y + kWidth) * kDoubleWidth + gtd.x;

// 入力テクスチャからピクセルを参照する

shRectPixel[index0] = rInputTex[ GetInputPixelIndex( basePos, gtd.xy ) ].w;

shRectPixel[index0 + kWidth] = rInputTex[ GetInputPixelIndex( basePos, gtd.xy + uint2(kWidth, 0) ) ].w;

shRectPixel[index1] = rInputTex[ GetInputPixelIndex( basePos, gtd.xy + uint2(0, kWidth) ) ].w;

shRectPixel[index1 + kWidth] = rInputTex[ GetInputPixelIndex( basePos, gtd.xy + uint2(kWidth, kWidth) ) ].w;

// すべてのグループ内スレッドはここで同期をとる

GroupMemoryBarrierWithGroupSync();

// 自身のピクセルを取得する

int2 pos = gtd.xy + kHalfWidth;

float own = shRectPixel[pos.y * kDoubleWidth + pos.x];

pos -= SSAO_RECT_RANGE;

float occ = 0.0f;

float pixel = 0.0f;

for( int y = 0; y <= SSAO_RECT_RANGE * 2; ++y )

{

int index = (pos.y + y) * kDoubleWidth + pos.x;

for( int x = 0; x <= SSAO_RECT_RANGE * 2; ++x )

{

...

}

}

float ret = 1.0f - (occ / pixel);

rwOutputTex[ uint2(did.x, did.y) ] = (float4)ret;

}

肝となるのは共有メモリとスレッドの同期部分です。

groupshared の冠詞がついている変数が共有メモリです。64*64 の深度値を保存できるだけのメモリを確保しています。

DispatchCS() 関数内の最初で入力テクスチャ rInputTex から4テクセルをフェッチしています。

この後に GroupMemoryBarrierWithGroupSync() 命令を呼んでいます。

この命令はスレッドグループ内の共有メモリに対するバリア命令で、その上、スレッドグループ内のすべてのスレッドがこの命令の場所まで待つことになります。

これにより共有メモリの内容は保証されるため、以降の処理で共有メモリが参照されても問題なくなる、と言うわけです。

なお、Compute Shader 4.0 はDirectX10.1対応ビデオボードでも使用できますが、今回のサンプルはDirectX11対応ビデオボード専用です。

CS4.0 では32*32=1024のスレッドを実行することが出来ませんし、RWTexture2D が使用できません。

私の環境ではCS版の方がPS版よりおよそ倍の速さになりました。

それでも全体で2ms程度かかっているので、より高速な手法を求められるでしょう。

高速化の手段としては、

1.カメラから遠いピクセルは処理を行わない

2.カメラからの距離に応じて矩形範囲を減らす

という工夫はいいのではないかと思います。実装していないので正しく表現できるかどうか不明ですが。

特に2番目は映像的にも必要になる実装だと思います。

今の矩形範囲を固定でやる手段ではスクリーン状で一定の矩形範囲で遮蔽率が計算されてしまいます。

これだと遠い場所ほど広い範囲で遮蔽計算が行われてしまうことになり、影にならなそうな部分が影になってしまったりします。

『アンチャーテッド2』では参照する深度バッファを半分の解像度(多分、縦横半分の1/4解像度)にしているそうですが、これはSPUのローカルストレージ対策と思われます。

また、輪郭部分が明るくなってしまう問題は輪郭部分で上下左右に引き延ばすという手段で解決しているようです。

詳しいことは添付されているサンプルをチェックしてみてください。

起動時はPS版のSSAOとなっていますが、テンキー2でCS版に切り替わります。テンキー1で元に戻ります。

Pキーで回転を停止、スペースキーで処理時間の計測を開始します。

おまけ。

CS版を高速化している際に面白い、というよりちょっと困った現象がありました。

ループを高速化する際にインクリメントで対応できる部分があったのでインクリメントにしてみました。上記CSのindexの計算です。

驚いたことに、そうしたら遅くなりました。しかも3倍くらい遅くなったのです。

シェーダのアセンブリコードを調べてみても、インストラクション数は減っているにもかかわらずです。

同僚に聞いてみたところ、どうやら最近のシェーダはアセンブリコードを眺めてもあまり意味がないようです。

ドライバの中で別の命令に変換されてしまうので、アセンブリコード自体が中間言語のようなものになってしまっていると言うことです。

ドライバ次第で変わるため、ボードが同じでもドライバが違うと別の結果をもたらすこともあるそうで、これだと高速化が難しいと言わざるを得ないですね。

確かに、今のDirectXのヘルプにはアセンブラ命令が掲載されていません。アセンブラレベルでの最適化は非推奨なようです。

これからシェーダ高速化のノウハウを蓄積してかないと厳しくなりそうです。