上一节的路径追踪器在通用场景里收敛良好,但把一盏灯放进玻璃球后面,桌面上那圈焦散亮纹会让它原形毕露:直接光采样连不上(中间隔着折射链),BSDF 采样弹不进(要连续穿过两个窄口),噪声稳定得像刻上去的。这正是 2.3 范式地图预告过的光源侧窄路问题——本节的主角光子映射,从光源那一头出发补上这半张地图。
光子映射把求解拆成两阶段。第一阶段:撒光子。从每个光源按其发光分布随机发射大量光子包,每个光子沿直线飞行,命中表面后按材质的散射规律决定命运——被吸收(终止)、镜面弹射(继续飞,但注意:纯镜面弹射不记录,原因见下)、漫散射(把命中点、入射方向、携带通量登记进光子图,然后按某种概率继续或终止)。百万级光子最终在漫反射表面上积成一张"辐射到达密度图"。第二阶段:收集。相机侧的正常路径追踪照旧,只是每逢需要间接光估计时,不再随机弹跳,而是在当前命中点周围找 k 个最近光子,用它们的通量除以包围面积做局部密度估计——光子密的地方亮度高,物理量纲恰好是辐照度。
阶段一:photons = [] for i in 1..N: p = emit_photon_from_lights() # 按光源分布采样 for bounce in 0..MAX: hit = scene.intersect(p.ray) if not hit: break if hit.material.is_diffuse: photons.append(位置=hit.pos, 方向=p.dir, 通量=p.flux) p = scatter(hit, p) # 按材质概率:吸收终止或继续弹射 建空间索引(kd 树或网格)加速近邻查询 阶段二:每条相机路径在需要间接光处: 邻域 = 查询 k 个最近光子 半径 = 邻域最远光子距离 L_indirect += 平均通量 / (π · 半径²) · bsdf_eval / k # 核密度估计
一个容易忽略的设计细节:镜面弹射不记入光子图。镜面方向的散射信息密度太高、方向性太强,用局部密度近似它等于把锐利高光糊成一团;正确分工是镜面链继续弹射追踪(相机侧或光子侧都行),光子图只承载"漫反射的到达分布"。这一条分工线,正是"哪些效果归谁"的族谱判据。

光子映射是有偏的——但要怪得具体。核密度估计用"k 个光子占多大圈"近似密度,当光子数有限时圈偏大,估计值系统性偏低,且在边缘处渗光(焦散轮廓比真实模糊一圈)。偏差随光子数增大而缩小,但经典光子映射把光子图一次性建死,图撒完就定版,收敛慢且无法渐进入库。
渐进光子映射(PPM)给出漂亮的救赎:每轮撒一批光子后不丢弃旧图,而是对每个收集点做"半径收缩"——本轮找到 k 个光子后,把统计半径按比例缩小(典型收缩 k 加 alpha 分 alpha 分之一),已积累的通量和半径一起更新。收缩的算术经过精心设计:半径按光子数的平方根倒数收缩,通量累计恰好渐近收敛到正确值——偏差随轮次推进消失。它把"光子图的分辨率"从一个一次性选择,变成随时间自愈的过程。另一个工程红利是存储:每个收集点只需记半径与通量累计,不必存百万光子全图。
光子图与路径追踪的合流方式也有讲究:漫反射间接光走光子密度估计,镜面链走路径追踪,再用 MIS 式的权重分账——这就是"顶点连接与合并"家族的雏形,把两边的强项焊在一张无偏性折中的账本上。
完整复盘同一个测试:点光源在玻璃球后上方,桌面白瓷。方案 A,纯路径追踪每像素 1024 样本:画面整体干净,唯独焦散区噪点顽固——1024 条路径里命中那条"两次折射汇聚"链路的样本寥寥,相对误差在亮纹处超过五成。方案 B,经典光子映射一百万光子:焦散位置与形状立刻正确,但轮廓发虚(偏差可见),圈边渗光;把光子加到一千万,虚边收窄却仍不锐利。方案 C,渐进光子映射跑 50 轮、每轮 20 万光子:焦散轮廓逐轮收锐,最终与参考图并列对看已难分辨——偏差被时间磨平。解读:三个方案各付了一样钱——A 付采样算力却付不到点上,B 付一次性的偏差,C 用迭代时间把偏差分期偿还。变式:把光源换成面光源,焦散软化,三种方案的差距缩小——窄路问题随光源变宽而缓解,选型的紧迫性也随之下降。
光子映射的调参自成一体,先给一张旋钮小抄,再讲它们怎么互相牵制。
| 旋钮 | 典型起点 | 拧大了 | 拧小了 |
|---|---|---|---|
| 每轮光子数 | 十万到百万级 | 内存吃紧、边际收益递减 | 密度估计噪点密布 |
| 每收集点目标光子数 | 20 到 100 | 轮廓更糊、偏差上移 | 估计抖动、斑点 |
| 最终收集样本数 | 50 到 200 | 时间翻倍、增益平缓 | 焦散区噪点可见 |
| 搜索半径初值 | 场景尺度的百分位 | 糊 | 一轮收缩到没光子可用 |
四个旋钮的牵制关系比单独取值重要:光子数翻倍后即便目标光子数不变,统计半径也会自然收窄——质量提升来自半径收缩,不是光子变多本身,这正是渐进式设计的洞察所在。调参次序也有讲究:先定目标光子数与半径初值让低频间接光立起来,再动光子总数磨焦散锐度,最后加最终收集样本数收尾。次序反了,一上来就堆光子,会把机时花在边际收益最低的那一档。
三个高频故障顺便存档。故障一,焦散区出现环状亮斑:搜索圆在曲面交界处覆盖了介质内部的光子,介质光子与表面光子必须分册登记。故障二,渐进多轮后画面反而发灰:收缩系数取了过激的值,统计半径过早锁死,后续轮次的光子挤进一个不再生长的估计里——把收缩放缓到标准值,灰雾会随轮次自行退去。故障三,动画帧间焦散闪烁:每轮撒点独立随机,亮纹位置轻微跳动;换成蓝噪声撒点或让相邻帧共享光子调度,闪烁自然平息。
最后留一条选型提醒:光子映射的性价比随光源变宽而跳水。天光室内这类宽路场景,路径追踪加直接光采样早已便宜又无偏,再请光子是白养一套系统;它真正的战场始终是窄光源与间接镜面链。判断要不要上它,先看场景里有没有"必须经窄口才能到达"的光路——有,它是救兵;没有,它是负担。
再把渐进式的"分期偿还"算成数字:经典方案一次撒一千万光子拿一份焦散,渐进式五十轮、每轮二十万,消化同样多的光子,但每轮结束都有一份可看、可评估、可叫停的中间结果——生产线上"先出预览再决定要不要磨"的流程,只有渐进式供得起。这层价值与画质无关,与生产节奏有关,却是它在工业界站稳的真正原因:渲染是流水线生意,能分期的账本永远比一次性结清的账本好卖。顺带一个验收技巧:检查中间结果要看"半径图"而不只看成图——把每个收集点的统计半径可视化,半径大的区域就是当前偏差大的区域,收敛进度一目了然,比肉眼比图可靠得多。可视化诊断这个习惯,第 9 章的剖析方法论会正式收编。
主力与救兵都到位了。族谱上还有整片中间地带——缓存、探针、双向会师,下一节一次盘完。