レンダリングエンジンのコアロジック部分からDirectXの依存性を排除した
世の中、マルチプラットフォームが当たり前になってきた。
ぶっちゃけ、マルチプラットフォームにする必要性はまったく感じなかったのだが(再三言っているが、プロダクト開発ではなく、あくまでゲームエンジニアとしての研鑽のためエンジンを開発している)
それはそれとして、ロジック部分にハードウェアに対する依存性が生じているのが生理的に許せなくなったので
フォトンマッピングの実装途中なのに唐突にブランチを終了してハードウェアの抽象化を始めた。
真の抽象化は、大手ベンダーの開発しているグラフィクスAPI全てのレンダリングパイプラインを抽象化して統一したスマートでエクセレントなグラフィックレンダリングライブラリを開発することだが、そもそも私はOpenGLやDirectXのv11以外はたいして分かっていない。MetalやVulkanに至っては1ミリたりとも知らない。特に、DirectX 12やMetal、Vulkanといった「ローレベルAPI」と言われる、よりハードウェアアーキテクチャに近いAPIは、現在使っているDirectX11とは仕様が多く異なっている。例えばパイプラインステートオブジェクトやディスクリプタといった部分だろうか。これらのAPIはコンシューママシンのグラフィクスAPIに通ずるものがあるのでそのうちマスターしたい。
今回はあくまで概念的な抽象化ではなく、具体的に言えば静的リンクアーキテクチャレベルでの抽象化を行った。つまり、ロジック部分のコンパイルに、DirectX系統のシンボルを一切関与させないことを目標にした。
やったこと言えば、ひたすらリソースの仮想クラス化、およびそのパラメータの汎用化だ。そして、GPUリソースを作成するデバイスおよびレンダリングコマンドを発行するデバイスコンテキストを隠蔽した、抽象ImmediateCommandクラスを実装した。なんでImmediateなんだと聞かれたら、今のところDeferredContextの必要性を感じてないとしか言いようがない。そのうち実装する。
私はもともとテンプレートメタプログラミングを愛好するプログラマだが、やはりどうしてもハードウェアの抽象化というかなり無理のあるロジックをプログラムで仕込もうと思うと、どうしてもダウンキャストを前提とする多くの仮想基底クラスを振り回すような設計になってしまう。静的にImmediateCommandsクラスの挙動を決定する設計も考えなくはなかったが実現の困難さに気づき断念した。
なにはともあれ、膨大な作業によってなんとか抽象化が完了し、依然と同じ挙動をすることを確認するところまで行った。
最初からやっておけばよかったと無限に後悔している。Windows限定でいいやとか鼻をほじりながらプログラムを書いていた昔の自分を殴り飛ばしたい。
PRはこんな感じになった。なかなかの量の差分である。
https://github.com/Riyaaaaa/EnhancedRTRenderingEngine/pull/10
リファクターの余地は大いにあるが、ひとまずは満足した。フォトンマッピングの実装に戻ろうと思う。
Hashed-Octree(ハッシュ化八分木)による空間分割を実装した
そろそろレイトレがしたい。具体的に言えばフォトンマッピングでGIを実装したい。
だが、フォトンマッピングを実装するには現状あまりにも足りないものが多すぎる。
その最たるものが、高速な衝突判定アルゴリズムだ。
シンプルに設計されたレイトレ専用のアプリケーションなら、レイの衝突判定は、シーン中の全てのオブジェクトに対して総当たり的にやってもいいだろう。メッシュも、プリミティブな形状に限定すれば、高速に処理できる。
しかし、現在私が作ってるのは曲がりなりにもリアルタイムレンダリングエンジンだ。シーンの大きさや配置されるオブジェクトの数は固定ではない。現実的な処理時間で動作させなくてはならない。また、メッシュも多くのポリゴンを有する複雑なモデルが存在するかもしれない。
そのためには、レイの衝突判定において、衝突しないことが自明なオブジェクトに対しては処理をスキップするような仕組みが必要である。
このような仕組みで最も直感的なものは、ユニフォームグリッドによる空間分割だろう。
シーン全体を一様なグリッドで分割し、所属するグリッド内のオブジェクトのみで衝突判定を行う。
ただし、この分割法には多くの問題がある。まず、グリッドの横断が想定されていない。空間の分割の粒度をどれくらいにするにせよ、位置、サイズによっては必ず複数のグリッドにまたがるオブジェクトが出現するはずである。基本的には一つのオブジェクトが複数のグリッドから参照される形になるだろう。また、メモリ効率も悪い。一般に、空間というものは疎なもので、基本的には何もない。そして、偏りがあるものである。例えば、空中にオブジェクトがあることはまれだが、あるテーブルに着目した時、そのテーブルの上には多くのオブジェクトがあることが期待される。このような分布の空間において、一様なグリッドで分割するのはあまりにも非効率である。
このようなケースにおける最適な空間分割法として、八分木(Octree)がある。
まず、ルートの空間を定義する。次に、X-Y-Zについてそれぞれ2分割する。分割後の空間を空間レベル1(L=1)として、同様の操作を任意の空間レベルまで行う。
ユニフォームグリッドとの大きな違いは、空間レベルという概念があること。「グリッドをまたぐ」というのは存在せず、仮にグリッドをまたぐ場合は、それは1ランク上の「親空間」と呼ばれる、より大きな分割単位の空間に所属する仕組みになる。また、グリッドの番号にはモートンオーダーを用いることで、ビット演算を駆使してあるオブジェクトの属するすべての空間レベルのグリッド番号を取得することも可能である。
この辺の細かい説明は、すでに必要十分な説明を記載しているサイトがいくつもあるので省略することにする。
その8 4分木空間分割を最適化する!
ちなみに、アルゴリズム的には木構造であるが、モートンオーダーに対して空間レベルの等比数列の総和のオフセットを付加することで、任意の空間レベルの任意のグリッドのオーダーをハッシュ化することができる。
上記のサイトでもそれは行われているが、実装が配列になっているため、結局常に (8^L - 1 / 7)の要素数の配列の動的確保を要求していて効率が悪い。 std::unordred_mapのようなハッシュマップを使えば、疎な空間における効率的なメモリ確保が実現できるだろう。
実装にあたって、八分木分割後のビジュアライザーが必要だと感じたので、そちらもついでに作った。
空間座標から所属するモートンオーダーの算出方法は上記サイト様に記載のある通りだが、モートンオーダーから空間座標を逆算する計算が不明だったので、ここに記しておく。
AABB CalculateOctreeBoxAABBFromMortonNumber(uint32_t number) { int level = 0; // ハッシュ値から所属する最小空間のモートンオーダーに変換 while (number >= std::pow(8, level)) { number -= std::pow(8, level); level++; } uint32_t s = 0; for (int i = level; i > 0; i--) { s = s | (number >> (3 * i - 2 - i) & (1 << i - 1)); } uint32_t x = s; s = 0; for (int i = level; i > 0; i--) { s = s | (number >> (3 * i - 1 - i) & (1 << i - 1)); } uint32_t y = s; s = 0; for (int i = level; i > 0; i--) { s = s | (number >> (3 * i - i) & (1 << i - 1)); } uint32_t z = s; // _rootAABB.size: ルート空間のサイズ。空間レベルで割って所属する空間レベルの分割サイズを求める Size3D boxSize = _rootAABB.size() / (1 << level); // _rootAABB.bpos: ルート空間の開始座標 Vector3D bpos = Vector3D(x * boxSize.w, y * boxSize.h, z * boxSize.d) + _rootAABB.bpos; // 所属するAABB return AABB(bpos, Vector3D(bpos.x + boxSize.w, bpos.y + boxSize.h, bpos.z + boxSize.d)); }
これでハッシュ値から、所属するAABBを求めることができる。
戦略的には3bitごとに区切られたX-Y-Zのビットに着目して、特定の次元について切り詰める。例えば、110111001というモートンオーダーをXについて切り詰めると、011だ。今回の場合は空間レベルは最大8に設定したので、24桁のビットがある。もう少し効率的な方法がある気もするが....
実際に計算して可視化してみた。
各オブジェクトが所属する最小八分木空間を計算した後は、それと同じサイズのボックスメッシュを生成し、ワイヤーフレーム描画してそれっぽく見せている。
同じオブジェクトでも、位置によってはグリッドをまたいでしまうため、より大きなAABB(より高いレベルの空間)に所属している様子が分かる。
これで空間内の衝突判定の準備は整った。次は、レイが空間を通過するときの衝突リストの作成だが...いまいち効率的な方法が思いついていない。
Variance Shadow Maps(分散シャドウマップ)を実装した
Variance Shadow Maps(分散シャドウマップ)を実装しようと思う。
これはソフトシャドウの実装の一つで、シャドウのエッジをなんかいい感じにする技術だ。
まず、普通のシャドウマップの実装を再掲する。
bool IsVisibleFromDirectionalLight(float4 shadowCoord) { float w = 1.0f / shadowCoord.w; float2 stex = float2((1.0f + shadowCoord.x * w) * 0.5f, (1.0f - shadowCoord.y * w) * 0.5f); float depth = DirectionalShadowMap.Sample(ShadowSampler, stex.xy).x; if (shadowCoord.z * w <= depth + 0.00005f) { return true; } return false; }
まず、ライト視点のカメラを仮定してパースペクティブ・ビュー行列を作成し、シーンの深度を保存する。
そして、実際のレンダリングパスで対象の頂点を再度ライト視点に復元、格納済みの深度と比較してシャドウ判定を行う。
格納された深度よりも描画対象の深度が低ければシャドウなし、高ければシャドウありだ。
しかし、この2値的なアルゴリズムだと、どうしてもエッジが硬い違和感のあるシャドウになってしまう。これは、シャドウマップの解像度が低くなればよりジャギーが発生し違和感が顕著になる。
リアルの世界のシャドウと言えば、硬いエッジものはそうは見かけない。なぜなら、光源が面積を持っていたり、あるいはシャドウに間接光が入射するなど、様々な影響が存在するからだ。しかし、残念ながら現在はそのどちらも実装できていないので、シャドウのエッジを”誤魔化す”ことにする。
そこで登場するのがVariance Syadow Mapsだ。この手法は、物理的には正しくないが、それなりに見栄えの良いソフトシャドウを作ることができる。更に、これの優れた点は、現状多く存在するシャドウの実装と容易に組み合わせることが可能な点だ。
以下に示すのは、Variance Shadow Maps(以下VSM)の実装に使われる、確率論分野のチェビシェフの不等式である。
ここでtが確率論でいう標本、μが平均値、σが分散である。
これをシャドウマップに適用するのが、Variance Shadow Mapsだ。確率論の不等式をシャドウマップに適用というと突拍子もない話だが、この式によって得られる「0~1に正規化された確率」を「影の濃さとして適用する」と言えばなんとなく想像はつくだろうか。
ここで、チェビシェフの不等式に使う平均値(期待値)、分散だが、これは局所平均値を用いることで計算できる。
その計算方法とは、すなわちシャドウマップにぼかしフィルタを適用した後の値だ。例えば、移動平均フィルタのカーネルは一様分布であるので、そのフィルタを適用した後の各テクセルの値は局所平均値になることは自明であろう。(元論文)http://www.punkuser.net/vsm/vsm_paper.pdfでは、ガウシアンフィルタを使うようだ。
シャドウマップには現状、深度ステンシルバッファを使っている。残念ながらこれは最も最適な手法だが融通が利かないので、深度バッファではなくレンダーターゲットに焼き付ける手法でシャドウマップを作成する。レンダーターゲットを使えば、1つのピクセルにつきrgbaの4つのスロットを使うことができる。rに深度を、ついでにgに深度の2乗を格納しておくことで、まとめてE(x), E(x^2)を計算することができる。
というわけで、DirectXで実装してみよう。DirectXには、GPU上のリソースに手を加えるという軟弱な機能はないので(CPUにマップすれば可能だが無駄すぎる)、ガウスフィルタを適用したいテクスチャと同じサイズのビューポートを作り、そこに画面いっぱいに板ポリゴンを配置、ガウスフィルタを適用しながらその板ポリゴンを描画すると、レンダーターゲットにガウスフィルタの適用したテクスチャのできあがりだ。
レンダーターゲットに深度を焼きこむピクセルシェーダは省略でいいだろう。zとz^2を出力するだけだ。
ガウスフィルタは、X-PassとY-Passに分離できるので、2つのシェーダーを作り2パスに分けて描画することにする。
X-Pass
struct pixcelIn { float4 pos : SV_POSITION; float2 tex : TEXCOORD; }; Texture2D txDiffuse : register(t0); SamplerState samLinear : register(s0); cbuffer ConstantBuffer : register(b0) { float4 weight1; float4 weight2; float2 texsize; } float4 main(pixcelIn In) : SV_Target { float MAP_WIDTH = texsize.x; float3 col = weight1.x * txDiffuse.Sample(samLinear, float2(In.tex) + float2(+1.0f / MAP_WIDTH, 0)); col += weight1.y * (txDiffuse.Sample(samLinear, In.tex + float2(+3.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-3.0f / MAP_WIDTH, 0))); col += weight1.z * (txDiffuse.Sample(samLinear, In.tex + float2(+5.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-5.0f / MAP_WIDTH, 0))); col += weight1.w * (txDiffuse.Sample(samLinear, In.tex + float2(+7.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-7.0f / MAP_WIDTH, 0))); col += weight2.x * (txDiffuse.Sample(samLinear, In.tex + float2(+9.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-9.0f / MAP_WIDTH, 0))); col += weight2.y * (txDiffuse.Sample(samLinear, In.tex + float2(+11.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-11.0f / MAP_WIDTH, 0))); col += weight2.z * (txDiffuse.Sample(samLinear, In.tex + float2(+13.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-13.0f / MAP_WIDTH, 0))); col += weight2.w * (txDiffuse.Sample(samLinear, In.tex + float2(+15.0f / MAP_WIDTH, 0)) + txDiffuse.Sample(samLinear, In.tex + float2(-15.0f / MAP_WIDTH, 0))); return float4(col, 1.0f); }
Y-Pass
struct pixcelIn { float4 pos : SV_POSITION; float2 tex : TEXCOORD; }; Texture2D txDiffuse : register(t0); SamplerState samLinear : register(s0); cbuffer ConstantBuffer : register(b0) { float4 weight1; float4 weight2; float2 texsize; } float4 main(pixcelIn In) : SV_Target { float MAP_HEIGHT = texsize.y; float3 col = weight1.x * txDiffuse.Sample(samLinear, float2(In.tex)); col += weight1.y * (txDiffuse.Sample(samLinear, In.tex + float2(0, +2.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -2.0f / MAP_HEIGHT))); col += weight1.z * (txDiffuse.Sample(samLinear, In.tex + float2(0, +4.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -4.0f / MAP_HEIGHT))); col += weight1.w * (txDiffuse.Sample(samLinear, In.tex + float2(0, +6.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -6.0f / MAP_HEIGHT))); col += weight2.x * (txDiffuse.Sample(samLinear, In.tex + float2(0, +8.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -8.0f / MAP_HEIGHT))); col += weight2.y * (txDiffuse.Sample(samLinear, In.tex + float2(0, +10.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -10.0f / MAP_HEIGHT))); col += weight2.z * (txDiffuse.Sample(samLinear, In.tex + float2(0, +12.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -12.0f / MAP_HEIGHT))); col += weight2.w * (txDiffuse.Sample(samLinear, In.tex + float2(0, +14.0f / MAP_HEIGHT)) + txDiffuse.Sample(samLinear, In.tex + float2(0, -14.0f / MAP_HEIGHT))); return float4(col, 1.0f); }
フィルタの重みは以下のように計算できる。
struct GaussianCBuffer { float weight[8]; Size texsize; }; AlignedBuffer<GaussianCBuffer> buf; float total = 0; constexpr float disperision = 10.0f; for (int i = 0; i < 8; i++) { float pos = 1.0f + 2.0f * (float)i; buf.weight[i] = std::expf(-0.5f * pos * pos / disperision); if (i == 0) { total += buf.weight[i]; } else { total += 2.0f * buf.weight[i]; } } for (int i = 0; i < 8; i++) { buf.weight[i] /= total; }
ここでdisperisionパラメータは、シャドウの見栄えがよくなる適切なパラメータを指定する。
このシェーダーで描画されたシャドウマップのrとbを使って、"影になる確率"を求める。(実際には確率を濃度として使うのでこの表現には語弊がある)
float GetVarianceDirectionalShadowFactor(float4 shadowCoord) { float w = 1.0f / shadowCoord.w; // 頂点シェーダでGPUは勝手にwでx, y, zを割る。その再現 float2 stex = float2((1.0f + shadowCoord.x * w) * 0.5f, (1.0f - shadowCoord.y * w) * 0.5f); // -1 ~ 1を 0 ~ 1にマッピング float2 depth = DirectionalShadowMap.Sample(ShadowSampler, stex.xy).xy; float depth_sq = depth.x * depth.x; // E(x)^2 float variance = depth.y - depth_sq; // σ^2 = E(x^2) - E(x^2) variance = min(1.0f, max(0.0f, variance + 0.0001f)); float fragDepth = shadowCoord.z * w; float md = fragDepth - depth.x; // t - μ float p = variance / (variance + (md * md)); // σ^2 / (σ^2 + (t - μ)^2) return max(p, fragDepth <= depth.x); // P(x >= t)を満たすときのみ }
あとはこれで得られるファクターを、ライティング結果に掛けるだけだ。
VSM実装前と実装後の比較画像を貼る。
VSM
Not VSM
うむ。ソフトになった....うん? うん...(論文とちょっと違う感じになってるのが気になる。ソフトというより残像っぽい)
今回はここまで。
Cube Map Reflectionを実装した
Cube Map Reflectionを実装した。
前回、スカイボックスを実装したが、そのスカイボックスのリソースであるキューブテクスチャを環境マップとして用いて、リフレクションを実装することにする。
といっても、一応、拡張性を持たせるために、シーン内に複数の環境マップが存在し、最も近く、小さく精度が高いと期待できる環境マップをメッシュごとに探索し、レンダリング時のリフレクションリソースとして使用する、という仕組みを作った。
その際に、オブジェクトにUUIDを付与する必要性が出てきたので、Boost Libraryにお世話になりオブジェクトのIDを一意に定められるようにした。
#include <boost/lexical_cast.hpp> #include <boost/uuid/uuid_io.hpp> #include <boost/uuid/uuid.hpp> #include <boost/uuid/random_generator.hpp> //~~~ std::size_t uuid = std::hash<std::string>()(boost::lexical_cast<std::string>(boost::uuids::random_generator()()));
UUID -> std::string -> std::size_t
の変換を噛ませている。UUIDのフォーマットはXXXXX-XXXXXXX-XXXXXのような形式(桁数は適当)だが、これをそのまま使うにはC++はあまりにも文字列に対して無力なので、ハッシュ化することにする。SHAアルゴリズムを使っているはずなので、 (少なくともVC++はFNV-1アルゴリズムだった。)UUIDの一意性は失われ危険がある気はするが、まあ別にプロダクトを作っているわけではないのでこの辺はざっくり適当でいいだろう。問題が起こったらどうにかする。
さて、肝心のリフレクションだが、すでに実装しているスペキュラ反射の一部を流用しつつ実装する。
これはSchlickの近似を使ったフレネルの公式である。
ここで は垂直入射時の反射係数(物理的な正確さを求めるなら、RGBそれぞれの反射能の定数ベクトル)である。マテリアルのパラメータとして与える。cosθは正規化された法線・視線ベクトルの内積で求まる。
さて、ここでリフレクションが起きている頂点の色というのは、屈折方向の色と反射方向の色の合成で求まる。先ほど求めた反射係数と使うと
TextureCube EnviromentMap : register(t2); SamplerState EnviromentSampler : register(s2); float FrenselEquations(float reflectionCoef, float3 H, float3 V) { return (reflectionCoef + (1.0f - reflectionCoef) * pow(1.0 - saturate(dot(V, H)), 5.0)); } float3 ReflectionFrensel(float4 posw, float4 norw, float4 eye, float eta) { float3 N = norw; float3 I = normalize(posw.xyz - eye); float3 R = reflect(I, N); float3 T = refract(I, N, eta); float fresnel = FrenselEquations(pow(eta - 1 / eta + 1, 2), N, I); float3 reflecColor = EnviromentMap.Sample(EnviromentSampler, R); float3 refracColor = EnviromentMap.Sample(EnviromentSampler, T); float3 col = lerp(refracColor, reflecColor, fresnel); return col; }
このように求まる。ここで、etaというのは物質の屈折率だ。大気の屈折率は1として反射係数を計算している。
さて、これで求まる反射の色だが、これをどのようにPBRに当てはめるか?
一応作っているのは、物理ベースであるので、レンダリング方程式に乗っ取って適用したい。
結論から言うと、現時点での実装では物理的な正しさをこのリフレクションにもたせるのは不可能だ。なぜなら、基本的に現実の物質は多少なりとも光を拡散するからである。
まず、環境マップによるリフレクションとは何か?という定義から考えなくてはならない。
反射が発生するとしている以上は、環境マップは光を放っているということにして、その光が物体に入射しているのだという前提で環境マップリフレクションは実装されている。つまり、環境マップは光源なのである。しかも、テクセル一つ一つが、光源として色をもって光を放っている。その数は横*縦*高さ。
このように、テクスチャを光源と定義してライティングをすることをImage Based Lightingという。
ここで一度原点に戻ってレンダリング方程式を掲載してみよう。
ここで、右辺第一項は自身が光源として放つ光のことなので今回は無視してよい。
右辺第二項は、(否透過材質であると仮定すれば)入射してくる光と、法線ベクトルと入射ベクトルの内積、そして反射を表すBSDF関数の積について、半球積分したものだ。そう、半球上のあらゆる入射方向についての総和だ。
この理論でいくならば、全テクセルを光源と定義した環境マップにおいては、その半球上に存在する全テクセルについて、ライティングの計算をしなくてはならない。喜ばしいことに、直接光だけの計算で考えるならば環境マップは離散データなので、モンテカルロ法とかそういう手法で頭を悩ませずとも数値積分することができる。
うむ。解決。
とはならない。
非現実的だからだ。
なぜなら、ライティング処理はピクセルシェーダーでピクセルごとに処理するのが最も一般的で精度が高くなるので、全ピクセルで行われる。つまり、メッシュ上の全てのピクセルについて、かつ環境マップの解像度に比例した膨大な量の光源についてライティングをしなくてはならない。そんなもの、今日のGPUでもFPS60/30を保って処理するのは不可能である。
そのため、今回は、屈折ベクトル及び反射ベクトルの先にあるテクセルの2つを光源として、しかもそのまま雑にピクセルカラーに加算するという、PBRもへったくれもない近似手法でごまかすことにしよう。
この近似は、完全鏡面でかつ、全ての光源が無限遠に存在しかつ減衰しないという仮定に即したものとなる。
というわけで、既存のディレクショナル・ポイントライトの拡散・鏡面反射の計算の後に大胆に以下のコードを挿入することにする。
specular += ReflectionFrensel(IN.posw, IN.norw, Eye, 0.2f);
実行すると以下のようになる。
球のモデルが実はサッカーボールなせいで地味に凹凸がうまれている。
さて、次回の予定は未定だ。PRTか、ランタイムシェーダーコンパイルか....うーむ。何を実装しよう。
SkyBoxを実装した
SkyBoxを実装した。
といっても、リフレクションの前哨戦みたいなものだが。
リフレクションは基本的には環境マップで実装される。理想的には、そのオブジェクトを中心とする環境マップを一個一個作ることだが、動的な環境マップとなるとあまりにも現実的ではない。そのため、環境マップを入れ子構造にし、リフレクションの精度を高めたいところに小さな環境マップを作成するという方針が、近年のゲームエンジンの基本戦略である。
さて、そんな入れ子構造の最も外側の環境マップ、それがスカイボックスだ。そして、環境マップとしてだけではなく、シーンの背景としても活用される。
実装としてはいたって単純で、キューブテクスチャ―と、頂点法線と面の向きを反転した内側向きのボックスを用意する。
それをシーンを包むように巨大にスケールさせ、ボックスにキューブテクスチャをマッピングすれば完成だ。とても簡単である。今回の実装にあたって一番苦労したのはボックスモデルの用意だ。手元にちょうど良い.xモデルを吐き出せる3Dモデリングソフトがなかったので、手打ちで作った。
にしても、書くことがないので、仕方なしに文字数を稼ぐために、DirectXのキューブテクスチャの初期化処理でも張ることにする。こんな手続きじみたもの、なんの技術的な価値もないが....。適当に抜粋する。
// device: ID3D11Device width: テクスチャ一枚の横幅 height: 縦幅 textures: 6枚のテクスチャ param: テクスチャ ComPtr<ID3D11Texture2D> mTexture; ComPtr<ID3D11ShaderResourceView> mView; ComPtr<ID3D11SamplerState> mSampler; std::vector<D3D11_SUBRESOURCE_DATA> initData; param.width = textures[0].Width(); param.height = textures[0].Height(); param.arraySize = textures.size(); initData.resize(6); for (int i = 0; i < param.arraySize; i++) { initData[i].pSysMem = textures[i].get(); initData[i].SysMemPitch = textures[i].Stride(); } D3D11_TEXTURE2D_DESC desc; desc.Width = width; desc.Height = height; desc.MipLevels = 1; desc.ArraySize = 6; desc.Format = CastToD3D11Format<DXGI_FORMAT>(param.format); desc.SampleDesc.Count = 1; desc.SampleDesc.Quality = 0; desc.Usage = CastToD3D11Format<D3D11_USAGE>(param.usage); desc.BindFlags = CastToD3D11Format<UINT>(param.bindFlag); desc.CPUAccessFlags = CastToD3D11Format<UINT>(param.accessFlag); desc.MiscFlags |= D3D11_RESOURCE_MISC_FLAG::D3D11_RESOURCE_MISC_TEXTURECUBE; auto hr = device->CreateTexture2D(&desc, &initData[0], mTexture.ToCreator()); if (FAILED(hr)) { return false; } D3D11_SHADER_RESOURCE_VIEW_DESC SRVDesc = {}; SRVDesc.Format = GetShaderResourceFormat(desc.Format); SRVDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURECUBE; SRVDesc.TextureCube.MostDetailedMip = 0; SRVDesc.TextureCube.MipLevels = 1; hr = device->CreateShaderResourceView(mTexture.Get(), &SRVDesc, mView.ToCreator()); if (FAILED(hr)) { return false; } D3D11_SAMPLER_DESC samplerDesc; samplerDesc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; samplerDesc.AddressU = D3D11_TEXTURE_ADDRESS_WRAP; samplerDesc.AddressV = D3D11_TEXTURE_ADDRESS_WRAP; samplerDesc.AddressW = D3D11_TEXTURE_ADDRESS_WRAP; samplerDesc.MipLODBias = 0.0f; samplerDesc.MaxAnisotropy = 1; samplerDesc.ComparisonFunc = D3D11_COMPARISON_ALWAYS; samplerDesc.BorderColor[0] = 0; samplerDesc.BorderColor[1] = 0; samplerDesc.BorderColor[2] = 0; samplerDesc.BorderColor[3] = 0; samplerDesc.MinLOD = 0; samplerDesc.MaxLOD = D3D11_FLOAT32_MAX; // Create the texture sampler state. hr = device->CreateSamplerState(&samplerDesc, mSampler.ToCreator()); if (FAILED(hr)) { return false; }
ああ...パラメータをDirectXの列挙体やフラグに変換する関数の説明が面倒くさい...(開発中のエンジンからコピペってきたので)
....ので省略する。大事なのはサブリソースの配列にそれぞれのテクスチャの先頭アドレスを突っ込み、それでテクスチャを初期化することだ。
また、テクスチャのMiscFlag、サブリソースビューのViewDimension、それぞれにCubeTextureの設定をする必要がある。そのほかは普通のテクスチャの初期化と一緒だろうか?
ちなみにHLSLでは前回も説明した通り、TextureCubeには位置ベクトルでアクセスする。
TextureCube TextureMap : register(t10); SamplerState samLinear : register(s10); float4 main(pixcelIn IN) : SV_Target { return TextureMap.Sample(samLinear, IN.posw.xyz); }
スカイボックスにはライティングもなにもないので、これだけになる。poswはワールド変換後の頂点座標だ。スカイボックスはワールドの原点に配置するので、ワールド座標がそのまま位置ベクトルとして使える。
レンダリングするとこうなる。
360度どこを向いても背景がついた。
現在ライティングがまだまだPBRのレベルに達していないので、実際の写真を使っているスカイボックスを使うと違和感がある....まあ仕方なし。
宙に浮いている謎の球体は、次回スカイボックスのリフレクションに使う。この球体にスカイボックスを映していこうと思う。
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のパースペクティブ行列を導出してみるべし。
このブーリアンを返す関数を使ってライティング処理をするかしないかをピクセルごとに判定すれば、全方位シャドウの実装は完了だ。実際にライトの周辺にいろいろおいてレンダリングしてみるとこうなる。
蛇足になるが、テクスチャからフェッチしてきた深度と、光源とピクセルの距離の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くらいになってた。結構書いたなぁ。まあ差分が膨れ上がるのも、拡張性が低いクソ設計になっているのが原因なのだが。あーリファクタしたい。
HLSLのTextureCubeの情報が少なすぎる
ランス10やってたら気づいたら土日が終わっていた
さて、ポイントライトの全方位シャドウや、リフレクション用の環境マップの実装をしているのだが
HLSLのTextureCubeの情報が少なすぎる
c++ - Direct3D 11: How to access cube map faces in memory on the CPU side (should ID3D11DeviceContext::Map work with subresources?)? - Stack Overflow
A: CPUでCubeMap読み取りたいんだけどバグる。そもそもマイクロソフトの資料がうんちすぎて、そもそもTextureCubeの6面がメモリ用で連続なのかもわからん。でも常識的に考えて連続やと思うんだけど。誰か教えて。
B:連続やで。多分あんさんのコード間違ってる。
うーん皆苦労してんだなあ。
ただ、GPUメモリ内で連続である確証が得られたのはよかった。私も同様のことをしたいと思っていたから。
環境マップの作成のためには、当然ながら立方体六面のすべての面の描画が必要になるわけだが、残念ながらTextureCubeを使ってバーン!と描画するわけにはいかない。
なぜならば、そもそも根本的に描画する対象が違うからだ。法線、深度、もろもろの情報をMRTを使って一度の描画キックで描画するディファードレンダリングとは根本的に用途が違う。6面描画するなら6回描画しなければならない。つまり、個別のテクスチャとして描画する必要がある。
具体的には6方向のビュー行列を作って、それを定数バッファに格納して6回頂点シェーダを起動する。一般的にはZバッファ...Directx11ではDepthStencilViewの描画テクスチャを、シェーダーリソースとして再利用する形式が一般的。ピクセルシェーダとレンダーターゲットはnullptrを渡せばよい。
適当に一部抜粋
DirectX::XMVECTOR CubeTexture::lookAt[6] = { XMVectorSet(1.0f, 0.0f, 0.0f, 0.0f), // +X XMVectorSet(-1.0f, 0.0f, 0.0f, 0.0f), // -X XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f), // +Y XMVectorSet(0.0f, -1.0f, 0.0f, 0.0f), // -Y XMVectorSet(0.0f, 0.0f, 1.0f, 0.0f), // +Z XMVectorSet(0.0f, 0.0f, -1.0f, 0.0f) // -Z }; DirectX::XMVECTOR CubeTexture::up[6] = { XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f), // +X(Up = +Y) XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f), // -X(Up = +Y) XMVectorSet(0.0f, 0.0f, -1.0f, 0.0f), // +Y(Up = -Z) XMVectorSet(0.0f, 0.0f, 1.0f, 0.0f), // -X(Up = +Z) XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f), // +Z(Up = +Y) XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f), // -Z(Up = +Y) }; DirectX::XMMATRIX* PointLight::GetViewMatrixes() { if (_isDirtyMatrix) { for (int i = 0; i < 6; i++) { DirectX::XMVECTOR pos = XMVectorSet(_point.x, _point.y, _point.z, 0.0f); auto dir = static_cast<CUBE_DIRECTION>(i); _viewMatrixes[i] = XMMatrixLookToLH(pos, CubeTexture::lookAt[dir], CubeTexture::up[dir]); } _isDirtyMatrix = false; } return _viewMatrixes; }
で、あとはこれで得られるビュー行列の配列と、共通の視野角90度・アスペクト比1:1のパースペクティブ行列で描画すれば、6枚分の深度テクスチャが得られるが....
これをCubeTextureに再パックしたい。
D3D11_TEXTURE2D_DESC texElementDesc; depthTexture[0]->GetDesc(&texElementDesc); D3D11_TEXTURE2D_DESC texArrayDesc; texArrayDesc.Width = texElementDesc.Width; texArrayDesc.Height = texElementDesc.Height; texArrayDesc.MipLevels = texElementDesc.MipLevels; texArrayDesc.ArraySize = 6; texArrayDesc.Format = texElementDesc.Format; texArrayDesc.SampleDesc.Count = 1; texArrayDesc.SampleDesc.Quality = 0; texArrayDesc.Usage = D3D11_USAGE_DEFAULT; texArrayDesc.BindFlags = D3D11_BIND_SHADER_RESOURCE; texArrayDesc.CPUAccessFlags = 0; texArrayDesc.MiscFlags = D3D11_RESOURCE_MISC_TEXTURECUBE; ComPtr<ID3D11Texture2D> texArray = 0; if (FAILED(hpDevice->CreateTexture2D(&texArrayDesc, 0, texArray.ToCreator()))) return; for (UINT i = 0; i < 6; i++) { hpDeviceContext->CopySubresourceRegion(texArray.Get(), D3D11CalcSubresource(0, i, texArrayDesc.MipLevels), 0, 0, 0, depthTexture[i], 0, nullptr); }
唐突に出てくる変数名とかは雰囲気で。
つまるところ、CopySubresourceRegionを使ってテクスチャを一枚一枚TextureCubeに先頭からコピーしていく。
正直TextureCubeのミップレベルの構造がよくわからんかったので自信がなかったが、うまく動いた。+X-X+Y-Y...と一通りのミップレベル0のテクスチャの後に、ミップレベル1...2...と続いていく形式じゃないのかと不安だった。どうやらただの6枚のテクスチャ配列らしい。
これでCubeTexture自体は実装できたので、さっそくシャドウを実装したいところだが、先にTextureCubeビューワーを作る。兎にも角にも、バグ発生時にシャドウの計算と、深度環境マップの作成のどちらでバグっているのかが分からなかったら話にならないからだ。
HLSLでは、TextureCubeのSampleメソッドはこういうシグニチャになっている。
float4 Sample(Sampler, float3);
Texture2DArrayと同じシグニチャ。つまり、第二引数の三次元ベクトルは、x、yはテクスチャUVで、Zがインデックスだな?と思ったら全然違った
あまりのも情報が少なすぎるので、DirectxのサンプルをあさりまくってTextureCubeを実際に使っているプログラムを探す。すると、引数に渡している引数の変数に代入している式に見覚えのある数式が....あ、これ反射ベクトルの計算式だ。
というわけで、TextureCubeの第二引数はUVではなく反射ベクトルだった。わ、わかりづれぇ..
ちがった。
反射ベクトルを使うのはリフレクション用途で環境マップにアクセスするとき。もっとシンプルに、立方体の中心から面に向かう3次元ベクトルでアクセスする。
これは、単純なテクスチャの表示には不便そうだ。なので、テクスチャビューアーはTextureCubeが渡された時には内部で展開して一枚のテクスチャにする機能を加えることにする。さっきのプログラムの真逆のことをするだけでいい。
D3D11_TEXTURE2D_DESC faceDesc(desc); D3D11Texture faceTexture; ComPtr<ID3D11Texture2D> faceTextureSrc; faceDesc.ArraySize = 1; faceDesc.BindFlags = D3D11_BIND_FLAG::D3D11_BIND_SHADER_RESOURCE; faceDesc.MiscFlags = 0; hpDevice->CreateTexture2D(&faceDesc, nullptr, faceTextureSrc.ToCreator()); hpDeviceContext->CopySubresourceRegion(faceTextureSrc.Get(), 0, 0, 0, 0, texture, D3D11CalcSubresource(0, index, desc.MipLevels), nullptr);
これで深度環境マップが正しく描画されているか確認できるようになった。
下六枚が部屋の中心に置いたポイントライトのシャドウマップ。
左上のは遥か上空にあるディレクショナルライトのシャドウマップ。
単純計算で全方位シャドウは6倍のコストがかかる。メモリも6倍。うーん、これはほいほいと毎フレーム計算したくないな。
左から順に+X, -X, +Y, -Y, +Z, -Zの面なので、雰囲気的にそれらしく描画はできているようだ。まだシャドウそのものを実装したわけではないので、確信はないが。
今週中にポイントライトの実際のシャドウの計算をHLSLを使って実装していきたい。
今回はこの辺で。