6.2 路径采样

路径追踪把渲染方程改写成可估计的形式:方向按余弦加权采样,权重退化成 albedo 的连乘,一条路径就是一个样本。本节用确定性 SplitMix64 走完整条路径,量出 maxDepth 从 1 抬到 16 时平均亮度从 26032.93 爬到 33362.13,再用俄罗斯轮盘赌把每条路径的求交次数从 12.1170 砍到 2.7717。

6.2 路径采样

6.1 把「一个像素的颜色」写成了第二类积分方程,也证明了它的解是「每次弹射一项」的几何级数。但级数是无穷的,积分没有闭式解。实时混合光追那篇靠屏幕空间缓存绕开这一步(见 实时光线追踪的混合方案 );本节要做的是把方程逐项变成一个可以真的跑一遍的东西:方向从哪里取、一条路径算几弹、什么时候停、停了之后结果还准不准。

把方程改写成可估计的形式

渲染方程在点 $x$ 处是

$$ L_o(x, \omega) = L_e(x, \omega) + \int_{\Omega} f_r, L_i(x, \omega’), \cos\theta’, \mathrm{d}\omega’ $$

要估这个积分,就要挑一个概率密度 $p(\omega’)$ 在半球上采样,估计量是

$$ \langle L_o \rangle = L_e + \frac{1}{N}\sum_{i=1}^{N} \frac{f_r(x, \omega, \omega’_i), L_i(x, \omega’_i), \cos\theta’_i}{p(\omega’_i)} $$

关键的一步是选 $p$。取余弦加权 $p(\omega’) = \cos\theta’/\pi$(半球上归一化:$\int_\Omega \cos\theta’,\mathrm{d}\omega’ = \pi$),Lambertian 的 $f_r = \rho/\pi$,于是

$$ \frac{f_r \cos\theta’}{p} = \frac{(\rho/\pi)\cos\theta’}{\cos\theta’/\pi} = \rho $$

权重退化成 albedo 本身——不需要在每次弹射时算 BRDF、算余弦、再除 pdf。一条路径的贡献就是沿途 albedo 的连乘,乘上命中自发光体时读到的辐射亮度。$L_i$ 里那个积分递归展开一次,估计量就变成「再走一步」,这正是路径追踪的全部。

Malley 法:整数、无三角函数

余弦加权采样有一个经典的整数实现(Malley 法):在单位圆盘上均匀取一点 $(u_1, u_2)$,把它沿法线方向抬到半球上。因为圆盘面积元与半球投影立体角元一一对应,抬升后的分布恰好是 $\cos\theta/\pi$。

本卷的采样器全程整数:

# snippet: Malley 法的整数实现(余弦加权半球采样,无三角函数)
重复:
  u1, u2 = rng_q16(state)          # [0, 65536) 均匀
  x = 2*u1 - ONE, y = 2*u2 - ONE   # 映射到 [-1, 1)
  r2 = fmul(x,x) + fmul(y,y)
直到 r2 < ONE                       # 落在单位圆盘内
z = fsqrt(ONE - r2)                 # 抬到半球
方向 = normalize(x*t + y*b + z*n)   # (t, b, n) 是法线的正交基

没有 sin / cos / sqrt(浮点意义下的),只有 fmul、fsqrt(isqrt(x << 16))和一次拒绝采样。拒绝采样是确定性的:被拒绝多少次完全由 RNG 流决定,所以两侧只要共享同一条流,连「拒绝了几次」都逐位相同。这也是 6.2 的 P 层能做逐字节对照的前提。

为什么是余弦加权,而不是均匀采样

均匀采样 $p = 1/(2\pi)$ 也能用,估计量变成

$$ \frac{f_r \cos\theta’}{1/(2\pi)} = \frac{2\rho\cos\theta’}{\cos\theta’} \cdot \frac{\cos\theta’}{1} \cdot \frac{1}{2} = 2\rho\cos\theta’ $$

每次弹射都要多算一个 $\cos\theta’$ 并乘 2。更重要的是方差:均匀采样在掠射角($\cos\theta’ \to 0$)依然按同样的频率投样本,而那些样本的权重趋近于 0——等于把样本浪费在几乎不出能量的方向上。余弦加权采样让样本密度正比于出射能量的角分布,落在掠射角上的样本自然变少。

这条取舍在 7.1 讲 pdf 与权重时会展开成一般形式($p \propto f_r\cos\theta$ 时方差最小);本节只取它最省事的一个特例:权重恰好是 $\rho$,连余弦都不用算。

场景:一个没有解析解但答案唯一的盒子

下面的实验全部跑在一个 64×48 的封闭漫反射盒子上:边长 2,六个内向面(左墙红 200/60/60、右墙绿 60/190/60、其余中性 170),盒内悬一个自发光球(辐射亮度 4.0,不反射)与一个漫反射球(albedo 200)。

选这个场景有三个理由:

  1. 没有解析解。六个面互相可见,Neumann 级数要展开到无穷阶,不像双板腔那样能写出闭式。
  2. 答案唯一。同一份 config、同一个种子流、同一套整数运算,参考解每次都逐位相同。没有「大概是这样」的余地。
  3. 间接光占比高。盒子是封闭的,光跑不掉,只能在六个面之间来回弹——直接光照只占 59.2%(6.1 的灯箱实测),剩下的全靠多次弹射。这让 maxDepth 的效应被放大到肉眼可见。

maxDepth 的单位是「最多弹几次」

代码里那个 maxDepth 参数容易写错单位。本卷的定义是:

# snippet: path_radiance 里 maxDepth 的判定次序(决定「最多弹几次」的语义)
# 命中自发光体 -> 累加 throughput × emission 并终止
# 否则:
if bounces >= maxdepth:
    break          # 停下,不再采样新方向
dirv, state = cosine_hemisphere(n, state)   # 采样新方向
bounces += 1

于是 maxDepth = 1 就是直接光照:一次弹射都不做,只统计主光线直接看到自发光体的那些像素。写反成「先采样再判断」会让 maxDepth = 1 凭空多出一次间接弹射,误差从 −22% 变成 −12%,而且看上去「更准」——这类错位很难从图像上发现,只能靠数字。

场景是一个 64×48 的封闭漫反射盒子(边长 2,左墙红、右墙绿,盒内一个自发光球辐射亮度 4.0 与一个漫反射球)。盒子没有解析解,但有一个唯一的正确图像:参考解取 maxDepth=64、俄罗斯轮盘赌 p=0.5、每像素 512 个样本。

#!/usr/bin/env python3
# file: lantern/sim/cg_trace_06_2_parity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_06_2_parity.py
"""6.2 最小复现:maxDepth 截断曲线 + 俄罗斯轮盘赌开关对照。

复用一个唯一正确的参考解(maxDepth=64、RR p=0.5、512 spp),再把 maxDepth 从 1 抬到
16 看平均辐射亮度怎么爬上来;最后在 maxDepth=16 下开关轮盘赌,比较求交次数与亮度。
盒子没有解析解,但**参考解是确定的**:同一份 config、同一个 SplitMix64 种子流,
每次运行逐位相同。

只调用 sim/cg_trace_06_2_path_sampling.py 里的函数,不重写求交与采样。
"""

import json
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cg_trace_06_2_path_sampling as pt  # noqa: E402
import cgtrace  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SPP, REF_SPP, RR_P, RR_START = 48, 512, 32768, 2


def main():
    with open(os.path.join(ROOT, "config", "cg_trace_06_box.json"), encoding="utf-8") as fh:
        cfg = json.load(fh)
    planes, spheres = pt.build_scene(cfg)
    w, h = cfg["width"], cfg["height"]

    ref_acc, _ = pt.render(cfg, planes, spheres, w, h, 64, REF_SPP, RR_P, RR_START)
    ref = pt.to_mean_q16(ref_acc, REF_SPP)
    fmean = float(ref.sum()) / (w * h * 3)

    print("== 6.2 路径采样 ==")
    print(f"参考解:{w}x{h},maxDepth=64,RR p=0.5,{REF_SPP} spp,平均辐射亮度 {fmean:.4f}")
    print(f"参考图像素缓冲 sha256:{cgtrace.digest(pt.to_png(ref))}")
    print(f"{'maxDepth':>9} {'求交/路径':>10} {'平均亮度':>12} {'相对误差%':>12}")
    for k in (1, 2, 3, 4, 8, 16):
        acc, rays = pt.render(cfg, planes, spheres, w, h, k, SPP, RR_P, RR_START)
        mean = pt.to_mean_q16(acc, SPP)
        rm = float(mean.sum()) / (w * h * 3)
        print(f"{k:>9} {rays / (w * h * SPP):>10.4f} {rm:>12.4f} {(rm - fmean) / fmean * 100:>12.6f}")
    print("俄罗斯轮盘赌开关(maxDepth=16,同一批像素种子):")
    for p in (0, RR_P):
        acc, rays = pt.render(cfg, planes, spheres, w, h, 16, SPP, p, RR_START)
        mean = pt.to_mean_q16(acc, SPP)
        rm = float(mean.sum()) / (w * h * 3)
        tag = "关" if p == 0 else "开 p=0.5"
        print(f"  RR {tag:<8} 求交/路径 {rays / (w * h * SPP):>8.4f}  平均亮度 {rm:>10.4f}"
              f"  相对误差 {(rm - fmean) / fmean * 100:>10.6f}%")


if __name__ == "__main__":
    main()

实跑输出:

== 6.2 路径采样 ==
参考解:64x48,maxDepth=64,RR p=0.5,512 spp,平均辐射亮度 33495.4115
参考图像素缓冲 sha256:7179037da4daa9a81ebddf873b2e3d4d17e4c8cc39a77da666753db176bc8e56
 maxDepth      求交/路径         平均亮度        相对误差%
        1     1.9238   26032.9334   -22.279106
        2     2.3646   29318.0204   -12.471532
        3     2.5784   31224.2260    -6.780587
        4     2.6794   32167.5355    -3.964352
        8     2.7674   33199.3261    -0.883958
       16     2.7717   33362.1281    -0.397915
俄罗斯轮盘赌开关(maxDepth=16,同一批像素种子):
  RR 关        求交/路径  12.1170  平均亮度 33519.6940  相对误差   0.072495%
  RR 开 p=0.5  求交/路径   2.7717  平均亮度 33362.1281  相对误差  -0.397915%

把曲线读成一张表:

maxDepth求交/路径平均亮度(Q16.16)相对参考解L1 相对误差MAE(8 位)
1(直接光照)1.923826032.9334−22.279106%23.315560%30.3584
22.364629318.0204−12.471532%17.429675%22.6955
32.578431224.2260−6.780587%16.232485%21.1359
42.679432167.5355−3.964352%15.965693%20.7735
82.767433199.3261−0.883958%16.667990%21.6509
162.771733362.1281−0.397915%16.889179%21.8822

三件事在这里同时发生,而且方向不同:

  • 平均亮度单调上升,相对误差单调收缩。 每多一阶弹射就多接住一层间接光,$1 \to 16$ 把误差从 −22.28% 压到 −0.40%。这是收敛。
  • 求交次数饱和得早得多。 从 4 阶到 16 阶,平均亮度涨了 3.7%,求交次数只从 2.6794 涨到 2.7717(+3.4%)。原因是路径在封闭盒子里很快撞到自发光体或从「前墙」出去,绝大多数路径在第 3~4 次求交就终止了——maxDepth 从 4 抬到 16 只是把少数长路径放进来。
  • 逐像素误差不跟着均值误差走。 L1 相对误差在 maxDepth=4 触底(15.965693%)之后反而回升到 16.889179%。均值误差量的是总量,L1 量的是每像素;间接光让暗像素变亮的同时也把噪声带了进来,两张图的像素差并没有因为总量更准而变小。

俄罗斯轮盘赌:用方差换时间

maxDepth 硬截断在 4 阶就够准了,但 6.1 的灯箱实测告诉我们间接光占 40.8%,而 4 阶以上的路径虽然少,贡献并不为零。想既保留长路径又不无谓地追踪短路径,标准做法是俄罗斯轮盘赌:每弹射一次,以概率 $p$ 继续、以 $1-p$ 终止;终止的话这条路径的贡献记为 0,继续的话把权重除以 $p$。

存活概率在本卷里取 rr_p = 32768,即 $p = 0.5$。权重补偿的作用是把期望拉回来:

$$ E[\text{继续}] = p \cdot \frac{w}{p} + (1-p)\cdot 0 = w $$

期望不变,方差变大。 这正是 6.3 要单独展开的那条分界。这里先看代价收益:maxDepth=16 下关掉轮盘赌,每条路径平均 12.1170 次求交;打开之后是 2.7717 次——降到 22.9%。平均亮度从 33519.6940 变成 33362.1281,相对参考解从 +0.072495% 变成 −0.397915%,两者都在 0.4% 以内。

配置求交/路径平均亮度相对参考解
RR 关(maxDepth=16)12.117033519.6940+0.072495%
RR 开(p = 0.5,maxDepth=16)2.771733362.1281−0.397915%

省下 77.1% 的求交,代价是亮度偏差从 +0.07% 变成 −0.40%。这个偏差不是轮盘赌引入的系统误差(轮盘赌无偏),而是同一批像素种子下样本数不变、方差变大带来的随机波动——6.3 会把这两件事分开量。

确定性:一条路径可以被重放

整套采样只用一个 64 位整数状态(SplitMix64)。每像素的起点由纯整数混合给出:

# snippet: 每像素的确定性 RNG 起点(纯整数混合,无 random / 无时间戳)
s = (seed * 0x9E3779B97F4A7C15 + px * 0xC2B2AE3D27D4EB4F + py * 0x165667B19E3779F9) & RNG_MASK

没有 random、没有 Math.random、没有时间戳。于是「重新跑一遍」不是「得到差不多的图」,而是得到同一个整数。这条性质让 P 层的逐字节对照成为可能,也让 6.3 的方差统计可复现。

P 层:像素缓冲的 sha256

TypeScript 侧用 BigInt 重画一张 32×24、maxDepth=4、8 spp、RR 关的小图(同一份 config、同一个种子),把像素缓冲做 sha256:

// file: lantern/core/test/cg_trace_06_2_parity.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_06_2_parity.test.ts'
// 6.2 的最小 P 复现:用 BigInt 内核重画那张 32x24 对照图,像素缓冲的 sha256 必须
// 与 Python 侧逐字节相同;再把「maxDepth 越大越亮」与「轮盘赌不改变期望」两条钉住。

import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import {
  buildScene, render, toMeanQ16, toPng, type SceneConfig,
} from "../src/lantern/cg_trace_06_2_path_sampling.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(
  readFileSync(join(root, "config", "cg_trace_06_box.json"), "utf8"),
) as SceneConfig;
const par = JSON.parse(
  readFileSync(join(root, "out", "cg_trace_06_2_parity.json"), "utf8"),
) as { width: number; height: number; maxDepth: number; spp: number; rrQ16: number; rrStart: number; sha256: string };
const curve = (JSON.parse(
  readFileSync(join(root, "out", "cg_trace_06_2_sampling.json"), "utf8"),
) as { curve: { maxDepth: number; meanRadianceQ16: number; relErrPct: number }[] }).curve;

const { planes, spheres } = buildScene(cfg);

test("【核心】32x24 对照图的像素缓冲 sha256 与 Python 侧相同", () => {
  const { acc } = render(cfg, planes, spheres, par.width, par.height,
    par.maxDepth, par.spp, BigInt(par.rrQ16), par.rrStart);
  const buf = toPng(toMeanQ16(acc, par.spp));
  const got = createHash("sha256").update(buf).digest("hex");
  assert.equal(got, par.sha256);
  assert.deepEqual(Buffer.from(buf), readFileSync(join(root, "out", "cg_trace_06_2_parity.rgb")));
});

test("maxDepth = 1 是直接光照:平均亮度只有参考解的 77.7%", () => {
  assert.equal(curve[0].maxDepth, 1);
  assert.equal(curve[0].meanRadianceQ16.toFixed(4), "26032.9334");
  assert.equal(curve[0].relErrPct.toFixed(6), "-22.279106");
  assert.equal(curve[5].meanRadianceQ16.toFixed(4), "33362.1281");
  // 单调:每放宽一阶都变亮,且误差绝对值为负(始终偏暗)
  for (let i = 1; i < curve.length; i += 1) {
    assert.ok(curve[i].meanRadianceQ16 > curve[i - 1].meanRadianceQ16);
    assert.ok(curve[i].relErrPct > curve[i - 1].relErrPct);
  }
});
$ node --test 'core/test/cg_trace_06_2_parity.test.ts'
✔ 【核心】32x24 对照图的像素缓冲 sha256 与 Python 侧相同
✔ maxDepth = 1 是直接光照:平均亮度只有参考解的 77.7%

判据对象是像素缓冲而不是 PNG 容器字节——容器由各自的编码器决定,缓冲才是渲染结果。实测两侧的 sha256 都是 9c672e9e58f1faf7cf0d410e94e54d83e6905b9b6680f03804076fcf6cd1e5ec,同时逐字节比对磁盘上的 out/cg_trace_06_2_parity.rgb(32×24×3 = 2304 字节)。这一条把整条链路钉住了:相机基、拒绝采样的次数、maxDepth 的语义、权重累加的次序,任何一处写错都会换掉这个哈希。

本节的位置

到这里,渲染方程已经能被真的跑出来:方向从余弦加权分布里取,一条路径一个样本,maxDepth 决定弹几阶,轮盘赌决定要不要继续。但输出是一个带方差的估计值,而 maxDepth 是一个硬截断。下一节要把这两件事彻底分开:截断丢掉的能量去哪了(偏差),轮盘赌带来的波动为什么不算错(方差)。

阅读导航:上一节:6.1 渲染方程 · 下一节:6.3 无偏性与偏差 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配