初めに
この記事はレイトレ合宿8アドベントカレンダー7/17の1個目の記事として書かれたものです。 この記事ではレイトレーシングにおけるImage Based Lightingの扱い、純パストレにおける実装、重点的サンプリングの手法及び実装について解説していきます。
IBL(Image Based Lighting)
IBLとはImage based Lightingの略で環境光をテクスチャで作ろうという手法です。IBLそのものはリアルタイム、オフライン問わず何処でも使われていてリアルな環境ライティングを作ろうとすれば不可欠な手法です。
レイトレーシングでも金属やガラスなどの鏡面的なBSDFが含まれるシーンでIBLを着けてあげると、映り込みがしっかりするので質感がリアルになります。

IBLの実装
IBLは背景となるので遥か遠く(無限遠方)から光を出す光源として働いているものとして扱われます。IBLのモデルとしてはよく半径が大きくシーンを包み込むような球(スカイスフィア)が使われます。


IBLは無限遠方の光源として扱わなければならないため、こうした有限の半径の球体で表現するのは不適切です。基本的に無限の半径を持つ球体としてスカイスフィアを考えていく必要があります。
半径を無限にした場合、入射してくる光というのは方向にのみ依存することになります。これはつまり、シーンの何処でも、方向が一緒であればスカイスフィアから取得する輝度は同じという意味となります。
なぜこうなるのかはshockerさんの記事がわかりやすいです。大まかにいうと半径が大きくなるとシーン内の位置の差は相対的に小さくなるためです。
このため、IBLの計算の際はレイの方向のみを見て計算することになります。
HDRとuv座標
IBLは光源として扱うのですから輝度は当然1以上を取ることもできます。しかしながら通常のpngやjpegと言った画像フォーマットのテクスチャをIBLに使用することは不適切です。これらのフォーマットでは輝度が0~1の範囲になるように切り捨てており、それ以上の色というのは白飛びと言った形で輝度が押さえられてしまっているからです。
IBLとか照明のテクスチャは一般に輝度をそのまま保存しているHDRというか画像フォーマットを使用します。HDRの画像(HDRI)はPolyhavenなどのサイトで簡単に手に入れられます。
ここらで手に入るHDRIは基本的にスカイスフィアに貼り付けることを前提にしているので歪んだ感じになっています。

どうやってスカイスフィアにHDRIをテクスチャリングするかと言うと、極座標の方位角、偏角をuv座標として扱うことで行います。要は世界地図とおんなじ感じです(正距円筒図法)。
受け取ったレイの方向に対して方位角、偏角を極座標変換で求め、それぞれ最大値で割ってあげることでHDRIに対するuv座標を求めることができます。
実装
以上を踏まえてIBLの処理の流れは
空に飛んでいく(何にも当たらない)レイの方向ベクトルをスカイスフィアに渡す
方向ベクトルの極座標を求めて、uv座標を取得する
UV座標よりHDRIテクスチャから輝度を返す
という感じで行っていきます。
実装に必要なものは、
- HDRI画像の読み込み
テクスチャ読み込みのライブラリでstbというものがあります。これはヘッダオンリーでそこまで複雑なものではなく、またHDRI含め色々と画像を配列として読み込んでくれます。これを使ってやるのがシンプルでいいと思います。
- 極座標変換
これは単に数学とかであるやつをそのままやればいいです。ただしy-up左手座標など自らの使う座標系に合わせなきゃ行けなかったり、あとはC++標準の逆関数の定義域に注意する必要があったりします
Vec2 calcUVSphere(const Vec3& dir)const { Vec2 uv; float theta = std::acos(std::clamp(dir[1], -1.0f, 1.0f)); float p = std::atan2(dir[2], dir[0]); float phi = (p < 0) ? (p + PI2) : p; uv[1] = std::clamp(theta * PI_INV, 0.0f, 1.0f); uv[0] = std::clamp(phi * PI_INV2, 0.0f, 1.0f); return uv; }
といった形で作ることができます
IBLを付けると以下のような感じになります。これだけで結構リアルな感じになります。

IBLの重点的サンプリング
以上によってIBLは完成しました。しかしながら、使用するHDRIによってはパストレしてみると何かやたらとファイアフライが出てしまいます。10000sample ぐらいでやってもなんだかノイズが酷い画像が出てきてしまいます。

太陽が映る野外などのHDRIでは非常に小さいくせにやたらと高い輝度を持っている部分(一般には太陽が映る場所)が存在しています。この輝度が高いピクセルは事実上太陽光としてシーンに大きな寄与を与えるため、そこに向かうパスというのは非常に重要になってきます。しかし、一方で純粋なパストレーシングですと空に向かっていくレイの方向はそう言ったHDRIの輝度に無関係にBSDFからランダムに決定されるため、結果として先ほどのような激しいノイズが出てしまうというわけです。
実際に棒グラフで太陽光が存在するHDRIの輝度分布を出してみると以下のようになっています。大体のピクセルの輝度というのは0.0~2.0程度で分布していますが、太陽光が存在するピクセルの輝度は非常に高く、このHDRIだと2500000と数万倍レベルで高い輝度を持っています。そんな高い輝度を持ってるピクセルは棒グラフを見てもわかる通り、ほぼ一点に偏在しています。このようなHDRIの指向性というのが上記のノイズの原因となっているのです。

こうした極端な指向性を持つIBLの問題に対してはNEEの出番となります。NEEでIBLの非常に高い輝度を持つピクセル方向を重点的にサンプリングしてあげることができれば、当然太陽光などの指向性を持つHDRIに高い効果が得られるはずです。
こうしたことより、IBLにも重点的サンプリングを行える枠組みを作る必要があるわけです。重点的サンプリングの結果は一目瞭然で、重点的サンプリングをしているシーンとしていないシーンを比べるとかなりの差が現れます。


ということで、IBLの重点的サンプリングを作っていくわけですが、一つの問題としてどうやってテクスチャから重点的サンプリングするかという問題があります。
テクスチャは単なる離散的な配列情報ですが、重点的サンプリングでよく用いられる逆関数法を考えるには連続な情報でないといけません。ということで、二次元のテクスチャ空間(u,v)でテクスチャの分布を区分的関数(piecewise function)という関数として考えることで、PDFやCDFを求めサンプリングできるようにしていきます。
これは各ピクセルに対応する区分ごとに定数値を持っていて、イメージとしてはまさにヒストグラムのような関数です。

このような形で輝度の分布を表すことでテクスチャからの重点的サンプリングを考えることができます
一次元区分的関数
目的のテクスチャは二次元でありますが、一次元の区分関数の実装さえできれば二次元の拡張は簡単です。なのでまずは一次元の区分的関数について考えていきます。
各ピクセルにと言う値が入れられている時、一次元関数を以下のように作ります。
各区分では定数値になっており、そのグラフはヒストグラムのようになっています。

私たちの目的としては、この区分的関数に沿うようなサンプリングをしたいわけです。それに対して、もしこの関数に従うPDF とCDF を求めることさえできれば、逆関数法でこのようなサンプリングを実装することができます。
この関数に比例するようなPDF というのは簡単に作ることができます。各区分に対して、以下のように正規化する形で定数値を変更します。また、xは正規化して[0,1]の範囲にし、区分数(ピクセル数)として、
こうすることでPDFの関数として以下のように作ることができます。
なんとも無理矢理感がある作り方ですが、ちゃんと全積分してやると1になることがわかり、PDFの要件は正しく満たされています。
このPDFは元の関数と形状が一致しているため、CDFさえ求められれば完璧な重点的サンプリングを行うことができます。
ではCDFについて求めていきますが、ちゃんと定義から求めますと以下のようになります。
これをグラフ化してみると右肩上がりの折れ線グラフみたいな感じになっています。こうみれば各区分のCDFを記録しておいて両端のCDFから線形補完してあげることで作られていることがわかると思います。そのため、実装では各区分のCDFを保存しておくことでCDFを作ることができます。の場合は線形補完によって表せます。

PBR-bookの実装では以下のような形で実装されています。
CDFの逆関数
こうしてCDFを求めることはできましたが、逆関数法ではこの逆関数が必要となります。 以下のようなアルゴリズムで逆関数の値を求めることができます。
乱数u を受け取り
を満たすoffset を探索する
中間の
がどこに存在するのかを線形補完によって求める

探索の実装では、cdfのそれぞれの値はソート済みの配列のため二分探索によって求めます。この時のPDFはpdfはとなります。
実装
PBR-Bookを元に実装したものは以下のような形になっています。
struct Distribution1D {
std::vector<float> func, cdf;
float funcInt;
//constructor
Distribution1D(const std::vector<float>& infunc) {
unsigned int n = infunc.size();
func = infunc;
cdf.push_back(0);
for (int i = 1; i < n + 1; ++i) {
cdf.push_back(cdf[i - 1] + func[i - 1] / float(n));
}
funcInt = cdf[n];
float divFuncInt = 1.0f / funcInt;
if (funcInt == 0) {
for (int i = 1; i < n + 1; ++i) {
cdf[i] = float(i) / float(n);
}
}
else {
for (int i = 1; i < n + 1; ++i) {
//cdf normalize
cdf[i] *= divFuncInt;
}
}
}
int Count() const { return func.size(); }
unsigned int getOffset(float u)const {
//cdfの2分探索
int first = 0, len = cdf.size();
while (len > 0) {
int half = len >> 1, middle = first + half;
if (cdf[middle] <= u) {
first = middle + 1;
len -= half + 1;
}
else {
len = half;
}
}
return std::clamp(first - 1, 0, int(cdf.size()) - 2);
}
//サンプリング
float getSample(const float u, float& pdf, unsigned int& offset) const {
offset = getOffset(u);
float du = u - cdf[offset];
if ((cdf[offset + 1] - cdf[offset]) > 0.0) {
du /= (cdf[offset + 1] - cdf[offset]);
}
pdf = func[offset] / funcInt;
return (offset + du) / Count();
}
float getPDF(unsigned int index) const {
return func[index] / (funcInt * Count());
}
};
1次元区分的関数のクラスでは各区分の値(ピクセルの輝度)を保存するfunc、CDFを保存するcdf、そしてfuncの積分値であるを保存するfuncIntという情報を持って置く必要があります。
std::vector<float> func, cdf;
float funcInt;
コンストラクタではfuncは受け取った生のデータで、CDFは上記の式からfuncの値と区分数$N$で漸化式的に作っていきます。この時,cdfにはまだ積分値がわからないので
抜いて計算します。このため、最終的にcdfの最後には
の値が入っているのでfuncIntに移します。そして最後にすべてのcdfに対してfuncIntで割ってあげることで初期化が完了します。
では肝心の受け取った乱数 = [0,1]から逆関数法を使って重点的サンプリングを行う処理について説明していきます。getSampleではまず最初にoffsetという値を取得していますが、これは先ほど説明したように乱数
を超える1個手前のcdfの区分の番号です。位置関係などは上記の通りでこの探索はgetOffsetの方で実装しており、二分探索によってoffsetを取得しています。
unsigned int getOffset(float u)const {
//cdfの2分探索
int first = 0, len = cdf.size();
while (len > 0) {
int half = len >> 1, middle = first + half;
if (cdf[middle] <= u) {
first = middle + 1;
len -= half + 1;
}
else {
len = half;
}
}
return std::clamp(first - 1, 0, int(cdf.size()) - 2);
}
offsetが取得できればoffsetとoffset+1の間のどの辺に存在するのかを計算すれば返すべきサンプリング結果を求めることができます。これは単に線形補完的に求めてあげた上で[0,1]になるようCountで正規化し求めています。一方でpdfについてはoffset番目の区分のpdfを返せばいいので、func[offset] / funcIntという結果になります。なおoffsetの値は後々二次元区分的関数でも使いますので保存しておいてください。
float getSample(const float u, float& pdf, unsigned int& offset) const {
offset = getOffset(u);
float du = u - cdf[offset];
if ((cdf[offset + 1] - cdf[offset]) > 0.0) {
du /= (cdf[offset + 1] - cdf[offset]);
}
pdf = func[offset] / funcInt;
return (offset + du) / Count();
}
以上のように1次元区分的関数を実装することができました。
二次元区分的関数
一次元区分関数の実装が終われば二次元区分関数の実装は簡単です。以下のような実装によって二次元区分的関数を作ることができます。
class Distribution2D {
private:
std::vector<std::unique_ptr<Distribution1D>> pConditionalV;
std::unique_ptr<Distribution1D> pMarginal;
public:
Distribution2D(const std::shared_ptr<Image>& data, int nu, int nv, unsigned int mipmapLevel = 1) {
for (int i = 0; i < nu / mipmapLevel; i++) {
std::vector<float> func;
for (int j = 0; j < nv / mipmapLevel; j++) {
func.push_back(YfromRGB(data->getPixel(i * mipmapLevel, j * mipmapLevel)));
}
pConditionalV.push_back(std::make_unique<Distribution1D>(func));
}
std::vector<float> mfunc;
for (int i = 0; i < pConditionalV.size(); i++) {
mfunc.push_back(pConditionalV[i]->funcInt);
}
pMarginal = std::make_unique<Distribution1D>(mfunc);
}
Vec2 getSample(const Vec2& rnd, float& pdf)const {
Vec2 uv;
unsigned int index, dummyv;
float pdf1, pdf2;
float u = pMarginal->getSample(rnd[0], pdf1, index);
float v = pConditionalV[index]->getSample(rnd[1], pdf2, dummyv);
pdf = pdf1 * pdf2;
return Vec2(u, v);
}
float getPDF(const Vec2& uv)const {
unsigned int u = std::clamp(static_cast<int>(uv[0] * pMarginal->Count()), 0, pMarginal->Count() - 1);
unsigned int v = std::clamp(static_cast<int>(uv[1] * pConditionalV[u]->Count()), 0, pConditionalV[u]->Count() - 1);
return pConditionalV[u]->func[v] / pMarginal->funcInt;
}
};
流れを見ていきましょう。まずメンバ変数として定義しているものは以下のようになっています。
std::vector<std::unique_ptr<Distribution1D>> pConditionalV;
std::unique_ptr<Distribution1D> pMarginal;
二次元区分的関数はまず列ごとに分解しそれぞれで1次元区分的関数を考えます。その後、それらをまとめる形で列ごとのfuncIntを分布とみなした1次元区分的関数をさらに作ることで二次元区分的関数を構築します。実装では列ごとの分布はpConditionalVに格納され、pConditionalVのfuncIntを分布としてpMarginalとしてまとめています。
なお実装ではHDRIのRGBピクセルデータをdataとして直接受け取ることを前提としているのでちょっと本筋からずれますがYfromRGBのあたりはRGBを輝度Yに変換してfuncの値として扱うという処理が書いてありますので注意してください。
Distribution2D(const std::shared_ptr<Image>& data, int nu, int nv, unsigned int mipmapLevel = 1) {
for (int i = 0; i < nu / mipmapLevel; i++) {
std::vector<float> func;
for (int j = 0; j < nv / mipmapLevel; j++) {
func.push_back(YfromRGB(data->getPixel(i * mipmapLevel, j * mipmapLevel)));
}
pConditionalV.push_back(std::make_unique<Distribution1D>(func));
}
std::vector<float> mfunc;
for (int i = 0; i < pConditionalV.size(); i++) {
mfunc.push_back(pConditionalV[i]->funcInt);
}
pMarginal = std::make_unique<Distribution1D>(mfunc);
}
このように構成されるので、サンプリングは「pMarginでどの列を取るかをサンプリングする」「サンプリングされた列のpConditionalVからサンプリングする」という形で縦横のサンプリングをすることができるというわけです。この際のpdfに関しては、サンプリングの際はそれぞれの分布で得られたpdfをかけてあげれば問題ないです。
Vec2 getSample(const Vec2& rnd, float& pdf)const {
Vec2 uv;
unsigned int index, dummyv;
float pdf1, pdf2;
float u = pMarginal->getSample(rnd[0], pdf1, index);
float v = pConditionalV[index]->getSample(rnd[1], pdf2, dummyv);
pdf = pdf1 * pdf2;
return Vec2(u, v);
}
pdfなどの注意点
サンプリング方法
NEEでは光原の点を直接サンプリングする必要がありますが、IBLでは無限遠方にあることが前提のため、スカイスフィア上の点をサンプリングすることはできません。仮にできたとしても幾何項に距離の変数が含まれるため、寄与が0となってしまいます。
これをどうするのかというと、IBL上のサンプリングは方向サンプリングとして扱うことで解決することができます。
理論的な部分で見てみましょう。一旦スカイスフィアの半径を有限として、という方向をサンプリングしたとしてその確率密度を[tex:p\omega]、そしてその方向と対応するスカイスフィアの点を
、法線を
、そこまでの距離を
とします。

に変換することが以下の式として変換することが出来ます。
ここで問題となる幾何項と確率密度の比を見ると、
ということになります。当然、が含まれていない式なのでスカイスフィアの半径を
にして無限遠方に持っていったとしてもこの式は問題ありません。
このように確率密度と測度の関係から、方向サンプリングの確率密度さえ得られれば無限遠方でも問題がないため、方向サンプリングによってNEEの光源サンプリングを行います。実装上では普通の面光源との兼ね合いで点サンプリング、方向サンプリングが入り混じることになるのでそこだけは注意して分岐させてください。
ヤコビアン
以上の話で方向ベクトルからテクスチャのuvを得たことから逆算すれば、サンプリングしたテクスチャのUVから対応する方向ベクトルも計算するかたちで方向サンプリングをすることができます。
しかしながら幾つかの変換を伴うため、その確率密度にヤコビアンを考える必要があります。
まず、は共に[0,1]ですが、これを偏角、方位角
へと以下のように変換します。
この時のヤコビアンは、
となります。
そして次に、偏角、方位角
より方向ベクトル
の成分はそれぞれ以下の変換で得ることができます。
このヤコビアンは、
となります。
以上の変換によってu,vの確率密度はヤコビアンで割っていき、最終的な方向ベクトルに関する確率密度というのは、
となります。
このようにサンプリングしたUV座標から方向ベクトルとその確率密度
を得ることができます。実装の方では上記の処理をそのまま行っています。
Vec3 enviromentSmapling(const Vec2& sample, float& pdf, Vec3& le) const { Vec2 uv = LE->getUVsample(sample, pdf); le = LE->getTex(uv[0], uv[1]); float theta = uv[0] * PI, phi = uv[1] * PI2; float costheta = std::cos(theta), sintheta = std::sin(theta); float cosphi = std::cos(phi), sinphi = std::sin(phi); bool hasDireLight = hasDirectionalLight; pdf *= (hasDireLight) ? 0.5f : 1.0f; pdf /= (2.0f * PI * PI * sintheta); return Vec3(sintheta * cosphi, sintheta * sinphi, costheta); }
MISの対応
※MISにてバグが発見されているのでここは間違っている可能性があることに留意してください
MISを行う場合、パストレ側でIBLに飛んで行ったときにNEE側の確率密度が必要になります。IBLに衝突したuvに対する確率密度を得て、その後同様にヤコビアンをかけてあげればNEE側の確率密度を得ることができます。
注意として、この確率密度の測度は立体角(方向)なので、MISウェイトの計算の際でNEE側の確率密度を変換する必要はありません。これはパストレ側でも同様です。
処理のまとめ
色々と実装して混乱してしまいがちなのでIBLの重点的サンプリングの流れをまとめると
- 一様乱数で
を作成
- Distribution2Dで
からサンプル
とPDFを取得
をUVとしてHDRIからRGBを取得
- サンプル
を
に変換、方向ベクトル
を計算
- PDFをヤコビアン用いて変換
- HDRIのRGB、PDF、サンプリング方向
を返す
終わりに
IBLの実装は重点的サンプリングまでやると結構重いものになりますが、いざレンダリングして太陽光がはっきりと出た画像を見ると実装して良かったなと感じるぐらいリアルさが増していて、実用的なレンダラーとかを考えるとやっぱりIBLの実装を理解しておくのは重要だなと感じます。この記事を読んでIBLについての理解の足掛かりになれば幸いです。
余談ですがテクスチャから重点的サンプリングする手法はIBLだけに限らず他にも使われているらしく、RealtimeGIのGlobalIlluminationBasedonSurfelsという手法では、それぞれの方向と得られた寄与をテクスチャに保存し、それから重点的サンプリングすることで寄与が高い方向を取り効率よくレイを飛ばす最適化を行ってるとのことです。
PBR-Bookとかこの手法とかで使うテクスチャはミップマップみたいに結構解像度を落としたものを使用してるらしく、それでもちゃんと動くのでHDRIを生で受け取るのではなくもう少し解像度落としたデータを使う方がより実用的になるかもしれません。
アドベントカレンダーの記事をもう1つ書いたのでもしよかったら見てください
デバッグ
テクスチャのサンプリング
テクスチャの重点的サンプリングのデバッグとしてはpdfの確認とサンプリングの可視化が思いつきます。
PDFは全領域で積分したら1になるという条件があるので、各ピクセルのpdfとその区分の面積の積を計算して総和を求めれば1になるはずです。
一応、各ピクセルのpdfを画像として出力するということも考えられますが、解像度が高いと1ピクセルのpdfは0.001とか結構小さくなりがちなのでほぼ黒みたいな画像しか出ないことが多いです。 太陽光が含まれるHDRIとかならその部分はpdfがかなり高くなるため、一応そこだけ明るく表示されますのでその確認ぐらいになら使えます。
サンプリングしたuvからプロットを作ればサンプリングの可視化ができ、輝度の差が激しいHDRIを使えば結構顕著にサンプリングが集まりますのでこれで確認するのも可能です。

NEEやMISのテスト
White Furnace Testを行えばバイアスがあるかどうか判断することができます。IBLを一様光源(輝度は0.9)にして、球体1つを置き、Lambertで反射率を1にしたシーンを用意し、レンダリングするのがWhiteFurnaceTestであり、バイアスがなければ球体は背景と同化することが知られています。バイアスがある場合は輝度の収束先が異なり、球の形状が見えてしまいます。


NEEやMISのテストとしてこれがよく使用されています。
参考文献
無限遠光源の扱い方 - Computer Graphics - memoRANDOM