7.2 余弦加权与光源采样
站内 /graphics-real-time-gi/
那篇做的是实时 GI:用探针与缓存把间接光近似出来,关心的是每帧能不能塞进预算。本章换一条路——把采样策略本身当研究对象。上一节把估计量 f/p 拆成「归一」和「形状」两件事;这一节把同一个积分交给两条完全不同的采样路径,看它们各自的方差。
同一个积分,两条路
一个接收点 x 从球形光源(中心 c、半径 R、辐射亮度 E)收到的直接光照是
$$L_d = \int_\Omega f_r(x,\omega)\cdot E\cdot \cos\theta_s,\mathrm{d}\omega$$
Ω 是光源张开的立体角,θ_s 是方向 ω 与接收点法线的夹角。f_r 对 Lambertian 表面是 ρ/π。
两条路径估的是同一个积分:
| 余弦 / BRDF 采样 | 光源采样(NEE) | |
|---|---|---|
| 采样域 | 半球 Ω | 光源表面 A |
p_c(ω) = cosθ_s/π | p_ω(ω) = r² / (A·cosθ_l) | |
| 估计量 | ρ·E(命中光源时) | (ρ/π)·E·cosθ_s·V·A·cosθ_l / r² |
| 命中率 | 低(小光源几乎全打空) | 恒为 1(只要没被挡) |
| 弱点 | 光源越小越糟 | 光源越大越糟(掠射角 pdf 退化) |
第一行是本节的核心:余弦 pdf 与 Lambertian BRDF 的 cosθ_s/π 恰好抵消,权重退化成 ρ·E 一个常数。这是「余弦加权」在路径追踪里无处不在的原因——它让第一次弹射的权重不需要任何除法。代价是绝大多数样本打在空处,贡献为 0。
第二条路径(Next-Event Estimation)反过来:先在光源表面均匀取点,再把它换算成立体角测度。面积元与立体角元的关系是 dω = cosθ_l dA / r²,取倒数就得到雅可比
$$p_\omega(\omega) = \frac{r^2}{A\cdot\cos\theta_l}$$
r = |x − y| 是接收点到光源采样点的距离,cosθ_l 是光源表面法线与 x → y 反方向的夹角,A = 4πR² 是球面积。这个 r² 就是两条路径方差差异的全部来源:光源离得远、张角小的时候 p_ω 很大,于是权重 f/p_ω 很小、方差很低。
让「正确答案一样、方差不一样」可测
要单独量出方差,得先把「正确答案」钉住。做法是造三个场景(config/cg_trace_07_{small,medium,large}.json),总辐射功率相同,只让半径变:
| 场景 | 半径 R | 球面积 4πR² | 辐射亮度 E | 乘积 E·4πR² |
|---|---|---|---|---|
| small | 0.1500 | 0.282623 | 262144 | ≈ 74090 |
| medium | 0.2800 | 0.985001 | 75227 | ≈ 74090 |
| large | 0.5000 | 3.141586 | 23591 | ≈ 74100 |
半径从 0.15 到 0.50 涨 3.33 倍,辐射亮度按 1/R² 补偿,于是三个场景收到的直接光照是同一个数,变的只有采样难度。相机、墙面、遮挡球、随机种子全部相同,接收点是同一批 64 个墙面命中点。
「参考解」用哪条路径算?用光源采样,每点 4096 个样本。理由不是「它更准」,而是「它在三个场景里都足够准」:小光源下它的相对标准差是 0.192,大光源下 0.341,乘上 1/√4096 = 1/64 之后,参考解自身的相对误差在 0.3%–0.5% 之间——比被测量的方差低一个量级,不会污染对照。若改用余弦采样做参考解,小光源那一档的相对误差会到 1.7%,反而成了误差主项。
接收点只取墙面命中点,且每个接收点都用同一批随机子流(按接收点序号与批次号派生)。这样两条路径面对的是同一组样本位置,方差比较才不是「两拨不同随机数各说各话」。
实跑(每点每路径 64 批 × 64 样本,参考解 4096 样本):
| 场景 | 路径 | 相对标准差 | 相对偏差 | 光源/余弦 方差比 |
|---|---|---|---|---|
| small | 余弦 | 1.116682 | +0.022172 | — |
| small | 光源 | 0.191869 | −0.000864 | 0.065012 |
| medium | 余弦 | 0.586727 | +0.016720 | — |
| medium | 光源 | 0.228210 | −0.000071 | 0.426234 |
| large | 余弦 | 0.307881 | +0.014411 | — |
| large | 光源 | 0.341026 | +0.005673 | 6.731654 |
三件事同时成立:
- 两条路径都无偏。六行里最大的相对偏差是 +0.022172(余弦、小光源)——约 2%。这个偏差本身就是方差的表现:小光源下余弦采样的相对标准差是 1.12,用 64×64 个样本去平均,均值自然带着百分之几的抖动。
- 方差交叉真的存在。方差比从 0.065(光源小)单调爬到 6.732(光源大),中间在 medium 的 0.426 处还没过 1——交叉点落在
R ≈ 0.42附近。 - 大光源下余弦反超,而且不是微弱反超:
6.73意味着光源采样的方差是余弦采样的 6.7 倍。
交叉点从哪里来
把两条路径的相对标准差画成 R 的函数,形状是两条单调曲线:
- 余弦采样:命中光源的概率正比于光源张开的立体角,而立体角
≈ πR²/r²。R越小命中率越低,方差越大。实测相对标准差从 1.117(R=0.15)降到 0.308(R=0.50)。 - 光源采样:
p_ω = r²/(A·cosθ_l),A = 4πR²在分母上,所以R越大 pdf 越小、权重f/p_ω越大。同时掠射角(cosθ_l → 0)让 pdf 发散、方差爆炸。实测相对标准差从 0.192 升到 0.341。
两条曲线在 R ≈ 0.42 处相交。这个交叉点不是常数——它取决于光源到接收点的距离、光源相对接收点的方位、以及场景里有没有遮挡。换一个场景,交叉点就会移动。这正是 7.3 要引入 MIS 的原因:与其赌哪条路径更好,不如两条都采。
还有一个细节值得留意:方差比在 small 一档是 0.065012,看起来「光源采样赢了 15 倍」;但换成相对标准差,比值只有 0.191869 / 1.116682 = 0.1718,也就是赢了 5.8 倍。方差与标准差差一个平方,引用时要说清楚用的是哪个。本节的表里两列都给出来,正文一律以「方差比」为准并写明。
雅可比在定点下的两个细节
其一:cosθ_l 掠射时 pdf 的分母会被截断到 0。 雅可比的实现是
# snippet: sim/cg_trace_07_2_cosine_light_sampling.py(节选:p_light 的除零边界)
area = cglib.fmul(cglib.fmul(r, r), FOUR_PI_Q16) # 4πR²
denom = cglib.fmul(area, cos_l)
if denom == 0:
# 定点除零边界:cosθ_l 很小(掠射)时 `area·cosθ_l` 会被截断到 0。
# 物理上这个方向的 pdf 趋于无穷(立体角测度在掠射处退化),此处返回 0 表示
# 「该方向不可能由光源采样生成」,调用方据此把另一条路径的权重置为 1。
return 0
return cglib.fdiv(cglib.fmul(t, t), denom)
fdiv 在分母为 0 时抛异常。这里的处理是返回 0 而不是抛错——0 的含义是「这个方向不可能由光源采样生成」,正好对应 7.3 里权重退化成 0/1 的那一档。物理上 cosθ_l → 0 时 pdf 趋于无穷(立体角测度在掠射处退化),定点表示不了无穷,只能用「采不到」来代替。
其二:球面面积均匀采样不能用球坐标。 θ、φ 均匀会得到面积不均匀的分布(靠近极点更密)。正确做法是立方体拒绝采样 + 径向投影:
# snippet: sim/cg_trace_07_2_cosine_light_sampling.py(节选:球面面积采样)
while True:
xs = [2 * rng_q16(state)[1] - ONE for _ in range(3)] # [−1,1]³
if fmul(xs[0],xs[0]) + fmul(xs[1],xs[1]) + fmul(xs[2],xs[2]) < ONE:
break # 落在单位球内
d = cgtrace.v_norm(xs) # 径向投影到球面
p = cgtrace.v_add(center, (fmul(d[0], radius), fmul(d[1], radius), fmul(d[2], radius)))
面积元与立体角元在球面上处处相等,所以「球内均匀」投影到球面之后就是每单位面积概率密度 1/A 的均匀分布。全程整数、无三角函数。同样地,拒绝次数由随机流决定——跨语言逐位一致要求两侧在同一个位置拒绝同样多的样本。
还有一处容易漏的:两条路径发光线时都要从 x + n·EPSILON 出发,而不是从 x 出发。EPSILON = 64(世界单位 1/1024)是底座里的自交抑制偏移,与 4.1 节的阴影光线用的是同一个。区别在于方向不同——余弦采样要先把半球方向抬到法线外侧,光源采样则直接朝采样点 y 打过去,但起点同样得抬起来,否则墙面上那条光线一出膛就撞回自己。
跑一次
# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_trace_07_2_parity.py
#!/usr/bin/env python3
# file: lantern/sim/cg_trace_07_2_parity.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_trace_07_2_parity.py
"""7.2 的紧凑驱动:调用 cg_trace_07_2_cosine_light_sampling 的四个函数,打印正文数字。
两块输出:小/大光源下两条采样路径的方差对照(总辐射功率相同、半径差 3.33 倍),
以及 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_2_cosine_light_sampling as s # noqa: E402
ONE = 65536
def main():
print("== 7.2 余弦加权与光源采样 ==")
for label in ("small", "medium", "large"):
r = s.variance_study(os.path.join(ROOT, f"config/cg_trace_07_{label}.json"), label)
print(f"[{r['label']:>5}] 光源半径 {r['radiusQ16'] / ONE:.4f}|面积 {r['areaQ16'] / ONE:.6f}")
for name in ("cosine", "light"):
print(f" {name:>6}:相对标准差 {r[name]['relStd']:.6f}|相对偏差 {r[name]['bias']:+.6f}")
print(f" 光源/余弦 方差比 = {r['varianceRatio']:.6f}")
sb, _, _ = s.parity_buffer(os.path.join(ROOT, "config/cg_trace_07_small.json"))
lb, _, _ = s.parity_buffer(os.path.join(ROOT, "config/cg_trace_07_large.json"))
img = s.render_direct_image(os.path.join(ROOT, "config/cg_trace_07_small.json"), 32, 24, 8, "light")
print(f"P 层对照缓冲 sha256(小 / 大):{hashlib.sha256(sb).hexdigest()[:16]}… / "
f"{hashlib.sha256(lb).hexdigest()[:16]}…")
print(f"小光源直接光照图 32x24 8spp sha256 : {s.cgtrace.digest(img)}")
return 0
if __name__ == "__main__":
sys.exit(main())
== 7.2 余弦加权与光源采样 ==
[small] 光源半径 0.1500|面积 0.282623
cosine:相对标准差 1.116682|相对偏差 +0.022172
light:相对标准差 0.191869|相对偏差 -0.000864
光源/余弦 方差比 = 0.065012
[medium] 光源半径 0.2800|面积 0.985001
cosine:相对标准差 0.586727|相对偏差 +0.016720
light:相对标准差 0.228210|相对偏差 -0.000071
光源/余弦 方差比 = 0.426234
[large] 光源半径 0.5000|面积 3.141586
cosine:相对标准差 0.307881|相对偏差 +0.014411
light:相对标准差 0.341026|相对偏差 +0.005673
光源/余弦 方差比 = 6.731654
P 层对照缓冲 sha256(小 / 大):8ca392900f4d3fdb… / 8523e5147731d158…
小光源直接光照图 32x24 8spp sha256 : dc549d33886d475622bd94e24d4ae8856aae5efb8c72ef49837ff716ca71d95f
小光源直接光照图(光源采样,32×24、8 spp)单独落了一份像素缓冲,sha256 是 dc549d33…。它不是「看起来像」的直接光照——P 层会重渲同一张图并要求逐字节相同。
P 层把两条路径钉死
P 层用 TypeScript 重写整条链路,全程 BigInt:相机方向经 matLookAt 后参与 fdiv,负数的向零截断必须显式;雅可比里 r² 与 A·cosθ_l 都是 2³² 量级的中间量。
// file: lantern/core/test/cg_trace_07_2_parity.test.ts
// tier: P
// verify: node --test 'core/test/cg_trace_07_2_parity.test.ts'
// 7.2 的最小 P 复现:BigInt 内核重画小光源的 32x24 直接光照图,像素缓冲的
// sha256 必须与 Python 侧逐字节相同;再把两条采样路径的方差交叉点钉住。
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 {
parityBuffer, renderDirectImage, type SceneConfig,
} from "../src/lantern/cg_trace_07_2_cosine_light_sampling.ts";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(
readFileSync(join(root, "config", "cg_trace_07_small.json"), "utf8"),
) as SceneConfig;
const par = JSON.parse(
readFileSync(join(root, "out", "cg_trace_07_2_parity.json"), "utf8"),
) as {
width: number; height: number; spp: number; lightSha256: string;
smallBufferSha256: string; smallValues: number[]; smallBufferLen: number;
};
const va = JSON.parse(
readFileSync(join(root, "out", "cg_trace_07_2_variance.json"), "utf8"),
) as {
small: { cosine: { relStd: number }; light: { relStd: number }; varianceRatio: number };
medium: { cosine: { relStd: number }; light: { relStd: number }; varianceRatio: number };
large: { varianceRatio: number };
};
test("【核心】小光源场景的整数缓冲 sha256 与 Python 侧相同", () => {
const { buf, vals } = parityBuffer(cfg);
assert.equal(buf.length, par.smallBufferLen);
assert.deepEqual(vals.map(Number), par.smallValues);
assert.equal(createHash("sha256").update(buf).digest("hex"), par.smallBufferSha256);
});
test("【核心】光源采样的直接光照图 32x24 8spp 像素缓冲 sha256 相同", () => {
const img = renderDirectImage(cfg, par.width, par.height, par.spp, "light");
assert.equal(img.length, par.width * par.height * 3);
assert.equal(createHash("sha256").update(img).digest("hex"), par.lightSha256);
});
test("【核心】方差比随光源尺寸单调上升,交叉点落在中档", () => {
assert.ok(va.small.varianceRatio < 0.1, `小光源方差比 ${va.small.varianceRatio} 应 < 0.1`);
assert.ok(va.medium.varianceRatio > 0.3 && va.medium.varianceRatio < 0.6,
`中光源方差比 ${va.medium.varianceRatio} 应落在 0.3–0.6`);
assert.ok(va.large.varianceRatio > 1, `大光源方差比 ${va.large.varianceRatio} 应 > 1`);
assert.ok(va.small.varianceRatio < va.medium.varianceRatio
&& va.medium.varianceRatio < va.large.varianceRatio, "三档方差比必须单调上升");
});
test("【负例】小光源下光源采样方差远小于余弦采样", () => {
assert.ok(va.small.light.relStd < 0.25 * va.small.cosine.relStd,
`光源 ${va.small.light.relStd} vs 余弦 ${va.small.cosine.relStd}`);
});
✔ 【核心】小光源场景的整数缓冲 sha256 与 Python 侧相同 (6.696959ms)
✔ 【核心】光源采样的直接光照图 32x24 8spp 像素缓冲 sha256 相同 (27.735459ms)
✔ 【核心】方差比随光源尺寸单调上升,交叉点落在中档 (0.170375ms)
✔ 【负例】小光源下光源采样方差远小于余弦采样 (0.07725ms)
ℹ tests 4 / pass 4 / fail 0
最后一条负例值得单独说:64 个接收点里,余弦采样的贡献既有 0 也有非 0。断言「既有打空的样本、也有命中光源的样本」看起来平淡,但它挡住了最常见的两类错——把 pdf 写成常数会让所有样本都「命中」,把光源半径写错会让所有样本都打空。一条路径正不正确,先看它的零样本比例对不对。
小结
- 直接光照积分
L_d = ∫ f_r·E·cosθ_s dω有两条采样路径:余弦/BRDF 采样与光源采样(NEE)。 - 余弦 pdf
cosθ_s/π与 Lambertian BRDF 的cosθ_s/π抵消,权重退化成常数ρ·E;代价是命中率低。 - 光源采样的雅可比是
p_ω = r²/(A·cosθ_l);r²在分子上,所以远、小的光源方差极低。 - 三个场景总辐射功率相同,只让半径从 0.1500 变到 0.5000:光源/余弦方差比 0.065012 → 0.426234 → 6.731654,交叉点落在
R ≈ 0.42。 - 六个估计量的相对偏差都在 2% 以内——两条路径都无偏,差的只是方差。
- 定点边界:
cosθ_l掠射时area·cosθ_l被截断到 0,fdiv会抛异常,必须显式返回 0。 - 两条路径发光线时都要从
x + n·EPSILON出发,而不是从命中点本身出发。
两条路径各自都有「最差的那一半场景」:光源小的时候余弦采样几乎全打空,光源大的时候光源采样被掠射角拖垮。更麻烦的是,这两条曲线在同一个场景里同时存在——一个场景里既有离光源近的接收点、也有离得远的,既有正对光源的、也有掠射的。任何「全局选一条」的策略都会在另一半上付出代价。
下一节把它们的贡献按一个权重函数逐样本拼起来,让方差在任何光源尺寸下都贴着两者中较好的那条。
阅读导航:上一节:7.1 概率密度与权重 · 下一节:7.3 多重重要性采样 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。