ジオメトリシェーダでライン描画

DirectXには一応、線を描画する機能がある。
プリミティブ型にラインリストやラインストリップを指定すれば、頂点バッファの頂点を線として描画してくれる。

ただし、その線には太さという概念がない。線を描くならできればそれなりの太さが欲しい。

太さのもった線を描画するのには、やはり三角形を使って描画するしかないのだが、ここでジオメトリシェーダが役に立つ。

グラフィックパイプラインにおけるジオメトリステージとは、頂点シェーダのあと、ピクセルシェーダの前に走るシェーダステージのことだ。

頂点シェーダが頂点ごと、ピクセルシェーダがラスタライズ後のピクセルごとに処理が走るのに対し、ジオメトリシェーダは「プリミティブ単位」で処理が走る。つまり、プリミティブ型に三角形を指定していれば、ジオメトリシェーダの入力は3頂点というわけだ。そして、ジオメトリシェーダは複数のプリミティブを出力することができる。

このジオメトリシェーダは今回のようなケースで非常に役に立つ。
「ラインリスト」のプリミティブを受け取り、「三角形リスト」を出力する、という処理を書くことができる。線を受け取り、それを三角形二つで構成された四角形を出力するジオメトリシェーダを作れば、太さのある線を描くことができる。

struct VSOutput
{
    float4 pos : SV_POSITION;
    float4 col : COLOR;
    float thickness : THICKNESS;
};

struct GSOutput
{
	float4 pos : SV_POSITION;
    float4 col : COLOR;
};

[maxvertexcount(6)]
void main(
	line VSOutput input[2],
	inout TriangleStream< GSOutput > output
)
{
	for (int i = 0; i < 2; i++)
	{
        float offset = input[i].thickness / 2.0f;

        {
            GSOutput element;
            element.pos = input[i].pos + float4(offset, 0.0f, 0.0f, 0.0f);
            element.col = input[i].col;
            output.Append(element);
        }
        {
            GSOutput element;
            element.pos = input[i].pos + float4(-offset, 0.0f, 0.0f, 0.0f);
            element.col = input[i].col;
            output.Append(element);
        }
        {
            GSOutput element;
            element.pos = input[(i + 1) % 2].pos + float4(offset * sign(i - 1), 0.0f, 0.0f, 0.0f);

            element.col = input[(i + 1) % 2].col;
            output.Append(element);
        }

        output.RestartStrip();
	}
}


今回は頂点情報に線の太さ情報を追加した。これはコンスタントバッファーで与えてもいいだろう。

さて、これを使って現在はレイトレース及びそのビジュアライザを実装している。
レイの挙動を視覚的に確認することができるので、デバッグに大いに役立っている。


f:id:riyaaaaasan:20180406235024p:plain



近いウチにレイトレースのブログ記事も投稿する予定だ。

レンダリングエンジンのコアロジック部分からDirectXの依存性を排除した

 世の中、マルチプラットフォームが当たり前になってきた。

 ぶっちゃけ、マルチプラットフォームにする必要性はまったく感じなかったのだが(再三言っているが、プロダクト開発ではなく、あくまでゲームエンジニアとしての研鑽のためエンジンを開発している)

 それはそれとして、ロジック部分にハードウェアに対する依存性が生じているのが生理的に許せなくなったので

 フォトンマッピングの実装途中なのに唐突にブランチを終了してハードウェアの抽象化を始めた。

 真の抽象化は、大手ベンダーの開発しているグラフィクスAPI全てのレンダリングパイプラインを抽象化して統一したスマートでエクセレントなグラフィックレンダリングライブラリを開発することだが、そもそも私はOpenGLDirectXの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桁のビットがある。もう少し効率的な方法がある気もするが....

実際に計算して可視化してみた。
各オブジェクトが所属する最小八分木空間を計算した後は、それと同じサイズのボックスメッシュを生成し、ワイヤーフレーム描画してそれっぽく見せている。


f:id:riyaaaaasan:20180328232059p:plain


同じオブジェクトでも、位置によってはグリッドをまたいでしまうため、より大きな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)の実装に使われる、確率論分野のチェビシェフの不等式である。

{ \displaystyle
E(x) = \int_∞^{-∞} xp(x)dx
\\
E(x^2) = \int_∞^{-∞} x^2p(x)dx
\\
μ = E(x)
\\
σ^2 = E(x^2) - E(x)^2
\\
P(x{\geq}t) {\leq} P_max(t) {\equiv} \frac{σ^2} {σ^2 + (t - μ)}
}

ここで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パスに分けて描画することにする。

{ \displaystyle
p(x_a, y_j) = \frac{1}{N} \sum_{i} exp(-\frac{(x_i - x_a)^2}{2σ^2}p(x_i, y_j))
\\
p(x_a, y_b) = \frac{1}{N} \sum_{j} exp(-\frac{(y_j - y_b)^2}{2σ^2}p(x_a, y_j))
}

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
f:id:riyaaaaasan:20180315215626p:plain

Not VSM
f:id:riyaaaaasan:20180315215615p:plain


うむ。ソフトになった....うん? うん...(論文とちょっと違う感じになってるのが気になる。ソフトというより残像っぽい)

今回はここまで。

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の一意性は失われ危険がある気はするが、まあ別にプロダクトを作っているわけではないのでこの辺はざっくり適当でいいだろう。問題が起こったらどうにかする。


さて、肝心のリフレクションだが、すでに実装しているスペキュラ反射の一部を流用しつつ実装する。

{ \displaystyle
F_r ≈ F_ 0 + (1 - F_0)(1 - cosθ)^5
}


これはSchlickの近似を使ったフレネルの公式である。
ここで {F_0} は垂直入射時の反射係数(物理的な正確さを求めるなら、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という。

ここで一度原点に戻ってレンダリング方程式を掲載してみよう。

{ \displaystyle
L_{o}(x,\vec{\omega}) = L_{e}(x,\vec{\omega}) + \int_{\Omega} f_{r}(x,\vec{\omega}',\vec{\omega}) L_{i}(x,\vec{\omega}') (\vec{\omega}' \cdot \vec{n}) d\vec{\omega}
}

ここで、右辺第一項は自身が光源として放つ光のことなので今回は無視してよい。
右辺第二項は、(否透過材質であると仮定すれば)入射してくる光と、法線ベクトルと入射ベクトルの内積、そして反射を表すBSDF関数の積について、半球積分したものだ。そう、半球上のあらゆる入射方向についての総和だ。

この理論でいくならば、全テクセルを光源と定義した環境マップにおいては、その半球上に存在する全テクセルについて、ライティングの計算をしなくてはならない。喜ばしいことに、直接光だけの計算で考えるならば環境マップは離散データなので、モンテカルロ法とかそういう手法で頭を悩ませずとも数値積分することができる。

うむ。解決。

とはならない。

非現実的だからだ。

なぜなら、ライティング処理はピクセルシェーダーでピクセルごとに処理するのが最も一般的で精度が高くなるので、全ピクセルで行われる。つまり、メッシュ上の全てのピクセルについて、かつ環境マップの解像度に比例した膨大な量の光源についてライティングをしなくてはならない。そんなもの、今日のGPUでもFPS60/30を保って処理するのは不可能である。

そのため、今回は、屈折ベクトル及び反射ベクトルの先にあるテクセルの2つを光源として、しかもそのまま雑にピクセルカラーに加算するという、PBRもへったくれもない近似手法でごまかすことにしよう。

この近似は、完全鏡面でかつ、全ての光源が無限遠に存在しかつ減衰しないという仮定に即したものとなる。

というわけで、既存のディレクショナル・ポイントライトの拡散・鏡面反射の計算の後に大胆に以下のコードを挿入することにする。

specular += ReflectionFrensel(IN.posw, IN.norw, Eye, 0.2f);


実行すると以下のようになる。


f:id:riyaaaaasan:20180311013600p:plain



球のモデルが実はサッカーボールなせいで地味に凹凸がうまれている。

さて、次回の予定は未定だ。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はワールド変換後の頂点座標だ。スカイボックスはワールドの原点に配置するので、ワールド座標がそのまま位置ベクトルとして使える。

レンダリングするとこうなる。


f:id:riyaaaaasan:20180309225919p:plain


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パースペクティブ行列を導出してみるべし。

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

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くらいになってた。結構書いたなぁ。まあ差分が膨れ上がるのも、拡張性が低いクソ設計になっているのが原因なのだが。あーリファクタしたい。