我记得有段时间在调一个软件渲染Demo,光栅化管线已经能跑得有模有样了:模型、深度缓冲、Blinn-Phong着色、阴影贴图都能出图。但一遇到"镜面反射"就开始抓瞎——屏幕空间反射在这个Demo里根本没法处理屏幕外的物体,玻璃球后面的东西也完全看不到折射。后来我干脆把整套光栅化丢掉,用Whitted-Style光线追踪重写了一遍,效果直接上升了一个档次。这篇文章就从原理到实现细节,把Whitted-Style光线追踪完整拆一遍,适合正在学计算机图形学、或者已经写完光栅化想进阶效果的读者参考。
Whitted-Style光线追踪(也就是经典的递归光线追踪)是1980年被提出的算法,它的核心思路并不复杂:从相机出发,对每个像素发射一根主光线,遇到物体后就递归地计算反射、折射,并对每个光源发射一根阴影光线。整套流程走完,把各级着色贡献叠加起来,就是最终像素颜色。理解了这套东西,后面再去看路径追踪、辐射度这些高级算法,会顺很多。
1. 为什么需要Whitted-Style:光栅化解决不了的问题清单
1.1 光栅化的工作方式与三个致命短板
先捋一下光栅化是怎么工作的:把场景里的三角形逐个投影到屏幕上,用深度测试决定屏幕上每个像素最终能看到哪个三角形的哪个点,然后对这个可见点做局部光照着色。问题就出在"每个像素只对场景查询一次"——我们看到一个光滑球面,它反射了身后的红色窗帘,但光栅化管线在计算球面颜色时并不知道窗帘的存在,所以球面上就缺了那抹红色。
具体来说有三个典型场景是光栅化很难处理的。一是镜面反射链,两面镜子对着放,光栅化想模拟这种效果只能靠环境贴图或反射探针,但反射探针本质上是预烘焙,场景动态变化就崩了。二是透明物体折射,玻璃球后面的景象会弯折、扭曲,这在光栅化里通常需要渲染多遍或者用近似技术,效果都一般。三是阴影判断,Shadow Map虽然能出阴影,但存在自阴影偏移、分辨率不足导致的阴影锯齿等问题,而且难以做出高质量的大面积区域阴影。
这三个短板本质上都是同一个根源:光栅化只做了一次"视线查询",光线没有在场景里继续传播下去。想模拟反射、折射这类依赖视线多次弹跳的效果,就必须把"视线传播"这件事本身做出来。
1.2 Whitted方案的核心定位:不是更快,而是"更全"
Whitted在1980年提出的方案,主旨就是补上光栅化缺失的"视线传播"能力。当光线打到物体表面时,除了做一次着色计算,还继续往反射方向发一根反射光线,往折射方向发一根折射光线,同时向每个光源发一根阴影光线。如果反射光线又碰到另一个物体,同样再来一次递归处理。
这种算法也被称为反向光线追踪——现实中光从光源出发,经过无数次弹射最终进入眼睛,但如果我们正向追踪,绝大多数光线根本进不了相机,纯属浪费计算。反向追踪则保证每一根光线最终都贡献到某个像素上,效率高得多。
从算法史角度看,Whitted-Style是所有现代全局光照算法的起点。后面的分布式光线追踪(用大量采样替代单一递归路径)、路径追踪(用蒙特卡洛方法逼近渲染方程)都是在Whitted框架上加概率和能量守恒衍生出来的。所以哪怕你最终的目标是学路径追踪,先把Whitted这条线彻底吃透,后面工程量会小很多。
2. 光线生成的数学准备:从相机到像素的一束光
2.1 光线方程与相机坐标系搭建
光线在数学上用一个起点加一个方向表示:
P(t) = O + t * D, t > 0O是起点,D是单位方向向量,t是沿方向走的距离。t>0保证我们不计算起点本身。这个方程就是整个光线追踪的地基,求交、阴影、递归反射全部复用这个表达。
接下来要解决的是:相机输出的每个像素,如何转换成一条三维空间中的光线。首先需要给相机建一个坐标系,通常包括三个正交单位向量:前向F(相机看向的方向)、右向R、上向U。相机还有一个关键参数视野角FOV(垂直方向的角度),以及画面的宽高比aspect。
如果你写过投影矩阵,这些概念应该不陌生。光线追踪里更简单,不需要矩阵乘法,直接利用这几个向量构造出空间中的一条射线。但有一个细节容易搞错:像素坐标和屏幕坐标的方向。图像坐标中y轴通常向下,而三维空间中相机的y轴向上,这一步翻转漏掉的话,渲染出的画面会上下颠倒。
2.2 从像素坐标到世界空间光线的完整推导
假设图像宽为width、高为height,像素坐标为(i, j)。先把像素坐标归一化到[0, 1]区间(注意要用像素中心,也就是加0.5偏移避免采样到像素边缘):
u = (i + 0.5) / width v = (j + 0.5) / height再把它们映射到[-1, 1]区间,并和视野角挂钩。垂直方向用FOV计算半高,水平方向用半高乘以宽高比计算半宽:
halfH = tan(fov / 2) halfW = halfH * aspect x = (2 * u - 1) * halfW y = (1 - 2 * v) * halfH这里y用了1 - 2v而不是2v - 1,就是因为图像坐标y轴向下,稍微不注意就会得到上下翻转的画面。有了这个屏幕坐标后,主光线的方向就是:
D = normalize(R * x + U * y - F)或者说,相机前方的焦点距离可以根据需求缩放屏幕坐标,比如放大焦距产生望远镜效果。实际项目中我一般直接把上面这个方向归一化就行,焦距的控制通过缩放x、y实现。
一个常见的工程细节:透视投影中,光线的起点是相机位置(所有光线共用一个O),方向按透视锥体展开;而正交投影中,像素光线的方向都相同,起点才根据像素位置偏移。大多数软渲染Demo用透视模型更直观。
3. 求交运算:Whitted光线追踪的命中率核心
3.1 隐式曲面与二次方程求交
从相机发出的光线要判断撞到了什么,最基础的是和隐式曲面求交。以球体为例,球面方程为||P - C||² = R²,把P(t)代入得到:
||O + tD - C||² = R²展开后是一个关于t的二次方程at² + bt + c = 0。D如果归一化,a就等于1。判别式小于0说明光线没碰到球,等于0说明擦边,大于0则有两个实根,取最小的正根作为最近的交点。
这段逻辑写出来很直观,但有一个隐藏前提:解出的t必须大于epsilon(后面专门讲),而且球体这样需要处理多个实根的情况在三角形网格里同样存在——最近交点才是我们看到的,阴影光线判断则只需要知道有没有交点,不需要最近的。
3.2 三角网格的Möller–Trumbore算法与重心坐标
实际场景大多是三角网格,每条光线遍历所有三角形做一次交运算是性能瓶颈所在。在OpenGL里我们用光栅化处理三角形,光线追踪里则常用Möller–Trumbore算法,用线性代数求解光线与三角形的交点,同时得到重心坐标。
简化的实现如下:
bool rayTriangleIntersect(const Ray& ray, const Vertex& v0, const Vertex& v1, const Vertex& v2, float& t, float& u, float& v) { Vector3f edge1 = v1 - v0; Vector3f edge2 = v2 - v0; Vector3f pvec = cross(ray.direction, edge2); float det = dot(edge1, pvec); if (fabs(det) < 1e-8) return false; // 光线与三角形平行 float invDet = 1.0f / det; Vector3f tvec = ray.origin - v0; u = dot(tvec, pvec) * invDet; if (u < 0 || u > 1) return false; Vector3f qvec = cross(tvec, edge1); v = dot(ray.direction, qvec) * invDet; if (v < 0 || u + v > 1) return false; t = dot(edge2, qvec) * invDet; return t > 0; }这里的u、v是重心坐标,第三重心坐标w = 1 - u - v。重心坐标的价值在于做插值:顶点法线、顶点颜色、纹理坐标都可以用同样的u、v、w加权插值出来,这样表面上每个点都有连续的材质信息,而不是三角面片内一个值。
3.3 自相交、epsilon偏移与浮点精度处理
这条要特别注意。光线求交本身看似简单,但在浮点运算下有一个非常隐蔽的坑:理论上你从球面上的某个交点发一条反射光线,这个交点应该正好落在球面上,但浮点误差会让这个点稍微"陷入"球面内部一点点。反射光线从球面内侧出发,立刻又和球面自身相交,产生一个极小t值的错误交点,导致球面上出现大量不规则黑点或亮斑。
解决思路有两个层面。第一,所有求交时只接受t > epsilon的命中,一般的epsilon取1e-4到1e-6,具体要看场景尺度。注意这个epsilon不能取太大,否则贴近表面的小物体会被光线直接"穿透"。第二,在递归阴影和反射时,维护一个命中物体ID集合,当阴影光线和反射光线判断遮挡时,跳过当前所在物体自身的交点。两招一起上,自相交现象基本能杜绝。
另一种三角形网格特有的问题是裂缝。顶点共享时,如果三角形的顶点坐标在精度上有一点点差异,光线可能从两个相邻三角面之间的缝里漏过去。遇到这种情况,习惯做法是沿着表面法线方向把交点做一个微小的偏移,相当于把起点从几何表面推到实际表面的上面,再做后续追踪。
4. 递归追踪的完整链路:反射、折射与阴影判断
4.1 镜面反射方向推导与递归着色流程
假设光线从相机出发,沿方向d打到物体表面,表面法线为单位向量N。把d看作入射光线的传播方向,反射光线方向可以通过向量分解得到:
reflectDir = d - 2 * dot(d, N) * N可以这么理解:d在法线方向的分量dot(d, N) * N会在反射时反向,而垂直法线的分量保持不变,组合起来就是这个公式。要小心法线的方向约定——如果法线不在入射光线的同一侧,结果会完全错乱,所以求交时最好统一规定法线方向和入射方向夹角小于90度,必要时对法线做翻转。
递归着色流程如下:
function shade(point, rayIn, depth): if depth > maxDepth: return 0 hit = nearestIntersect(point, rayIn) if not hit: return backgroundColor color = localShade(hit) // Blinn-Phong 或者基于光源的直接光照 if hit.material.isMirror: reflectDir = computeReflectDir(rayIn.direction, hit.normal) color += hit.material.ks * shade(hit.position + eps*hit.normal, reflectDir, depth+1) if hit.material.isTransparent: refractDir = computeRefractDir(rayIn.direction, hit.normal, hit.material.etaRatio) color += hit.material.kt * shade(hit.position + eps*hit.normal, refractDir, depth+1) return color这个递归函数是Whitted风格光追的心脏。主循环对每个像素发射光线,调用shade,最终返回的颜色就是该像素的最终颜色。
4.2 折射定律、临界角与菲涅尔近似
折射比反射麻烦一些,要遵守斯涅尔定律:η1 * sinθ1 = η2 * sinθ2,这里θ1是入射角(相对法线),θ2是折射角,η1和η2分别是两种介质的折射率。空气的折射率约为1.0,水的折射率约为1.33,玻璃约1.5——这些参数决定了"筷子插入水里看起来折断了"的程度。
计算折射方向时的关键一步是检查临界角。当光从折射率高的介质射向折射率低的介质,且入射角足够大时,折射角的正弦会超过1,这时不再有折射光线,发生全反射。代码里的表现就是:
cosTheta = -dot(rayIn.direction, hit.normal) // 注意符号约定 sinThetaSq = eta * eta * (1 - cosTheta * cosTheta) if sinThetaSq > 1: return reflectDir // 全反射 else: return refractDir然后在反射和折射的权重分配上,Whitted当年混的是材质里的镜面反射系数和透射系数,但真实世界的玻璃球从正面看透光更多,从侧面看反光更多。工程上常用Schlick菲涅尔近似来计算反射比例:
R0 = ((η1 - η2) / (η1 + η2))² R(θ) = R0 + (1 - R0) * (1 - cosθ)^5这个公式给的效果已经足够真实:掠射角(视线几乎平行于表面)时反射接近100%,这也是湖面在傍晚夕阳下像是镜子一样的原因。
4.3 Shadow Ray:硬阴影与遮挡判定
Whitted风格的阴影判断非常直接:对每个可见光源,从击中点打一条光线到光源位置。如果中途被其他物体挡住,就认为当前点处于阴影中,不参与该光源的直接光照。这里的实现细节在于距离判断:
Vector3f toLight = light.position - hitPoint; float lightDist = length(toLight); if (nearestIntersect(hitPoint + eps*hit.normal, toLight, lightDist)) { // 被遮挡,不计算该光源贡献 } else { // 可见,进行直接光照着色 }有一个经常翻车的地方:阴影光线的起点仍然要做epsilon偏移,否则又会和当前表面自相交。同时,遍历求交时一定要求交点满足t < lightDist且t > eps,否则光源前面自己的表面会导致所有像素都变成阴影。
Whitted-Style产生的阴影天然是硬阴影,因为光源被当成一个点。想实现软阴影,一个自然扩展是对面光源区域发射多个阴影光线,统计可见比例,然后把多个光源采样结果做平均。这个思路已经是分布式光线追踪的雏形了——你完全可以在Whitted框架里慢慢加这类扩展。
5. 让程序活着跑完:加速结构与越界防护
5.1 BVH加速结构与AABB包围盒
朴素实现中,每根光线要和场景里所有三角形求交,而Whitted风格每像素至少要发一根主光线,加上反射、折射、阴影可能在十条以上。没有加速结构的话,渲染一张1920×1080的图在模型上会直接卡死。
最常用的加速结构是BVH(Bounding Volume Hierarchy)。思路很简单:给每个三角形算一个AABB包围盒,把所有三角形放在一个大盒子里,不断按某条轴切分成两堆,递归建树。求交时先测光线是否穿过节点的包围盒,穿不过就直接裁掉整个子树,穿过了再往下递归。最理想情况下,每次求交复杂度从O(N)降到O(logN)。
AABB求交用slab方法:把包围盒看作三个方向的区间[xmin,xmax]、[ymin,ymax]、[zmin,zmax],光线进入盒子需要同时满足三个轴的区间约束,最后取全局进入t和离开t的判断合并。代码形式如下:
bool rayAABBIntersect(const Ray& ray, const Bounds3f& bbox, float& tNear, float& tFar) { tNear = -INFINITY; tFar = INFINITY; for (int i = 0; i < 3; i++) { float inv = 1.0f / ray.direction[i]; float t1 = (bbox.min[i] - ray.origin[i]) * inv; float t2 = (bbox.max[i] - ray.origin[i]) * inv; if (t1 > t2) swap(t1, t2); tNear = max(tNear, t1); tFar = min(tFar, t2); if (tNear > tFar) return false; } return tNear < tFar; }构建BVH时,一个简单有效的策略是:统计所有三角形包围盒的中心点,沿最长轴排序,取中位数把三角形分为左右两组,递归建树。这样建出来的树虽然不是最优的SAH方案,但对于教学和中小型Demo完全够用。等你把效果调满意了,再研究表面积启发式构建法也不迟。
5.2 递归深度限制与能量衰减截断
递归不能无限跑,不然两面平行镜子之间的光线会无限反复反射,直到进程栈溢出。工程上通常设置最大递归深度,常见值是5到8层。为什么这个深度够用?因为每次反射/折射都会乘一个权重系数(反射系数ks或菲涅尔反射比例),这些系数小于1,几层之后能量贡献已经衰减到肉眼不可见的范围,继续递归纯粹浪费算力。
更精细的做法是跟踪当前路径上的累计能量衰减系数,当衰减值低于某个阈值(比如0.01)就提前终止递归。这个阈值判断和深度限制可以同时使用,效果比单一深度限制更好,因为粗糙表面的反射路径衰减很快,光滑镜面则可以多跑几层。
缓冲区溢出之外还有一个性能陷阱:每个交点的阴影光线数量乘以光源数量,会在场景中放大。如果有几十个光源,每像素几十根阴影光线是常见的事。优化方案是主动限制光源的可见范围,或者预先对高贡献光源做排序。最简单的做法是把光源数量控制在个位数以内跑通流程,再逐步增加。
6. 渲染结果的验证思路与常见画错误判
6.1 分通道检查法:把每个贡献拆开调试
光线追踪代码写完后,最容易犯的错误是一股脑把所有贡献混在一起,最后画面不正常时根本不知道是几何错了、法线错了、还是递归逻辑错了。我的习惯是给渲染器加几个Debug输出模式,把单通道单独导出来看。
第一个是深度图t通道,把每个像素最近交点的t值映射成灰度输出。这个图能快速验证光线求交是否正确——球体应该呈现由中心向外逐渐变亮的圆形渐变,如果出现奇怪的条纹或空洞,说明求交有问题。第二个是法线可视化通道,把交点法线的xyz分量映射到rgb输出。这个图可以直观看到法线的朝向是否一致,常用于排查反射光线的"扭曲"问题。第三个是材质通道,给不同材质赋纯色,直接看哪个像素命中哪种材质,这对验证递归调用的命中位置特别有效。
分通道检查之所以有效,是因为它把连续依赖的渲染管线解耦了。几何错误会让反射方向本身就不对,这时再看整体画面只会更糊涂。我强烈建议在接入材质和光源之前,先用这几个通道把几何部分彻底验证一遍。
6.2 几个典型Bug:黑点、裂缝、阴影遗漏
我在调试Demo时遇到的黑点问题,最终定位是epsilon太小导致的自相交。表面反射光线在t极小处命中了自身,表现为球面上出现密集的暗色噪点。调到1e-5级别后黑点消失,但也不能无限调大——有些细小的几何细节会被eps穿透,出现"光线下潜"的现象。
三角形网格上的裂缝问题则和求交的三角形索引顺序相关。相邻三角形的顶点顺序不一致时,法线方向会不同,光照效果会出现"补丁感"。检查方法很简单,把渲染结果切换到法线通道,如果相邻面片的法线可视化颜色跳变明显,就去检查建模或导入阶段是否统一了顶点绕序。
阴影方面最常见的错误是整片场景没有阴影或者阴影全黑。没有阴影通常是因为阴影光线的求交范围没有限制在光源距离内,或者忽略了当前物体自身。一次忘写t < lightDist的判断,导致所有物体之间都在互相"遮挡",画面惨不忍睹。我现在的做法是把阴影判断封装成一个独立函数,单独做一个白色灯光、纯色物体的测试场景验证,通过了再接复杂场景。
6.3 性能调优的实用底线
如果你的光追程序能在中等规模场景(几千三角形)下渲染出图像就算基本成功。继续优化时,有一个性价比很高的手段:以像素为单位做超采样抗锯齿,即每个像素发射多根主光线,方向微微偏移,最后平均。这在Whitted框架里几乎不加成本,效果却非常明显,能消除物体边缘的锯齿感。采样数可以选择1、4、9、16,配合随机抖动或者均匀分布在像素内的采样点,画面质量提升是本质级别的。
另外,反射/折射权重系数建议从0.8和0.7起步,根据实际材质微调。真实玻璃的反射率通常没有那么高,系数太大会让场景过曝,太小又会让反射、折射效果不明显。调参时记得同时观察反射通道和折射通道的单独输出,避免被整体曝光欺骗。
从零实现一遍Whitted-Style光线追踪之后,我对图形学那些抽象概念的理解扎实了很多。它不复杂,核心也就一个递归函数加求交运算,但正是这份简洁让后来的路径追踪、光子映射能够继续往上盖楼。如果你正在写自己的光追渲染器,千万别急着追求酷炫特效,先把主光线的深度通道和法线通道调对,再一层层叠反射和折射。这样每加一个特性,画面都能给出明确的反馈,整个调试过程会顺很多。