做图形学这么久,要说哪个知识点最让人“一听就懂、一写就废”,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 >= 0O是光线起点,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 递归终止:深度限制与颜色衰减
递归必须有终止条件,否则镜面球互相对着会无限递归。常用两种控制方式:
- 递归深度限制:比如最多递归5层,超过就直接返回黑色或环境色。这个深度够用,因为每多一层对最终颜色的贡献会快速衰减。
- 颜色能量阈值:设置一个最小贡献阈值,比如如果当前累积的反射系数低于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,叶子节点存一小批三角形。构建递归进行:
- 把当前节点里的三角形按某个轴排序。
- 用某个分割位置把三角形集合分成两份。
- 为两份子集各算一个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风格最好的毕业礼物。