7.3 多重重要性采样

余弦采样与光源采样各有一半场景吃亏,MIS 的做法是两条都采、再按权重函数拼起来。本节量出平衡启发式 `p_i/Σp_j` 与幂启发式 `p_i²/Σp_j²` 在三种光源尺寸下的方差:小光源与较优单路径打平(1.0447),中等与大光源分别降到 0.9053 与 0.6341;权重归一残差 ≤ 1,而幂启发式在 pdf < 1/256 时下溢成 0+0。

7.3 多重重要性采样

站内 /graphics-real-time-gi/ 那篇做的是实时 GI:靠屏幕空间探针与光照贴图把间接光近似出来。本章换一条路——把采样策略本身当研究对象。上一节量出了两条采样路径的交叉点:小光源下光源采样赢 15 倍,大光源下余弦采样赢 6.7 倍。问题是真实场景同时包含这两种情况,任何「全局选一条」的策略都会在另一半上吃亏。

两条都采,按权重拼

多重重要性采样(MIS)的做法很直接:两条路径都采,再把它们的贡献按一个权重函数拼起来。

$$w_i(\omega) = \frac{p_i(\omega)}{\sum_j p_j(\omega)} \quad\text{(平衡启发式)}, \qquad
w_i(\omega) = \frac{p_i(\omega)^2}{\sum_j p_j(\omega)^2} \quad\text{(幂启发式,}\beta=2\text{)}$$

估计量是 Σ_i (1/N_i) Σ_k w_i(ω_ik)·f(ω_ik)/p_i(ω_ik)。两条路径各采 N_i 个样本,每个样本的贡献先乘上它自己那条路径的权重,再累加。

权重的作用在于它读的是 pdf 的值,不是路径的身份:

  • 余弦采样打不到小光源时,那些方向的 p_c > 0 但 p_l = 0,于是 w_c = 1、w_l = 0——不会丢掉能量,也不会把方差大的那条路径放大。
  • 光源采样碰上大光源的掠射角时,p_l → 0,权重自然转到余弦那边。
  • 两条 pdf 都不为零的方向上,w_c + w_l = 1,两条路径各出一部分。

这正是 7.2 末尾那个「掠射处 p_light 返回 0」的用处:返回 0 等价于声明「这个方向只有余弦采样采得到」,权重自动退化成 0/1,不需要任何特判。

MIS 保持无偏的关键在于权重只依赖方向,不依赖路径的身份:w_i(ω) 对 p_i 求期望时可以提到积分号外,于是 ∫ w_c·f dω + ∫ w_l·f dω = ∫ (w_c + w_l)·f dω = ∫ f dω。p_light 在掠射角返回 0 不破坏这一点——它只是把 w_c 推到 1、w_l 推到 0,等式两边同时成立。

权重归一:定点下的残差

w_c + w_l 在实数上恒等于 1。定点下它不精确——fdiv 向零截断,两次除法各丢一点。实跑一组固定的 pdf 对(Q16.16):

p_cp_l平衡 w_c + w_l幂 w_c + w_l
6553606553665536
0655366553665536
65536655366553665536
32768655366553565535
3276898306553565535
2000400006553565535
5000036553565536
11655360

前七行给出本节最重要的一条定点结论:权重和的残差 ≤ 1(ONE = 65536 的最后一位)。它不是靠运气——平衡启发式是 a/(a+b) 与 b/(a+b) 两次向零截断,两个商的和只能落在 65535 或 65536;幂启发式同理。实测三个场景里 64 个接收点、64 批样本的权重残差最大值都是 1。

最后一行是真正的边界:p_c = p_l = 1(即 pdf 都是 1/65536)时,幂启发式的 w_c + w_l = 0。原因在 7.1 就埋下了——fmul(a, a) 在 a < 256 时被截断到 0,于是 a² + b² = 0,fdiv(0, 0) 的分母为 0。代码里对这一档的处理是直接返回 0:

# snippet: sim/cg_trace_07_3_multiple_importance_sampling.py(节选:权重函数)
def weight(a, b, mode):
    if a <= 0 and b <= 0:
        return 0                      # 两条路径都采不到:权重取什么都不影响估计量
    if mode == "balance":
        return fdiv(a, a + b)
    a2, b2 = fmul(a, a), fmul(b, b)
    if a2 + b2 == 0:
        return 0                      # 幂启发式的定点下溢:p < 256 时 a² 被截断到 0
    return fdiv(a2, a2 + b2)

返回 0 在这里是安全的,因为 p_c = p_l = 0 的方向上两条路径都不产生贡献,权重取任何值都不改变估计量。但必须显式写出来——fdiv(0, 0) 会抛 ZeroDivisionError,让整条渲染链路崩掉。

方差对照:MIS 贴着较优路径

三个场景(半径 0.1500 / 0.2800 / 0.5000,总辐射功率相同)各跑一遍,四种估计量:余弦、光源、MIS-平衡、MIS-幂。每点每采样路径 64 个样本,64 批,相对标准差对参考解归一化后逐点平均:

场景半径余弦光源MIS-平衡MIS-幂平衡/较优单路径
small0.15001.0741050.1865310.1948630.1982961.0447
medium0.28000.5759250.2221060.2010720.2069530.9053
large0.50000.3089110.3373170.1958920.2011520.6341

四件事同时成立:

  1. 小光源(R = 0.15):光源采样是最优的(0.1865),MIS-平衡 0.1949,比值 1.0447——MIS 只比最优路径差 4.5%。这里没有额外收益,因为光源采样已经接近理论最优(7.2 实测相对标准差 0.1919),MIS 只是在少量方向上做修正。
  2. 中等光源(R = 0.28):MIS-平衡 0.2011 已经同时优于两条单路径(0.5759 与 0.2221),比值 0.9053。注意 7.2 里这个半径的交叉点还没到(方差比 0.426),光源采样仍占优,MIS 却已经反超它了。
  3. 大光源(R = 0.50):MIS-平衡 0.1959,比值 0.6341——比两条单路径里较好的那条(余弦 0.3089)还低 37%。
  4. 幂启发式始终略差于平衡启发式。三个场景的比值分别是 1.0631 / 0.9318 / 0.6512,都比平衡那一列大 3%–4%。这与「β = 2 更尖锐、更能压制差路径」的直觉相反,但在这个场景里成立:pdf 比值没有极端到需要幂启发式,而它的定点下溢与更大的截断误差反而成了负担。

六行估计量的相对偏差全部在 4% 以内(最大是 small 场景余弦采样的 −0.037993),MIS 两个启发式的偏差都在 0.2% 以内——权重没有引入偏差,它只重分配方差。

需要强调的是,「MIS 优于单路径」不是无条件成立的:小光源那一档的 1.0447 就说明 MIS 会输给精心挑选的单路径。MIS 买的是「不用事先知道哪条路径更好」这个性质,代价是固定的两倍光线。如果场景的光源尺寸事先已知且分布均匀,直接选那条路径更划算。

光线预算要说清楚

MIS 每次迭代各取一个余弦样本与一个光源样本,所以一次迭代 = 两条光线。上表的对照口径是:

估计量每点光线数
余弦(单路径)64
光源(单路径)64
MIS-平衡128(64 次迭代 × 2)
MIS-幂128(64 次迭代 × 2)

也就是说,MIS 用两倍的光线预算换来了 medium 场景 0.9053 与 large 场景 0.6341 的比值。如果把预算压到相同(MIS 只跑 32 次迭代 = 64 条光线),它的方差会上升约 √2 倍,比值变成 1.28 / 0.89 / 0.90 附近——medium 与 large 仍占优,small 则明显吃亏。

所以 MIS 不是免费的。 它的价值在于「不用事先知道哪条路径更好」:7.2 的交叉点随场景移动,而 MIS 在任何一档都不差于较优路径太多,在中等和大光源下还显著更好。要不要开 MIS,取决于场景里光源尺寸的分布——如果整个场景只有一盏小光源,直接用光源采样更省。

这也是为什么主流渲染器把 MIS 做成可选开关而不是默认行为:离线渲染的积分器通常默认开(光线预算不值钱),而实时管线里每多一条阴影光线都要重新算一遍预算,是否开 MIS 取决于那盏灯是「太阳」还是「面积灯」。本卷的路径追踪器默认开 MIS,正是因为场景里同时有小面积光源与大面积天光。

跑一次

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_07_3_parity.py
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_07_3_parity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_07_3_parity.py
"""7.3 的紧凑驱动:调用 cg_trace_07_3_multiple_importance_sampling 的函数,打印正文数字。

三块输出:MIS 权重归一表(含幂启发式的定点下溢行)、三种光源尺寸下
{余弦, 光源, MIS-平衡, MIS-幂} 的方差对照、以及 P 层逐字节对照缓冲的 sha256。
"""

import hashlib
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import cg_trace_07_3_multiple_importance_sampling as m  # noqa: E402

ONE = 65536


def main():
    print("== 7.3 多重重要性采样 ==")
    print("权重归一(w_c + w_l 与 ONE 的残差,Q16.16):")
    for r in m.weight_table():
        print(f"  p_c={r['pc']:>6} p_l={r['pl']:>6} : 平衡 {r['balanceSum']:<7} 幂 {r['powerSum']}")
    for label in ("small", "medium", "large"):
        st = m.study(os.path.join(ROOT, f"config/cg_trace_07_{label}.json"), label)
        print(f"[{st['label']:>6}] 半径 {st['radiusQ16'] / ONE:.4f},每路径 {st['samplesPerTechnique']} 样本"
              f"(单路径 {st['singleRays']} 光线,MIS {st['misRays']} 光线)")
        for name in ("cosine", "light", "balance", "power"):
            d = st[name]
            print(f"    {name:>7}:相对标准差 {d['relStd']:.6f}|相对偏差 {d['bias']:+.6f}")
        print(f"    MIS 平衡/较优单路径 = {st['balanceVsMin']:.4f},"
              f"MIS 幂/较优单路径 = {st['powerVsMin']:.4f},"
              f"残差 max(平衡/幂)= {st['weightResidualMaxQ16']['balance']}/"
              f"{st['weightResidualMaxQ16']['power']}")
    buf, _ = m.parity_values()
    print(f"P 层对照缓冲 sha256 : {hashlib.sha256(buf).hexdigest()}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
== 7.3 多重重要性采样 ==
权重归一(w_c + w_l 与 ONE 的残差,Q16.16):
  p_c= 65536 p_l=     0 : 平衡 65536   幂 65536
  p_c=     0 p_l= 65536 : 平衡 65536   幂 65536
  p_c= 65536 p_l= 65536 : 平衡 65536   幂 65536
  p_c= 32768 p_l= 65536 : 平衡 65535   幂 65535
  p_c= 32768 p_l=  9830 : 平衡 65535   幂 65535
  p_c=  9830 p_l= 32768 : 平衡 65535   幂 65535
  p_c=  2000 p_l= 40000 : 平衡 65535   幂 65535
  p_c=     1 p_l=     1 : 平衡 65536   幂 0
  p_c= 50000 p_l=     3 : 平衡 65535   幂 65536
[ small] 半径 0.1500,每路径 64 样本(单路径 64 光线,MIS 128 光线)
     cosine:相对标准差 1.074105|相对偏差 -0.037993
      light:相对标准差 0.186531|相对偏差 +0.001024
    balance:相对标准差 0.194863|相对偏差 +0.000369
      power:相对标准差 0.198296|相对偏差 -0.000502
    MIS 平衡/较优单路径 = 1.0447,MIS 幂/较优单路径 = 1.0631,残差 max(平衡/幂)= 1/1
[medium] 半径 0.2800,每路径 64 样本(单路径 64 光线,MIS 128 光线)
     cosine:相对标准差 0.575925|相对偏差 -0.011372
      light:相对标准差 0.222106|相对偏差 +0.001686
    balance:相对标准差 0.201072|相对偏差 -0.001069
      power:相对标准差 0.206953|相对偏差 -0.001244
    MIS 平衡/较优单路径 = 0.9053,MIS 幂/较优单路径 = 0.9318,残差 max(平衡/幂)= 1/1
[ large] 半径 0.5000,每路径 64 样本(单路径 64 光线,MIS 128 光线)
     cosine:相对标准差 0.308911|相对偏差 +0.005613
      light:相对标准差 0.337317|相对偏差 +0.007169
    balance:相对标准差 0.195892|相对偏差 +0.001579
      power:相对标准差 0.201152|相对偏差 +0.001230
    MIS 平衡/较优单路径 = 0.6341,MIS 幂/较优单路径 = 0.6512,残差 max(平衡/幂)= 1/1
P 层对照缓冲 sha256 : 70d0aef0c993bf23669edc33dc2232e111b5f0534efde455b3245d76396473d7

P 层把权重钉死

P 层重写权重函数与整条 MIS 估计量,断言对象是固定 4 个接收点 × {平衡, 幂} × 4 次迭代的整数缓冲:

// file: lantern/core/test/cg_trace_07_3_parity.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_07_3_parity.test.ts'
// 7.3 的最小 P 复现:读 Python 侧 cg_trace_07_3_mis.json,把权重归一残差、
// 幂启发式的定点下溢、以及 MIS 相对两条单路径的比值钉住。

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

import { ONE } from "../src/lantern/fixed.ts";
import { weight, weightTable } from "../src/lantern/cg_trace_07_3_multiple_importance_sampling.ts";

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const mis = JSON.parse(
  readFileSync(join(root, "out", "cg_trace_07_3_mis.json"), "utf8"),
) as {
  small: { balanceVsMin: number; powerVsMin: number };
  medium: { balanceVsMin: number };
  large: { balanceVsMin: number };
  parity: {
    weightTable: { pc: number; pl: number; balanceSum: number; powerSum: number }[];
  };
};

test("【核心】权重表逐行与 Python 侧一致", () => {
  const rows = weightTable();
  assert.equal(rows.length, mis.parity.weightTable.length);
  rows.forEach((r, i) => {
    assert.equal(Number(r.pc), mis.parity.weightTable[i].pc);
    assert.equal(Number(r.pl), mis.parity.weightTable[i].pl);
    assert.equal(Number(r.balanceSum), mis.parity.weightTable[i].balanceSum);
    assert.equal(Number(r.powerSum), mis.parity.weightTable[i].powerSum);
  });
});

test("【核心】平衡启发式的权重和恒等于 ONE(残差 ≤ 1)", () => {
  for (const r of weightTable()) {
    if (r.pc === 0n && r.pl === 0n) continue;
    const d = r.balanceSum - ONE;
    assert.ok(d >= -1n && d <= 1n, `平衡权重和 ${r.balanceSum} 与 ONE 的残差 ${d} 应不超过 1`);
  }
});

test("【核心】MIS 在中等/大光源下严格优于两条单路径", () => {
  assert.ok(mis.medium.balanceVsMin < 0.95, `中等光源 ${mis.medium.balanceVsMin} 应 < 0.95`);
  assert.ok(mis.large.balanceVsMin < 0.7, `大光源 ${mis.large.balanceVsMin} 应 < 0.7`);
  assert.ok(mis.small.balanceVsMin > 0.95 && mis.small.balanceVsMin < 1.15);
});

test("【负例】幂启发式的定点下溢:两个 pdf 都小于 256 时权重退化成 0", () => {
  assert.equal(weight(1n, 1n, "power"), 0n);
  assert.equal(weight(255n, 255n, "power"), 0n);
  assert.ok(weight(256n, 256n, "power") > 0n);
  assert.equal(weight(1n, 1n, "balance"), 32768n);
});
✔ 【核心】权重表逐行与 Python 侧一致 (0.612208ms)
✔ 【核心】平衡启发式的权重和恒等于 ONE(残差 ≤ 1) (0.131916ms)
✔ 【核心】MIS 在中等/大光源下严格优于两条单路径 (0.072959ms)
✔ 【负例】幂启发式的定点下溢:两个 pdf 都小于 256 时权重退化成 0 (0.066875ms)
ℹ tests 4 / pass 4 / fail 0

assert.ok(weight(256n, 256n, "power") > 0n, ...) 这一行钉住的是下溢的边界:256 = 1<<8,fmul(256, 256) = (256*256)>>16 = 1,恰好是幂启发式能表示的最小非零分子。把阈值写成 255 会让 weight(255n, 255n) 也过,那就漏掉了真正的边界位置。

小结

  • MIS 两条路径都采,按 w_i = p_i/Σp_j(平衡)或 p_i²/Σp_j²(幂)拼贡献;权重读的是 pdf 的值,不是路径的身份。
  • 定点下权重和的残差 ≤ 1:平衡与幂启发式都只能落在 65535 或 65536。三个场景、64 接收点 × 64 批实测残差最大值都是 1。
  • 幂启发式在 p < 256(pdf < 1/256)时 fmul(p, p) 下溢成 0,a² + b² = 0,权重退化成 0+0;代码里必须显式返回 0,否则 fdiv(0, 0) 抛异常。
  • 方差对照(每路径 64 样本,MIS 用两倍光线):小光源 1.0447(与较优路径打平)、中等 0.9053、大光源 0.6341(比两条单路径里较好的还低 37%)。
  • 平衡启发式在三个场景都略优于幂启发式(1.0447/0.9053/0.6341 对 1.0631/0.9318/0.6512)——本场景的 pdf 比值没有极端到需要 β = 2。
  • MIS 不引入偏差:六行估计量的相对偏差都在 4% 以内,MIS 两个启发式在 0.2% 以内。

到这里,采样策略的三种基本手法就齐了:换 pdf 的形状(7.1)、换采样域(7.2)、把多个采样域拼起来(7.3)。下一章离开「直接光照」,开始处理间接光照——光打到墙上之后的那一次反弹。

阅读导航:上一节:7.2 余弦加权与光源采样 · 下一节:8.1 间接光照 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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