この記事はフォトンマッピングの理論の続きです。前回はフォトンマッピングの理論的な定式化ついて扱いましたが、今回はその内容を元に実装について解説します。
コード全体は以下で見ることができるので、あわせて参考にして下さい。
計算対象の式
まずはフォトンマッピングで計算したい対象の式を確認します。
今、カメラ側からレイを飛ばしてという経路を構築したとします。最後の点においてフォトンマップを用いて放射輝度推定を行うとします。
この時番目の画素への寄与は、前回の式(3)から以下のように計算できます。
- : カメラ側からの経路構築のサンプル数
- : 光源から飛ばしたフォトンの数
- : 番目のカメラ側からの経路(経路の長さは)
- : 番目のフォトンの光源からの経路(経路の長さは)
- : 番目のフォトンのThroughput
- : カーネル関数
- : 点におけるBSDF
- : 番目のカメラ側からの経路のThroughput
この式はフォトンのThroughputカーネルの値 におけるBSDFカメラ側からのレイのThroughputの平均になっています。
は以下のようになります。
複雑に見えますが、基本的には経路の各点(光源とカメラ上の点を除く)でBSDFの値 幾何項 その点をサンプリングする確率密度の値 を計算して掛け合わせていく形です。は通常のパストレーシングのThroughputと同じ形になっています。
フォトンマッピングでは式(1)を数値計算していきます。以降ではその実装方法について見ていきます。
フォトンマップの構築
データ構造
式(1)をもう一度見てみます。
これを計算するためにはフォトンのThroughput , フォトンが格納された時のフォトンの位置, フォトンが格納された点におけるフォトンの入射方向 が必要です。
そのためフォトンマップにはこの3つのデータを保存しておくようにします。フォトンを表現する構造体Photon
は以下のように定義すると良いでしょう。
struct Photon {
Vec3f throughput; // スループット
Vec3f position; // フォトンの位置
Vec3f wi; // フォトンの入射方向
};
式(1)の計算では全フォトンに関して総和を取っていますが、前回も述べたように実際にはカーネル関数としては
のように距離が以上離れたフォトンに対してはカーネルの値が0になるようなものを使います。そのための近傍にあるフォトンに対してのみ総和を取れば良いです。そのためフォトンマップには近傍探索が効率良く行えるデータ構造を採用します。
近傍探索が効率良く行えるデータ構造としてはKd-treeやOctreeが挙げられます。ここではKd-treeを実装することにします。
Kd-treeの実装
これについては既に良い記事があるのでここでは省略します。以下を参照してください。
私の実装例は以下で見れます。
フォトントレーシング
Kd-treeを実装できたら、次は光源からシーンに向けてフォトンを飛ばし、フォトンマップにフォトンを格納していく処理を書いていきます。
フォトンの生成
まずは光源からフォトンを生成し、シーン中にフォトンを飛ばす必要があります。以下のような流れでこれを行っていきます。
- シーンから光源を1つ選ぶ
- 選んだ光源上で点をサンプリングする
- 方向をサンプリングする
- 点から方向にフォトンを飛ばす
光源の選択
今シーンに光源が個あるとします。1に関しては個の光源から等確率でランダムに1つの光源を選ぶような処理が簡単でしょう。この場合番目の光源を選ぶ確率は
となります。
光源上の点サンプリング
光源を選んだら光源上で点をサンプリングします。光源の表面で一様に点をサンプリングすると仮定すると、光源の表面積をとして点サンプリングの確率密度は
となります。
光源上の方向サンプリング
続いて方向サンプリングを行います。拡散光源ならコサイン比例半球サンプリングをし、指向性光源の場合は特定の方向だけをサンプリングするようにすると良いでしょう。今、方向サンプリングとしてコサイン比例半球サンプリングを使ったと仮定すると、方向をサンプリングする確率密度は
となります。ここでは方向と点における法線のなす角度です。
フォトンのThroughputの初期化
最後にフォトンのThroughputを初期化してフォトンを飛ばします。この時点でのフォトンのThroughputは式(2)から
となります。ここで光源をランダムに1つ選択することによって分母に光源を選ぶ確率が入っていることに注意します。また、点から方向を選んだことによって暗黙的に次の点が定まっていることに注意します。
式(7)をを使って書き直すことを考えます。における法線とのなす角度をとすると、幾何項は定義から
となります。また、は点サンプリングと方向サンプリングの確率密度の変換公式
を使うことで、式(7)は以下のように書き換えられます。
式(4), 式(5), 式(6)を代入すると最終的に以下のような形になります。
この値でフォトンのThroughputを初期化すれば良いです。
実装
ここまでの処理をコードにすると以下のようになります。
// 光源上から出るレイをサンプリングして返す。この時throughputも初期化する。
Ray sampleRayFromLight(const Scene& scene, Sampler& sampler,
Vec3f& throughput) {
// 光源をシーンからランダムに1つ選ぶ
float light_choose_pdf;
const std::shared_ptr<Light> light =
scene.sampleLight(sampler, light_choose_pdf);
// 光源上の点をサンプリングする
float light_pos_pdf;
const SurfaceInfo light_surf = light->samplePoint(sampler, light_pos_pdf);
// 方向をサンプリングする
float light_dir_pdf;
const Vec3f dir =
light->sampleDirection(light_surf, sampler, light_dir_pdf);
// サンプリングした点, 方向を使ってレイを生成
Ray ray(light_surf.position, dir);
// Throughputを初期化する
throughput = light->Le(light_surf, dir) /
(light_choose_pdf * light_pos_pdf * light_dir_pdf) *
std::abs(dot(dir, light_surf.shadingNormal));
return ray;
}
この実装ではThroughputの初期化を式(8)で行っています。
光源側からの経路構築とフォトンの格納
次にフォトンをシーン中に飛ばし、光源側からの経路構築を行っていきます。経路構築の途中でDiffuse面に当たる度にフォトンをフォトンマップに追加していきます。
実装は以下のようになります。ここではフォトンの配列を最初に用意して、そこにフォトンを追加していき、最後にKd-treeを構築するような流れになっています。
また、高速化のためにフォトンの追跡はOpenMPを用いて並列に行っています。throughput
はに対応します。
std::vector<Photon> photons;
// スレッドごとにSamplerを用意する
std::vector<std::unique_ptr<Sampler>> samplers(omp_get_max_threads());
for (int i = 0; i < samplers.size(); ++i) {
samplers[i] = sampler.clone();
samplers[i]->setSeed(samplers[i]->getSeed() * (i + 1));
}
// OpenMPを用いて並列にフォトンを飛ばす
// nPhotonsGlobal: フォトン数
// globalPhotonMap: フォトンマップ
#pragma omp parallel for
for (int i = 0; i < nPhotonsGlobal; ++i) {
auto& sampler_per_thread = *samplers[omp_get_thread_num()];
// 光源からレイをサンプリングし、Throughputを初期化する
Vec3f throughput;
Ray ray = sampleRayFromLight(scene, sampler_per_thread, throughput);
// 光源からの経路構築
for (int k = 0; k < maxDepth; ++k) {
IntersectInfo info;
if (scene.intersect(ray, info)) {
// レイが当たった点のBSDFの種類を取得
const BxDFType bxdf_type = info.hitPrimitive->getBxDFType();
// Diffuse面ならフォトンを配列に追加
if (bxdf_type == BxDFType::DIFFUSE) {
#pragma omp critical
{
photons.emplace_back(throughput, info.surfaceInfo.position,
-ray.direction);
}
}
// ロシアンルーレットを行って経路構築を続けるか決める
if (k > 0) {
const float russian_roulette_prob = std::min(
std::max(throughput[0], std::max(throughput[1], throughput[2])),
1.0f);
if (sampler_per_thread.getNext1D() >= russian_roulette_prob) {
break;
}
throughput /= russian_roulette_prob;
}
// BSDFを使って方向サンプリングを行い、次のレイの方向を決める
Vec3f dir;
float pdf_dir;
const Vec3f f = info.hitPrimitive->sampleBxDF(
-ray.direction, info.surfaceInfo, TransportDirection::FROM_LIGHT,
sampler_per_thread, dir, pdf_dir);
// Throughputとレイを更新する
throughput *= f *
cosTerm(-ray.direction, dir, info.surfaceInfo,
TransportDirection::FROM_LIGHT) /
pdf_dir;
ray = Ray(info.surfaceInfo.position, dir);
} else {
// フォトンがシーン外に飛んでいった場合
break;
}
}
}
// フォトンマップの構築(Kd-treeのビルド)
globalPhotonMap.setPhotons(photons);
globalPhotonMap.build();
経路構築の部分はパストレーシングと同様のアルゴリズムになっています。ここではロシアンルーレットを行って経路構築を確率的に打ち切ることを行っています。
Throughputの更新は式(2)の後ろの項
に対応しています。元々の式は分母がから始まっていますが、の場合は既に式(8)で計算されているので、この式ではスキップされています。
ここでは点は点からBSDFを用いた方向サンプリングによって暗黙的に取っているので、Throughputの初期化の所で述べたのと同様に、点サンプリングと方向サンプリングの確率密度の変換公式
を用いることで、式(9)は以下のように書き換えられます。
Throughputの更新の所のコードはこの式を計算しています。
放射輝度の計算
フォトンマップを構築することが出来たので、次はカメラ側からレイを飛ばしてフォトンマップを用いた放射輝度推定を行います。
式(1)をもう一度見てみます。
フォトンマップの構築で, , は既に求まっています。ここで計算する必要があるのはの部分です。
カメラ側からの経路構築
今、カメラ側からパストレーシングによって経路構築をして経路を得て、においてフォトンマップを用いて放射輝度推定を行うとします。
式(1)からがDiffuse面の場合は近傍にあるフォトンを求めることで、番目のサンプルの番目の画素への寄与は以下で計算できます。
ここではの近傍にある(カーネルの値が0にならないような)フォトンの数です。ここで総和を近傍にあるフォトン数ではなく、フォトンマップ構築時に飛ばしたフォトン数で割っていることに注意してください。
がSpecular面の場合はその近くにフォトンはないので、次の方向をサンプリングしてレイを再帰的に追跡し、どこかでDiffuse面に当たったら式(10)を計算するようにします。
実装
まずは式(10)の計算をコードに落とし込みます。の項は後で掛け合わせてあげることにして、それ以外の項を計算するようなコードを書くと以下のようになります。
// ある点から反射する放射輝度をフォトンマップを用いて計算する
// nPhotonsGlobal: 光源から飛ばしたフォトンの総数(N_p)
// nEstimationGlobal: 何個の近傍フォトンを推定に使うか(K)
// globalPhotonMap: フォトンマップ
Vec3f computeRadianceWithPhotonMap(const Vec3f& wo,
const IntersectInfo& info) const {
// 近傍にあるK個フォトンのインデックスを取得する(K近傍探索)
float max_dist2;
const std::vector<int> photon_indices =
globalPhotonMap.queryKNearestPhotons(info.surfaceInfo.position,
nEstimationGlobal, max_dist2);
// 式(10)の計算(T_iの項はまだない)
Vec3f Lo;
for (const int photon_idx : photon_indices) {
const Photon& photon = globalPhotonMap.getIthPhoton(photon_idx);
const Vec3f f = info.hitPrimitive->evaluateBxDF(
wo, photon.wi, info.surfaceInfo, TransportDirection::FROM_CAMERA);
Lo += f * photon.throughput;
}
if (photon_indices.size() > 0) {
Lo /= (nPhotonsGlobal * PI * max_dist2);
}
return Lo;
}
この実装ではカーネル関数として
を使用し、半径は個の近傍フォトンの最大距離になるようにしています。つまり半径が点周辺のフォトンの集まり具体によって変わる形になっています。Jensenの青い本ではそのような実装になっていたので、それに合わせました。
もちろん半径を固定するカーネル関数を使っても良いです。ただしこの場合は飛ばしたフォトン数が少ないと半径内に近傍フォトンがなく、推定した値が0になってしまうという問題が起こります。
一方で半径を個の近傍フォトンの最大距離にするような実装では、必ず個のフォトンの総和を取ることになるのでそういった問題は起こりません。
カメラ側から経路構築をし、式(10)を計算するコードは以下のようになります。以下のコードではthroughput
がに対応します。
// ray_inの方向から来る放射輝度をフォトンマップを用いて計算する
Vec3f integrate(const Ray& ray_in, const Scene& scene,
Sampler& sampler) const {
Ray ray = ray_in;
Vec3f throughput(1, 1, 1);
// カメラ側からの経路構築
for (int k = 0; k < maxDepth; ++k) {
IntersectInfo info;
if (scene.intersect(ray, info)) {
// 光源に直接当たった場合は光源の放射輝度を直接返す
if (info.hitPrimitive->hasAreaLight()) {
return throughput *
info.hitPrimitive->Le(info.surfaceInfo, -ray.direction);
}
// レイが当たった点のBSDFの種類を取得
const BxDFType bxdf_type = info.hitPrimitive->getBxDFType();
// Diffuse面に当たった場合にはフォトンマップを用いて式(10)を計算
if (bxdf_type == BxDFType::DIFFUSE) {
return throughput *
computeRadianceWithPhotonMap(-ray.direction, info);
}
// Specular面に当たった場合には次の方向をサンプリングして、再帰的にレイを追跡する
else if (bxdf_type == BxDFType::SPECULAR) {
// 方向サンプリング
Vec3f dir;
float pdf_dir;
Vec3f f = info.hitPrimitive->sampleBxDF(
-ray.direction, info.surfaceInfo, TransportDirection::FROM_CAMERA,
sampler, dir, pdf_dir);
// Throughputとレイを更新する
throughput *= f *
cosTerm(-ray.direction, dir, info.surfaceInfo,
TransportDirection::FROM_CAMERA) /
pdf_dir;
ray = Ray(info.surfaceInfo.position, dir);
}
} else {
// レイがシーンに当たらなかった場合
break;
}
}
return Vec3f(0);
}
引数のray_in
はカメラから生成したレイです。
フォトンマップを用いた放射輝度推定では光源から直接カメラに入ってくるような経路や、光源からSpecular面が続いてカメラに至るような経路を含めることができないので、レイが光源に直接当たった場合には光源の放射輝度をThroughputをかけて返すようにします。
Throughputの更新では光源側からの経路構築の時と同様の議論から
を計算しています。
以上で式(10)を計算できるようになりました。これでが求まるので、あとはに関して平均を取ってあげれば番目の画素の値が求まります。
画像を出してみる
ここまでの処理を実装してコーネルボックスをレンダリングしてみると以下のようになります。
パラメーター | 値 |
---|---|
フォトン数() | 1000000 |
近傍フォトンの数() | 100 |
サンプル数() | 100 |
フォトン数はかなり多いにも関わらず、謎の模様が出てしまっていることが分かります。これはカーネル関数近似の誤差によるものです。
この模様を取り除くためにはさらに多くのフォトンを使ってカーネルの半径を縮小させていく必要があります。しかしこれ以上多くのフォトンを使うのはメモリ的に厳しくなってきます。
そこでJensenの青い本ではFinal Gatheringという手法を使ってこの模様を取り除くことを行っています。
長くなってきたので今回はここで終わりにしようと思います。次回はFinal Gatheringについて説明していきます。
References
- Jensen, Henrik Wann. Realistic image synthesis using photon mapping. AK Peters/crc Press, 2001.
- https://pbr-book.org/3ed-2018/Light_Transport_III_Bidirectional_Methods/Stochastic_Progressive_Photon_Mapping#
- Jensen, Henrik Wann. "Global illumination using photon maps." Eurographics workshop on Rendering techniques. Springer, Vienna, 1996.
- Veach, Eric. Robust Monte Carlo methods for light transport simulation. Stanford University, 1998.
- Hachisuka, Toshiya, Jacopo Pantaleoni, and Henrik Wann Jensen. "A path space extension for robust light transport simulation." ACM Transactions on Graphics (TOG) 31.6 (2012): 1-10.