DirectXの話 第110回
Order Independent Transparency
DirectX11になってグラフィックが綺麗になるのもいいんですが、今まで面倒だったけど我慢していた部分を解決するってのもありだと思うわけで。
ユーザにとってはあんまり関係なかったりするんで後回しにされやすいかもしれませんね。
というわけで、今回は Order Independent Transparency(OIT) をやってみました。
日本語に直訳すると”順番に依存しない透明処理”となるでしょう。
日本のゲームは海外のゲームと比べて半透明を多く使うとよく言われます。
実際、海外ではよく使われる Deferred Lighting は半透明については遅延処理が行えないため(最近は半透明も制限付きで使える技術もあったりしますが)、日本の開発と合わないと言われています。
日本のゲームで Deferred Lighting を使ってるゲームってあるんですかね? 聞いたことないんですが。
でまあ、Deferred Lighting とはあまり関係ないのですが、半透明がよく使われるために問題が発生しやすいのが描画順序です。
半透明はその性質上、描画順序に依存します。基本的に、オブジェクト(もしくはメッシュ?)を奥から手前にソートして描画することになります。
それなりにオブジェクト(もしくはメッシュ)が細かく分かれていればZソートだけでも問題ないですが、半透明の物を内包する半透明オブジェクトはそういうわけにもいきません。
例えば、半透明のグラスの中のやはり半透明な氷とか。正常な描画順序は、グラスの奥→氷→グラスの手前ですが、実際に作成するとグラス→氷か氷→グラスの順序となるでしょう。
このような問題を解決する手段として OIT が存在します。実装方法はいくつかあるようですが、今回は ATI が発表していたリンクリストを使う物を実装してみました。
DirectX11 の Compute Shader に Unordered Access View が存在します。前回、前々回で使用してましたね。
この UAV は Compute Shader 以外に Pixel Shader でも使用することが出来ます。当然、読み書き可能です。
制約としては、レンダリングターゲットと UAV は合わせて8つまで使用することが出来る、というくらいでしょうか。
これをどのように使用するかというと、前述の通りリンクリストとして使用します。
下の図が今回の実装を図示した物です。
図にするとわかりやすいですね。
使用する UAV は Screen Info と Pixel Info の2つです。名前については適当に名付けたので、ググっても出てこないことに注意してください。
Screen Info はピクセル数分の UINT で構成されたバッファです。初期値はすべて -1 で、ピクセルに描画が行われると Pixel Info のインデックス値が入力されます。
Pixel Info はリンクリストの次のインデックス(index)と描画されたカラー値(color)、そして描画された色の対応する深度(depth)を持った構造体を複数持っています。
描画の流れは、まず Pixel Info から使われていない構造体のインデックスを取得し、これを Screen Info に保存します。
Pixel Info に各情報を書き込みますが、このとき、indexには以前に Screen Info に保存されていたインデックスを書き込みます。
すべての半透明オブジェクトについてこれを行ったら、最後にポストプロセス的に半透明の解決を行います。
各ピクセルにおいて描画された情報を Pixel Info のリストをたどって取得します。それをZソートし、計算してレンダリングターゲットに書き込みます。
ね、簡単でしょ?
実は、この理屈と上の図が微妙に合っていなかったりするんですが、図を描いたあとに気がつきました。面倒なので直しません。大丈夫ですよね?
では、この簡単な理屈をソースコードに落としてみます。
//! UAV
struct PixelParam
{
float4 color;
float depth;
int next;
};
RWByteAddressBuffer rwbScreenInfo : register( u0 );
RWStructuredBuffer<PixelParam> rwsPixel : register( u1 );
//! ピクセルシェーダ
[earlydepthstencil]
void RenderPS( OutputVS inPixel )
{
// ピクセルの座標を求める
uint pixel_pos = (uint)(floor(inPixel.pos.y) * kScreenWidth + floor(inPixel.pos.x)) + 1;
// カウンタをインクリメント
uint bucket_index;
rwbScreenInfo.InterlockedAdd( 0, 1, bucket_index );
// ピクセル座標のインデックスを入れ替える
uint old_index;
rwbScreenInfo.InterlockedExchange( pixel_pos * 4, bucket_index, old_index );
// リンクリストに接続する
PixelParam param;
param.next = asint( old_index );
param.color = diffuseColor;
param.depth = inPixel.pos.z;
rwsPixel[bucket_index] = param;
}
頂点シェーダは極めて単純なので省きます。
Pixel Shader の前に [earlydepthstencil] と書かれた部分がありますが、これはピクセルシェーダの前にデプス・ステンシルテストを行えという命令です。
デプス・ステンシルバッファへの書き込みは出来なくなるそうですが、最初に不透明オブジェクトを書き込んである場合はこのあとにテストを行っても無意味になってしまうため早期カリングを行います。
さて、ここでちょっと気になるのがピクセルの座標を求めている部分。+1 している部分です。
この +1 は Screen Info の先頭に Pixel Info の使用量を保存しているために行っています。
ATI のスライドには RWStructuredBuffer::IncrementCounter() を使用するとありますが、サンプル作成中にそれを使用していてどうしてもうまくいきませんでした。
結局、この方法+ちょっと無駄なコードで正常に動作することを確認できたのですが、うまくいかなかったのはドライバのバグだったみたいです。
IncrementCounter() を使用する方法に直そうかとも思ったのですが、これでも問題ないはずなので(多分、速度的にもほぼ変わらないはず)直していません。
ここで気になるのが RWByteAddressBuffer::InterlockedAdd() と RWByteAddressBuffer::InterlockedExchange() ですが、これは名前の通り同期命令です。
マルチスレッドで動いているGPUの同期を取らないと酷い目に遭いますが、これはそれを防ぐための命令で、それぞれ加算と数値の交換を行います。
なお、RWStructuredBuffer::IncrementCounter() も同期命令です。どこかのスレッドでこれを使っている場合、他のスレッドはそれを待ってから命令を実行します。
次にリンクリストの描画プログラムを見ていきましょう。
//! 構造体バッファ
struct PixelParam
{
float4 color;
float depth;
int next;
};
ByteAddressBuffer rbScreenInfo : register( t0 );
StructuredBuffer<PixelParam> rsPixel : register( t1 );
//! ピクセルシェーダ
float4 RenderPS( OutputVS inPixel ) : SV_TARGET
{
// ピクセルの座標を求める
uint pixel_pos = (uint)(floor(inPixel.pos.y) * kScreenWidth + floor(inPixel.pos.x)) + 1;
// 開始のインデックスを取得する
int index = asint( rbScreenInfo.Load( pixel_pos * 4 ) );
clip( index );
// リンクリストを一旦ヒープに渡す
int heap[kMaxHeap];
int num = 0;
while( (0 <= index) && (num < kMaxHeap) )
{
heap[num] = index;
num++;
index = rsPixel[index].next;
}
// ソートする
// 省略
// ソート順番で色を書き出す
float4 color = { 0, 0, 0, 1 };
for( int i = 0; i < num; ++i )
{
float4 src = rsPixel[heap[i]].color;
color.rgb = src.rgb * src.a + color.rgb * (1.0 - src.a);
color.a *= (1.0 - src.a);
}
return color;
}
まず、ソート前にリンクリストの内容をローカルなヒープにコピーします。
このループに関する注意として、UAV をそのまま使用したらエラーになりました。UAV に依存するループはエラーとなるようです。
ソート部分は省略していますが、サンプルでは単純なバブルソートを使用しています。
実は、最初にヒープソートで実装しようとしたのですが、シェーダのコンパイルが通りませんでした。ループを展開しようとしたけど出来なかったよ、とか言われました。展開するなよ、と突っ込み入れたかったです。
もしもこの技術を本格的に使用するつもりであれば、重要なのはこのソート部分になると思います。ソートが遅いと目も当てられないでしょう。
ソートしたら順番に色の計算を行います。出力されるアルファ値は最後にレンダリングターゲットのカラーと積算され、出力されたカラーと加算されます。これで正常な半透明描画となります。
サンプルはいつも通り下から落としてください。
赤い箱の中に緑の球があり、それを水色の円柱が貫いています。すべてのオブジェクトが両面描画です。
不自然な半透明にはなっていないと思いますが、いかがでしょう?
ちなみに、サンプルは320*240という小さなサイズですが、それでも描画に1.2~1.4ms程度かかります。ゲームに実装するにはまだまだですね。
ソートさえもう少し何とかなればいいのかもしれませんが、ソートだけの問題でもないような気がします。
次回は未定です。
DirectX11らしいことをやりたいと思うのですが、そうするとテッセレーションか Dynamic Shader Linkage くらいかとも思うのですが、どちらも勉強してないので時間かかりそう。
GPU Proも届いたことだし、ここから何か実装してみようかとも思っています。