5396 字
14 分钟
详谈光线追踪与全局光照
2026-07-10
NOTE

为了节约篇幅,很多代码块采用了折叠的方式。

简单的辐射度量学#

立体角#

定义

Ω=Ar2[立体角单位 sr, 叫做球面度]\Omega = \frac{A}{r^2} \quad \text{[立体角单位 sr, 叫做球面度]}
  • A:锥体在球面上所截下的面积
  • r:球半径

球面坐标下的微分立体角 微分立体角描述的是三维空间中一个无限小方向,用球面坐标表示为(这里的字母跟微积分教材有出入,在图形学中,一般 θ\theta 是极角,ϕ\phi 是方位角):

dω=sinθ dθ dϕd\omega = \sin\theta \ d\theta \ d\phi
NOTE

为什么需要 sinθ\sin\theta 因子?因为在越靠近球顶部的位置,一个小的 dϕd\phi 实际覆盖的面积远小于在赤道附近的面积,所以需要一个 sinθ\sin\theta 修正

辐射度量#

  • 辐射通量:单位时间通过表面/区域的能量,相当于光的功率
  • 辐射强度:单位立体角上的辐射通量
  • 辐照度:表面单位面积接收到的辐射通量(收进来的光) E=dϕdAE=\frac{d\phi}{dA}
  • 辐射度:每单位投影面积、单位立体角的辐射通量(沿某个方向发出去/传过来的光)

辐射度定义

L(p,ω)=d2ΦdAcosθ dωL(\mathbf{p}, \omega) = \frac{d^2 \Phi}{dA \cos\theta \ d\omega}
如何理解辐射度定义?
  • 是比如你在看一个灯,dϕdA\frac{d\phi}{dA} 表示单位面积发出的功率。
  • 注意到灯是向所有方向发出光,所以我们还需要知道每一个方向分到多少光,因此再乘上 dϕdω\frac{d\phi}{d\omega},表示单位面积、单位方向上的光。
  • 为什么分母还要让 dAdA 乘上 cosθcos\theta?因为我们眼睛看向灯不一定是正对着看的,会有一定倾斜角,我们假设视线与灯表面法线夹角是 θ\theta,因此 dAcosθdA·cos\theta 就是 dAdA 在与视线方向垂直的平面上的投影,即真正有效面积。

辐照度与辐射度的关系

E(p)=ΩLi(p,ωi)cosθidωiE(\mathbf{p}) = \int_\Omega L_i(\mathbf{p}, \omega_i) \cos\theta_i \, d\omega_i

这也就是为什么说:

  • 辐照度:一个单位表面收到的所有方向光的总量。
  • 辐射度:一个单位表面朝某个方向发出的光强度。

渲染方程#

渲染方程的积分形式#

在表面点 p\mathbf{p}、出射方向 ωo\omega_o 上,出射辐射度满足:

Lo(p,ωo)=Le(p,ωo)+Ωfr(p,ωi,ωo)Li(p,ωi)cosθidωiL_o(\mathbf{p}, \omega_o) = L_e(\mathbf{p}, \omega_o) + \int_\Omega f_r(\mathbf{p}, \omega_i, \omega_o) \, L_i(\mathbf{p}, \omega_i) \cos\theta_i \, d\omega_i

翻译:一个点朝某个方向发出的光 = 自己发出的光 + 从周围所有方向来的光经过材质反射后的总和。

各项含义(下标 ii 指的是 incoming 进入):

  • LoL_o:出射辐射度(在点 p 朝观察方向 ωo\omega_o发出的光。)
  • LeL_e:自发光项
  • frf_r:BRDF,材质把入射光变成出射光的比例
  • LiL_i:从某方向 ωi\omega_i 射到 p 的入射辐射度
  • cosθi=nωi\cos\theta_i = \vec{n} \cdot \omega_i:即 Lambert 余弦
  • Ω\Omega:上半球,积分微元 dωi=sinθi dθi dϕid\omega_i = \sin\theta_i \ d\theta_i \ d\phi_i

渲染方程递归#

Li(p,ωi)L_i(\mathbf{p}, \omega_i) 本身就是从 ωi-\omega_i 方向射向 p\mathbf{p} 的光——如果场景中沿 ωi-\omega_i 走遇到点 p\mathbf{p}',(p\mathbf{p}' 是该光线的源头,可能是反射面,也可能直接是光源)那么:

Li(p,ωi)=Lo(p,ωi)L_i(\mathbf{p}, \omega_i) = L_o(\mathbf{p}', -\omega_i)

对于点 p\mathbf{p}' 又可以依照此法进行回溯,找到点 p\mathbf{p}'',直到最后找到光源,这便是 全局光照

Neumann 级数展开#

这一节便是把光无限反弹的过程展开,知道为什么计算机能够算出来。
定义光传输算子 TT

(TL)(p,ωo)=Ωfr(p,ωi,ωo)L(p,ωi)cosθidωi(TL)(\mathbf{p}, \omega_o) = \int_\Omega f_r(\mathbf{p}, \omega_i, \omega_o) L(\mathbf{p}', -\omega_i) \cos\theta_i \, d\omega_i

TT:一次反弹操作,也就是说输入 LL,经过 TT,便得到 TLTL(让这束光在场景中传播一次)。
因此,渲染方程写成算子形式:L=Le+TLL = L_e + TL
进一步形式上求解:

L=Le(IT)=Le+TLe+T2Le+T3Le++TnLe=n=0TnLeL = \frac{L_e}{(I - T)} = L_e + T L_e + T^2 L_e + T^3 L_e + \cdots + T^n L_e= \sum_{n=0}^{\infty} T^n L_e

TnLeT^n L_e 项表示第 n 次弹射

然而无限级数无法计算,于是我们通过

  • Monte Carlo 随机采样(因为积分 TT 本身包含 ω\int_\omega,其方向无限,因此随机取几个方向进行估计积分)
  • Russian Roulette 截断(把无限级数截断成有限级数进行计算,人为创造概率终止,最后为了保证平均结果不变,还要增加权重补偿。)

BRDF 与材质模型#

双向反射分布函数#

双向反射分布函数(Bidirectional Reflectance Distribution Function):

fr(p,ωi,ωo)=dLo(p,ωo)dEi(p,ωi)=dLo(p,ωo)Li(p,ωi)cosθidωi[sr1]f_r(\mathbf{p}, \omega_i, \omega_o) = \frac{dL_o(\mathbf{p}, \omega_o)}{dE_i(\mathbf{p}, \omega_i)} = \frac{dL_o(\mathbf{p}, \omega_o)}{L_i(\mathbf{p}, \omega_i) \cos\theta_i \, d\omega_i} \quad \text{[sr}^{-1}\text{]}

可以简单理解为:BRDF=出射光入射光BRDF = \frac{出射光}{入射光},根据单位为 sr1{sr}^{-1} 可知,BRDF 描述的是每单位立体角方向上的反射能力

BRDF三条性质:

  • 非负性
  • Helmholtz 互易性(交换入射出射方向,BRDF 一样)
  • 能量守恒: 对任意 ωo\omega_o
Ωfr(ωi,ωo)cosθidωi1\int_\Omega f_r(\omega_i, \omega_o) \cos\theta_i \, d\omega_i \leq 1

Lambert 漫反射#

定义:

frLambert=ρdπ,ρd[0,1]3f_r^{\text{Lambert}} = \frac{\rho_d}{\pi}, \qquad \rho_d \in [0, 1]^3

ρd\rho_d 称为漫反射率(albedo)。
要求能量守恒:

Ωρdπcosθidωi=ρdπ02π0π/2cosθsinθdθdϕ=ρdπ2π12=ρd1\int_\Omega \frac{\rho_d}{\pi} \cos\theta_i \, d\omega_i = \frac{\rho_d}{\pi} \int_0^{2\pi} \int_0^{\pi/2} \cos\theta \sin\theta \, d\theta \, d\phi = \frac{\rho_d}{\pi} \cdot 2\pi \cdot \tfrac{1}{2} = \rho_d \leq 1

ρd=1\rho_d = 1 时为完美白漫反射。
表面把入射光均匀散射到整个半球,但为了保证所有方向加起来反射能量等于 ρd\rho_d,必须除以以 cosθcos\theta 加权后的半球积分面积 π

Phong / Blinn-Phong(经验模型)#

从 BRDF 角度看,Phong 本质是在设计一个 frf_r,描述光如何从 ωi\omega_i 分布到 ωo\omega_o

frPhong=fdiffuse+fspecular=ρdπ+ρs(n+2)2π(rωo)shininessf_r^{\text{Phong}} = f_{diffuse} + f_{specular} = \frac{\rho_d}{\pi} + \rho_s \frac{(n+2)}{2\pi} (\vec{r} \cdot \omega_o)^{shininess}

由于 shininessshininess 取不同值,高光总能量不同,因此我们需要乘一个系数(归一化因子)(n+2)2π\frac{(n + 2)}{2\pi},让整个半球积分恰巧为1

若是 Blinn-Phong 模型,则用半程向量 h\vec{h} 替换 r\vec{r},归一化因子也变化:

frBlinn-Phong=fdiffuse+fspecular=ρdπ+ρs(n+8)8π(hωo)shininessf_r^{\text{Blinn-Phong}} = f_{diffuse} + f_{specular} = \frac{\rho_d}{\pi} + \rho_s \frac{(n+8)}{8\pi} (\vec{h} \cdot \omega_o)^{shininess}

Cook-Torrance#

现代 PBR 核心数学模型:Cook-Torrance 认为真实物体表面不是一个平面,而是由无数微小镜子组成,最终看到的反射效果取决于这些微镜子的朝向分布、镜面反射强度以及微镜子之间的遮挡。

fr=DFG4(ωin)(ωon)f_r = \frac{D F G}{4 \, (\omega_i \cdot \vec{n})(\omega_o \cdot \vec{n})}
  • DD:有多少微表面朝向正确
  • FF:这些微表面反射多少光
  • GG:有多少光能真正到达/离开
  • 分母:几何修正

法线分布函数 D(h)D(h)#

D(h)D(h):微表面的法线方向分布,从宏观来看决定着高光形状。
GGX(Trowbridge-Reitz)分布

DGGX(h)=α2π[(nh)2(α21)+1]2D_{\text{GGX}}(\vec{h}) = \frac{\alpha^2}{\pi \left[(\vec{n}\cdot\vec{h})^2 (\alpha^2 - 1) + 1\right]^2}
  • α\alpha 即粗糙度,之所以平方是因为物理模型相较于艺术家输入的 α\alpha 更希望地粗糙度区域变化更明显,即对于小 α\alpha 更接近镜面。
  • nh\vec{n}·\vec{h} 表示微表面法线和宏观法线夹角。

Schlick 菲涅尔近似 F(ωi,h)F(\omega_i, h)#

F(θ)=F0+(1F0)(1cosθ)5F(\theta) = F_0 + (1 - F_0)(1 - \cos\theta)^5
  • θ\theta:入射角
  • F0F_0:正入射反射率。普通非金属 F00.04F_0 \approx 0.04,这意味着正面看只有 4% 镜面反射,96% 漫反射;金属的 F0F_0 用 RGB 三通道,因为金属高光自带颜色。

Smith G#

由于有遮挡,我们需要知道有效贡献比例:

G(ωi,ωo)=G1(ωi)G1(ωo),G1(ω)=2(nω)(nω)+α2+(1α2)(nω)2G(\omega_i, \omega_o) = G_1(\omega_i) \cdot G_1(\omega_o), \quad G_1(\omega) = \frac{2(\vec{n}\cdot\omega)}{(\vec{n}\cdot\omega) + \sqrt{\alpha^2 + (1-\alpha^2)(\vec{n}\cdot\omega)^2}}

光线-几何体相交#

光线参数化#

将光线翻译成高中解析几何里的直线方程:

r(t)=o+td,t[tmin,tmax]\mathbf{r}(t) = \mathbf{o} + t \vec{d}, \quad t \in [t_{\min}, t_{\max}]
  • o\mathbf{o}:起点位置
  • d\vec{d}:方向(通常取单位向量)
  • tt:光线上的第几个位置
  • tmin>0t_{\min} > 0tmint_{\min} 取 0 会因为自相交而产生 Shadow acne(阴影痤疮)(tmint_{\min} 典型值取 10410^{-4}tmaxt_{\max} 取无限大)

光线是光线追踪里最核心的数据结构:

struct Ray
{
Eigen::Vector3f origin;
Eigen::Vector3f direction;
float t_min = 1e-4f;
float t_max = std::numeric_limits<float>::infinity(); // 即 t_max 取正无穷
Eigen::Vector3f at(float t) const
{
return origin + t * direction;
}
}

光线-球体求交#

球面方程:pc2=r2\|\mathbf{p} - \mathbf{c}\|^2 = r^2 (其中,p 为球面上一点,c 为球心,R 为半径)
因为交点一定在光线上,所以将光线方程 p=o+td\mathbf{p} = \mathbf{o} + t\vec{d} 代入上式可得(关于 t 的一元二次方程):

t2(dd)+2t(oc)d+(oc)(oc)r2=0t^2 (\vec{d}\cdot\vec{d}) + 2t (\mathbf{o} - \mathbf{c})\cdot\vec{d} + (\mathbf{o} - \mathbf{c})\cdot(\mathbf{o} - \mathbf{c}) - r^2 = 0

计算判别式 Δ=b24c\Delta = b^2 - 4c 判断交点情况:

  • d\vec{d} 为单位向量时, a=dd=1a = \vec{d}\cdot\vec{d}=1

代码实现:

bool intersect_sphere(const Ray& ray, const Eigen::Vector3f& c, float radius, float& t_hit)
{
Eigen::Vector3f oc = ray.origin - c;
float b = 2.0f * oc.dot(ray.direction);
float c = oc.squaredNorm() - radius * radius; // squaredNorm() 为 Eigen 函数,平方长度
float disc = b * b - 4.0f * c;
if (disc < 0) return false;
float sqrt_disc = std::sqrt(dist); // 对判别式开方
float t0 = (-b - sqrt_d) * 0.5f;
float t1 = (-b + sqrt_d) * 0.5f;
if (t0 > t1) std::swap(t0, t1);
t_hit = (t0 >= r.t_min) ? t0 : t1; // 如果 t0 合法,就选 t0 (更近)
return t_hit >= r.t_min && t_hit <= r.t_max;
}

得到 thitt_{hit} 后,将其代入光线求得交点,然后计算法线 n=pcRn=\frac{p - c}{R},再进入光照计算。

光线-三角形求交#

核心思想:把交点用重心坐标表示。(这里的重心坐标不要理解为以前几何中的重心,而是三个顶点对交点坐标贡献的权重)
由于交点既在光线上,也在三角形上:

o+td=(1uv)V0+uV1+vV2\mathbf{o} + t\vec{d} = (1-u-v)\mathbf{V}_0 + u \mathbf{V}_1 + v \mathbf{V}_2

为何用重心坐标(u, v):

  • 只有三个未知量,如果用重心(x, y, z)将有四个未知量(t 也是未知量)
  • 计算好 (u, v)后,后续法线,uv,顶点颜色都可以直接进行插值,一举两得。

继续移项可得:

oV0=td+uE1+vE2o - V_0 = -td + uE_1 + vE_2

其中,E1=V1V0{E}_1 = \mathbf{V}_1 - \mathbf{V}_0E2=V2V0{E}_2 = \mathbf{V}_2 - \mathbf{V}_0

将上式变为矩阵形式可得:

[dE1E2](tuv)=oV0\begin{bmatrix} -d & E_1 & E_2 \end{bmatrix} \begin{pmatrix} t \\ u \\ v \end{pmatrix} = o - V_0

这相当于就是三元一次方程组,t,u,vt, u, v 是未知量。此时理论上求解 (t,u,v)(t, u, v) 只用把最左边的矩阵变成逆矩阵移到右边即可,但求逆矩阵很慢,这里便使用一个数学技巧,Cramer 法则 + 标量三重积恒等式 (a×b)c=(b×c)a(\vec{a}\times\vec{b})\cdot\vec{c} = (\vec{b}\times\vec{c})\cdot\vec{a},计算结果全由叉乘和点乘表示。

最后结果可得:

t=(S×E1)E2PE1,u=PSPE1,v=(S×E1)dPE1t = \frac{(\mathbf{S}\times\mathbf{E}_1)\cdot\mathbf{E}_2}{\mathbf{P}\cdot\mathbf{E}_1}, \quad u = \frac{\mathbf{P}\cdot\mathbf{S}}{\mathbf{P}\cdot\mathbf{E}_1}, \quad v = \frac{(\mathbf{S}\times\mathbf{E}_1)\cdot\vec{d}}{\mathbf{P}\cdot\mathbf{E}_1}

其中 S=oV0\mathbf{S} = \mathbf{o} - \mathbf{V}_0P=d×E2\mathbf{P} = \vec{d}\times\mathbf{E}_2det=(d×E2)E1det = (\vec{d}\times\mathbf{E}_2)\cdot\mathbf{E}_1(行列式)

代码实现:

bool moller_trumbore(const Ray& ray, float& t, float& u, float& v,
const Eigen::Vector3f& v0,
const Eigen::Vector3f& v1,
const Eigen::Vector3f& v2)
{
const float EPS = 1e-8f; // 图形学中经常出现 EPS,表示一个极小量,如果一个数小于它,那么就可以认为是0
Eigen::Vector3f e1 = v1 - v0;
Eigen::Vector3f e2 = v2 - v0;
Eigen::Vector3f p = ray.direction.cross(e2);
Eigen::Vector3f s = ray.origin - v0;
float det = p.dot(e1);
float inv_det = 1.0f / det;
if (std::abs(det) < EPS) return false; // 光线平行于三角形,因为 det = 0,矩阵不可逆,三向量共面。
// 求u
u = p.dot(s) * inv_det;
if (u < 0 || u > 1) return false;
// 求v
Eigen::Vector3f q = s.cross(e1);
v = q.dot(r.direction) * inv_det;
if (v < 0 || u + v > 1) return false;
// 求t
t = q.dot(e2) * inv_det;
return t >= r.t_min && t <= r.t_max;
}

AABB 轴对齐包围盒#

目的:快速排除大量不可能相交的物体。
我们用 slab 法 得到空间包围盒。 slab 表示平行同一个轴的两平面之间的区域,这里会用到三个 slab,分别平行 x,y,z 轴,他们在三维空间中一定会围成一个立方体,这个立方体就是轴对齐包围盒。
下面计算一束光线进入和穿出包围盒的坐标:

先看下面这张图,先理解二维平面光线的行为。

AABB 光线求交的 slab 方法二维示意图

蓝色的竖直条带就是 “x方向的slab” —— 两条竖直虚线之间的无限长条形区域。光线只要在这个x范围内,就算 “在x的slab里”。
橙色的水平条带同理,是 “y方向的slab” —— 两条水平虚线之间的无限长条带。

对每个slab单独看,光线在该slab内部的t区间是 [t_near, t_far]。图中标注了:

  • x方向:t_near.x ≈ 0.23, t_far.x ≈ 0.65
  • y方向:t_near.y = 0.25, t_far.y ≈ 0.88

而光线真正”同时在两个slab里” (也就是在AABB里) 的那一段,是这两个区间的交集——图中绿色粗线部分,所以:

  • t_enter = max(t_near_x, t_near_y) = 0.25
  • t_exit = min(t_far_x, t_far_y) = 0.65

如果 t_enter > t_exit,说明光线在还没同时进入所有slab之前,就已经从某个slab跑出去了,即与包围盒不相交。

推广到三维同理:

tenter=max(tnear,x,tnear,y,tnear,z),texit=min(tfar,x,tfar,y,tfar,z)t_{\text{enter}} = \max(t_{\text{near},x}, t_{\text{near},y}, t_{\text{near},z}), \quad t_{\text{exit}} = \min(t_{\text{far},x}, t_{\text{far},y}, t_{\text{far},z})

相交条件 tentertexitt_{\text{enter}} \leq t_{\text{exit}}texit0t_{\text{exit}} \geq 0

AABB 结构体与相交判断的代码实现 [点击展开]
struct AABB
{
Eigen::Vector3f pmin, pmax; // AABB 顶点的最小、最大坐标,只需两个坐标即可确认一个 AABB
bool intersect(const Ray& ray, float& t_enter, float& t_exit) const
{
const float EPS = 1e-8f;
t_enter = -std::numeric_limits<float>::infinity();
t_exit = std::numeric_limits<float>::infinity();
// 三个轴分别计算
for (int i = 0; i < 3; i++)
{
float d = ray.direction[i]; // Eigen 重载了[]。direction[0] 表示 x,direction[1] 表示 y
if (std::abs(d) < EPS) // i 分量几乎为0,则光线平行于 i 面
{
if (ray.origin[i] < pmin[i] || ray.origin[i] > pmax[i]) return false;
continue;
}
float inv_d = 1.0f / d;
// 求与盒子的交点,已知交点在 i 面上,pmin 和 pmax 知晓,代入光线方程求解 t
float t0 = (pmin[i] - r.origin[i]) * inv_d;
float t1 = (pmax[i] - r.origin[i]) * inv_d;
if (t0 > t1) std::swap(t0, t1);
// 更新进入与穿出坐标
t_enter = std::max(t_enter, t0);
t_exit = std::min(t_exit, t1);
if (t_enter > t_exit) return false;
}
return t_exit >= std::max(0.0f, ray.t_min); // 作 max 是一个防御性写法,防止 t_min 被意外设置成负数。
}
}

空间加速结构(BVH)#

BVH 的核心思想#

BVH:Bounding Volume Hierarchy 即包围体层次结构。它将 Box 逐级分类直至形成一棵树,最后的叶子里才放真正的三角形。
光线射入过程:

  • 光线与根 AABB 求交失败:直接整棵树跳过
  • 若与根相交,判断光线与哪个子节点 AABB 相交,判断完后直接舍弃另外一半的子节点进入下一层。
  • 如此一直递归到子叶与真正三角形求交

加速结构最后将复杂度变为了 O(logn)O(\log n)

数据结构(现代 C++ 写法)#

struct BVHNode
{
AABB bounds; // 每个节点都有一个自己的包围盒
std::unique_ptr<BVHNode> left;
std::unique_ptr<BVHNode> right;
std::vector<const Object*> prims; // 里面的元素是物体的指针,Object 是所有几何体的统一接口
// 判断是否为叶子
bool is_leaf() const
{
return !left && !right;
}
}
// 保存交点信息的核心结构体
struct Intersection
{
bool happened = false; // 是否发生相交
float t = std::numeric_limits<float>::infinity(); // 光线参数 t
Eigen::Vector3f coords; // 交点坐标
Eigen::Vector3f normal;
const Object* obj = nullptr; // 被击中的物体
Eigen::Vector2f uv;
};

递归构建#

递归构建的代码实现 [点击展开]
std::unique_ptr<BVHNode> build(std::vector<const Object*>& objs)
{
auto node = std::make_unique<BVHNode>();
// 计算当前节点包围盒
for (auto* o : objs)
node->bounds.expand(o->get_bounds());
// 叶子阈值
if (objs.size() <= 4)
{
node->prims = objs;
return node;
}
// 选择分割方向,一般分割最长的轴,比如返回0代表分割 x 轴
int axis = node->bounds.longest_axis();
// 找中位数但不完全排序
std::nth_element(objs.begin(), objs.begin() + objs.size()/2, objs.end(),
[axis](const Object* a, const Object* b)
{
return a->get_bounds().centroid()[axis] < b->get_bounds().centroid()[axis];
}); // 匿名函数告诉算法如何比较两个物体
// 分成左右数组 left 与 right
std::vector<const Object*> left(objs.begin(), objs.begin() + objs.size()/2);
std::vector<const Object*> right(objs.begin() + objs.size()/2, objs.end());
node->left = build(left);
node->right = build(right);
return node; // move 语义
}

光线遍历#

目标:判断光线 r 是否与以 n 为根的 BVH 子树相交,并更新最近交点 best。

BVH 中光线遍历求交 [点击展开]
bool traverse(const BVHNode* n, const Ray& ray, Intersection& best)
{
if (node == nullptr) return false;
float t_enter, t_exit;
// 当前节点的 AABB 与光线不相交
// 第一次裁剪。同时传入的是引用,间接返回 t_enter,t_exit
if (!n->bounds.intersect(ray, t_enter, t_exit)) return false;
// 第二次裁剪子树,已经有更近的交点
if (t_enter > best.t) return false;
/*处理叶子节点*/
if (n->is_leaf)
{
bool hit = false;
for (auto* p : n->prims) // p 就是一个图元
{
Intersection tmp;
// 利用多态,intersect 根据不同图元调用不同图元的求交函数
if (p->intersect(r, tmp) && tmp.t < bext.t)
{
// 如果碰到图元,且交点更近
best = tmp;
hit = true;
}
}
return hit;
}
// 内部节点,先计算左右子节点 AABB 的进入距离 (t_enter),然后优先遍历更近的一侧
float left_enter, left_exit;
float right_enter, right_exit;
bool hit_left_box =
node->left &&
node->left->bounds.intersect(ray, left_enter, left_exit);
bool hit_right_box =
node->right &&
node->right->bounds.intersect(ray, right_enter, right_exit);
bool hit = false;
// 两边都命中
if (hit_left_box && hit_right_box)
{
if (left_enter < right_enter)
{
hit = traverse(node->left, ray, best) || hit;
hit = traverse(node->right, ray, best) || hit;
}
else
{
hit = traverse(node->right, ray, best) || hit;
hit = traverse(node->left, ray, best) || hit;
}
}
else if (hit_left_box)
{
hit = traverse(node->left, ray, best) || hit;
}
else if (hit_right_box)
{
hit = traverse(node->right, ray, best) || hit;
}
return hit;
}
TIP

工业级 BVH (PBRT / Embree) 一般不会像这样递归,而是:

  • 非递归栈(stack) 遍历
  • 对左右孩子按 t_enter 排序,优先访问近的一侧
  • 配合 SIMD(AVX/SSE)一次测试多个 AABB
  • 对 BVH 节点进行缓存友好的内存布局优化。

SAH:表面启发式#

目标:让划分方式最小化 期望光线-节点相交代价
前面我们建 BVH 用的是最长轴的中位数划分,这样简单且快,但查找不一定最快。

SAH 核心思想#

让光线以后尽量少访问节点
根据经验结论:随机光线击中一个盒子的概率,近似正比于盒子的表面积。于是论文里直接假设:

P(hitchildhitparent)=SA(child)SA(parent)P(\mathit{hit child} \mid \mathit{hit parent}) = \frac{SA(\mathit{child})}{SA(\mathit{parent})}

表示:假设一条随机光线已经击中了父节点的包围盒,那么它同时击中某个子节点包围盒的概率,等于子节点与父节点的表面积之比。

BVH 构建时,SAH 用来估计将一组图元分割成左右两个子节点后,遍历这条光线的期望开销:

Cost=Ctrav+SA(L)SA(N)nLCisect+SA(R)SA(N)nRCisectCost = C_{\text{trav}} + \frac{SA(L)}{SA(N)} \cdot n_L \cdot C_{\text{isect}} + \frac{SA(R)}{SA(N)} \cdot n_R \cdot C_{\text{isect}}
  • CtravC_{\text{trav}}:访问一个 BVH 节点的固定成本
  • SA(L)SA(N)\frac{SA(L)}{SA(N)}:光线进入左子树的概率
  • nLn_L:左边图元数量
  • CisectC_{\text{isect}}:做一次图元相交判断的成本

Bucket 思想#

把最长轴平均分为12个桶,每个图元根据中心放进去,现在只用试划分11次,计算每次划分的代价得到最小代价的划分位置。

桶分加速的代码实现 [点击展开]
constexpr int NUM_BUCKETS = 12;
struct Bucket
{
int count = 0; // 桶中图元数
AABB bounds;
}
int best_split_sah(const std::vector<const Object*>& objs, int axis, const AABB& all)
{
Bucket buckets[NUM_BUCKETS];
// 遍历所有图元
for (auto* o : objs)
{
Eigen::Vector3f center = o->get_bounds().centroid();
float t = (center[axis] - all.pmin[axis]) / (all.pmax[axis] - all.pmin[axis]); // 比例
int b = std::min(NUM_BUCKETS - 1, int(t * NUM_BUCKETS)); // 算桶编号
buckets[b].count++;
buckets[b].bounds.expand(o->bounds());
}
float best_cost = std::numeric_limits<float>::infinity();
int best_i = -1; // 最佳切分位置,之后比如得到 best_i = 5,表示在5,6桶之间切。
// 开始尝试所有切法
for (int i = 0; i < NUM_BUCKETS - 1; i++)
{
AABB b0, b1; // 每种切法所分得的左右盒子
int n0 = 0, n1 = 0; // 左右两边各多少图元
// 计算左边
for (int j = 0; j <= i; j++)
{
b0.expand(buckets[j].bounds);
n0 += buckets[j].count;
}
// 计算右边
for (int j = i + 1; j < NUM_BUCKETS; j++)
{
b1.expand(buckets[j].bounds);
n1 += buckets[j].count;
}
if (n0 == 0 || n1 == 0) continue; // 极端情况,图元全在0桶,导致右边没有图元,这样的切分是无意义的
// 计算 SAH
float cost = 0.125f + (n0 * b0.surface_area() + n1 * b1.surface_area()) / all.surface_area();
// 更新最优
if(cost < best_cost)
{
best_cost = cost;
best_i = i;
}
}
return best_i;
}

蒙特卡洛积分#

基本蒙特卡洛估计量#

这里先明确,我们要计算的目标是 f(x)dx\int f(x) dx,基本蒙特卡洛方法:从某个概率密度 p(x)p(x) 里抽样 X1,,XNX1,…,X_N 构造估计量:

I=Ωf(x)dxI^=1Ni=1Nf(Xi)p(Xi),XipI = \int_\Omega f(x) \, dx \approx \hat{I} = \frac{1}{N} \sum_{i=1}^{N} \frac{f(X_i)}{p(X_i)}, \quad X_i \sim p

之所以除以 p(Xi)p(X_i),是为了补偿概率。如果是均匀采样,则 p(Xi)=1p(X_i)=1,如果是非均匀采样,概率小的权重应更大(概率小说明很难抽到,抽到一次便代表很多没抽到的点),概率大的权重应更小。

无偏性(期望正好为 II:即平均意义下,答案一定正确,数学证明:

E[I^]=f(x)p(x)p(x)dx=f(x)dx=I\mathbb{E}[ \hat{I} ] = \int \frac{f(x)}{p(x) }\cdot p(x) \, dx = \int f(x) \, dx = I

方差分析#

单个样本的方差是 Var[Y]=E[Y2]E[Y]2Var[Y] = E[Y^2] - E[Y]^2,这里 Y=f(X)p(X)Y = \frac{f(X)}{p(X)}

E[Y2]=(f(x)p(x))2p(x)dx=f2(x)p(x)dxE[Y^2] = \int \left( \frac{f(x)}{p(x)} \right)^2 p(x) dx = \int \frac{f^2(x)}{p(x)} dxE[Y]2=I2E[Y]^2 = I^2

所以单个样本方差是 f2pdxI2\int \frac{f^2}{p} dx - I^2NN 个独立样本取平均,方差除以 NN,就得到方差公式:

Var[I^]=1N(f2(x)p(x)dxI2)Var[\hat{I}] = \frac{1}{N} \left( \int \frac{f^2(x)}{p(x)} dx - I^2 \right)

这里 I2I^2 是与 pp 无关的常数,由于标准差与噪点息息相关,我们必须让方差最小,于是本质是让 f2/p\int f^2 / p 最小。

我们可以用拉格朗日乘算法求 f2/p\int f^2 / p 最小值,此时的约束条件是 p(x)dx=1\int p(x)dx = 1,最终得到最优 p(x)f(x)p^*(x) \propto |f(x)|

p(x)=f(x)f(y)dyp^*(x) = \frac{|f(x)|}{\int |f(y)| dy}

还记得我们要求的是 II 吗,但注意到最优 p(x)p^*(x) 里的分母 f(y)dy\int |f(y)| dy 恰巧就是 II,这便产生的悖论,我们只能理论知道方差最小能到多少(零),但是没法直接套公式使用,于是便引出了重要性采样。

重要性采样(IS)#

我们不能求出精确的 p(x)p^*(x),那就只能退一步让 p(x)p(x) 的形状大致跟 f(x)|f(x)| 一样:

p(x)f(x)(近似但不要求精确匹配)p^*(x) \propto |f(x)| (近似但不要求精确匹配)

在渲染方程被积函数 frLicosθif_r L_i \cos\theta_i 里,LiL_i 通常只在光源方向上较大,cosθi\cos\theta_i 在法线附近较大,frf_r 在镜面方向附近较大。沿这些”大值方向”采样能显著降方差

余弦加权采样#

Melly 方法与余弦加权采样直觉图

Melly 操作:

  • 在一个单位圆盘上,均匀地(按面积均匀)撒点 (x,y)(x, y)
  • 把每个点竖直向上抬至半球面上,即补一个 z=1x2y2z = \sqrt{1 - x^2 - y^2}

这就是余弦加权采样”点集中在法线附近”这个现象的几何来源,跟半球本身的曲率相关。所以,偏向法线方向(θ\theta 小)采样的概率密度更大

p(ω)=cosθπp(\omega) = \frac{\cos\theta}{\pi}

概率密度的严格推导:
关键恒等式:圆盘上的面积微元 dAdiskdA_{\text{disk}},和它投影对应的半球立体角微元 dωd\omega,满足

dAdisk=cosθdωdA_{\text{disk}} = \cos \theta d\omega

圆盘上是均匀采样,密度 pdisk=1πp_{\text{disk}} = \frac{1}{\pi} (单位圆盘面积为 π\pi)。 根据概率密度在变量替换下必须保持”概率质量守恒”:

pdiskdAdisk=p(ω)dωp_{\text{disk}} dA_{\text{disk}} = p(\omega) d\omega

代入 dAdisk=cosθdωdA_{\text{disk}} = \cos \theta d\omega 即可得:

p(ω)=cosθπp(\omega) = \frac{\cos \theta}{\pi}
Eigen::Vector3f sample_cosine_hemisphere(float u1, float u2) // u1 和 u2 就是两个服从 [0,1) 均匀分布的随机数
{
float r = std::sqrt(u1); // 求半径,即此点在圆盘上对应的极坐标中的 r
float phi = 2.0f * PI * u2; // 求极角
float x = r * std::cos(phi);
float y = r * std::sin(phi);
float z = std::sqrt(std::max(0.0f, 1.0f - u1));
return {x, y, z};
}
  • 为什么求 r 要开方?我们取点的方法是按面积均匀采样的,这要求半径的概率满足 rur \propto \sqrt{u},(圆环面积随半径线性增长,越往外单位角度的面积越大)

均匀半球采样(作对比)#

这里换了个思路,不走投影,而是直接对半球的极角做反函数采样,半球上均匀分布的 θ\theta 累积分布是 F(θ)=1cosθF(\theta) = 1 - \cos\theta

1cosθ=u1    θ=arccos(1u1)1 - \cos \theta = u_1 \implies \theta = \arccos(1 - u_1)

这样得到的 p(ω)=12πp(\omega) = \frac{1}{2\pi} 不会偏向任何方向,处处相同。

Lambert 材质下余弦加权方差远小于均匀采样#

渲染方程里 Lambert 材质的被积函数是 frLi(ω)cosθf_r \cdot L_i(\omega) \cdot \cos \theta,其中漫反射 BRDF 是常数 fr=ρ/πf_r = \rho / \pi( ρ\rho 是反照率)。 代入蒙特卡洛估计量 1Nf(ωi)p(ωi)\frac{1}{N} \sum \frac{f(\omega_i)}{p(\omega_i)}

用余弦加权 p=cosθ/πp = \cos \theta / \pi

frLicosθcosθ/π=(ρ/π)Licosθπcosθ=ρLi(ω)\frac{f_r \cdot L_i \cdot \cos \theta}{\cos \theta / \pi} = \frac{(\rho / \pi) \cdot L_i \cdot \cos \theta \cdot \pi}{\cos \theta} = \rho \cdot L_i(\omega)

cosθ\cos \thetaπ\pi 完全约掉了,这说明每次采样只剩下 ρLi(ω)\rho \cdot L_i(\omega),估计量的波动只取决于 LiL_i (入射光本身在各方向的强弱差异),而不再受 cosθ\cos \theta 这个额外的、在掠射角处会把贡献压到接近零的因子影响。

分层采样#

纯随机(蒙特卡洛)采样虽然无偏,但不可避免会出现点随机扎堆,随机留出大片空白的情况,于是我们将积分域划分成 MM 哥等分块,每块独立采样 N/MN / M 个样本,这样保证每个格子都有样本,且样本在格子内是随机偏移的

Varstrat=1M2k=1MVarkVaruniform\text{Var}_{\text{strat}} = \frac{1}{M^2} \sum_{k=1}^{M} \text{Var}_k \leq \text{Var}_{\text{uniform}}
  • Vark\text{Var}_k:每个格子内部的方差

工程上最简单的 N×N\sqrt{N}\times\sqrt{N} 网格 + 格内抖动,就能明显减噪。

多重重要性采样 (MIS)#

当被积函数是好几个”性格迥异”的因子相乘时,不存在一个单一的 p(x)p(x) 能同时匹配所有因子的形状。

比如直接光照的被积函数为 fr(ω)Li(ω)f_r(\omega)\cdot L_i(\omega)

  • fr(BRDF)f_r(\text{BRDF}) 在光泽材质下会有一个又窄又高的镜面反射瓣,只有对着反射方向附近采样才能命中,这种情况下 “BRDF采样” 效率很高。
  • Li(入射光)L_i(\text{入射光}) 如果是一个小面积光源或点光源,只在光源所在的一小片方向上非零,这种情况下”光源采样”效率很高。

我们对两种方式进行加权组合:

I^MIS=k1nkj=1nkwk(Xk,j)f(Xk,j)pk(Xk,j)\hat{I}_{\text{MIS}} = \sum_k \frac{1}{n_k} \sum_{j=1}^{n_k} w_k(X_{k,j}) \frac{f(X_{k,j})}{p_k(X_{k,j})}

多重重要性采样

幂启发式

wk(x)=(nkpk(x))2j(njpj(x))2w_k(x) = \frac{(n_k p_k(x))^2}{\sum_j (n_j p_j(x))^2}

最朴素的想法是”按 (p_k) 的大小线性分配权重”(这叫 balance heuristic,把上式的平方去掉就是),Veach 发现平方一下效果更好:如果某个策略在 (x) 点的 (p_k(x)) 相对其他策略很小(比如光源采样在一个高光溢出的方向上概率几乎为零),那 (f/p_k) 这一项本来就很容易因为分母极小而炸出一个离群的巨大数值(这就是渲染里常见的”萤火虫”噪点的来源之一)。平方之后,这个小 (p_k) 对应的权重会被压得比线性分配时更低,相当于在容易出现极端噪声的地方,更果断地把信任度让给 pdf 更大的那个策略,从源头上抑制了萤火虫噪点,这也是为什么工业界渲染器几乎都默认用 power heuristic 而不是最朴素的 balance heuristic。

TIP

BRDF采样天生擅长”追高光”,光源采样天生擅长”追亮点”,两者的peak常常根本不在一个方向上。MIS的价值就是让你两个都采,并且用一套有数学保证(无偏)的权重规则,自动让每个样本在它”更擅长”的地方发挥更大作用。

路径追踪#

基础路径追踪(Kajiya 1986)#

整个函数的流程:

发射光线Ray求最近交点交点 pIntersection否则返回背景色若命中加入自发光 LeEmission终止则返回俄罗斯轮盘赌采样新方向 ωiSample递归追踪LiIncoming Radiance渲染方程LoOutgoing Radiance\underbrace{\text{发射光线}}_{\text{Ray}} \xrightarrow{\text{求最近交点}} \underbrace{\text{交点 }p}_{\text{Intersection}} \xrightarrow[\text{否则返回背景色}]{\text{若命中}} \underbrace{\text{加入自发光 }L_e}_{\text{Emission}} \xrightarrow[\text{终止则返回}]{\text{俄罗斯轮盘赌}} \underbrace{\text{采样新方向 }\omega_i}_{\text{Sample}} \xrightarrow{\text{递归追踪}} \underbrace{L_i}_{\text{Incoming Radiance}} \xrightarrow{\text{渲染方程}} \underbrace{L_o}_{\text{Outgoing Radiance}}

朴素实现(只按 BRDF 采样)

Eigen::Vector3f path_trace_naive(const Ray& r, const Scene& s, int depth)
{
Intersection hit = s.intersect(r);
if (!hit.happened) return s.background; // 光线未击中任何目标,返回背景颜色。
Eigen::Vector3f L = hit.material->emission; // 自发光 L_e
if (depth >= MAX_DEPTH) return L;
// 俄罗斯轮盘赌
const float p_rr = 0.8f;
if (random_float() > p_rr) return L;
// 按 BRDF 采样新方向
Eigen::Vector3f wi;
float pdf;
Eigen::Vector3f f = hit.material->sample(-r.direction, hit.n, wi, pdf);
if (pdf < 1e-8f) return L;
float cos_t = std::max(0.0f, hit.n.dot(wi));
Ray next{hit.p + EPSILON * hit.n, wi};
Eigen::Vector3f Li = path_trace_naive(next, s, depth + 1);
L += f.cwiseProduct(Li) * cos_t / pdf / p_rr;
return L;
}

分直接与间接的路径追踪#

关键改进:每次交点显式采光源算直接光照,只让递归负责”下一次弹射后的间接光”——这样光源采样的低方差被充分利用。

具体流程如图:

路径追踪 shader 函数的递归流程。

  • 每个像素,相机只发出一条主光线,打到第一个交点 P1。
  • 调用 shade(P1, ...)。这个函数内部会再发出两类新光线:一条固定射向光源的shadow ray (用来算P1这一点收到的直接光),一条按 BRDF 采样出来的弹射方向 (用来算 P1 这一点收到的间接光)。
  • 弹射方向那条光线打到 P2,shade(P1) 内部递归调用自己 shade(P2, ...),得到一个返回值。
  • shade(P2) 内部又重复第2步<自己发一条shadow> ray,自己再决定要不要继续弹射到P3……
  • 每一层的返回值,都乘上对应的 BRDF、cosθ、pdf 等权重,一路加回给上一层,最后回到 P1,得到 P1 这一个像素样本的最终颜色。
Eigen::Vector3f shade(const Intersection& hit, const Eigen::Vector3f& wo, const Scene& s)
{
// 终止条件之一:交点是光源本身,直接返回自发光即可
if (hit.material->has_emission()) return hit.material->emission;
// —— 直接光照:从光源上采一点,不参与递归 ——
Eigen::Vector3f L_dir = Eigen::Vector3f::Zero();
Intersection light_hit;
float light_pdf;
s.sample_light(light_hit, light_pdf); // 在场景所有光源(面积)上按面积均匀采一个点,拿到这个点和它的采样概率密度 light_pdf
Eigen::Vector3f ws = (light_hit.p - hit.p).normalized();
float dist2 = (light_hit.p - hit.p).squaredNorm();
// shadow ray的可见性测试,若中间有遮挡,直接光贡献为0
Ray to_light{hit.p + EPSILON * hit.n, ws};
Intersection chk = s.intersect(to_light);
if (chk.happened && (chk.p - light_hit.p).squaredNorm() < 1e-3f)
{
Eigen::Vector3f f = hit.material->eval(ws, wo, hit.n);
float cos_t = std::max(0.0f, hit.n.dot(ws));
float cos_tl = std::max(0.0f, light_hit.n.dot(-ws));
L_dir = light_hit.material->emission.cwiseProduct(f) * cos_t * cos_tl / dist2 / light_pdf; // 把"面积域"的光源采样,转换成渲染方程需要的"立体角域"贡献,
}
// —— 间接光照:俄罗斯轮盘赌 + BRDF 采样 ——
Eigen::Vector3f L_indir = Eigen::Vector3f::Zero();
const float p_rr = 0.8f; // 以 p_rr 的概率选择继续弹射
if (random_float() < p_rr)
{
Eigen::Vector3f wi = hit.material->sample(wo, hit.n);
float pdf = hit.material->pdf(wi, wo, hit.n);
if (pdf > 1e-8f)
{
Ray next{hit.p + EPSILON * hit.n, wi};
Intersection next_hit = s.intersect(next);
if (next_hit.happened && !next_hit.material->has_emission())
{
Eigen::Vector3f f = hit.material->eval(wi, wo, hit.n);
float cos_t = std::max(0.0f, hit.n.dot(wi));
L_indir = shade(next_hit, -wi, s).cwiseProduct(f) * cos_t / pdf / p_rr;
}
}
}
return L;
}

无光源重要性采样的后果,噪点极多(画面高800,宽400)

为什么 next_hit.material->has_emission() 为真时不加进 L_indir?

直接光照里我们已经显式采了光源。如果间接递归再撞上光源,那条路径被算了两次,结果偏亮。

光源球的锥体采样#

如果对光源球做”表面均匀采样”,会有一半样本落在球背面(从着色点看不到,直接浪费)。我们可以只在光源球对着着色点的那个”锥形范围”里采样方向,样本利用率高很多:

我们的目标是在一个球形光源对应的立体角范围内均匀采样方向。设当前着色点为 P\mathbf{P},球形光源球心为 L\mathbf{L},光源半径为 RR,则两者距离为:

d=LPd=||\mathbf{L}-\mathbf{P}|distanceSquared=d2=LP2distanceSquared=d^2=||\mathbf{L}-\mathbf{P}||^2

观察点 P\mathbf{P} 看向球光源时,光源覆盖一个圆锥区域。设圆锥最大夹角为 θmax\theta_{max},根据球的几何关系:

sinθmax=Rd\sin\theta_{max}=\frac{R}{d}

因此:

cosθmax=1R2distanceSquared\cos\theta_{max} = \sqrt{1-\frac{R^2}{distanceSquared}}

为了在立体角内均匀采样,需要让:z=cosθz=\cos\theta 在线性范围:[cosθmax,1][\cos\theta_{max},1] 内均匀分布,因此:

z=cosθmax+r2(1cosθmax)z = \cos\theta_{max} +r_2(1-\cos\theta_{max})

整理得到:

z=1+r2(1R2distanceSquared1)z = 1+r_2 \left( \sqrt{1-\frac{R^2}{distanceSquared}}-1 \right)

然后使用球坐标生成方向:

x=cosϕsinθx=\cos\phi\sin\thetay=sinϕsinθy=\sin\phi\sin\thetaz=cosθz=\cos\theta

其中:ϕ=2πr1\phi=2\pi r_1sinθ=1z2\sin\theta=\sqrt{1-z^2}

因此最终采样方向为:

ω=(cosϕ1z2,sinϕ1z2,z)\omega= ( \cos\phi\sqrt{1-z^2}, \sin\phi\sqrt{1-z^2}, z )

该方向位于以光源方向为中心、半角为 θmax\theta_{max} 的圆锥内部,实现了针对球形光源的均匀立体角采样,能够显著降低路径追踪中的方差。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

详谈光线追踪与全局光照
https://www.naie-char.cc/posts/rrytrace/笔记光线追踪与全局光照/
作者
萘Naie_Char
发布于
2026-07-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录