Omnidirectional Shadow Mapping(全方位シャドウマップ)を実装した

 全方位シャドウマップを実装した。

 資料が少なくて実装が結構手間だった。
 こういうときよく頼りにしているもんしょという方のブログでは、全方位マップは双方物曲面かつディファードレンダリングでの実装だったので、参考にできなかった。私はキューブマップとフォワードレンダリングで実装している。
 双方物曲面で実装したほうが効率的だが、キューブマップの方がアルゴリズムとしては単純なのでこちらを優先して実装した。気が向いたら差し替えようと思う。

 前回も触りだけ紹介したが、全方位シャドウマップとは、ポイントライトのシャドウ実装に使われるアルゴリズムだ。

 ディレクショナルライトやスポットライトは、指向性があるので、そのライトの影響下にある全てのオブジェクトを内含するAABBを定義してテクスチャに深度を書き込み、実際のレンダリングパスでそのテクスチャから深度をフェッチしてきて比較することでシャドウを実装できる。

 一方、ポイントライトは全方向に光を放つので、ライトの影響下のオブジェクトを一つのテクスチャに収めるということはできない。
 そのため、どうにかして全方向の深度を書き込む必要があるのだが、その手法としてキューブマップを使う方法がある。ポイントライトを中心とする立方体の6面のテクスチャを定義して、X軸、Y軸、Z軸それぞれの方向に向かって描画し、全方向の深度情報を保存する方法だ。

 そのため一連のレンダリングのパスは

1. ライトから+X方向のビュー行列を使いステシンシルバッファに深度を描画(レンダーターゲットは必要ない)
2. ライトから-X方向の...
3. 略
4. 略
5. 略
6. ライトから-Z方向の...
7. 実際のカメラから描画、1~6で作ったキューブテクスチャを使いライティング

 となる。つまりポイントライトを置くだけで本来1回の描画キックで済んでいたパスが一気に7倍に膨れ上がる。ちなみに双方物曲面を使った場合はもっと少なく済む。


 ライティングでシャドウを計算するときは、各軸に垂直な正立方体テクスチャという性質を利用して、テクスチャからフェッチしてきた深度と、ライトから対象のピクセルのワールド座標の差分ベクトルの、X、Y、Zの最大値と比較すればよい。ただし、パースペクティブ行列による視推台の対応を考慮する。

 HLSLで実装するとなると、以下のようになる。

bool IsVisibleFromPointLight(float3 posw, int index) {
    float3 pointDir = posw - PLightParams[index].pos;
    float depth = PointShadowMap.Sample(PShadowSampler, normalize(pointDir)).x;
    float3 absVec = abs(pointDir);
    float z = max(absVec.x, max(absVec.y, absVec.z));

    float normZComp = 100.0f / (100.0f - 0.10f) - (100.0f * 0.10f) / (100.0f - 0.10f) / z;
    return normZComp <= depth + 0.005f;
}

 相変わらずいきなり出てくる変数名は雰囲気で察してほしい。
 デプスステンシルバッファに書き込まれる深度は、その際に使ったパースペクティブ行列により圧縮された深度が格納されるので、再現する必要がある。計算式上では、normZCompの計算式がそれにあたる。100がfar-planeで、0.1がnear-planeだ。この計算は厳密にやるなら定数バッファからビュー・パースペクティブ行列を与え計算すべきではあるだろうが、深度のみの計算だけで十分な上、ディレクショナルライトとは違い、この値は基本一定である(と勝手に思っている)ので、直接計算式をぶち込んだ。この方が高速であろう。計算式の意味が分からん人は実際にDirectXパースペクティブ行列を導出してみるべし。

 このブーリアンを返す関数を使ってライティング処理をするかしないかをピクセルごとに判定すれば、全方位シャドウの実装は完了だ。実際にライトの周辺にいろいろおいてレンダリングしてみるとこうなる。

f:id:riyaaaaasan:20180307222323p:plain

 蛇足になるが、テクスチャからフェッチしてきた深度と、光源とピクセルの距離のXYZの最大値を比較するという発想に至るまでもそこそこ時間はかかったのだが、それ以上に昔からずっと存在していたっぽいバグにはまった。

 頂点シェーダで頂点情報を描きだす際、ワールド座標からビュー座標に変換するのはいつも通りなのだが
法線も雑にワールド変換行列をかけて計算してしまっていた。

    float3 nor;
    float4 pos = float4(IN.pos, 1.0f);

    OUT.posw = mul(pos, World);
    pos = mul(OUT.posw, View);
    pos = mul(pos, Projection);
    nor = mul(float4(IN.nor, 1.0f), World).xyz;
    nor = normalize(nor);

    OUT.pos = pos;
    OUT.norw = float4(nor, 1.0f);

 (やたらとfloat4とfloat3を相互変換しているのは、wの値が不定な可能性があるため。そのうちちゃんと1.0fで初期化されるよう定数バッファの処理をリファクタしたい)
 これがバグを起こしたダメなコード。法線をワールド変換しているが、これは明らかに間違いである。
 法線はそもそも純粋な「向き」である。つまり、ワールド変換における、平行移動・拡大縮小・回転のうち、影響を受けるべきなのは拡大縮小・回転だけだ。頂点をワールド変換するとき、平行移動行列をT、拡大縮小をS、 回転をRとしたとき、その頂点の法線は(1/S * R)をかけることで正しく変換される。この辺の説明はほかのしっかりしたサイトを見たほうが早い。
 さて、ワールド変換行列から都合よくそんな成分を抜き出せるかどうか、と言われると、実はめっちゃ簡単なのだ。

 ワールド変換行列の転置行列の逆行列がそれにあたる。

 端的に言えば、転置行列で平行移動成分が消え、逆行列でスケール成分が逆数になる。回転成分はそのまま。

 この行列を定数バッファから与えて法線にかけてあげればよい。

 考えれば当たり前だが、こんな致命的な頂点シェーダのバグを残したまま大きな機能を実装していたせいで、発生したバグの原因の追究に無駄に時間を取られた。反省。もっと基礎の実装のデバッグを重点的にやっていこう。


 次はスカイボックスとリフレクションを実装したいと思う。
 ただし、そろそろコードをカオスになってきたので、リファクタも本格的にやっていきたいところだ。
 もっともネックになっているのは、やはりシェーダか。その性質ゆえ、シェーダの頂点レイアウトやバッファ変更が大きくプログラムに影響してしまう。最も生産的なのは、「プログラム側でシェーダを生成しランタイムコンパイルすること」だ。このシステム構築にはとんでもない手間がかかるが、必ず必要となるはずだ。

 やるべきことが山積みだが、一つ大きな機能の実装を終えたことでとりあえずひと段落ついた感じはある。この全方位シャドウのFeatureブランチをマージしたらDiffがAddition1300 Delete300くらいになってた。結構書いたなぁ。まあ差分が膨れ上がるのも、拡張性が低いクソ設計になっているのが原因なのだが。あーリファクタしたい。