/ Ch04 光线追踪

第4章:光线追踪 — 零基础讲义

讲义说明
本讲义基于 Steve Marschner & Peter Shirley 所著《虎书5》第5版第4章(p.79-96)。
光线追踪是生成照片级真实感图像的基础——从摄像机发射光线,追踪它们与场景中物体的交互。
本章是全书的"灵魂章节",理解光线追踪就等于理解了计算机图形学的一半。
本版插画采用 Guizang 材质插画风格重新绘制。

学习目标

  1. 理解光线追踪的基本算法流程
  2. 掌握透视投影的几何原理
  3. 学会计算从摄像机出发的光线
  4. 掌握光线与球体求交的完整代数推导
  5. 掌握Möller-Trumbore三角形求交算法
  6. 理解反射、折射的几何推导
  7. 掌握Russian Roulette概率终止策略
  8. 理解BVH加速结构的原理

4.1 基本光线追踪算法

4.1.1 核心思想

光线追踪 = 模拟光线在场景中的传播路径

基本流程出奇地简单——只有三步:

光线追踪基本流程
图4-1:光线追踪三步流程——发射光线、求交点、输出像素

生活类比:你站在一个黑暗的房间里,透过一个小孔看外面的世界。每根从你眼睛出发的"视线"都打到物体上,告诉你那个方向是什么颜色——这就是光线追踪的直观理解。

4.1.2 为什么光线追踪这么重要?

因为核心逻辑只有三步:发射→求交→着色。几乎所有视觉效果(反射、折射、阴影)都是在这个框架上加"递归"实现的。这个框架的简洁性就是它的力量。

function ray_trace(ray, scene, depth):
  hit = scene.intersect(ray)
  if no hit:
    return background_color
  
  // 第一步:局部着色
  color = local_shading(hit, scene.lights)
  
  // 第二步:递归追踪
  if depth < max_depth:
    // 如果是反射表面
    if hit.material.reflective:
      refl_ray = reflect(ray, hit.normal, hit.point)
      color += hit.material.ks * ray_trace(refl_ray, scene, depth + 1)
    
    // 如果是透明表面
    if hit.material.transparent:
      refr_ray = refract(ray, hit.normal, hit.point, hit.material.ior)
      color += hit.material.kt * ray_trace(refr_ray, scene, depth + 1)
  
  return color

4.2 透视投影的几何基础

4.2.1 针孔相机模型

最简模型:一个(相机位置)+ 一个平面(成像平面)。

针孔相机模型
图4-2:针孔相机模型——光线通过针孔在成像平面上形成倒像

光线从相机位置出发,穿过成像平面上的对应点。越远的物体看起来越小——这就是透视效果。

关键理解:透视的本质是"把3D视线方向映射到2D像素坐标"。这是全部渲染的第一步。

4.2.2 相机坐标系

定义相机需要三个向量和一个

e    = 相机位置 (eye point)
g    = 视线方向 (gaze direction)
v_up = 向上向量 (view up vector)

// 右手坐标系
w = -g / ||g||           // z轴(指向场景内部)
u = (v_up × w) / ||v_up × w||  // x轴(向右)
v = w × u                // y轴(向上)
符号名称含义
e眼睛位置相机在世界坐标系中的位置点
g视线方向从相机指向场景的方向向量
v_up向上方向指示相机"头顶"的方向
u右方向相机坐标系的x基向量
v上方向相机坐标系的y基向量
w前方向(负z)相机坐标系的z基向量

4.2.3 视平面参数

视平面(image plane)定义在相机坐标系的 -d 处(沿w轴负方向):

          视平面(在z=-d处)
    ┌─────────────┐
    │  t          │  t = 上边界 (top)
    │   │         │
    │───┼───── r  │  r = 右边界 (right)
    │   │         │
    │  b          │  b = 下边界 (bottom)
    └─────────────┘
    l = 左边界 (left)

d = 视平面到相机的距离(焦距的模拟)
nx = 图像宽度(像素数)
ny = 图像高度(像素数)

4.3 光线生成

4.3.1 光线参数方程

光线用参数方程表示:

p(t) = o + t·d, t ≥ 0
符号含义
o光线原点(即相机位置 e)
d光线方向(单位向量,或至少归一化)
t沿光线的距离参数
p(t)光线在距离t处的点

t 的含义非常直观:t=0 时在相机位置,t=1 时沿方向d走了1个单位距离,t 越大走得越远。

4.3.2 像素坐标到视平面的映射(核心公式)

给定像素坐标 (i, j),其中 i 是水平方向(0 到 nx-1),j 是垂直方向(0 到 ny-1):

u = l + (r - l) × (i + 0.5) / nx
v = b + (t - b) × (j + 0.5) / ny

这个公式到底在做什么?逐项拆解:

含义解释
(r - l)视平面的总宽度右边界减左边界 = 视平面的水平范围
(i + 0.5)像素中心的偏移i是像素序号(从0开始),+0.5取像素中心
(i + 0.5) / nx像素中心的相对位置0~1之间的比值,表示该像素在总宽度中的位置比例
l + (r - l) × 比例映射到视平面坐标从左边界的偏移量
u最终视平面x坐标该像素中心在视平面上的x位置

为什么用 i + 0.5 而不是 i

因为像素是一个区域,而不是一个点。每个像素的范围是从 i 到 i+1,像素中心就在 i+0.5 处。用像素中心采样是最自然的选择。

像素 (i, j) 在图像中:
┌───────────┬───────────┬───────────┐
│   (0,0)   │   (1,0)   │   (2,0)   │
│     ·     │     ·     │     ·     │  · = 像素中心
├───────────┼───────────┼───────────┤       (i+0.5, j+0.5)
│   (0,1)   │   (1,1)   │   (2,1)   │
│     ·     │     ·     │     ·     │
├───────────┼───────────┼───────────┤
│   (0,2)   │   (1,2)   │   (2,2)   │
│     ·     │     ·     │     ·     │
└───────────┴───────────┴───────────┘
🤔 想一想:如果 (i+0.5) 换成 i 会怎样?
图像会整体向左偏移半个像素,相当于"采样点"在像素的左上角而不是中心。极端情况下,图像边缘的像素可能只采样到一半区域,产生偏差。

4.3.3 从视平面到光线方向

有了 (u, v) 坐标后,在相机坐标系中计算光线方向:

// 在相机坐标系中的方向
ray_dir_cam = u·u + v·v - d·w

// 变换到世界坐标系
ray_dir_world = camera_to_world × ray_dir_cam
ray_dir_world = normalize(ray_dir_world)

// 最终光线
ray = Ray(origin = camera_pos, direction = ray_dir_world)

为什么是 -d·w

视平面在相机坐标系中位于 z = -d 处(负z方向),所以视平面上的点的z坐标就是 -d。方向向量从相机原点指向视平面上的点,所以方向向量 = (u, v, -d) 在相机坐标系中。

4.3.4 特殊情况:正交投影

正交投影没有透视效果——所有光线方向相同:

// 所有光线方向都相同,平行于 -w
ray_dir = -w(对所有像素都一样)

// 起点偏移到不同像素位置
origin = camera_pos + u·u_vec + v·v_vec
🤔 想一想:透视和正交的区别是什么?
透视 = 从一点发出不同方向的光线(看远处物体变小)。正交 = 所有光线平行(物体大小不随距离变化,用于工程制图)。

4.4 光线与物体求交

4.4.1 光线与球体求交(完整推导)

这是最基本也是最重要的求交计算。我们从头开始推导:

光线与球体求交的几何图示
图4-3:光线与球体求交——求解二次方程得到最近交点,法线垂直于球面

第一步:写出两个方程

光线方程(参数形式):
p(t) = o + t·d,  t ≥ 0

球体方程(隐式形式):
||p - c||² = R²
其中 c 是球心,R 是半径,p 是球面上的点。

第二步:代入

把光线方程代入球体方程:

||o + t·d - c||² = R²

第三步:展开

定义向量 m = o - c(从球心指向光线原点):

||m + t·d||² = R²

展开点积:
(m + t·d)·(m + t·d) = R²
(m·m) + 2t(m·d) + t²(d·d) = R²

第四步:整理为标准二次方程

(d·d)t² + 2(m·d)t + (m·m - R²) = 0

这就是标准的 At² + Bt + C = 0 形式:

A = d·d           // 方向向量长度的平方
B = 2(m·d)        // 2 × (从球心到光线原点的向量与方向的点积)
C = m·m - R²      // 光线原点到球心距离的平方 - 半径平方
系数几何意义
A光线方向长度的平方(通常 d 为单位向量时 A=1)
B与光线方向和球心相对位置有关
C光线原点相对于球体的位置(负值表示原点在球内)

第五步:求判别式

D = B² - 4AC

- D < 0:无实根 → 光线与球无交点
- D = 0:一个根 → 光线与球相切(只交于一点)
- D > 0:两个根 → 光线穿过球体(两个交点)

第六步:求解t值

t₁ = (-B - sqrt(D)) / (2A)
t₂ = (-B + sqrt(D)) / (2A)

选择规则:选最小正的 t 值(即最早碰到的交点)。
if D < 0: return NO_HIT
t1 = (-B - sqrt(D)) / (2A)
t2 = (-B + sqrt(D)) / (2A)
if t1 > 0: t_hit = t1
else if t2 > 0: t_hit = t2
else: return NO_HIT

// 交点位置
p_hit = o + t_hit·d

// 法线方向(单位向量)
n = (p_hit - c) / R
🤔 想一想:如果光线原点在球体内部,会是什么情况?
此时 C < 0,但判别式 D = B² - 4AC > 0(因为 A>0, C<0 所以 -4AC > 0),所以有两个实根。t₁ 是负数(因为从球内发出的光线,"向后"碰到的球面),t₂ 是正数(向前碰到的球面)。这可以用于渲染半透明材质或参与介质。

完整的球体求交函数:

function intersect_sphere(ray, sphere):
  // 从球心到光线原点的向量
  m = ray.o - sphere.center
  R = sphere.radius
  
  // A系数
  A = dot(ray.d, ray.d)
  
  // B系数(注意2被提出到判别式中)
  B = 2 * dot(m, ray.d)
  
  // C系数
  C = dot(m, m) - R * R
  
  // 判别式
  D = B * B - 4 * A * C
  
  if D < 0:
    return NO_HIT
  
  sqrt_D = sqrt(D)
  t1 = (-B - sqrt_D) / (2 * A)
  t2 = (-B + sqrt_D) / (2 * A)
  
  // 选择最小的正t
  if t1 > 1e-6:
    return make_hit(t1, ray.o + t1 * ray.d)
  if t2 > 1e-6:
    return make_hit(t2, ray.o + t2 * ray.d)
  
  return NO_HIT

4.4.2 光线与三角形求交(Möller-Trumbore算法)

Möller-Trumbore算法是计算机图形学中使用最广泛的光线-三角形求交算法。它的优势在于不需要预先计算平面方程,只需要三角形的三个顶点和光线参数即可求解。

第一步:重心坐标基础

三角形由三个顶点 a, b, c 定义。三角形内任意一点 p 可以用重心坐标(barycentric coordinates)表示:

// 重心坐标表示
p = α·a + β·b + γ·c

其中三个系数满足 α + β + γ = 1,且三个数都非负时 p 在三角形内部。

等价写法:

p = a + β·(b - a) + γ·(c - a)
= (1 - β - γ)·a + β·b + γ·c
符号含义约束
α顶点a的权重α = 1 - β - γ
β顶点b的权重β ≥ 0
γ顶点c的权重γ ≥ 0
β + γ必须在 [0, 1] 之间β + γ ≤ 1

第二步:联立方程

光线方程:

p = o + t·d

三角形方程:

p = a + β·(b - a) + γ·(c - a)

联立得:

o + t·d = a + β·(b - a) + γ·(c - a)

整理:

t·d + β·(a - b) + γ·(a - c) = a - o

写成矩阵形式:

[-d,  b-a,  c-a] × [t, β, γ]^T = o - a

也就是用三个向量组成一个3×3矩阵,求解三个未知数 t, β, γ。

第三步:Cramer法则求解

利用Cramer法则和向量混合积性质,可以高效求解:

e1 = b - a         // 三角形边1
e2 = c - a         // 三角形边2
s  = o - a         // 从顶点a到光线原点的向量

// 计算行列式
p_vec = cross(d, e2)
det = dot(e1, p_vec)

// 如果行列式接近0,光线平行于三角形
if |det| < ε: return NO_HIT

inv_det = 1 / det

// 计算β(代码中的u)
t_vec = s
u = dot(t_vec, p_vec) × inv_det
if u < 0 or u > 1: return NO_HIT    // β不在[0,1]内

// 计算γ(代码中的v)
q_vec = cross(t_vec, e1)
v = dot(d, q_vec) × inv_det
if v < 0 or u + v > 1: return NO_HIT  // γ<0 或 β+γ>1

// 计算t(交点距离)
t_hit = dot(e2, q_vec) × inv_det
if t_hit < 0: return NO_HIT

return make_hit(t_hit, u, v)

第四步:逐行代码翻译

function ray_triangle_intersect(ray, a, b, c):
  // === 第1步:计算三角形的两条边向量 ===
  e1 = b - a
  e2 = c - a
  
  // === 第2步:计算p_vec = d × e2 ===
  // 这是光线方向与三角形一条边的叉积
  // 用于后续计算行列式和β
  p_vec = cross(ray.d, e2)
  
  // === 第3步:计算行列式 det = e1 · (d × e2) ===
  // 这个行列式实际上就是  |d, e1, e2| 的标量三重积
  // 它衡量的是由d, e1, e2三个向量构成的平行六面体的体积
  det = dot(e1, p_vec)
  
  // === 第4步:检查光线是否平行于三角形 ===
  // 如果行列式接近0,说明d, e1, e2三个向量共面
  // 即光线方向与三角形所在平面平行
  if |det| < 1e-6:
    return NO_HIT
  
  inv_det = 1.0 / det
  
  // === 第5步:计算β坐标 ===
  // t_vec = o - a 是从顶点a到光线起点的向量
  t_vec = ray.o - a
  
  // u = (t_vec · (d × e2)) / det = β
  // 这是重心坐标公式中顶点b的权重
  u = dot(t_vec, p_vec) * inv_det
  
  // β必须位于[0,1]范围内
  if u < 0.0 or u > 1.0:
    return NO_HIT
  
  // === 第6步:计算γ坐标 ===
  // q_vec = t_vec × e1 = (o - a) × (b - a)
  q_vec = cross(t_vec, e1)
  
  // v = (d · ((o - a) × e1)) / det = γ
  // 这是重心坐标公式中顶点c的权重
  v = dot(ray.d, q_vec) * inv_det
  
  // γ必须≥0,且β+γ≤1(保证α=1-β-γ≥0)
  if v < 0.0 or u + v > 1.0:
    return NO_HIT
  
  // === 第7步:计算交点距离t ===
  // t = (e2 · ((o - a) × e1)) / det
  t = dot(e2, q_vec) * inv_det
  
  // t必须为正(光线在相机前方)
  if t < 1e-6:
    return NO_HIT
  
  // === 第8步:命中!返回交点信息 ===
  // 重心坐标:α = 1 - u - v, β = u, γ = v
  // 法线可用重心坐标插值顶点法线
  hit_point = ray.o + t * ray.d
  barycentric = (1 - u - v, u, v)
  
  return make_hit(t, hit_point, barycentric)

表格总结Möller-Trumbore每一步的几何意义:

步骤计算内容几何意义
1e1, e2三角形的两条边向量
2p_vec = d × e2光线方向与e2张成的平面的法线
3det = e1·p_vec标量三重积,检测光线是否平行于三角形
4检查det若|det|<ε,光线平行于三角形平面,无交点
5u = (o-a)·p_vec / det重心坐标β
6v = d·((o-a)×e1) / det重心坐标γ
7t = e2·((o-a)×e1) / det光线参数t,即交点到原点的距离
8检查t > 0确保交点在光线正方向上
🤔 想一想:为什么Möller-Trumbore算法比"先求平面交点再判断是否在三角形内"快?
传统方法需要先算平面方程(4次乘除法求交),再投影到2D判断点是否在三角形内(至少3次叉积)。M-T算法只用一次Cramer法则直接求出t, β, γ,避免了重复计算,且β和γ可直接用于纹理坐标插值,一举多得。

4.4.3 光线与平面求交(扩展)

平面隐式方程:

n·p = c

其中 n 是法线,c 是常数(从原点到平面的有符号距离)。

代入光线 p = o + t·d:

n·(o + t·d) = c
n·o + t(n·d) = c
t = (c - n·o) / (n·d)
符号含义
n平面的法线方向(单位向量)
c平面方程的常数项
o光线原点
d光线方向

注意:n·d = 0 时,光线与平面平行,无交点。


4.5 着色与阴影

4.5.1 局部光照

在光线击中点计算颜色,考虑:

  • 光源位置和强度
  • 表面法线
  • 材质属性(漫反射、高光、镜面反射色)
这一部分在第5章中有完整详述,这里只做简要概述。

4.5.2 阴影测试

阴影 = 从击中点向光源发射"阴影光线"(shadow ray)

阴影光线测试
图4-4:阴影光线测试——从击中点向光源发射阴影光线,检测是否被遮挡
// 检查点 p 是否在阴影中
function test_shadow(hit_point, normal, light_pos, scene):
  // 从击中点向光源方向发射光线
  // 注意:加一个小偏移ε避免"自相交"
  shadow_ray_dir = normalize(light_pos - hit_point)
  shadow_ray = Ray(hit_point + ε·normal, shadow_ray_dir)
  
  // 光源距离
  light_dist = distance(hit_point, light_pos)
  
  // 如果中间有物体挡住,就在阴影中
  hit = scene.intersect(shadow_ray)
  if hit and hit.t < light_dist:
    return true    // 被遮挡 → 阴影
  return false

关于 ε 偏移

浮点运算有精度误差,交点 p 可能实际上位于表面的"内部"。如果不做偏移,从 p 出发的阴影光线可能重新与自己相交(自相交),造成奇怪的条纹/黑点。通常取 ε = 1e-4 到 1e-6。

4.6 递归光线追踪

4.6.1 反射光线的几何推导(三段式)

反射公式 r = d - 2(d·n)n 可以从几何角度分三步理解:

反射光线的几何推导
图4-5:反射定律——入射角θ等于反射角θ,反射公式 r = d - 2(d·n)n

第一步:分解入射方向

入射方向 d 可以分解为平行于法线 n 的分量和垂直于法线 n 的分量:

d_parallel = (d·n)·n     // 沿法线方向的分量
d_perp = d - (d·n)·n     // 垂直于法线的分量
     d
      ↘
       │ d_perp
       │
   ────┼────→ 表面(水平)
       │
      ↑ d_parallel(指向表面内部)
      n

第二步:反射改变法线分量方向

反射时:

  • 平行分量反转方向(因为反射"弹回")
  • 垂直分量方向不变(因为表面是平的)
r_parallel = -(d·n)·n    // 法线分量反向
r_perp = d - (d·n)·n     // 垂直分量不变

第三步:合成反射方向

r = r_parallel + r_perp = d - 2(d·n)·n
      ↙ r
     /
    │ r_perp(不变)
    │
   ────┼────→ 表面(水平)
    │
   r_parallel ↑ 反向

验证:

反射光线的性质:

  • r·n = -d·n(反射后法线分量方向相反)
  • ||r|| = ||d||(反射不改变长度)
  • r_perp = d_perp(保持垂直分量不变)
// 验证 r·n = -d·n
r·n = (d - 2(d·n)·n)·n
    = d·n - 2(d·n)·(n·n)
    = d·n - 2(d·n)
    = -d·n   ✓

完整的反射着色代码:

// 递归追踪反射
function trace_reflection(ray, hit, scene, depth):
  if depth >= max_depth:
    return Color(0, 0, 0)
  
  // 计算反射方向
  n = hit.normal
  d = ray.direction
  r = d - 2 * dot(d, n) * n
  r = normalize(r)
  
  // 发射反射光线(加ε偏移避免自相交)
  refl_ray = Ray(hit.point + ε * n, r)
  
  // 递归追踪
  refl_color = trace(refl_ray, scene, depth + 1)
  
  // 乘以反射系数
  return hit.material.ks * refl_color

4.6.2 折射(斯涅尔定律)

折射比反射复杂,因为光线进入不同介质时会发生偏折:

// Snell定律
η₁·sinθ₁ = η₂·sinθ₂

// 折射光线方向(完整公式)
t = (η₁/η₂)·(d - (d·n)·n) - n·sqrt(1 - (η₁/η₂)²·(1 - (d·n)²))
符号含义
η₁入射介质的折射率(空气 η≈1.0003 ≈ 1)
η₂折射介质的折射率(水 η≈1.33,玻璃 η≈1.5)
θ₁入射角(光线与法线夹角)
θ₂折射角
d入射方向
n表面法线
t折射方向

根号内为负时发生全反射(光不从介质中射出)。

4.6.3 终止策略:俄罗斯轮盘赌(Russian Roulette)

问题:递归光线追踪深度无限,但实际计算资源有限。简单地设最大深度(如5层)效率低——有些路径需要更多递归,有些不需要。

俄罗斯轮盘赌是一种无偏的随机终止策略,核心思想是:

每条光线以概率 q 终止,以概率 (1-q) 继续,但继续时颜色结果要除以 (1-q) 以保持期望值不变。

公式表达:

// 俄罗斯轮盘赌
P = survival_prob(生存概率,如 0.7)

if random() > P:
  // 光线"死亡"——终止追踪
  return Color(0, 0, 0)
else:
  // 光线继续追踪,但颜色要除以P补偿
  color = trace_recur(ray, scene, depth + 1)
  return color / P

为什么这保持了期望不变?

期望值计算:

E[result] = P × (E[color] / P) + (1-P) × 0 = E[color]

数字例子:

假设到第5次反射时:

  • 生存概率 P = 0.7
  • 如果继续追踪,返回的颜色贡献为 (0.3, 0.5, 0.2)
  • random() = 0.52(活着!) → 最终返回 (0.3/0.7, 0.5/0.7, 0.2/0.7) = (0.429, 0.714, 0.286)

第6次反射时:

  • 生存概率 P = 0.7
  • random() = 0.88(死了!)→ 返回 (0, 0, 0),这条路径终止

长期来看,有70%的光线会继续,30%会终止,但蒙特卡洛期望值等于真实值。

结合最大深度的完整策略:

function trace(ray, scene, depth):
  if depth > max_depth:
    // 一定深度后使用俄罗斯轮盘赌
    P = 0.7
    if random() > P:
      return Color(0, 0, 0)
  else:
    // 浅层递归直接继续
    P = 1.0
  
  hit = scene.intersect(ray)
  if no hit:
    return background_color
  
  color = local_shading(hit)
  
  // 反射
  if hit.material.reflective:
    refl_ray = make_reflection(ray, hit)
    refl_color = trace(refl_ray, scene, depth + 1)
    color += hit.material.ks * refl_color / P
  
  return color
🤔 想一想:俄罗斯轮盘赌和普通最大深度截断相比有什么好处?
普通截断会在固定深度处粗暴截断,造成"能量丢失"(看起来暗)。轮盘赌使终止变得平滑,能量损失为0(期望意义上),同时平均计算量可控。

4.7 加速结构

4.7.1 为什么要加速?

暴力测试所有三角形是不现实的:

100万三角形 × 1920×1080像素 × 每条光线测试所有三角形 = 2万亿次求交!
即使每次求交只要1纳秒,也需要2000秒(半小时以上)渲染一帧。

解决方案:空间加速结构——快速排除不可能相交的区域。

4.7.2 包围盒层次结构(BVH)

生活类比:俄罗斯套娃(Matryoshka)

想象一套俄罗斯套娃——最小的娃娃放在稍大的娃娃里面,稍大的再放在更大的里面……你要找某个特定的娃娃时:

  • 先打开最大的娃娃,里面有一堆中等的娃娃
  • 你知道要找的娃娃在左边那个中等娃娃里,所以右边的不用管了
  • 打开左边中等娃娃,里面有几个更小的
  • 如此反复,直到找到你要的娃娃

BVH的工作原理完全一样:场景被组织成一棵树,每个节点是一个"娃娃"(包围盒),里面装着"更小的娃娃"(子节点)或"真正的娃娃"(三角形)。遍历时,如果光线没碰到大娃娃,它里面的所有小娃娃都不用检查。

BVH 包围盒层次结构
图4-6:BVH层次结构——场景被递归划分为包围盒,光线测试时快速排除不相交的区域
BVH结构示意图:

        场景(最大的套娃)
        /             \
  左半场景          右半场景
   (中等娃娃)       (中等娃娃)
    /      \          /      \
  物体A    物体B    物体C    物体D
 (小娃娃)  (小娃娃) (小娃娃)  (小娃娃)

BVH节点数据结构:

struct BVHNode:
  bbox: AABB         // 包围盒(大娃娃的外壳)
  left: BVHNode*     // 左子节点
  right: BVHNode*    // 右子节点
  triangles: List    // 只有叶子节点有三角形
  is_leaf: bool      // 是否为叶子节点

BVH遍历算法(递归版本):

function intersect_bvh(node, ray):
  // 第1步:如果光线没碰到当前节点的包围盒,直接返回
  // 这就好比打开大娃娃发现连外壳都碰不到,根本不用继续拆
  if not intersect_bbox(node.bbox, ray):
    return NO_HIT
  
  // 第2步:如果是叶子节点,测试所有三角形
  if node.is_leaf:
    closest = NO_HIT
    for tri in node.triangles:
      hit = intersect_triangle(tri, ray)
      if hit and hit.t < closest.t:
        closest = hit
    return closest
  
  // 第3步:如果是内部节点,递归测试子节点
  // 注意:先测近的子节点,这样可以利用"近处裁剪"加速
  hit_left = intersect_bvh(node.left, ray)
  hit_right = intersect_bvh(node.right, ray)
  
  // 返回最近的交点
  return closer_of(hit_left, hit_right)

BVH构建(SAH启发式划分):

function build_bvh(triangles, depth):
  // 计算所有三角形的包围盒
  bbox = union_of_bboxes(triangles)
  
  if triangles.count <= MAX_LEAF_SIZE or depth > MAX_DEPTH:
    // 叶子节点:直接存三角形
    return new BVHNode(is_leaf=true, triangles=triangles, bbox=bbox)
  
  // 沿着最长的坐标轴划分
  axis = longest_axis(bbox)
  
  // 按轴排序三角形中心
  sort_triangles_by_center(triangles, axis)
  
  // 在中间位置划分
  mid = triangles.count / 2
  left_tris = triangles[0:mid]
  right_tris = triangles[mid:]
  
  // 递归构建
  return new BVHNode(
    is_leaf=false,
    bbox=bbox,
    left=build_bvh(left_tris, depth+1),
    right=build_bvh(right_tris, depth+1)
  )

BVH vs. 其他加速结构:

加速结构优点缺点
BVH构建简单,动态场景易更新非均匀场景效率略低
KD-Tree均匀场景最优构建复杂,内存占用大
均匀网格实现最简单,动态场景友好内存占用大,"空单元格"浪费
八叉树自适应空间划分实现复杂,对光线追踪不如BVH常见

4.8 完整光线追踪器实现框架

把以上所有概念组装起来:

function render_scene():
  // 准备场景
  scene = load_scene("scene.obj")
  bvh = build_bvh(scene.triangles)  // 构建BVH加速结构
  
  // 准备相机
  camera = Camera(eye=vec3(0,0,5), gaze=vec3(0,0,-1), up=vec3(0,1,0))
  
  // 渲染循环
  for j in 0..height-1:
    for i in 0..width-1:
      // 生成光线
      u = l + (r - l) * (i + 0.5) / width
      v = b + (t - b) * (j + 0.5) / height
      ray_dir = normalize(u * camera.u + v * camera.v - d * camera.w)
      ray = Ray(camera.eye, ray_dir)
      
      // 追踪光线(递归深度从0开始)
      color = trace(ray, scene, bvh, depth=0)
      
      // 设置像素颜色
      image.set_pixel(i, j, color)
  
  save_image(image, "output.png")

function trace(ray, scene, bvh, depth):
  // 最大深度限制
  if depth > MAX_DEPTH:
    return Color(0, 0, 0)
  
  // 使用BVH加速求交
  hit = bvh.intersect(ray, scene)
  
  if not hit:
    return scene.background_color
  
  // 漫反射+高光着色
  color = simple_shade(hit, scene.lights)
  
  // 反射递归
  if hit.material.reflectivity > 0:
    refl_dir = ray.direction - 2 * dot(ray.direction, hit.normal) * hit.normal
    refl_ray = Ray(hit.point + 1e-4 * hit.normal, refl_dir)
    refl_color = trace(refl_ray, scene, bvh, depth + 1)
    color += hit.material.reflectivity * refl_color
  
  // 阴影
  for light in scene.lights:
    shadow_dir = normalize(light.position - hit.point)
    shadow_ray = Ray(hit.point + 1e-4 * hit.normal, shadow_dir)
    if bvh.intersect(shadow_ray, scene) is not None:
      color *= 0.3  // 阴影衰减
  
  // 俄罗斯轮盘赌
  if depth > 3:
    p = 0.7
    if random() > p:
      color = Color(0, 0, 0)
    else:
      color = color / p
  
  return color

全章总结

核心洞察:光线追踪的核心思想极其简单——对每个像素发射一条光线,找到它碰到的第一个物体。但"简单"不意味着"不强大":

步骤核心操作关键公式/算法
发射光线从摄像机穿过像素中心u = l + (r-l)(i+0.5)/nx
球体求交求解二次方程 At²+Bt+C=0A=d·d, B=2m·d, C=m·m-R²
三角形求交Möller-Trumbore算法Cramer法则求解t, β, γ
反射反射方向公式r = d - 2(d·n)n
着色计算表面颜色(详见第5章)
加速BVH层次包围盒俄罗斯套娃式递归排除
终止俄罗斯轮盘赌随机终止 + 期望补偿

一句话总结:光线追踪 = 对每个像素发射光线 → 求交 → 着色 → 递归反射/折射,用BVH加速,用俄罗斯轮盘赌优雅终止。

目录 X
章节 X