☰
Whitted风格光线追踪从0到1:光线生成、求交、递归与BVH加速
2026/10/16 1:26:47 网站建设 项目流程

做图形学这么久,要说哪个知识点最让人“一听就懂、一写就废”,Whitted-Style光线追踪绝对排得上号。课堂上讲原理时,反射、折射、阴影几条线画得清清楚楚,可真到了自己动手写实现,才发现光是“光线怎么生成”“求交为什么老是有黑点”“递归到底该多深”这些问题就能把人磨掉半条命。

这篇文章我把Whitted风格光线追踪的完整实现链路拆开讲一遍,从相机光线生成、数学求交、递归光线分派,到BVH加速结构和各种工程化排错技巧,全部按我实际调试项目时的思路来写。不管你是正在学图形学课的学生,还是想自己写一个离线渲染器玩玩的开发者,看完都能直接照着搭出一套能跑通的实现框架。

1. 为什么是Whitted:先搞清楚光栅化差在哪

1.1 光栅化渲染在做什么

聊光线追踪之前,必须先把光栅化这件事捋清楚。光栅化的核心思路是“物体找像素”——把三维空间里的三角形顶点,经过模型、视图、投影变换,映射到屏幕上的二维像素格子里,然后逐三角形填充颜色。这个过程非常快,GPU里管线化的就是这套东西,所以游戏实时渲染几乎都走光栅化。

但光栅化在处理“全局效果”时天生吃亏。比如一个镜面球把周围物体的倒影映出来,或者光照被某个物体挡住后在地上留下一片阴影,这些都不是单个三角形能独立决定的。传统光栅化下,阴影要靠Shadow Mapping额外跑一遍深度测试,镜面反射要靠Planar Reflection单独渲染场景,每个效果都要单独加补丁,效果还往往只能做到近似。

这里面的根本问题是,光栅化里每个像素的颜色是由局部信息决定的:光源、材质、法线、视线方向。三角形和三角形之间、物体和物体之间的光线关系,想靠这套体系做出来,复杂度会爆炸。

1.2 Whitted光路:一根光线走出一条树

Whitted-Style光线追踪的思路和光栅化正好反过来,叫“像素找物体”。对屏幕上的每个像素,从相机位置射出一根光线,让光线进入场景,追踪它碰到的每一个物体,并根据材质继续递归地生成反射光线、折射光线,同时往光源方向发一根阴影探测光线。这个递归过程最早是Turner Whitted在1980年提出的,所以就叫Whitted风格。

你仔细看这个模型,它处理的东西恰好是光栅化最头疼的三件事:

  • 阴影:往光源方向发射的Shadow Ray一旦被遮挡,这个点就直接判定为在阴影里。
  • 镜面反射:反射方向的光线继续追踪,把远处物体的颜色带回来。
  • 折射/透射:光线穿过透明物体后继续追踪,把背后的场景带回来。

这就是为什么Whitted风格被称为“递归光线追踪”——每个可见点并不是一个独立的光照计算,而是由一个光线树来决定的。一个像素最终的颜色,可能是某个点直接光照的颜色,加上反射光线带回来的颜色,再加上折射光线带回来的颜色。

1.3 代价决定了它的定位

当然,这整套东西的代价是肉眼可见的。一个像素少说也要递归五六层,每层都要对场景里的物体做求交测试。如果没有加速结构,光求交就是天文数字般的计算量。因此Whitted风格几乎不会出现在实时渲染里,但它离线渲染的地位很稳固,因为一次渲染可以跑几分钟甚至几小时,换来的是一张带真实反射、折射、阴影的效果图。

而且我觉得,Whitted风格最大的价值其实是“教学上的干净”。它把光线追踪里最核心的几个数学概念全部暴露出来——射线方程、隐式曲面隐式求交、三角形求交、BVH加速、递归分派——每一步都可以独立验证、独立调试。理解了Whitted,再去看路径追踪那套蒙特卡洛积分,就会发现思维坡度平缓得多。

2. 光线生成与相机模型:每个像素的光线到底从哪来

2.1 从相机到像素:视口坐标系建立

很多人写光追时第一步就懵了:我怎么知道某个像素对应的光线方向是什么?这事的本质是把屏幕像素坐标换算到相机本地坐标系里。

先定义相机。光追里的相机没有镜头和焦距那一套,最常用的是针孔相机模型。你需要三样东西:

  • 相机位置eye,也就是所有光线的起点。
  • 视角中心点lookAt,告诉相机往哪看。
  • 向上的方向up,确定相机本身不歪。

用这三样可以建立一组正交基:

w = normalize(eye - lookAt) u = normalize(cross(up, w)) v = cross(w, u)

你看,这里用了两次叉乘叫“格拉姆-施密特正交化”,目的是把相机的朝向变成右手坐标系的三根轴。这样相机无论摆在哪、往哪看,我们都能拿到一组标准正交基,之后所有的像素射线方向都在这组基底下计算。

2.2 像素坐标到光线方向的映射

假设我们的输出图像宽W、高H,视场的垂直张角是fov(比如60度)。那么视平面离相机的距离tan(fov/2),这里1.0表示从相机到视平面的标准化距离。取任意像素(i, j),它对应的视平面坐标是:

ndc_x = (i + 0.5) / W * 2 - 1 ndc_y = (j + 0.5) / H * 2 - 1 x = ndc_x * aspect * tan(fov/2) y = ndc_y * tan(fov/2)

这里的+0.5是像素中心的偏移,aspect是宽高比W/H。算出的x、y就是光线在相机坐标系下视平面上的坐标,方向就是normalize(-w + x*u + y*v)。这里有个细节,图像坐标的y轴通常向下,所以最后计算出来的ndc_y可能和视觉上的上下反过来,你需要在代码里处理成1 - ndc_y,不然渲染出来的图会是上下颠倒的。

我第一次写的时候,这个方向向量算出来反复不对,最后发现就是忘了aspect乘上去,导致图像被横向拉伸。

2.3 像素采样:不只射一根光线

如果每个像素只射一根光线,结果会出现比较明显的锯齿和噪点。现代实现里通常会对每个像素做多次采样,即超采样抗锯齿。最简单的做法是随机在像素范围内抖动:

offset_x = random_uniform(0, 1) offset_y = random_uniform(0, 1)

然后把(i + offset_x, j + offset_y)代入上面的映射公式。这样每个像素发出多个光线,最后取平均颜色,边缘就会平滑很多。这个抖动有个专门的说法叫Stochastic Sampling,它是后续一切蒙特卡洛方法的基础思路。哪怕你的目标是先跑通Whitted风格,我也建议从一开始就保留一个“每像素采样数(spp)”参数,后面调试和出效果图都用得上。

3. 求交测试:光线是怎么撞上物体的

3.1 隐式曲面求交:拿球体当标准例子

光线方程是所有求交测试的基础:

P(t) = O + t * D, t >= 0

O是光线起点,D是单位方向向量,t是从起点出发的距离。任一时刻光线上的点,都可以用这个t值唯一确定。

先看最简单的隐式曲面——球体。球心在C、半径为R的球面方程是:

|P - C|^2 = R^2

把光线方程代进去,展开后就得到一个一元二次方程:

|O + tD - C|^2 - R^2 = 0

设oc = O - C,则有:

a = dot(D, D) = 1 // 因为D是单位向量 b = 2 * dot(oc, D) c = dot(oc, oc) - R^2

判别式Δ = b² - 4ac。如果Δ小于0,说明光线和球没有交点;如果等于0,光线擦着球面经过;大于0则有两个交点。注意,这里最后取的是“最近的、且t为正”的交点,因为我们要找的是光线遇到的第一个物体。用求根公式:

t = (-b - sqrt(Δ)) / 2a

如果这个t已经小于0,那说明球在相机后面,不能算数,要换t = (-b + sqrt(Δ)) / 2a再看。

隐式曲面的好处是数学形式封闭,求交完全靠解方程,简单、精确、没有三角形网格那么琐碎的边界判断。所以你在学习阶段一定要先把球体求交写对——它后面所有递归逻辑的“测试床”都是它。

3.2 显式三角形求交:Möller–Trumbore算法

真实场景里不会有那么多完美的球体,大部分几何都是三角形网格。三角形求交最经典、也是我用下来最顺手的算法是Möller–Trumbore,它把“光线和三角形求交”和“重心坐标计算”合并在一起做,一步就能拿到结果。

三角形顶点是v0、v1、v2,光线是O + tD,交点可以用重心坐标表示:

O + tD = (1 - u - v) * v0 + u * v1 + v * v2

这里u和v是重心坐标。只要u >= 0、v >= 0、u + v <= 1,交点就在三角形内部。把这式子整理成矩阵形式,用克拉默法则解出三个未知数。实现如下:

def ray_triangle_intersect(O, D, v0, v1, v2): e1 = v1 - v0 e2 = v2 - v0 s = O - v0 p = cross(D, e2) q = cross(s, e1) denom = dot(p, e1) if abs(denom) < 1e-8: return None inv = 1.0 / denom u = dot(p, s) * inv v = dot(q, D) * inv t = dot(q, e2) * inv if u >= 0 and v >= 0 and u + v <= 1 and t > 0: return t, u, v return None

别看代码短,里面有个容易出错的点:denom接近0的时候表示光线方向和三角形平面平行,这时要么没交点,要么光线刚好在平面上,直接用1e-8这类极小数做阈值判断就好。

重心坐标u、v还有个额外红利:如果要给三角形贴上纹理,或者做顶点色插值、法线插值,重心坐标可以直接用于插值,不用再单独算一遍。

3.3 先粗筛再细查:轴对齐包围盒(AABB)

场景成千上万个三角形,每个像素的光线都跟所有三角形求交一次,那是绝对不可接受的。行业里通用的做法是给几何体外包一层轴对齐包围盒(AABB)。AABB是长方体,三个轴方向分别对齐坐标轴。求交的时候,先判断光线和包围盒是否有交,如果没有,里面的三角形就直接跳过。

AABB求交用“Slab方法”:对每一根轴,算出光线进入和离开该轴范围的两个t值,然后取交集。设包围盒在x轴方向的区间是[x_min, x_max]:

t_x1 = (x_min - O.x) / D.x t_x2 = (x_max - O.x) / D.x

如果D在两个方向上符号不同,需要对两个t值做一次排序,保证小的在前。三条轴分别算完后,最终光线进入包围盒的t是t_enter = max(t_x1, t_y1, t_z1),离开的t是t_exit = min(t_x2, t_y2, t_z2)。只要t_enter <= t_exit且t_exit > 0,就说明光线穿过了包围盒。

这里有个小坑:当D.x接近0时,t_x会变成一个极大或极小的数,可能造成浮点溢出。稳健的写法是先判断abs(D.x) < 1e-8,此时若O.x仍在区间内,就把该轴的t视为“不限制”。

4. Whitted递归主循环:反射、折射、阴影一条龙

4.1 最近交点与材质分派

光线进入场景后,我们要对所有物体做求交,选出t最小的那个交点为“命中点”。这里有个优先级的细节:场景里可能有多个透明物体叠在一起,但你真正要计算的只是离相机最近的那个表面。最近交点决定当前直接光照的着色点,同时它产生的反射、折射光线会继续往场景里投。

拿到命中点后,接着就是“材质分派”。Whitted风格里材质一般分三种:

  • 漫反射:不产生反射和折射,只计算直接光照。
  • 镜面反射:产生一条反射光线,颜色乘以反射系数。
  • 折射(透明):产生一条折射光线,颜色乘以透射系数。

实际材质往往是组合的,比如一个玻璃球,既有反射分量也有折射分量,这时光线要同时分出两条去追踪,最终颜色按比例kr * 反射颜色 + kt * 折射颜色混合。

4.2 阴影光线:最简单的全局效果

每个命中点都要往每个光源方向发一条Shadow Ray。做法是从命中点P指向光源位置L,方向D_shadow = normalize(L - P),然后对这个方向做求交测试。如果有物体挡住,也就是存在某个t满足0 < t < distance(L, P),那这个点就在阴影里。

这个判断虽然简单,但它牵扯到一个非常经典的Bug:自相交或阴影痤疮。因为光线从命中点出发,而命中点理论上就在物体表面,如果求交时浮点误差导致光线和原来的平面再次相交,就会出现“自己挡自己”的假阴影,画面上表现为一片片黑斑。解决办法很粗暴但有效:入射阴影光线的起点沿法线方向偏移一点点,比如P + N * 1e-4,把起点推开表面。这个偏移量不能太大,太大阴影会变形,太小又防不住浮点误差,我一般用1e-4到1e-3之间,具体要看场景尺度。

4.3 反射光线:方向计算和递归

反射方向是最容易记混的公式。给定入射方向D(注意是光线的传播方向),表面法线N,反射方向R是:

R = D - 2 * dot(D, N) * N

这个公式里D指向入射方向(朝向物体),N是单位法线,dot(D, N)在表面外侧是负数。以我自己的经验,公式记不住没关系,但要知道它做的是“把D以N为轴翻到另一边”。

反射光线的起点同样是P + N * epsilon,方向是R。这之后递归调用上一节讲的“沿着光线找最近交点”的逻辑。如果反射光线再次击中物体,就再计算该点的颜色,如此往复。

这里有一点特别容易被忽略:Whitted风格的递归反射是不做能量守恒的物理正确的。一条反射光线带回来的颜色,直接乘一个反射系数,比如0.8,然后叠加到当前颜色里。所以你在调反射材质的时候,多个物体互相反射会越叠越亮,最后曝光成一片白。这是Whitted风格的固有缺陷,不是你的代码写错了,后面做路径追踪时才会用BRDF做能量归一化。

4.4 折射光线:Snell定律和全反射

折射是Whitted里最容易出戏的环节。折射方向由Snell定律决定:

n1 * sin(theta1) = n2 * sin(theta2)

这里的n1和n2是光线穿过两种介质的折射率,比如空气约1.0、玻璃约1.5。你需要知道光线是从空气进玻璃,还是从玻璃出空气,这决定了n1/n2的比值。

实际计算折射方向T有现成公式:

cos_i = clamp(dot(-D, N), -1, 1) eta = n1 / n2 k = 1 - eta * eta * (1 - cos_i * cos_i)

如果k < 0,说明发生了全反射——光线无法折射出去,全部反射掉。这时候要按反射计算。如果k >= 0,折射方向:

T = eta * D + (eta * cos_i - sqrt(k)) * N

注意,这里的N要和D保持在法线的同一侧,也就是说如果你从物体内部穿出来,法线方向必须取反。最常见的错误是玻璃球渲染出来中间一团黑,就是因为没有区分“进入介质”和“离开介质”时法线的方向。我的习惯是约定:入射时dot(D, N) > 0表示从内部向外射,这时要把法线取反再参与计算。

4.5 递归终止:深度限制与颜色衰减

递归必须有终止条件,否则镜面球互相对着会无限递归。常用两种控制方式:

  1. 递归深度限制:比如最多递归5层,超过就直接返回黑色或环境色。这个深度够用,因为每多一层对最终颜色的贡献会快速衰减。
  2. 颜色能量阈值:设置一个最小贡献阈值,比如如果当前累积的反射系数低于0.01,就停止递归,因为后面的影响肉眼已经看不出来了。

两个可以结合用。我一般会用深度限制为主,因为它的表现稳定、运行时间可控。深度太浅会导致深色物体里的倒影缺细节,太深则浪费计算时间,玻璃材质建议开到6到8层。

5. 加速结构:没有BVH的Whitted光追跑不动

5.1 朴素光追为什么慢得离谱

如果整个场景有10万个三角形,图像又是1024x1024,每个像素做4次采样,那就是约400万根主光线。每根主光线对10万个三角形做一次求交,再加上递归过程中产生的反射、折射、阴影光线,总计算量直接是几十亿次求交。就算每次求交只需要1微秒,整体也要跑上几个小时。

所以加速结构不是优化手段,而是刚需。Whitted风格里最常用的有两种:均匀空间划分和层次包围盒(BVH)。

5.2 均匀空间划分:简单但边界不友好

均匀空间的思路是把场景用一个大的包围盒包住,然后切成固定大小的均匀网格(比如32x32x32的格子)。构建时把物体索引放进它所在的格子。求交时,光线按“网格步进”方式依次穿过格子,只对格子里的物体做求交测试。

这个方案的实现很简单,但它对场景分布很不敏感。一个大三角形放在网格边缘,会出现在大量格子里,造成重复求交。而且如果场景里的物体尺度差异很大,比如一个房间里有细小的针和巨大的沙发,均匀网格的效率会急剧下降。它的优点是构建非常快,适合动态场景,但静态场景下我更喜欢BVH。

5.3 BVH构建:SAH启发式的核心

BVH(Bounding Volume Hierarchy)是树形结构,每个节点是一个AABB,叶子节点存一小批三角形。构建递归进行:

  1. 把当前节点里的三角形按某个轴排序。
  2. 用某个分割位置把三角形集合分成两份。
  3. 为两份子集各算一个AABB,递归构建子树。

分割位置的选择决定了BVH的质量。最简单的做法是沿最长轴按三角形数量均分(median split),代码好写,但常导致空间重叠严重。更好用的是表面积启发式(SAH)。SAH的核心思想是估算光线穿过每个子节点AABB的概率正比于它的表面积,所以尽量让两棵子树表面积之和最小,能显著减少光线与包围盒求交的浪费。

SAH的实现思路是,在节点包围盒的长轴上选若干个候选分割位置,对每个位置计算一个cost:

cost = traversal_cost + area_left * n_left + area_right * n_right

最终取cost最小的分割位置。我个人建议:场景复杂时直接上SAH,虽然构建过程慢一点,但构建是一次性的,渲染时走树的效率差距能拉开好几倍。场景简单时用median split过得去,代码也能少写不少。

5.4 BVH遍历:先近后远的贪心策略

BVH求交遍历的写法也有讲究。对每个待测试的光线,从根节点递归:

如果光线没穿过当前节点的AABB,直接返回 如果当前节点是叶子,对叶子里的三角形做逐个求交 如果是内部节点,先递归距离更近的那个子节点,再递归距离更远的

“先近后远”的意思是,如果先在近子节点找到一个交点,在递归远子节点之前,可以先判断光线到达远子节点AABB的t_enter是否已经大于已找到的交点t_hit。如果t_enter > t_hit,说明远子节点里的物体肯定被前面的交点挡住了,直接剪枝跳过。这是BVH最大的加速来源之一。

6. 实战中的坑与排错:我最常遇到的一堆问题

6.1 阴影痤疮和自相交,还有一种叫“表面渗漏”

前面提到阴影痤疮是浮点误差引起的,但还有一种相反的现象:偏移量给太大,导致本应被遮挡的点透过物体看到光源,阴影里出现裂缝。这种情况就是“表面渗漏”,看起来像阴影里漏光。

排查方法很简单:把阴影光线单独可视化。写一个调试模式,让每个像素输出“是否被遮挡”的0或1,如果有黑点/漏光点,就盯着那个点是哪根光线造成的。我试过几次之后,习惯是偏移量按场景尺度的比例来设,比如直接用epsilon = scene.bounding_sphere_radius * 1e-4,而不是固定写死一个数字,这样在不同尺寸的场景里都能稳定工作。

6.2 玻璃渲染成黑坨坨:本质是法线朝向问题

玻璃材质渲染出来是黑的、或者只有边缘有一圈亮光,十有八九是折射方向计算时法线方向选错了。光线进入玻璃时,法线与光线方向夹角是钝角;穿出玻璃时,光线从内部射向外部,法线与光线方向夹角变成锐角。

如果你不分支处理,判全反射的k会判断错误,导致本该折射的光线被当成全反射处理。我的写法是:

if dot(D, N) > 0: N = -N n1, n2 = eta_inside, eta_outside else: n1, n2 = eta_outside, eta_inside

这一步写对后,玻璃球渲染出来透明感立刻就有了。

6.3 图像噪点一直消不掉,先别急着加采样

很多人看到画面噪点多,第一反应是提高每像素采样数,从4改到64,结果渲染时间爆炸。其实在Whitted风格里,噪点来源往往是阴影光线偏移量不合适、或者反射递归深度不够。先检查这两个,再回去调spp。

如果确认是采样不够,可以在低分辨率(比如512x512)下测试,把max_depth开到6,spp设为16,确认效果稳定后再放大分辨率,这样调试周期能缩短好几个量级。

6.4 调试技巧:把递归路径“画”出来

有一个我用过无数次的方法:在递归引擎里加一个“路径可视化”开关。每根光线的命中点列表全都记录下来,然后把这些命中点渲染成场景里的小球。这样你一眼就能看出来某根光线的反射方向是不是被偏折了、路径有没有跳到不合理的区域。很多反射错位、折射穿墙的问题,用这种方式定位几乎是一次性的。

6.5 性能和画面质量的权衡建议

最后分享一个配置建议,我按这个参数组合在实际项目里跑得很稳:

参数建议值说明
递归深度6再深对画面贡献已不明显
每像素采样16画面足够平滑,速度可接受
阴影偏移场景半径 * 1e-4防痤疮又防渗漏
BVH叶子大小4个三角形太多则求交冗余,太少则树太深
输出分辨率先512再上2K调试用小图,成品用大图

这套参数不是死的,如果你在做动画序列帧,可以适当降低spp到8、把深度降到5,换取每帧渲染时间减半,画面上几乎看不出差别;如果你在渲染静帧,可以放心把spp拉到64、深度加到8,时间换质量是值得的。

6.6 一个容易被忽视的问题:光线起点偏移在反射/折射时同样需要

很多人只在阴影光线里加了偏移,但反射、折射光线从命中点出发时一样会有自相交问题。镜面球上出现一圈圈规则的亮暗条纹,往往就是反射光线的起点还贴在表面上。我的做法是在所有“从命中点发出”的光线统一起点偏移:P + N * epsilon,折射光线还要注意N已经做过方向调整,用调整后的法线方向偏移。

这个问题排查起来很有欺骗性,因为它在模糊表面上不太明显,一旦你放一个高光反射球体就原形毕露。所以一上来就把偏移逻辑做成公共函数,所有次级光线共用,能省掉后面一大堆定位时间。

Whitted-Style光线追踪之所以值得手工实现一遍,是因为它把渲染里“模拟光线传播”这一整套思维方式强行装进了你的脑子里。当你写完全部的求交、递归、加速结构,再回头看路径追踪、双向方法、光子映射那些进阶主题,就不会再觉得陌生了——所谓更高级的渲染算法,本质上都是在Whitted这个框架上,把“打哪根光线”“往哪打”“打完怎么算颜色”这三个问题换了一组更物理的答案。实现到能稳定出图之后,我强烈建议你试一个扩展:把漫反射表面从“直接光照”改成“随机半球方向继续递归”,你会发现自己已经在写一个简化版路径追踪了。这就是Whitted风格最好的毕业礼物。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询