ミップマップを使ってラフネスのある材質のリフレクションを実装した

 以前、環境マップを用いて、リフレクションを実装した。

Cube Map Reflectionを実装した - riyaaaaasan’s blog

 上記の記事では完全鏡面に限定した実装となっており、マテリアルのラフネス値を無視した綺麗で鮮明なリフレクションしか実現できていない。
 このリフレクションを拡散面にも適用させるには、IBLの定義より環境マップの全テクセルについてライティング処理を行わなくてはならないが、非現実的のため採用していない。

 今回は、拡散面のリフレクションの近似表現として、ミップマップによる実装を紹介する。

 ミップマップとは、オリジナルのテクスチャの縦横半分のテクスチャ、さらにその半分のテクスチャ....という徐々に解像度が小さくなる階層構造を持つ(Ex: 128*128(Origin), 64*64, 32*32, 16*16....1*1)、あらかじめ圧縮されたテクスチャ群のことである。ミップマップによって、あまり詳細を求められない遠くの物体を描画するときに、より高速で、そして圧縮時のアルゴリズムによってはより高品質な結果を得ることがきる。もちろんメモリはより消費するが、33%ほどの増加にしかならない。

 これを使って、リフレクションの拡散表現を行ってみよう。ラフネスの高い材質のリフレクションとはどんな感じか? 簡単な話で、ボヤけている。理屈は置いといて、直感的にそう感じる見た目をしている。つまり、ぼかしをかけたテクスチャを複数枚用意して、ラフネスの値に応じてぼけたテクスチャを使用すればよい。ぼけたテクスチャといえばいろいろ実現手段はあるが、圧縮して解像度を下げたテクスチャを使えば、それもまた「ぼけた」表現になる。その場合幸いミップマップを使えば非常に簡素な記述で済む。その圧縮テクスチャの用意の方法はなんでもよいが(ここのこだわり方で大きく品質が変わってくる。近年のレンダリングエンジンでは後述するがPRTといった技術を使ってより精度の高いテクスチャを生成する)、今回は最も簡単な最近傍(Nearest neiboghr)法を使って実装してみる。

 まずNearest neiboghrによる圧縮コードから。当該部分のみ抜粋する。

Texture2D CompressTexture::NearestNeighbor(Texture2D tex, Size2Dd compressedSize) {
    Size2Dd src_size(tex.Width(), tex.Height());
    unsigned int channels = tex.Channels();
    std::size_t size = channels * compressedSize.w * compressedSize.h;
    char* buf = new char[size];
    const char* srcbuf = reinterpret_cast<const char*>(tex.get());

    for (unsigned int i = 0; i < compressedSize.h; i++) {
        for (unsigned int j = 0; j < compressedSize.w; j++) {
            _Vector2D<unsigned int> src_idx(
                j / static_cast<float>(compressedSize.w) * src_size.w,
                i / static_cast<float>(compressedSize.h) * src_size.h);

            unsigned int idx = i * compressedSize.w * channels + j * channels;

            for (unsigned int c = 0; c < channels; c++) {
                buf[idx + c] = srcbuf[static_cast<int>(src_idx.y * channels * src_size.w + src_idx.x * channels + c)];
            }
        }
    }

    Texture2D dst(compressedSize.w, compressedSize.h, channels, buf, size);
    return dst;
}


非常に簡単なアルゴリズムなので解説は省略する。
次に、テクスチャキューブのミップマップの生成コードを抜粋する。

std::vector<Texture2D> TextureUtils::CreateMipmaps(Texture2D srcTex, unsigned int miplevels) {
    if (miplevels == 0) {
        miplevels = static_cast<unsigned int>(std::floor(std::log2(srcTex.Width())) + 1);
    }

    std::vector<Texture2D> mipmaps;
    mipmaps.reserve(miplevels);

    mipmaps.push_back(srcTex);

    Texture2D compressed = srcTex;
    Size2Dd size(srcTex.Width(), srcTex.Height());
    for (unsigned int i = 1; i < miplevels; i++) {
        size = size / 2;
        compressed = CompressTexture::NearestNeighbor(compressed, size);
        mipmaps.push_back(compressed);
        FileManager::getInstance()->AddCache<Texture2D>(srcTex.GetTextureName() + std::to_string(i), compressed);
    }

    return mipmaps;
}

std::vector<Texture2D> TextureUtils::CreateMipmaps(TextureCube srcTex, unsigned int miplevels) {
    if (miplevels == 0) {
        miplevels = static_cast<unsigned int>(std::floor(std::log2(srcTex.Size())) + 1);
    }

    std::vector<Texture2D> mipmaps;
    mipmaps.reserve(miplevels * 6);

    for (int j = 0; j < 6; j++) {
        std::vector<Texture2D> compresseds = CreateMipmaps(srcTex.textures[j], miplevels);
        std::copy(compresseds.begin(), compresseds.end(), std::back_inserter(mipmaps));
    }

    return mipmaps;
}


TextureCubeの場合、メモリ上のテクスチャの並びは

PositiveX面テクスチャのミップマップ0.....N, NegativeX面テクスチャのミップマップ0....N.......NegativeZ面の...

という配置になるため、まず一面について着目し、ミップレベル0(オリジナル)から(必要であれば)log2(size) + 1までのテクスチャを生成し、配列に展開、また次の面ついて処理、という形になる。

あとは、この生成したテクスチャを使ってGPUリソースを作成するだけだ。
D3D11_TEXTURE2D_DESCのMipLevelsを指定し、D3D11_SUBRESOURCE_DATA配列で初期化する。D3D11_SUBRESOURCE_DATA配列のサイズは、TextureCubeの場合6 * MipLevelsになる。オリジナルのテクスチャの解像度が1024の場合、ミップレベルは最大10で、サブリソース配列のサイズは66。(0~10のミップレベルのテクスチャ11枚が6面分)

最後に、HLSLのピクセルシェーダで、テクスチャサンプリングの関数としてSampleメソッドの代わりにミップレベルを指定できるSampleLevelを使う。
今回は、ラフネスの0~1の値を単純にミップレベルについて線形に投影して実装した。
以下に示すのは、引数にラフネス値を増やした、リフレクションのカラーをフェッチするメソッドである。

float3 ReflectionFrensel(float4 posw, float4 norw, float4 eye, float eta, float roughness)
{
    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);

    float Mip = MAX_REFLECTION_MIPLEVEL * roughness;

    float3 reflecColor = EnviromentMap.SampleLevel(EnviromentSampler, R, Mip);
    float3 refracColor = EnviromentMap.SampleLevel(EnviromentSampler, T, Mip);

    float3 col = lerp(refracColor, reflecColor, fresnel);

    return col;
}

ここで、MAX_REFLECTION_MIPLEVELは適切な定数とする。今回は10で固定にしたが、環境マップの解像度を可変にするならコンスタントバッファーで与えるなりした方がいいだろう。

ちなみに、この説明だとミップマップのフェッチが離散的(0~10)になるのではないか? という疑問が生じるところだが、SampleLevelメソッドは小数を与えると、二つのテクスチャについてよしなに補完してくれる機能を持つ。これはサンプラーの設定で変えることができて、D3D11_FILTER_MIN_MAG_POINT_MIP_LINEAR等を指定すれば線形補完してくれる。


実行結果を示す。まずはラフネス0から。これは最も品質の高いミップレベル0のテクスチャをフェッチするため、従来通り完全鏡面的な見た目になる。

f:id:riyaaaaasan:20180411214352p:plain

次に、ラフネス0.5。今回の場合はミップレベル5辺りの画像、つまり1024 / (2^5)の解像度のテクスチャをフェッチしてくる。

f:id:riyaaaaasan:20180411214751p:plain

圧縮アルゴリズムが適当過ぎるせいで品質は低いが、目的は達成できた。
改良すべきはミップマップの生成手段だが、先ほど述べた通りPRTを使うのが主流のようだ。

PRT(Precomputed Raddiance Transfer)とは、事前に放射輝度を遮蔽含めて計算しておくというアプローチである。私もまだ自分で実装したわけではないので解説の紹介は控えるが、全方向の放射輝度を保存するために、球面調和関数を用いて近似することで、ランタイムで現実的な処理速度を実現する。球面調和関数は完全直交な関数であるので、任意の球面上の関数を展開しSH基底関数の線形結合で表すことができる。それにより遮蔽情報を含む放射照度マップは非常にコンパクトなデータになる。環境マップテクスチャについても球面調和展開しておく。すると、二つの展開された関数について、基底関数の係数について内積を取れば、積分の性質により全球積分、すなわち前述した「全ての環境マップテクセルについてのライティング」を実現できるのだ。もちろん、球面調和関数の次数に依存した近似表現だが。

そのうち実装したいが、先にフォトンマッピングを実装したいのでかなり先になりそうだ。

今回はここまで。