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_c | p_l | 平衡 w_c + w_l | 幂 w_c + w_l |
|---|---|---|---|
| 65536 | 0 | 65536 | 65536 |
| 0 | 65536 | 65536 | 65536 |
| 65536 | 65536 | 65536 | 65536 |
| 32768 | 65536 | 65535 | 65535 |
| 32768 | 9830 | 65535 | 65535 |
| 2000 | 40000 | 65535 | 65535 |
| 50000 | 3 | 65535 | 65536 |
| 1 | 1 | 65536 | 0 |
前七行给出本节最重要的一条定点结论:权重和的残差 ≤ 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-幂 | 平衡/较优单路径 |
|---|---|---|---|---|---|---|
| small | 0.1500 | 1.074105 | 0.186531 | 0.194863 | 0.198296 | 1.0447 |
| medium | 0.2800 | 0.575925 | 0.222106 | 0.201072 | 0.206953 | 0.9053 |
| large | 0.5000 | 0.308911 | 0.337317 | 0.195892 | 0.201152 | 0.6341 |
四件事同时成立:
- 小光源(R = 0.15):光源采样是最优的(0.1865),MIS-平衡 0.1949,比值 1.0447——MIS 只比最优路径差 4.5%。这里没有额外收益,因为光源采样已经接近理论最优(7.2 实测相对标准差 0.1919),MIS 只是在少量方向上做修正。
- 中等光源(R = 0.28):MIS-平衡 0.2011 已经同时优于两条单路径(0.5759 与 0.2221),比值 0.9053。注意 7.2 里这个半径的交叉点还没到(方差比 0.426),光源采样仍占优,MIS 却已经反超它了。
- 大光源(R = 0.50):MIS-平衡 0.1959,比值 0.6341——比两条单路径里较好的那条(余弦 0.3089)还低 37%。
- 幂启发式始终略差于平衡启发式。三个场景的比值分别是 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 间接光照 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。