パストレーサーで直接光計算(重点的サンプリング、合成pdf)

2025-07-24

ナイーブなパストレーサーは反射時にランダムな方向にトレースするため、たまたま光源に辿り着かない限り明かりに照らされず、ノイズを減らすにはかなりサンプル数を上げる必要がある。 これを明示的に光源計算することで改善できる。

smallpt_explicitを見てみる

smallptという簡潔でソースが公開されているパストレーサーがある。 同ページにその改善としてexplicit.cppで明示的に光源をサンプルするコードが公開されているので、どうやっているのか見てみた。 明示的な光源計算の計算部分:

Vec radiance(const Ray &r, int depth, unsigned short *Xi,int E=1){
...
// 拡散反射面の箇所
// Loop over any lights
Vec e;
for (int i=0; i<numSpheres; i++){
const Sphere &s = spheres[i];
if (s.e.x<=0 && s.e.y<=0 && s.e.z<=0) continue; // skip non-lights

Vec sw=s.p-x, su=((fabs(sw.x)>.1?Vec(0,1):Vec(1))%sw).norm(), sv=sw%su;
double cos_a_max = sqrt(1-s.rad*s.rad/(x-s.p).dot(x-s.p));
double eps1 = erand48(Xi), eps2 = erand48(Xi);
double cos_a = 1-eps1+eps1*cos_a_max;
double sin_a = sqrt(1-cos_a*cos_a);
double phi = 2*M_PI*eps2;
Vec l = su*cos(phi)*sin_a + sv*sin(phi)*sin_a + sw*cos_a;
l.norm();
if (intersect(Ray(x,l), t, id) && id==i){ // shadow ray
double omega = 2*M_PI*(1-cos_a_max);
e = e + f.mult(s.e*l.dot(nl)*omega)*M_1_PI; // 1/pi for brdf
}
}

return obj.e*E+e+f.mult(radiance(Ray(x,d),depth,Xi,0));
  • smallptで扱える形状は球のみで、光源も球という前提
  • 交点xから光源を見ると必ず円形になるが、その方向をランダムに選ぶ
  • レイを飛ばして(シャドウレイ)遮るものがなければ光源の影響を加える
  • 光源の影響は、f * M_1_PIがBRDF(拡散反射面なので)、l.dot(nl)が法線との向きによる影響度、omegaが光源が占める立体角

合計した直接光の影響eを加え、radianceの再帰呼び出しで間接光を計算する。

影響の重複の除外法

radianceの再帰呼び出しでは元々の動作と同様にランダム方向にトレースを続ける。 光源の影響は明示的に付加しているので、ランダムに選んだ方向が再度光源方向を調べてしまうと重複してしまう。

どうやって除外してるかというと、radianceの最後の引数E0を渡して自己発光obj.eと乗算することで直後の光源の影響が重複しないようにしている。

光源を球以外の形状にしたい場合(矩形)

上記では光源の形状が球という前提が必要になる。 これをもっと違う形状にしたい場合にどうするか。

Rustではじめるレイトレーシング入門.pdfという解説書の第4章「モンテカルロレイトレーシング」に書かれていた。 この解説書はRay Tracing in One Weekendを元にしているらしく、 特に第4章はRay Tracing: The Rest of Your Lifeをなぞっている。

どちらもちゃんと説明が書かれているのだが、モンテカルロ積分とか数学にあまりなじんでないこともありそのままでは理解が難しかった。 自分なりの解釈を書き出してみる。

重点的サンプリング (Importance Sampling)

モンテカルロ積分で区間内を一様にサンプルするのではなく、確率密度に基づいてサンプルしてやることでノイズ(分散)が減らせる。 ていうことでこれまでもパストレの物体表面での反射時に使用していて、「影響が大きい法線方向にサンプルを増やして細かく調べて、平行方向は影響小さいのでサンプルを減らす」という理解だった。

ただそれは反射面特性としてランバートを仮定していて入射した光が所定の方向にどのくらい反射するかという特性が、面への入射角で光の影響が減衰するということと(たまたま?)打ち消しあうから、ということでしか使ったことなかったからかもしれない。 実際には確率密度関数pdf除算する、なので値が大きい=サンプルとして選ばれる確率密度が高い候補は個々の結果としては影響が下げられ、値が小さい=選ばれる確率密度が低い候補は影響が上げられることになる。

どうも今まで逆の感覚だったがよく考えると実は道理で、パストレーサーでいえばナイーブに一様にサンプルする場合にたまたま光源にヒットする確率はとても小さいわけだけど、それを高頻度に取り上げるよう意図的に光源方向にサンプル方向を選んで影響を確実に考慮するのと引き換えに、頻度を持ち上げた分係数を下げてやることで辻褄を合わせることになる。 その割合がpdfで除算するってんだからうまくできてるもんだねぇ。

光源の直接サンプリング (Sampling Lights Directly)

smallpt_explicitでは光源は球という前提があったが、両著では矩形で扱っている。 「矩形が投影された立体角を計算してそこからサンプル」というのは難しそうなのでどうするかと思ったが、サンプルする点を微小面と考えて計算する。 光源上のランダムな点を一様に選び、その微小面がどのくらいの立体角に相当するかということから確率密度を計算する。

具体的には

となる (:サンプル方向(=)、:散乱の元の点、:光源上のサンプル点、:サンプル方向と光源面法線の角度、:光源の面積)。

  • これがpdf・積分すると1.0になるというのは、領域の面積を打ち消す、そして距離と向きの補正のヤコビアン、ということらしい(付録で数値的に確認)

合成PDF (Mixture Densities)

smallpt_explicitでは直接光計算+再帰トレースという具合に計算しているが、重複の除外が必要でちょっと扱いにくいのではないかと思う。 のでpdfを合成する方法を使用する。

pdfは区間の積分結果が1.0になるので、複数のpdfを重み付け和で合成すればそれもまたpdfになる。 パストレーサーの例でいえば、ある確率qで光源サンプリング、残り1-qで反射面での反射特性でのサンプリングという具合に合成して、トレース方向を選択させることができる。

選んだサンプル方向に対して合成したpdfのそれぞれにおいて、逆にそのサンプル点が選ばれる確率密度を計算してやる。 そしてそれらを混ぜ合わせた線形の比率で再度合計する。 例えばサンプルした方向が光源に向かわない場合には、光源サンプリング側のpdfで選ばれた確率密度は0となる。 逆に光源に向かう方向の場合には、光源サンプリングと表面反射サンプリングのどちらからも選ばれた可能性があり、確率密度は線形合成されて高くなる。

smallptに組み込んでみる

両著とも上記のほかにPDFをクラス化したりマテリアル側に散乱のpdfをメソッド化したりしてとてつもなくうまく構造化する。 が初学者の自分にとってはあちこち処理を追ったり計算の値を確認するのが混乱してしまい、動作を理解するには難しかった。 そこでsmallpt(explicitじゃない元の)に、ベタに組み込んでみることにした。

struct Rectangle {
Vec p, a1, a2, n; double area;
Vec e, c; // emission, color
Rectangle(const Vec& p_, const Vec& a1_, const Vec& a2_, const Vec& e_, const Vec& c_)
: p(p_), a1(a1_), a2(a2_), e(e_), c(c_) {
n = a1 % a2; area = n.length(); n = n * (1.0 / area);
}
double intersect(const Ray &ray) const { // returns distance, 0 if nohit
const double eps=1e-4;
Vec r = ray.o - p, alpha = ray.d % a2, beta = r % a1;
double inv = 1.0 / alpha.dot(a1), t = beta.dot(a2) * inv, u, v; // tはnanになり得る: 0.0 * inf = nan
if (t >= eps) {
if ((u = alpha.dot(r) * inv) >= 0 && u <= 1 &&
(v = beta.dot(ray.d) * inv) >= 0 && v <= 1) {
return t; }
}
return INFINITY;
}
Vec random_sample(unsigned short *Xi) const {
// TODO: 正方形じゃない場合の一様サンプル方法
double r1 = erand48(Xi), r2 = erand48(Xi);
return p + a1 * r1 + a2 * r2;
}
};
const Rectangle light(Vec(50-16,81.5,81.6-16), Vec(32,0,0), Vec(0,0,32), Vec(12,12,12), Vec());

Vec radiance(const Ray &r, int depth, unsigned short *Xi){
double t; // distance to intersection
Vec x, n, f, e; Refl_t refl;
if (int id; intersect(r, t, id)) {
const Sphere &obj = spheres[id]; // the hit object
x=r.o+r.d*t; n=(x-obj.p).norm(); f=obj.c; e=obj.e; refl=obj.refl; }
if (double t2 = light.intersect(r); t2 < t) { // 光源の交差判定を追加
t=t2; x=r.o+r.d*t; n=light.n; f=light.c; e=light.e; refl=DIFF; }
if (!isfinite(t)) return Vec(); // if miss, return black
Vec nl=n; if (n.dot(r.d)>=0) {nl=n*-1; e=Vec(0);}
double p = f.x>f.y && f.x>f.z ? f.x : f.y>f.z ? f.y : f.z; // max refl
if (++depth>5) { if (erand48(Xi)<p) f=f*(1/p); else return e; } //R.R.
if (refl == DIFF){ // Ideal DIFFUSE reflection
const double direct_prob = 0.5; // 直接光のパスを選ぶ確率
Vec nextdir;
// サンプルする方向の選択
if (erand48(Xi) < direct_prob) { // 光源上の点を一様サンプリングし、その方向を候補とする
Vec lp = light.random_sample(Xi); nextdir = (lp - x).norm();
} else { // 反射特性に従い、半球状をコサインに比例してサンプリング
double r1=2*M_PI*erand48(Xi), r2=erand48(Xi), r2s=sqrt(r2);
Vec w=nl, u=((fabs(w.x)>.1?Vec(0,1,0):Vec(1,0,0))%w).norm(), v=w%u;
nextdir = (u*cos(r1)*r2s + v*sin(r1)*r2s + w*sqrt(1-r2)).norm();
}
Ray nextray(x, nextdir);
// 選んだ方向が「選ばれる確率密度」を逆算
double pdf_light = 0; // 光源サンプルのpdf
double pdf_brdf = 0; // 反射面のpdf
if (double t3 = light.intersect(nextray); t3 < INFINITY) {
double distance_squared = t3 * t3;
double cosine = fabs(nextdir.dot(light.n));
pdf_light = distance_squared / (cosine * light.area);
}
pdf_brdf = fmax(nextdir.dot(nl), 0.0) * M_1_PI;
double pdf_value = direct_prob * pdf_light + (1 - direct_prob) * pdf_brdf; // 合成pdf
if (pdf_value <= 0.0) return e;
double scattering_pdf = fmax(nextdir.dot(nl), 0.0) * M_1_PI;
return e + f.mult(radiance(nextray, depth, Xi)) * (scattering_pdf / pdf_value);
// 以降、鏡面反射と透過材質の処理(元ソースに合流)
// obj.~は適宜置き換えること
} else ...
  • 矩形の光源を表現できるようRectangleクラスを追加し、lightグローバル変数に保持
  • シーンに存在する球配列spheresとは別に交差判定を呼び出す
  • 拡散反射面だったら合成pdfでトレース方向を選択
  • 確率密度関数から寄与度を算出

他にコードの変更が必要な箇所:

  • radiance関数の残りのコードからobj.を適宜削除
  • spheresの最後の、元のデカい光源球をコメントアウト
  • Vecクラス:lengthメソッド追加、外積%const付加
  • 任意:サンプリングの違いをわかりやすくするために、2x2のサブピクセルを無効にする

出力画像比較

左:元のsmallpt方式(cos重点的サンプリング)、右:光源サンプリングとcos重点サンプリングの半々  それぞれ1000spp(サンプル/ピクセル)

  • 床など全体的なノイズは減っているが、明るいドットが目立ってしまう(ファイアフライノイズ)
    • 半々じゃなく間接光をサンプルする割合を増やせば、ドットを減らせる
  • より光源が小さければさらに効果は高い
  • 同じsppだけど実行時間は元のsmallptのほうが1.8倍ほど時間がかかった (光源のアルベドが0.0なのでロシアンルーレットで打ち切られる確率が高まるからっぽい)

考察・あれこれ

  • 「光源が重要」、といっても合成のウェイトをむやみに高くすればいいわけではない
    • 直接光の影響は数回のサンプリングで収束してしまうので、そんなに高頻度でサンプルしたところで内容は深まらない
    • 逆に広い範囲から影響のある間接光のサンプル数が減ってしまい、ピクセルあたりのサンプル数を増やしても無駄撃ちになってしまう
      • マルチインポータンスサンプリング・バランスヒューリスティックでウェイトを調整?
  • 光源と逆向きや隠れてる面などでは光源のサンプリングは無駄になってしまうので、シャドウレイ自体は使って有効な場合のみにした方がよいかも?
  • 光源をサンプリングすることを”Next Event Estimation”というらしい、 それは意味がわかりづらくない?
  • 実際のところ太陽には使えるが、電球とか窓ガラスとか屋内や人工物のシーンでは事実上必ず光源の直接サンプリングには失敗するのでそのままでは使い所が難しいかも
    • ライトトレーシング、双方向パストレ?
  • Rectangleの交差判定で距離tnanになり得て(0.0 * infnanになる)、アーリーイグジットしてたらドツボにハマった、浮動小数点数でnanの可能性のある場合は条件を反転すると意味が変わる可能性がある!
  • 変数名:rayt_rustではpdf_value / spdf_valueとなっているがRest ofではscattering_pdf / pdf_valueとなっていて混乱する
    • rayt_rustのpdf_valueもマテリアルのscattering_pdf()で計算してるしscatteringなんだろう、ということでRest ofの方に合わせてみた
  • モンテカルロ積分やら重点的サンプリングやら、考えた人も組み合わせた人も天才すぎるやろ…
  • 6.2. Using a Uniform PDF Instead of a Perfect Matchで「pdfだけいじって反射方向のサンプリング方法はそのままでも同じ結果に収束する(6.3.)」ってホントに? これがサンプルを選ぶ確率とマッチしてる必要があるんじゃないの?

参考

他リンク

付録

smallpt-explitの不具合

explicitでは素のsmallptと違って光源が小さく中空に浮かぶようになっている。 これは直接光の計算するにはそうする必要があるんだろうかと疑問だったが、これだと天井も明るくなってしまいあまり見た目がよろしくないので元のsmallptと同じシーン配置にしたかった。

しかし結果がおかしく、ほぼ真っ暗になってしまう。 ちゃんとシャドウレイで光源から届くかどうかチェックしてるから動きそうなもんだけど、と原因を調べた:

  • 面が裏向きでも直接光の計算が加えられてしまっている
  • 光源サンプリング用の直交基底が正規化されてないため偏りが生じる

上記を修正したところ、元のシーンでも直接光を計算した場合にもナイーブなパストレと同じ結果になった (光源サンプリングの大半は失敗するので効果は薄いが)。 ソースの差分:

@@ -66,7 +66,7 @@
const Sphere &s = spheres[i];
if (s.e.x<=0 && s.e.y<=0 && s.e.z<=0) continue; // skip non-lights

- Vec sw=s.p-x, su=((fabs(sw.x)>.1?Vec(0,1):Vec(1))%sw).norm(), sv=sw%su;
+ Vec sw=(s.p-x).norm(), su=((fabs(sw.x)>.1?Vec(0,1):Vec(1))%sw).norm(), sv=sw%su;
double cos_a_max = sqrt(1-s.rad*s.rad/(x-s.p).dot(x-s.p));
double eps1 = erand48(Xi), eps2 = erand48(Xi);
double cos_a = 1-eps1+eps1*cos_a_max;
@@ -74,7 +74,7 @@
double phi = 2*M_PI*eps2;
Vec l = su*cos(phi)*sin_a + sv*sin(phi)*sin_a + sw*cos_a;
l.norm();
- if (intersect(Ray(x,l), t, id) && id==i){ // shadow ray
+ if (l.dot(nl)>0 && intersect(Ray(x,l), t, id) && id==i){ // shadow ray
double omega = 2*M_PI*(1-cos_a_max);
e = e + f.mult(s.e*l.dot(nl)*omega)*M_1_PI; // 1/pi for brdf
}

直接光のサンプル確率がpdfになっていることの確認

光源をサンプルする際のpdfというのが実際に積分すると1.0になっているのか理解できなかったので、モンテカルロ積分で確認してみた。

#include <time.h>

// Rectangle, lightなどは上のソースから

double random(double min, double max) {
return (rand() * (max - min) / (RAND_MAX + 1.0)) + min;
}
Vec random_in_unit_sphere() {
for (;;) {
Vec point(random(-1.0, +1.0), random(-1.0, +1.0), random(-1.0, +1.0));
if (point.squareLength() < 1.0)
return point;
}
}
Vec random_unit_vector() {
return random_in_unit_sphere().norm();
}
int main(){
srand(time(NULL));
rand();
Vec x(random(1.0, 99.0), random(0.0, 80), random(1.0, 99.0));
printf("x(%.2f, %.2f, %.2f), light.area=%g\n", x.x, x.y, x.z, light.area);

const long N = 100000000;
double sum = 0;
int n = 1;
Vec nl(0, 1, 0);
for (long i = 0; i < N; ++i) {
Vec nextdir = random_unit_vector();
if (nl.dot(nextdir) <= 0)
nextdir = nextdir * -1;
double pdf_value = 1.0 / (2 * M_PI);

Ray nextray(x, nextdir);
if (double t3 = light.intersect(nextray); t3 < INFINITY) {
double distance_squared = t3 * t3;
double cosine = fabs(nextdir.dot(light.n));
double pdf_light = distance_squared / (cosine * light.area);
sum += pdf_light / pdf_value;
}

if ((i + 1) == n) {
printf("%10ld: result: %g\n", i+1, sum / n);
n *= 10;
}
}
}