光源・視点双方からの経路を接続するパストレーサー(経路積分)

2026-08-26

双方向パストレーシングの理解のためレンダリング方程式を3点形式で扱ってみたが、 次のステップとして経路積分の動作確認したい。

レンダリング方程式の展開

レンダリング方程式を3点形式で書き下したもの(式(8.1)の\(\prime\)を下付き数字に変更):

$$ \begin{align*} L(\bm{x_1} \to \bm{x_0}) =& L_e(\bm{x_1} \to \bm{x_0}) + \\ & \int_{\mathcal{M}} L(\bm{x_2} \to \bm{x_1}) f_r(\bm{x_2} \to \bm{x_1} \to \bm{x_0}) G(\bm{x_2} \leftrightarrow \bm{x_1}) dA(\bm{x_2}) \end{align*} $$

この式の意味としては、\(\bm{x_1}\)から\(\bm{x_0}\)に入射する放射輝度\(L(\bm{x_1} \to \bm{x_0})\)は、\(\bm{x_1}\)からの放射\(L_e(\bm{x_1} \to \bm{x_0})\)と、\(\bm{x_1}\)で\(\bm{x_0}\)に向かう反射で表せる。 注意点として、\(L_e\)はその注目点が光源だった場合にのみ値を持ち、そうでない場合は0なので項としては無視できる。

いったん省略として\(K_i = f_r(\bm{x_i} \to \bm{x_{i-1}} \to \bm{x_{i-2}}) G(\bm{x_i} \leftrightarrow \bm{x_{i-1}})\)(引数表記を省略してるけど\(\bm{x_i}, \bm{x_{i-1}}, \bm{x_{i-2}}\)を引数とする関数)、 \(dA_i = dA(\bm{x_i})\)と記述することにして、\(L\)が再帰的になっているのを展開してみる:

$$ \begin{align*} &&&L(\bm{x_1} \to \bm{x_0}) \\ &=&& L_e(\bm{x_1} \to \bm{x_0}) + \\ & && \int_{\mathcal{M}} L(\bm{x_2} \to \bm{x_1}) K_2 dA_2 \\ &=&& L_e(\bm{x_1} \to \bm{x_0}) + \\ & && \int_{\mathcal{M}} \left( L_e(\bm{x_2} \to \bm{x_1}) + \int_{\mathcal{M}} L(\bm{x_3} \to \bm{x_2}) K_3 dA_3 \right) K_2 dA_2 \\ &=&& L_e(\bm{x_1} \to \bm{x_0}) + \\ & && \int_{\mathcal{M}} L_e(\bm{x_2} \to \bm{x_1}) K_2 dA_2 + \\ & && \int_{\mathcal{M}} \int_{\mathcal{M}} L(\bm{x_3} \to \bm{x_2}) K_3 dA_3 K_2 dA_2 \\ &=&& L_e(\bm{x_1} \to \bm{x_0}) + \\ & && \int_{\mathcal{M}} L_e(\bm{x_2} \to \bm{x_1}) K_2 dA_2 + \\ & && \int_{\mathcal{M}} \int_{\mathcal{M}} L_e(\bm{x_3} \to \bm{x_2}) K_3 dA_3 K_2 dA_2 + \\ & && \int_{\mathcal{M}} \int_{\mathcal{M}} \int_{\mathcal{M}} L(\bm{x_4} \to \bm{x_3}) K_4 dA_4 K_3 dA_3 K_2 dA_2 \\ &=&& \cdots \\ \end{align*} $$

といった具合にどこまでも展開できる。 一般化すれば、

$$ \sum_{k=1}^\infty \underbrace{\int_{\mathcal{M}} \cdots \int_{\mathcal{M}}}_{k-1} L_e(\bm{x_k} \to \bm{x_{k-1}}) \underbrace{K_k dA_k \cdots K_2 dA_2}_{k-1} $$

となる。 ここでのシグマの項=積分の深さごとに分かれた各項は「光源から放射された光が\(k-1\)回反射されて視点に届く経路」に対応する。 すべての項を足し合わせることで、シーン内のあらゆる経路・すべての反射回数の経路の影響を取り込んでいる。

  • このことによって光の影響を反射回数ごとに分けて計算できる、ということを以降で利用する

経路積分形式化

ここで\(\bar{x}_k\)が頂点列\(\bm{x_0} \bm{x_1} \cdots \bm{x_k}\)で構成される長さ\(k\)の経路とする。 この経路自体を積分変数として、積分範囲\(\Omega_k\)が長さ\(k\)のすべての経路とすれば、先ほどの式は

$$ \sum_{k=1}^\infty \int_{\Omega_k} L_e(\bm{x_k} \to \bm{x_{k-1}}) \left( \prod_{i=2}^k K_i \right) d\mu(\bar{x}_k) $$

と表せる。 これを経路積分と呼ぶ。

  • 面積−積測度とか出てきて数学的に厳密にやるには必要なんだろうけど、自分みたいな場合には「頂点列を積分単位として扱える」程度の理解で大丈夫

経路の確率密度

経路積分で表した式の解をモンテカルロ法で求める際に、サンプルした経路の確率密度が必要になる。 確率密度はその頂点の求め方によって計算できる:

  • 反射特性に基づく、反射方向の選択:角度の確率密度から面積のものに変換(3点形式での式(8.10))
  • 光源上の任意の点:面積の逆数
  • 経路同士の接続:お互いの確率密度を乗算

経路が頂点列から構成されるが、それぞれの頂点の確率密度を乗算して求める。

経路積分でのパストレーシング実装方針

論文を読み進めてきてここらで動作確認させたいと思ったが、当初所定の確率密度となるような経路をどのように生成したらいいのかわからず双方向パストレまで持っていかないとできないんじゃないかと思って途方に暮れたけど、ふと多重重点的サンプリングを使わずにできるんじゃないかと思ったのでやってみることにした。

手順としては、

  1. 経路長をランダムに決定
  2. 光源からと視点からの2つの部分経路の、双方の頂点数を決める
  3. 双方を始点から順にシーンを所定の反射回数追跡して部分経路を生成
  4. 双方をつないで望みの経路長の経路を得る

双方向パストレでは部分経路の終端同士以外にもあらゆる組み合わせを考慮することで効率的にサンプルするが、ここでは終端同士のみをつないだ経路1つだけを生成して、それに対する確率密度を計算してやる。 そうすることで生成した経路の確率密度が得られ、モンテカルロ法で計算できる。

経路長の選択

経路長は乱数で決定する。 適当に上限をつけて例えば100までとしても、一様乱数だと寄与が少ないであろう長い経路が選ばれる確率もそこそこ高く無駄が気になってしまう。 そこで確率\(p\)で0、そうでなかったら残りの\(1-p\)のうちの\(p\)で1、さらに残りのうちの\(p\)で2、…という具合に長さに従って確率が下げるようにしたい。

それには幾何分布というのを使うといいらしい。 0.0以上1.0未満の乱数\(\xi\)を使って、

$$ k = \lfloor \frac{\log \xi}{\log (1 - p)} \rfloor $$

で求められる。 その場合に、その経路長が発生する確率は

$$ (1 - p)^k \cdot p $$

となる。

  • 実際には光源と視点それぞれ最低1頂点ずつ計2頂点以上とする
  • この記事では経路長(=エッジ数)と頂点数を混ぜこぜで書いてしまっているが、ほとんど頂点数のことを指す

部分経路同士の接続

光源からと視点から追跡した部分経路の終端同士を結ぶことにより1つの経路を生成する。

注意点としては、それぞれの終端が拡散面(スペキュラ反射面ではない)の必要があるということだ。 スペキュラ反射面は入射した光の反射方向が1方向しかないデルタ関数となってしまうため、頂点を結んだ方向への反射率が0になってしまい寄与が与えられないので(ただし例外あり:後述)、その場合は結べないものとして除外する。

接続して得られた経路の確率密度は、部分経路それぞれの確率密度の単純な積で求められる。

接続時の係数としては、光源側から辿ってきた光が接続方向に反射する割合(BRDF)、2点間の係数(3点形式の式(8.3) 幾何項\(G\))、そして接続方向から視点側部分経路への方向に反射する割合(BRDF)を掛け合わせることで求める。

視点自体には接続しない

今回は視点の開始位置は部分経路には含めず、光源部分経路と直接結びつけないことにする。 ナイーブなパストレでのプライマリレイと同様にピクセルに対する一次レイでヒットした交点を視点側の最初の頂点とする。

そうすることでピクセルに関する積分を求めるだけになり、複雑にならずに済ます。

経路接続により生成される経路の制約

接続する頂点は非スペキュラ面に限ったので、部分経路を接続して生成できる経路には拡散面が続く頂点列を含む経路しか生成できないことになる。

また視点自体は頂点には含めないので、視点から直接光源を見るという経路も扱えない。

そういった問題を解消するために一定の割合でナイーブな(経路接続しない)パストレを行う。 拡散面が連続した場合には寄与なしとして扱い、スペキュラ面や単発の拡散面のみを辿って光源までたどり着いた経路だけを評価する。

こうすることで両手法で同じ経路をサンプルすることがなく排他であるため、組み合わせることで正しく描画できる。

部分経路探索時に光源にたどり着いた場合

ここでは光源から視点までの、反射回数ごとにわけられたあらゆる経路をサンプリングしようとしている。 その際に部分経路の探索でたまたま光源にたどり着いてしまった場合にはどうするか?

結論としては、所定の経路長が得られなかったものとして寄与を0(なし)とする(サンプルの引き直しは行わない)。

コード

例によってベースはsmallpt

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

ある程度は3点形式のときと同じだけど、いくつか重要な変更を加えた:

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, double initialPdfOmega, int maxDepth){
double pdfOmega = initialPdfOmega;
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;
double pdfArea = isinf(pdfOmega) ? 1.0 : pdfOmega * -nl.dot(r.d) / (t * t);
path->emplace_back(x, nl, obj.e, obj.c, obj.refl, pdfArea);
if (obj.e.x + obj.e.y + obj.e.z > 0) { // 光源に到達した場合は終了
return;
}
++depth; // ロシアンルーレットなし
if (obj.refl == DIFF) { // Ideal DIFFUSE reflection
Vec d = cosine_weighted_random_hemisphere(nl, Xi);
r = Ray(x, d);
pdfOmega = 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);
pdfOmega = INFINITY; // デルタ関数の反射面を示す
}
}
}
  • 頂点情報Vertexpdfを次の頂点を選択する確率密度ではなく、この頂点が選ばれた確率密度を表すよう変更
    • また角度に対する確率密度だったのを、面積の確率密度に変更
  • 所定の長さmaxDepthの部分経路を生成するために、ロシアンルーレットによる打ち切りは行わない
    • 光源に達してしまった場合・またはなにも交差しなかった場合には打ち切られるため、望みの経路長にならない可能性はある
  • スペキュラ面の場合には確率密度をINFINITYにして特別扱い

視点・光源部分経路生成

traceSceneを使って:

void generateEyeSubpath(const Ray& r, unsigned short *Xi, vector<Vertex>* path, int n){
traceScene(r, Xi, path, INFINITY, n);
}
void generateLightSubpath(unsigned short *Xi, vector<Vertex>* path, int n){
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 d = cosine_weighted_random_hemisphere(y_n, Xi);
double pdfOmega = y_n.dot(d) / M_PI;

// 光源の始点を追加
path->emplace_back(y, y_n, light.e, Vec(1), light.refl, pA_light); // light.c の代わりにVec(1)

if (n > 1)
traceScene(Ray(y, d), Xi, path, pdfOmega, n - 1);
}
  • 視点の開始座標は頂点列には追加しない
  • 光源側は、光源である球表面で一様になるよう乱数を発生させて開始位置とし、頂点のpdfには面積の逆数を与える。出射方向はコサイン比例

updateThroughput: スループットの更新

Vec updateThroughput(const Vec& throughput, const vector<Vertex>& path, int i, bool is_light) {
if (i + 1 >= path.size()) // 次の頂点がない:終了
return throughput;

const Vertex& vi = path[i];
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 = (is_light && i == 0) ? 1 : 1 / M_PI; // 光源部分経路の[0]は光源上の頂点からの放射で反射ではないため、特別扱いする必要がある

// 経路積分形式におけるスループットの更新比: bsdf * G
return throughput.mult(vi.c * (bsdf * G));
} else {
// Specular / Refractive (Delta BSDF)
return throughput.mult(vi.c);
}
}
  • 光源から発された光が、経路の各頂点での反射特性によってどのくらいの割合が届くのかを計算する
  • 3点形式では確率密度の除算も混ぜて計算していたが、分けるようにしてここでは扱わない
  • 光源側の最初の頂点は反射ではなく方向を選択しただけなので、BRDFを掛けないよう特別扱いする必要がある

connectVertices: 頂点同士の接続

bool connectVertices(const Vertex& ys, int s, const Vertex& zt, Vec* out) {
Vec wi = ys.p - zt.p;
double dist = wi.length();
wi = wi * (1 / dist);

double cos_out = zt.n.dot(wi);
double cos_in = -ys.n.dot(wi);
if (cos_out <= 0 || cos_in <= 0 ||
!is_visible(Ray(zt.p, wi), dist))
return false;

double G = (cos_out * cos_in) / (dist * dist);

Vec y_brdf = (s == 0 ? Vec(1) : ys.c * (1 / M_PI));
Vec z_brdf = zt.c * (1 / M_PI);
*out = y_brdf.mult(z_brdf) * G;
return true;
}
  • 2点間が正対していて、かつ遮られていない場合にのみ有効
  • 幾何項G、それに加えて接続点でのBRDFを掛ける
    • どちらもランバートという仮定なので\(\rho/\pi\)という値を直接用いているが、Glossyなども扱う場合には入出射方向から計算できるようにする必要があるでしょう
    • updateThroughput と同様に、光源開始点を特別扱い

radiance: ピクセルの色計算

radiance関数で上記を組み合わせて経路を生成し、モンテカルロ積分を求める:

Vec radiance(const Ray &r, unsigned short *Xi){
// 部分経路同士の接続だけでは L.*(DD).*E 以外が扱えないので、一定確率でナイーブなパストレを行う
constexpr double pnaive=0.005;
if (erand48(Xi) < pnaive) { // ナイーブなパストレ:直接光を描画するため
vector<Vertex> path;
generateEyeSubpath(r, Xi, &path, 100);
const Vertex& zt = path.back();
Vec color = zt.e * (1 / pnaive);
if (color.x + color.y + color.z > 0) { // 光源にたどり着いた場合にのみ寄与が得られる
for (size_t t = 0; t < path.size(); ++t) {
if (t + 1 < path.size() && path[t].refl == DIFF && path[t + 1].refl == DIFF)
return Vec(0); // DIFFが2回続いているため部分経路接続で生成可能、なのでこちらでは除外
color = updateThroughput(color, path, t, false) * (1 / path[t].pdf);
}
}
return color;
}

// 乱数により経路長を選択
constexpr double p = 0.4;
size_t k = 2 + (size_t)fmin(floor(log(erand48(Xi)) / log(1 - p)), 100); // 合計頂点数:幾何分布、短い経路長ほど発生しやすくする
size_t ne = irand(1, k, Xi); // 視点部分経路頂点数:1 <= ne <= k - 1
size_t nl = k - ne; // 光源部分経路頂点数:1 <= nl <= k - 1

vector<Vertex> eyeSubpath;
generateEyeSubpath(r, Xi, &eyeSubpath, ne);
vector<Vertex> lightSubpath;
generateLightSubpath(Xi, &lightSubpath, nl);
if (nl != lightSubpath.size() ||
ne != eyeSubpath.size()) // 望みの長さの経路が得られなかった
return Vec(0);

// 部分経路の終端頂点同士をつなげる(拡散反射面 DIFF 同士の場合のみ)
const Vertex& ys = lightSubpath.back(); // 光源側終端頂点
const Vertex& zt = eyeSubpath.back(); // 視点側終端頂点
Vec color(0);
if (Vec connect; ys.refl == DIFF && zt.refl == DIFF &&
connectVertices(ys, nl - 1, zt, &connect)) {
int valid = 1;
// 光源部分経路のスループットと確率密度を計算
const Vec& Le = lightSubpath[0].e;
Vec lightThroughput(Le);
double pdfLight = 1.0;
for (size_t s = 0; s < nl; ++s) {
lightThroughput = updateThroughput(lightThroughput, lightSubpath, s, true);
pdfLight *= lightSubpath[s].pdf;
if (s + 1 < nl && lightSubpath[s].refl == DIFF && lightSubpath[s + 1].refl == DIFF)
++valid;
}

// 視点部分経路のスループットと確率密度を計算
Vec eyeThroughput(1);
double pdfEye = 1.0;
for (size_t t = 0; t < ne; ++t) {
eyeThroughput = updateThroughput(eyeThroughput, eyeSubpath, t, false);
pdfEye *= eyeSubpath[t].pdf;
if (t + 1 < ne && eyeSubpath[t].refl == DIFF && eyeSubpath[t + 1].refl == DIFF)
++valid;
}

double pdfPathLen = pow(1 - p, k - 2) * p * (1 - pnaive);
double validRate = (double)valid / (k - 1);
color = connect.mult(lightThroughput).mult(eyeThroughput) * (1 / (pdfPathLen * pdfLight * pdfEye * validRate));
}
return color;
}
  • pnaiveの確率でナイーブなパストレを選択、それ以外は経路接続で行う
  • 部分経路の確率密度:Vertexpdfを掛けていくことで計算できる
  • 接続での確率密度:双方の部分経路の確率密度を掛け合わせる
  • validRateでの除算が必要な理由:
    • まったく同じ頂点列でも、光源・視点の経路長の選び方にバリエーションがある
    • どちらか(あるいは両方)がスペキュラ面である分割が選ばれたとしても本来ならばつなげるハズ、なぜならスペキュラ面の反射方向を正しく求めているから
    • しかし実際には寄与を0(無効)として扱ってしまうのでその分を補う必要がある(ロシアンルーレットと同様に成功率で割る)
    • 頂点数がkの場合、光源と視点の頂点数の組み合わせはk - 1通り(視点部分経路頂点数neおよび光源側nlは\([1, k)\)のk-1通り)
    • validは今回選ばれたパターンの他に、拡散面が続く箇所を調べて有効数をカウント

動作結果・感想

  • シーン全体のあらゆる経路を積分範囲、経路を積分変数として扱うという考え方に「そんなことできるの?」という衝撃を受けた
  • 経路の長さ=反射回数ごとにグループ分けして、別々に扱うという発想にも驚いた
  • こんなやり方で確率密度に沿って経路を生成し、ナイーブなパストレと同じ結果が計算できることにある意味感動
  • 壁の辺のあたりにファイアフライノイズが出る:
    • 頂点接続時の係数\(G\)は分母に距離の2乗が入るが、頂点同士が近距離だと極端に大きな値になってしまう
    • 通常のレイ追跡でこのような近距離の頂点が発生しても確率密度で相殺されるので問題にはならない、とのこと