DirectX9/シェーダーモデル2.0で実装するシェーディングと共存する浮動少数点テクスチャを用いないシャドウバッファ(shadow buffer)法によるソフトシャドウ・セルフシャドウ


シャドウバッファでググると、この時代になってもまだシェーダーモデル1.1でカツカツだったころの一旦画面全体の影イメージをZの大小の2値で別個に作ってしまう方式の解説と、こんなんじゃシャドウバッファなんて使えないと思われてしまいそうな画面写真がのさばっているのは如何なものかと思ったので、ここにもうちょっとだけ無理のきく環境上ではシャドウバッファが如何にリーズナブルな手法かということを解説しておきたいと思う。

1.バッファの確保

DirectX9ではZバッファを直接参照はできないので、Z情報を色成分へ収めるためテクスチャを確保する。

ここでの解像度がかなり大きく最終的な影の品質に影響する。Zの値は1ピクセルごとにはっきりと決定するので低解像度では斜線上に生じたZの段差のジャギーをどうしようもないからだ。だが1024x1024くらいの大きさで作っても結構大丈夫。フォーマットはA8R8G8B8で良い。あとで工夫するので浮動小数バッファでなくて構わない。

2.光源方向からのZ値をテクスチャへ保存

ワールド変換行列として対象モデルを光源から見た方向へ向ける回転行列をかけた上でorthogonalなビュー変換をかけ、Zの値をテクスチャへ書き出す。ここでできるだけ必要な部分をぴったり視垂台の中に収めるのも解像度と同じくらい品質に響くポイントである。当たり前の話だが。

このとき、そのまま単一の色成分へZ値を書いてしまうと256段階に丸められてしまいZ方向の解像度が下がるので、4倍してrgbaへ分散して保存する。これで1024段階のZ値解像度が確保できる。なおこの計算は頂点シェーダ上で行える。頂点間でrgba四つの値が独立に補間されても面白いことに問題にはならない。

ZFromLight = Position.z;
ZFromLight *= 4.0;
ZFromLight.g -= 1.0;
ZFromLight.b -= 2.0;
ZFromLight.a -= 3.0;

ちなみにここで使った頂点変換行列(ワールド*orthogonalビュー)はすぐに後の段階で使うことになるので保持しておくこと。

ところでシャドウバッファの仕組みの説明になるが、ここで取得したZ値は光源から見て各ピクセル一番手前の値を保存することになる。これが光が当たっている部分を示す署名のようなものとなり、同一ピクセルに描画されるべき部分でそれ以上のZをもつものは奥であるから影だと定めることができるわけである。

3.バッファをぼかす

バッファの1ピクセルは対応する場所が影なのかそうでないのかを表す貴重な情報なので近傍点と値を混ぜてしまうなんて言語道断ではないかと思われるかもしれないが、ここでバッファをぼかす。どうなるかは後でのお楽しみである。

A8R8G8B8フォーマットを使っていればサンプラの高速なフィルタが使えるので、以前書いた超高速ブラーのように、リニアフィルタをきかせて、数回95%ほどの大きさでもう一つのテクスチャ上へ書き出すのを繰り返し、最後に元の大きさへもどすだけでいい。ピクセルシェーダで自前で近隣の点をサンプルするようなことをやるとテクスチャがかなり大きいのでドツボにはまる。


ぼかしまで完了したシャドウバッファと実際描画されたもの ; rgbaに分けて値が入れられているためサーモグラフィーのようなことに。aチャンネル分が一番奥にあるが、aなので描けない。

4.バッファを参照しながら本番レンダリング

RenderTargetをテクスチャからBackBufferへ切り替えて最後のレンダリングを行う。2項で使った行列をもう一度頂点シェーダ上で頂点座標に適用すれば参照すべきシャドウバッファ上のテクスチャ座標と当該頂点のZ値がでてくる。注意したいのは2のときのxyは視垂台の-1.0〜1.0の範囲になっているので、これをテクスチャ空間の0.0〜1.0へ変換する必要があるということだ。そのためにあらかじめ2でとっておいた頂点変換行列へ、

0.5  0    0    0
0   -0.5  0    0
0    0    1.0  0
0.5  0.5  0    1

を合成しておく。
こうして得られた光源からの視垂台内座標は、シェーディングモードに応じて適切な補間をさせるために、三次元のテクスチャ座標名義で(float3 hoge : TEXCOORDX)ピクセルシェーダへ渡す。

ピクセルシェーダではxyでシャドウバッファのテクスチャを参照し、バッファ内のZ値を色成分として得る。各成分を単純に加算するだけで0.0〜4.0スケールの解像度1024のZ値が復元される。レンダリング中のZとバッファのZを見比べてレンダリング中のものの方が大きければ影であり、一方同じならばそこがバッファのZを書き込んだ当人であり、ここが最も手前の面であり、ここに光が当たっていると考えることができる。

5.ソフト化・陰と影の共存

陰の算出には様々なやりかたがあるだろうが、大本となる情報は必ず一つの面の法線ベクトルである。この法線が光源への方向からどの程度傾いているかで、どのくらい暗くするかが定まってくる。この法線や光源との内積等、陰を定める要素に対し、前項で算出された影を定める要素である光源からのZ値の差を介入させることで、陰と影を統一して扱うことができる。

ここでは陰の算出の手法を特定のものにしぼれなくては具体的な解説とならないので自分の場合を例にあげておく。

自分の場合、陰の算出のために、あらかじめ(0,1,0)が明るく徐々に回り込んで(0,-1,0)が最も暗くなる陰用キューブマップを作っておく。光源への真方向が天上(0,1,0)へ向くような回転行列を計算しておき、これを頂点シェーダ上でそれぞれ法線に適用する。適用された法線はそのままピクセルシェーダ上で陰用キューブマップの参照に用いられている。

4に説明したとおり、ピクセルシェーダ上でシャドウバッファ参照によって得たZ値と光源方向からの当該ピクセルのZ値との差分を求める。これにより範囲0.0〜-4.0の値が得られる。0であれば光が当たっている面であり、負方向に大きければ影である。影部を暗くするために、陰用キューブマップテクスチャ座標のyにこれを加えてベクトル全体を下に向かせる。ベクトルの下げ幅は差分の絶対値によるので差分の幅が直接暗さに影響することになる。ここでバッファをぼかしたことが生きてくる。影の輪郭部ではZ値は徐々に描画される面のZへ近づくために、影が落ちる面では影の輪郭部で徐々に0へ近づく差分が得られるのである。これによって影の輪郭はぼやける。

具体的なコーディングはbias部を細かく調整する必要があるが現状ではこのようになっている。

void calc_shadow(inout VS_OUTPUT In)
{
	float4 c = tex2D(s3,In.FromLight.xy); // シャドウバッファ参照
	float depth = c.x + c.y + c.z + c.w; // depth : バッファ内のZ
	depth = In.FromLight.z * -4.0 + depth; // 差分を計算
	depth = clamp(4.0*depth, -1.2, 0); // bias
	In.TexShade.y += depth; // キューブマップ用座標のyを削る
}

6.問題点

・本来であれば影を落とす対象にZが近ければ近いほど影の輪郭ははっきりするはずだが逆になる。タイトルの画像では本来顔にかかる髪の毛の影が一番濃く輪郭がハッキリしていなくてはならないが、逆に手の影の方が濃くはっきりしている。
・面法線が光線と平行であればあるほど影のベクトル移動の影響を受けにくくなり影が薄くなる。または見えなくなる。とくにZ差分が少なくなる影の発生源が近いときに顕著。