Path Tracing from Scratch: Cornell Box in 99 Lines
Cornell Box(康奈尔盒子)是测试全局光照的标准场景。画面里有两个直接光照产生不了的现象:
这两个现象是判断一个渲染器是否真正实现了全局光照的标志。
直接光照:光线打到表面后,连到光源算一次着色就结束。只算光源直接照到的部分。
路径追踪:光线打到表面后随机选一个方向继续弹射,弹到的下一个表面可能再弹,直到碰到光源或被概率终止。整条路径收集的光照贡献叠加起来,就是全局光照(Global Illumination, GI)。
| 维度 | 直接光照 | 路径追踪 |
|---|---|---|
| 光线弹射 | 1 次(到光源) | 多次 |
| 间接光照 | 无 | 有 |
| 颜色溢出 | 无 | 有 |
| 计算量 | 小 | 大(需多次采样) |
| 结果 | 确定性 | 随机(采样少有噪点) |
一个表面点 沿方向 发出的光 ,等于它自己发的光 ,加上来自整个半球 的入射光经表面反射后的贡献总和。这是几乎所有渲染算法都在近似的”大一统”公式,由 Kajiya 于 1986 年提出。
| 项 | 含义 | smallpt 里 |
|---|---|---|
| 沿 的出射辐射度 | radiance() 返回值 | |
| 自发光(只有光源非零) | 球的 e 字段 | |
| BRDF,入射 散射到 的比例 | 由 refl 材质类型决定 | |
| 从 方向来的入射光 | 别的点的 (递归) | |
| 入射角余弦(Lambert 定律) | 漫反射的余弦权重 | |
| 对整个上半球积分 | 蒙特卡洛采样近似 |
两个难点:
解析求解不可能,所以用蒙特卡洛方法随机采样来近似(见 B05)。
obj.e 只有光源球不为 0。radiance 三个分支处理的(下集)。要算积分 ,不必遍历定义域每个点:随机采样 个点,取函数值平均来估计积分。均匀采样时:
采样越多,估计越接近真实值。
渲染方程(B04)是对整个半球方向的积分。方向连续、无穷多,蒙特卡洛说:随机弹射几个方向,把那些方向的光照贡献平均,就是对积分的无偏估计。一次随机弹射 = 一次蒙特卡洛采样。
估计的误差随采样数下降的速度是:
这是 B06 噪点的根源,也是最反直觉的地方:要让噪点减半,采样量要翻 4 倍。
| 目标 | 需要的采样量 |
|---|---|
| 噪点减半 | |
| 噪点降到 1/10 |
1000 spp 看着不错,要做到 2 倍干净要 4000 spp。
蒙特卡洛估计是无偏的:当 时收敛到真实积分值。路径追踪理论上没有系统误差,只要采得够多结果就是对的。smallpt 是无偏路径追踪器。
每个像素独立地随机发射光线路径。采样少时,不同像素的估计值波动大,表现为噪点。噪点不是 bug,是尚未收敛的方差。 采样越多,对渲染方程积分的估计越准,噪点越少。
| 采样数 (spp) | 状态 |
|---|---|
| 4 | 几乎全噪点,每像素只发 4 条路径 |
| 100 | 可辨认,仍有明显噪点 |
| 1000 | 较干净,接近最终效果 |
收敛速度是 (见 B05):噪点减半要 ×4 采样。从 1000 spp 想做到 2 倍干净,需要 4000 spp;再干净一倍要 16000 spp。这是路径追踪慢的根本原因。
| 行号 | 模块 | 本集 block |
|---|---|---|
| 1–13 | Vec 向量数学 | B08 |
| 14 | Ray 射线定义 | B09 |
| 15–31 | Sphere + 求交 | B10/B11/B12 |
| 32–40 | 场景:9 个球 | B14/B15 |
| 41–43 | 工具函数 | B16 |
| 44–73 | radiance 核心(下集) | — |
| 74–99 | main:相机+采样(下集) | — |
本集讲基础设施(数据结构 + 求交 + 场景),下集讲灵魂(radiance 材质物理 + main 相机采样)。
Vec 只有 double x, y, z,但同时表示三种东西:
这种复用让代码极度精简。代价是可读性(看到 Vec 要猜它代表什么)。
Vec operator*(double b) const { return Vec(x*b, y*b, z*b); } // 标量乘
Vec mult(const Vec &b) const { return Vec(x*b.x, y*b.y, z*b.z); } // 分量乘
| 运算 | 用途 | 场景 |
|---|---|---|
operator*(double) | 标量乘(向量缩放) | 射线 |
mult(Vec) | 分量相乘(Hadamard 积) | 颜色混合:光打到红墙,反射 = 入射光 × 红色 |
mult 专门给颜色用:RGB 三通道各自相乘,模拟”表面吸收/反射各通道多少光”。
% 被重载为叉积,不是取模。这在正经项目里是大忌(违反直觉),99 行为了省掉 cross() 函数名这么干。读到 % 要反应过来是叉积。norm() 原地修改并返回引用:把自身除以长度,调用后原向量被改,别指望保留原值。struct Ray {
Vec o, d; // 起点(origin)、方向(direction)
};
射线上任意一点用参数 表示:
找最小的正 使 落在物体表面。对球体代入球面方程解出 (B11);对场景遍历所有物体取最近 (B16)。
d 必须归一化。否则求交公式 的简化不成立(B11)。smallpt 在 main 里保证 r.d.norm()。d 归一化。若 d 非单位, 只是参数不是真实距离,影响 eps 判断和深度比较。enum Refl_t { DIFF, SPEC, REFR }; // 漫反射 / 镜面 / 折射
struct Sphere {
double rad; // 半径
Vec p, e, c; // 球心(p)、自发光(e)、表面颜色/反照率(c)
Refl_t refl; // 材质类型
};
| 字段 | 含义 | 备注 |
|---|---|---|
rad | 半径 | 超大值(1e5)用来当墙(B15) |
p | 球心位置 | — |
e | 自发光颜色 | 只有光源非零,对应渲染方程 (B04) |
c | 表面颜色(反照率 albedo) | 决定吸收/反射多少光,用 mult 混色(B08) |
refl | 材质类型 | 决定 BRDF 分支(下集核心) |
e(自发光)与 c(反照率)别混淆。e 只有光源球非零,表示主动发光;c 是所有表面都有的反照率,表示反射光比例。渲染方程 里分别对应两项。(0.75, 0.25, 0.25) 反射 75% 红光、25% 绿、25% 蓝。球面上任意点到球心 的距离等于半径 :
射线上的点用参数 表示( 为起点, 为单位方向):
求交就是找最小的正 使 落在球面上。把射线方程代入球面方程的平方形式 :
令 ,展开点积 :
这是关于 的一元二次方程 。
判别式 :
| 几何意义 | 交点 | |
|---|---|---|
| 射线偏离球 | 无 | |
| 射线与球相切 | 1 个 | |
| 射线穿过球 | 2 个(进/出) |
两个解 ,取较小的正根就是最近的交点。
这是读 smallpt 源码最容易卡住的地方。标准二次方程 的判别式是 。smallpt 做了两步简化:
于是代码里的 det = b*b - op%op + rad*rad 实际算的是 ,sqrt(det) 是 。求根公式相应变成 ——省掉了因子 2 和 4。这是几何法射线-球求交的经典优化,数值与标准公式等价。
double intersect(const Ray &r) {
Vec op = p - r.o;
double b = op % r.d; // % 是点积; b = B/2
double det = b*b - op%op + rad*rad; // det = Δ/4
if (det < 0) return 0; // 判别式 < 0,未命中
else det = sqrt(det); // 现在 det = √Δ/2
double t = b - det; // 先试较近的解
if (t > eps) return t;
t = b + det; // 否则试较远的解
if (t > eps) return t;
return 0;
}
eps = 1e-4 丢弃过近的交点。从某个表面弹射出的新射线,因浮点误差,起点可能”略陷进”球内,导致算出极小的正 ,误判为”立刻又命中自己”。eps 把这些虚假交点排除掉。取值是权衡:太小挡不住自相交,太大漏掉真实近处交点, 是渲染领域的常用经验值。
det = Δ/4 的简化不成立,要用完整求根公式。det 是 ,不是判别式本身,也不是 。代码先判 det < 0(等价判别式 < 0),再 sqrt(det) 转距离。读 smallpt 时这个命名最容易误解。B11 推导了公式,这里是代码落地,重点在一个容易被忽略的小数 eps。
double intersect(const Ray &r) {
Vec op = p - r.o;
double b = op % r.d; // % 是点积;b = B/2
double det = b*b - op%op + rad*rad;
if (det < 0) return 0; // 判别式 < 0 → 未命中
else det = sqrt(det);
double t = b - det; // 先试近解
if (t > eps) return t;
t = b + det; // 再试远解
if (t > eps) return t;
return 0;
}
逐行对应 B11 的推导:op→、b→、det→(sqrt(det) 后为 )。算法本质:解二次方程,取较小的正根。
eps = 1e-4,解决一个麻烦:从某个表面弹射出的新射线,因浮点误差,起点可能”略陷进”球内,于是算出极小的正 (如 ),误判为”立刻又命中自己”。
eps 丢弃 的解,只接受明显在前方的交点。这就是为什么先试 b - det(近解),不行才试 b + det(远解)。取值是权衡:太小挡不住自相交,太大漏掉真实近处交点, 是渲染领域的常用经验值。
smallpt 用 det = b*b - op%op + rad*rad 这种形式。当 rad 很大(如当墙的超大球,半径 1e5)时,b*b 和 op%op 都是巨数相减,会损失有效数字。教学/demo 级别够用;生产级渲染器会用更稳的求根公式。
| 角色 | 半径 | 说明 |
|---|---|---|
| 左/右/后/前/上/下墙 | 1e5 | 6 个超大球,左红右蓝余白色 |
| 镜面球 | 16.5 | 小球,SPEC 镜面反射 |
| 玻璃球 | 16.5 | 小球,REFR 折射 |
| 光源 | — | 自发光 e 非零 |
6 个超大球构成房间,2 个小球是主角,1 个是光源。
前墙(朝相机那面)颜色设为黑色(不发光不反光),相当于这面墙不存在——房间”开口”朝向相机,相机才能看到里面。
1984 年 Cornell 大学提出的标准测试场景,专门检验全局光照算法。简单到能手工验证,又能暴露间接光照、颜色溢出等关键现象(B01)。几乎所有路径追踪教程都用它。
球面是弯的,墙是平的,怎么用球当墙?半径趋近无穷大时,球面局部接近平面。
左墙球:球心 x = 100001,半径 = 100000。
球面上最靠近房间的点 = 球心朝房间方向退一个半径:
这个巨大的球从右侧”顶住”了 这个平面位置。
房间宽约 98,相对半径 只占:
半径 10 万的球上取 0.1% 的小弧段,曲率几乎为 0,肉眼就是平面。类比:地球半径 6371 km,地表几公里范围感觉是平的——同理。
右墙对称:球心 x = -99901,半径 = 100000,。两面墙形成宽 98 的房间。
用大球当墙,避免了实现”平面求交”代码——所有几何统一用球体求交,99 行才装得下。
inline bool intersect(const Ray &r, double &t, int &id) {
int n = sizeof(spheres)/sizeof(Sphere);
double d, inf = t = 1e20;
for (int i = n; i--;) { // 遍历所有球
if ((d = spheres[i].intersect(r)) && // 该球有交点
d < t) { // 且更近
t = d; id = i;
}
}
return t < inf;
}
t 初始化为 (当无穷),对每个球调它的 intersect(B12),命中且更近就更新 t 和 id。循环结束 t 是最近交点距离,id 是那个球的索引。
只有 9 个球,遍历成本可忽略。工业级场景有上百万三角形,才需要加速结构(BVH / kd-tree)避免逐个测试。smallpt 场景小到暴力最优。
d && d<t 的短路:intersect 返回 0 表示未命中,d 为 0 即 false,&& 短路不比较距离。smallpt 把”命中判断”和”距离比较”压在一行。t 用引用传出:调用方拿到最近距离,用于算交点 x = r.o + r.d * t,id 用于查该球属性(颜色、材质)。