3点形式でパストレーサーを動かしてみる

2026-07-30

双方向パストレーシングを理解したいんだけど全体を一度に理解するのは難しい。 ひとまず3点形式というものが必要っぽいのでナイーブなパストレのまま計算だけ3点形式でやってみる。

概要

双方向パストレーシングを理論的に解説した論文としてEric VeachのRobust Monte Carlo Methods for Light Transport Simulationがあるが、まあムズくてボリュームもデカいので理解ができてない。 でどうしようかと悩んでたんだけど3点形式として扱うことが必要っぽいのでまずはそれ単体を動かしてみることにした。

経路積分の公式化

件の論文の8章 光輸送の経路積分公式化に出てくる方程式を参考にしつつ進める。

パストレの計算内容

ナイーブなパストレでは視点からレイを飛ばして、交差する物体の表面で反射を追跡していく。 これが実のところレンダリング方程式の近似解を求めることになる:

$$ L_o(\bm{x}, \vec{\omega_o}) = L_e(\bm{x}, \vec{\omega_o}) + \int_{\Omega} f_r(\bm{x}, \vec{\omega_i}, \vec{\omega_o}) L_i(\bm{x}, \vec{\omega_i}) (\vec{\omega_i} \cdot \vec{n}) d\vec{\omega_i} $$

積分変数\(\vec{\omega_i}\)を積分範囲\(\Omega\)である半球状の方向をランダムに選ぶことでモンテカルロ積分が計算できる。

3点形式

上記のレンダリング方程式は方向ベクトルで扱っている。 双方向パストレでは光輸送問題を経路積分で扱いたくて、それには3点形式を用いる必要がある。 3点形式は方向ベクトルの代わりに反射先の交点座標を扱う:

$$ \begin{align*} L(\bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) =& L_e(\bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) + \\ & \int_{A} L(\bm{x} \rightarrow \bm{x^\prime}) f_r(\bm{x} \rightarrow \bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) G(\bm{x} \leftrightarrow \bm{x^\prime}) dA(\bm{x}) \\ & \tag{8.1} \end{align*} $$

積分変数が\(\bm{x}\)で、積分範囲\(A\)は理論的にはシーンに存在するすべての物体表面が対象になる。 「そんなことが可能なの?」と思うんだけど、項\(G\):

$$ G(\bm{x} \leftrightarrow \bm{x^\prime}) = V(\bm{x} \leftrightarrow \bm{x^\prime}) \frac{|\cos(\theta_o) \cos(\theta_i^\prime)|}{||\bm{x} - \bm{x^\prime}||^2}. \tag{8.3} $$

に含まれる\(V\)が\(\bm{x}\)と\(\bm{x^\prime}\)が遮られない場合のみ1・それ以外は0という可視判定で、それにより頂点\(\bm{x^\prime}\)から直接見える=一番近い頂点のみが有効で、結果的に元の式と同じ内容になる。

また\(G\)には注目点\(\bm{x^\prime}\)のコサイン項\(\cos(\theta_i^\prime)\)だけじゃなく、次のサンプル点\(\bm{x}\)のコサイン項\(\cos(\theta_o)\)も必要で、計算は多少煩雑になる。

頂点が選択される確率密度の算出方

対象となる頂点の選択にシーン内のすべての物体表面からランダムサンプリングするというのはまあ無駄だし意味ないので、ナイーブなパストレで行う方法と同様に半球状の方向ベクトルを選んでその方向にレイを飛ばして交差した点を対象に選ぶ。 ただモンテカルロ積分を適用する際にサンプル点が選ばれる確率密度が必要になる。 方向ベクトルによって頂点を選んだ場合、確率密度はどうなるのか?

方向\(\omega_o\)が選ばれる確率密度を使って:

$$ p(\bm{x^\prime}) = p(\omega_o)\left( \frac{|\cos(\theta_i^\prime)|}{||\bm{x} - \bm{x^\prime}||^2} \right) \tag{8.10} $$

  • “括弧内の式は、\(\bm{x^\prime}\)における単位表面積あたり、\(\bm{x}\)において張られる立体角を表す”とのこと
  • 導出は微正面を単位球に投影した立体角だと思うが完全に理解はしてない…
  • 式(8.1)の3点形式では\(\bm{x^\prime}\)に対して\(\bm{x}\)が動く変数なので、記号が逆じゃない?

\(p(\omega_o)\)がその方向が生成される確率密度で、一様だったり拡散反射面の重点的サンプリングを用いるためのコサイン重み付きだったりで、使用した方向乱択アルゴリズムによって決まる。

3点形式のモンテカルロ積分

3点形式の積分項の近似解を求めるためにモンテカルロ積分を用いる。

一般に積分\(I = \int f(x) dx\)の近似解は、

$$ \hat{I} = \frac{1}{N} \sum_{i=1}^N \frac{f(x_i)}{p(x_i)} $$

で求められる。 \(N\)は試行回数、\(p(x)\)はサンプル点\(x\)が選択される確率密度を表す。

これを3点形式に適用し、また\(G\)(可視判定\(V\)は除く)と確率密度\(p\)(\(\bm{x^\prime}\)から\(\bm{x_i}\)をサンプルする確率密度)を代入すると、

$$ \begin{align*} \hat{I} &= \frac{1}{N} \sum_{i=1}^N L(\bm{x_i} \rightarrow \bm{x^\prime}) f_r(\bm{x_i} \rightarrow \bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) \cdot G(\bm{x_i} \leftrightarrow \bm{x^\prime}) \cdot \frac{1}{p(\bm{x^\prime} \rightarrow \bm{x_i})} \\ &= \frac{1}{N} \sum_{i=1}^N L(\bm{x_i} \rightarrow \bm{x^\prime}) f_r(\bm{x_i} \rightarrow \bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) \cdot \frac{|\cos(\theta_o) \cos(\theta_i^\prime)|}{||\bm{x_i} - \bm{x^\prime}||^2} \cdot \frac{||\bm{x^\prime} - \bm{x_i}||^2}{p(\omega_i^\prime) |\cos(\theta_o)|} \\ &= \frac{1}{N} \sum_{i=1}^N L(\bm{x_i} \rightarrow \bm{x^\prime}) f_r(\bm{x_i} \rightarrow \bm{x^\prime} \rightarrow \bm{x^{\prime\prime}}) \cdot \frac{|\cos(\theta_i^\prime)|}{p(\omega_i^\prime)} \end{align*} $$

と距離の2乗や選択先のコサイン項がちょうど消えて、元々のナイーブなパストレで行っている計算と同じになる。

  • 積分変数は違うが同じ内容の計算を行っているので、当然計算結果が同じなる

実装

以上の3点形式を組み込んだパストレを作ってみる。 実装は例によってsmallptを元にする。

経路をいったんバッファに記録する

smallptではradiance関数で再帰して直接色を返していた。 それをいったんバッファに貯めてあとから関係を追えるようにする:

struct Vertex {
Vec p, n, e, c; // 交点、法線、放射輝度、アルベド
Refl_t refl; // 反射タイプ (DIFF, SPEC, REFR)
double pdf; // 反射レイの確率密度
Vertex(Vec p_, Vec n_, Vec e_, Vec c_, Refl_t refl_, double pdf_=1)
: p(p_), n(n_), e(e_), c(c_), refl(refl_), pdf(pdf_) {}
};
void traceScene(Ray r, unsigned short *Xi, vector<Vertex>* path, int maxDepth=100){
for (int depth = 0; depth < maxDepth; ) {
double t; int id=0;
if (!intersect(r, t, id)) break;
const Sphere &obj = spheres[id]; // the hit object
Vec x=r.o+r.d*t, n=(x-obj.p).norm(), nl=n.dot(r.d)<0?n:n*-1;
if (obj.e.x + obj.e.y + obj.e.z > 0) { // 光源に到達
path->emplace_back(x, nl, obj.e, obj.c, obj.refl);
return;
}
double maxrefl = fmax(obj.c.x, fmax(obj.c.y, obj.c.z)), pdf=1;
if (++depth>5||!maxrefl) { if (erand48(Xi)<maxrefl) pdf=maxrefl; else break; } //R.R
if (obj.refl == DIFF) { // Ideal DIFFUSE reflection
Vec d = cosine_weighted_random_hemisphere(nl, Xi);
r = Ray(x, d);
pdf *= nl.dot(d) / M_PI; // 反射方向の確率密度を反映
} else {
Vec wi = reflect(r.d, n);
if (obj.refl == REFR) {
bool into = n.dot(nl)>0; // Ray from outside going in?
double nc=1, nt=1.5, nnt=into?nc/nt:nt/nc, ddn=r.d.dot(nl), cos2t;
if ((cos2t=1-nnt*nnt*(1-ddn*ddn))>=0) { // Total internal reflection
Vec tdir = refract(r.d, nl, nnt);
double a=nt-nc, b=nt+nc, R0=a*a/(b*b), c = 1-(into?-ddn:tdir.dot(n));
double Re=R0+(1-R0)*c*c*c*c*c;
if (erand48(Xi)>=Re) wi = tdir;
}
}
r = Ray(x, wi);
}
path->emplace_back(x, nl, obj.e, obj.c, obj.refl, pdf);
}
}
  • traceScene でシーン内をレイを追跡して、頂点列Vertexを記録する
    • pdfにはロシアンルーレットでの確率と、反射特性によるBRDFの係数を格納(拡散反射面)
    • 鏡面反射や屈折はデルタ関数なので別扱いする必要がある
  • radiance関数(後述)から呼び出して、得られた頂点列から計算する
    • ナイーブにやるのであれば、Gと確率密度を求めて計算する

直接光計算組み込み

3点形式を組み込んだ利点としてシーン内の任意の頂点を選ぶことができることを利用して、直接光を組み込んでみる。

光源上のランダムな点を選び、現在注目している交点との間が遮られているかどうかを調べる。 またその光源への方向が交点の法線と逆方向だったり、光源側の点の法線が裏向きだったりする場合も遮られているのと同様に、直接光源は無効とする。

有効であれば影響を加えるが、そのまま足し合わせてしまっては重複してしまうので、多重重点的サンプリング(Multiple Importance Sampling, MIS)を使う。 光源を直接サンプリングした場合とレイを追跡した場合のそれぞれの確率密度を比べて、信頼度の高い手法に高いウェイトを与えるようにする。 ウェイトの合計が1.0になるようにすることでバイアスを与えずにすむ。

光源サンプリングで選ばれる確率密度は、一様サンプリングであれば表面積の逆数で計算できる。 レイのトレース(BRDFサンプリング)で選ばれる確率密度は上の式(8.10)通り。

それを組み込んだ、最終的なradiance関数:

Vec radiance(const Ray &r, unsigned short *Xi){
vector<Vertex> path;
traceScene(r, Xi, &path);
if (path.empty())
return Vec(0);

constexpr int LIGHT_ID = numSpheres - 1;
const Sphere &light = spheres[LIGHT_ID];
// 光源の表面をランダムにサンプリング
Vec y_n = random_unit_direction(Xi);
Vec y = light.p + y_n * light.rad;
double light_area = 4 * M_PI * light.rad * light.rad;
double pA_light = 1 / light_area; // 光源上の一様点サンプリング面積PDF

Vec color(0);
Vec throughput(1); // パスの累積スループット比 W_i = f(x_0...x_i) / p(x_0...x_i)

for (size_t i = 0; ; ++i) {
const Vertex& vi = path[i];

// --- 1. 光源に到達した場合 ---
if (vi.e.x + vi.e.y + vi.e.z > 0) {
// ここで処理されるのは視点から直接またはスペキュラ反射で光源に辿り着く場合のみ(MISなし)。
color = color + throughput.mult(vi.e);
break; // 光源に到達したため以降の延伸・接続処理を終了
}

// --- 2. 光源上の点 y をサンプリングして接続する直接光計算 (Next Event Estimation) ---
// (拡散反射面 DIFF の場合)
if (vi.refl == DIFF) {
Vec wi = y - vi.p;
double dist = wi.length();
wi = wi * (1 / dist);

double cos_out = vi.n.dot(wi);
double cos_in = -y_n.dot(wi);
if (cos_out > 0 && cos_in > 0 &&
is_light_visible(Ray(vi.p, wi), dist)) {
// 接続エッジ (vi.p, y) の幾何因子 G(vi.p, y)
double G = (cos_out * cos_in) / (dist * dist);
double bsdf = 1 / M_PI;

// 経路積分形式における接続パス (x_0...x_m, y) の寄与評価:
// f(x)/p(x) = throughput * bsdf * G * L_e(y) / pA_light
Vec L = vi.c.mult(light.e) * (bsdf * G / pA_light);
// この接続点 y を BSDF サンプリングで選んだ場合の面積 PDF
double pA_bsdf1 = vi.pdf * (cos_in / (dist * dist));
double weight = mis_weight(pA_light, pA_bsdf1);
color = color + throughput.mult(L) * weight;
}
}

if (i + 1 >= path.size()) // 次の頂点がない:終了
break;

// --- 3. 視点サブパスを次の頂点へ延伸するための累積スループットの更新 ---
// (経路積分形式 Path Integral Formulation による明示的な幾何因子 G と 面積 PDF pA の計算)
if (vi.refl == DIFF) {
const Vertex& vj = path[i + 1];
Vec wi = vj.p - vi.p;
double dist = wi.length();
wi = wi * (1 / dist);

double cos_out = vi.n.dot(wi);
double cos_in = -vj.n.dot(wi);

// エッジ (vi, vj) の幾何因子 G(vi, vj)
double G = (cos_out * cos_in) / (dist * dist);
// 頂点 v における BSDF f_r = 1 / pi
double bsdf = 1 / M_PI;

// 方向 PDF p_w(wi) = (cos_out / pi) * pdf
// 角度積分 d_w から面積測度 dA への変換による面積 PDF pA(vj) の計算:
double pA = vi.pdf * (cos_in / (dist * dist));

// 経路積分形式におけるスループットの更新比: (bsdf * G) / pA
throughput = throughput.mult(vi.c * (bsdf * G / pA));

// 次の頂点が光源にたどり着いた場合:MIS
if (vj.e.x + vj.e.y + vj.e.z > 0) {
Vec wi = vj.p - vi.p;
double dist = wi.length();
wi = wi * (1 / dist);

// double cos_out = vi.n.dot(wi);
double cos_in = -vj.n.dot(wi);
// BSDF サンプリングにおける面積 PDF: pA_bsdf(v) = (cos_out / pi * vi.pdf) * (cos_in / dist^2)
double pA_bsdf2 = vi.pdf * (cos_in / (dist * dist));
double weight = mis_weight(pA_bsdf2, pA_light);
color = color + throughput.mult(vj.e) * weight;
break; // 光源に到達したため以降の延伸・接続処理を終了
}
} else {
// Specular / Refractive (Delta BSDF)
// デルタ関数の評価により (bsdf * G) / pA = vi.c / vi.pdf
throughput = throughput.mult(vi.c * (1 / vi.pdf));
}
}

return color;
}
  • 最初に光源上を1点ランダムサンプリングして、頂点列に対して使い回す
    • 直接光を計算するのは拡散反射面のみ
  • mis_weightでウェイト計算(パワーヒューリスティック)
  • トレースした影響を「スループット」として掛け合わせていく(反射で徐々に減衰していく)
  • Gと確率密度は実際のところ打ち消し合うのだけど、そもそも取り除いてしまうとなんの計算をしているのかわからなくなってしまうので残しておく
    • ただ同じ値を掛けて割ってるだけなので単に精度が落ちるだけだが、いちおう理解の補助として…
    • Gや確率密度の計算には次の頂点が必要
  • 光源に辿り着いたらトレースは打ち切り、という前提

あとがき

  • 計算内容的には普通のパストレと同じだけど、計算方法を変えることで多少は意味がわかるようになった
  • 角度方式だと占める立体角を計算するのもサンプルを生成するのも難しいが、経路方式だと対象の面積を計算したりサンプル可能なので、その点が重要なんだと思う
  • また角度と頂点の両方式で確率密度を変換できる