每次听完一节课,我都有个习惯:先把课件合上,在脑子里把“这节课到底解决了一个什么问题、为什么用这种办法”重新过一遍。GAMES101公开课的第十三讲恰恰就是这种“越品越有味道”的内容——它把前面光栅化铺垫好的整个渲染路线猛地一掰,告诉你:先别管那个三角形一个个画得有多快,我们换一种更笨、更暴力、但也更能逼近真实画面的思路,也就是Whitted-Style Ray Tracing。这篇文章可以看作是那份课后笔记的完整重述:从一个刚做完光线追踪小作业的人的角度,把光线定义、求交、递归追踪、阴影判定和实战里的那些坑通通捋一遍。如果你正在学这套公开课,或者想搞明白“光线追踪到底在追什么”,那这堂课和这篇笔记应该能帮上忙。
我先说个总体感受:这节课内容并不算长,信息密度却极高。很多人看完视频后的状态是“公式好像都抄下来了,但自己动手写代码时第一眼全黑、第二眼花屏”,问题通常不是出在听不懂,而是出在“没搞懂每一步到底在算什么”。所以下面的笔记我会按自己最容易接受的顺序来重排,不照着课件逐页抄,而是按“动机 → 光线数学 → 递归策略 → 像素落地 → 日常Debug”这条线走。
1. 为什么光栅化之后还要折腾光线追踪
1.1 光栅化的几个软肋
在这个系列的前半部分,我们已经见识过光栅化渲染的效率:一个场景几十万三角形,用投影、裁剪、深度测试就能一张张画出来,实时渲染的核心基本都靠这套思路。可光栅化有个非常尴尬的地方——它对“光在场景里来回弹跳”这件事几乎无能为力。你要在光栅化里模拟一个物体被另一个红墙映出淡淡的红色,得手写一堆近似技巧;想看到软阴影,往往得靠Shadow Map加一大堆滤波,还不能保证物理正确。换句话说,光栅化擅长回答“这个三角形该不该出现在屏幕上”,却很难回答“这个像素的颜色到底该是多少”。
更麻烦的是,光栅化处理“可见性”是基于几何投影的:从相机出发把三维物体投到二维平面。这本身没错,但它天然缺少一种反向思考——如果我从某个像素出发,反过来看它会碰到哪件物体呢?光线追踪的思路正是把这个顺序翻过来,这也是为什么它能在渲染效果上把光栅化按在地上摩擦。
1.2 光线追踪的直觉:把光路反过来走
我曾经给学生时代的一位同桌解释光线追踪,用的类比是“把电影倒着放”:现实世界里,光源发出光子,光子在场景里弹来弹去,最后有一部分进入人眼。光线追踪干的事则是让光线从相机出发,穿过每个像素,往场景里走,看它撞到什么东西,再顺着撞到的表面去分析光从哪来。这听起来像是在“倒放”,但好处是:我们只追踪那些真正会进入画面的光线,剩下的直接忽略,计算量一下子可控了。
所以这一课讲的Whitted-Style Ray Tracing,本质上是一套基于递归的着色模型:光线碰到物体表面后,继续朝反射方向、折射方向、还有指向每一个光源的方向各发一条新光线,把每个方向带回来的贡献累加起来,作为这个像素的最终颜色。你有没有发现,这里没有“把所有光子都模拟一遍”的说法,它是在用少量离散光线去近似无穷多的光子,这就是它既可行又不够真实的原因所在。
2. 光线追踪的第一步:光线的数学表达与求交
2.1 光线方程与参数化
做图形学的第一件事永远是定义坐标系和数学表达。光线追踪里的光线,通常写成这样:
// 光线方程:p(t) = o + t * d, t >= 0 Vector3f o; // 起点,一般是相机位置 Vector3f d; // 方向向量,需要提前归一化这里的t代表沿着方向走多远,t>=0是为了保证我们只考虑起点前方的东西。注意一个细节:方向向量d要不要归一化,会直接决定之后求交公式里t的含义。我见过好几个初学者在这个问题上翻车,d的长度不是1,求出来的交点和后面的光源距离计算全乱套。所以我在自己的代码里干脆加了个断言:生成光线时方向向量的长度必须近似等于1。这种防御式编程在写大作业时能省下大量Debug时间。
有了这个方程,接下来所有求交都变成了“解方程”:找到满足光线方程的同时也满足物体表面方程的t。这里的t越小,说明交点越靠近相机,也就是遮挡计算里真正该被看到的点。
2.2 与球体求交:一个二次方程
场景里最常见的隐式曲面就是球。球面上的点满足||p - c||^2 = r^2,把光线方程代进去,展开之后会得到一个关于t的一元二次方程:
t^2 * (d·d) + 2*t * (d·(o-c)) + (o-c)·(o-c) - r^2 = 0因为d是归一化的,d·d=1,所以系数能简化很多。接下来就是初中数学:判别式小于0,说明光线没碰到球;等于0说明擦到边缘(一般渲染里当作没碰到处理);大于0就取较小那个正根,因为那才是光线“最先”碰到球的位置。
这里我给你一个特别实用的建议:在实现球体求交时,把o-c这个向量提前算出来并复用,避免在代码里重复写三次一样的减法。不是性能问题,而是可读性问题。那时我写的第一个版本里全是一长串内联计算,最后调不出来都不知道是哪个分量出了问题。
2.3 与三角形求交:重心坐标与Möller-Trumbore算法
场景里真正大量出现的是显式几何,尤其是三角形网格。和三角形求交,最经典的办法是Möller-Trumbore算法。它的核心思路是:把三角形上的点用重心坐标表示,同时把交点也写成光线参数化的形式,于是变成三个未知数(t和两个重心坐标u,v)的线性方程组。
bool rayTriangleIntersect( const Vector3f& orig, const Vector3f& dir, const Vector3f& v0, const Vector3f& v1, const Vector3f& v2, float& t, float& u, float& v) { Vector3f E1 = v1 - v0; Vector3f E2 = v2 - v0; Vector3f S = orig - v0; Vector3f S1 = cross(dir, E2); Vector3f S2 = cross(S, E1); float det = dot(S1, E1); if (fabs(det) < 1e-8) return false; // 平行 float invDet = 1.0f / det; t = dot(S2, E2) * invDet; u = dot(S1, S) * invDet; v = dot(S2, dir) * invDet; return t > 0 && u >= 0 && v >= 0 && (u + v) <= 1.0f; }这段代码我在不同语言里写过不下三遍,几乎可以背下来。要注意的点有三个:第一,det绝对值接近0时说明光线和三角形几乎平行,直接返回false;第二,u和v必须在0到1之间且两者之和不超过1,否则交点虽然在三角形所在的平面里,却不在三角形内部;第三,t必须大于0,不接受负值交点。重心坐标u和v还有个额外用法——之后做纹理映射、插值法线全靠它,所以就算求交失败也别丢掉那套数学直觉。
2.4 在所有交点中找最近的命中点
光线进入场景,往往会和多个物体产生多个交点,但我们只需要最近的那个。最笨的办法就是遍历场景里所有物体,维护一个minT,每求出一个t < minT就更新它。这个办法正确,但真的非常慢。我第一版实现用100个球做测试,每帧渲染512乘512个像素,每个像素要遍历100个球,总共要算差不多2600万次求交,结果跑一张图要好几秒。这时候你就明白,为什么下一讲要讲包围盒和加速结构,光靠暴力遍历,一张复杂场景的图能把CPU烧穿。
提示:在写求交框架时,最好把“命中信息”统一封装成一个
Intersection结构体,至少包含t、碰到的物体指针、交点坐标、法线。这样后面扩展反射、折射、阴影时,代码不会变成一团乱麻。我见过很多同学的实现,每种物体单独写一个返回t的函数,到了递归追踪时恨不得写八层if else,那真的会把自己绕晕。
3. Whitted风格的核心:递归地追出反射、折射与阴影
3.1 从最近交点出发的三条追问
假设我们已经找到了像素光线在场景里的最近交点P。接下来所有着色问题都可以转化成三个问题:能不能看到光源?要不要继续反射?要不要继续折射?这就是Whitted风格的基本框架,它和后面会学到的路径追踪最大的区别也在这里——它只沿着镜面反射和折射的方向继续追,不会对漫反射表面做随机采样。所以你可以把它理解成“手电筒照镜子和玻璃球”的特效渲染器,而不是一个通用的全局光照算法。
我第一次理解这个框架时,觉得它特别像是“递归版的光照模型”:原本Blinn-Phong只算一个点对光源的直接响应,Whitted风格则把反射光线和折射光线带回来的颜色也当作“光源”,叠加到当前点。这样画面里就出现了“镜子反射镜子再反射球”的连环效果。
3.2 阴影判定:连接光源的那条测试光线
阴影的实现方式简单到让人怀疑:从交点P朝光源方向发一条“阴影测试光线”,如果这条光线在到达光源之前撞上了任何物体,说明P被遮挡,这个光源对着色点的贡献直接归零。这个逻辑看似朴素,但里面藏了几个实现细节,非常容易踩坑。
第一,阴影测试光线的起点不能原封不动放在P上。因为浮点数的精度问题,如果起点正好位于物体表面,那条阴影光线很可能刚出发就和自己所在的三角形计算出接近0的交点,导致整个表面看起来自带噪点。解决办法也很无脑:把起点沿法线方向偏移一点点,比如P + epsilon * normal。这个技巧在业界有个专门的叫法“Shadow Bias”或者“Shadow Acne修复”,也是你在选做作业里很可能遇到的第一个视觉BUG来源。
第二,如果你场景里有半透明物体,比如玻璃窗,那阴影光线要不要被它挡住?Whitted风格的经典做法是,只有不透明物体才算遮挡,半透明物体可以继续穿越并且透射部分光。这个选择会直接影响玻璃后面的阴影软硬程度。课程里没有深入展开,但做题时我的建议是先只考虑不透明遮挡,效果稳定了再去折腾折射参与阴影。
第三,阴影测试光线本身也要做最近交点检查。不过这里不同的是,我们只需要判断t是否小于“光源距离”。换句话说,只要在[0, distanceToLight]区间内有交点,就可以直接返回被遮挡。很多人在这一步还傻乎乎地把所有交点都求一遍,浪费了不少性能。
3.3 镜面反射与折射的递归追踪
在P处继续追反射光线时,新的光线方向就是我们熟悉的反射公式:r = d - 2 * (d·n) * n。这条反射光线从P出发,继续进入场景求交,得到的颜色再乘上一个反射系数,加到当前颜色上。折射方向则要用到斯涅尔定律,并引入折射率eta。这里最难的地方在于:光线进入物体和穿出物体时,法线方向是相反的,折射率的比值也是反着的,如果不多加小心,写出来的折射之字路径会直接导致画面一团黑。
我当时调试折射时用过一个很土的办法——把折射光线的方向打印出来,再用画线工具在场景里可视化。一开始总是发现光线钻进玻璃球后直接朝完全错误的方向飞走了,检查半天才发现是法线方向和eta没有配合好。后来我把“是否从外部进入物体”作为参数传给折射函数,而不是靠点积的符号去猜,这才稳住。
反射和折射的递归同样需要“深度限制”。一个典型场景是两面镜子面对面,光线可以无限次反射,如果不限制深度,你的程序会一直追到栈溢出。课程推荐的深度一般是4到8层,超过限制后直接返回一个默认黑色或者环境光颜色。你想想,第8次反射之后携带的能量已经衰减到肉眼几乎看不出来,继续追下去只是在浪费算力。
3.4 终止递归:深度限制与能量衰减
除了深度限制,还有个更物理的做法:每经过一次反射或折射,就把携带的颜色乘上一个小于1的系数(比如反射率Kr和折射率Kt)。这样一来,递归越深,颜色衰减越厉害,最后自然趋近于零,也就能在视觉上伪造“能量守恒”。
从工程角度看这两种手段缺一不可:深度限制是硬保底,防止无限递归;能量衰减是软手段,保证颜色平衡。我自己在实现时是这样写的:递归函数入口先判断深度,如果深度为0直接返回黑色;然后把当前节点的最终颜色叠加Kr或Kt之后再返回。这样做有几个好处,一来后续想加全局光照算法时,递归框架不用推翻重来;二来调试时能用深度着色模式一眼看出递归走到了几层。
4. 把理论落到像素上:光线生成与画面质量
4.1 从相机到像素的光线构造
Whitted风格光线追踪的整体流程,说穿了就是逐像素做求交和着色。但是“从相机发出的光线要怎么算”,也是新手很容易卡住的一个点。GAMES101用的这套正交/透视相机定义里,光线方向其实可以从像素坐标反推:先把像素坐标映射到裁剪空间,再根据相机的fov、宽高比构造方向向量。
我的建议是,不要先想高级的相机模型,而是用最简单的透视相机开始:假设相机在原点,看向-Z轴,光线起点固定为(0,0,0),通过每个像素中心计算方向。比如一个图像宽为w、高为h,像素(i,j)对应的光线方向可以这样算:
aspect = w / h tanHalfFov = tan(fov * 0.5 * pi / 180) x = (2 * (i + 0.5) / w - 1) * tanHalfFov * aspect y = (1 - 2 * (j + 0.5) / h) * tanHalfFov dir = normalize(x, y, -1)这里把像素中心加0.5,是为了让光线穿过像素而不是穿过像素左下角。也可以说,从几何上看,加0.5是在做一次“点采样”的坐标校正:光线的落点正好覆盖像素中心,后续采样模式才会比较对称。
强调一下:相机在世界空间不一定永远在原点,所以更通用的做法是把上述方向和起点乘上相机的视图变换矩阵。不过在课程作业里,通常可以先固定视角,等到整个流程跑通再去改相机,这样排查问题时变量少很多。
4.2 一个像素只打一条光线为什么会出现锯齿
课程最后对“每个像素只打一根光线”做了一定数量的分析,说一句扎心的实话:这个做法天然会带来锯齿。原因很简单——每个像素的记录结果只取决于一个点的颜色,像素内部的高频细节(比如几何边缘)全被忽略掉了。一根光线的采样根本不足以代表整个像素区域的照明情况。
一个立马见效的改进是超采样抗锯齿,也称SSAA:每个像素按N x N的网格采样N^2条光线,把颜色平均,再写进像素。比如4乘4超采样会把计算量放大16倍,图像边缘立刻变得平滑。我在做作业时从1乘1改到2乘2,画面质量提升就已经非常明显,再往上改,计算时间开始让人难以接受。这个权衡也解释了为什么之后会路径追踪里、点采样和蒙特卡洛方法会那么重要——组合数学的直觉通常就是从这样一个简单的反锯齿问题开始的。
4.3 简单的并行化思路
逐像素之间的计算是完全独立的,所以光线追踪天生适合并行。用CPU实现时,最简单的方式就是开多个线程,每个线程处理图像的不同行或者分块。用C++的话,可以直接用OpenMP的#pragma omp parallel for,几行代码就能让渲染速度翻好几倍。
不过并行之后要注意一个很小的问题:多个线程同时往同一个输出图像缓冲区里写不同像素,本身是安全的,因为大家写的位置不一样;但如果你的代码里用了全局变量来存“命中次数”或“调试日志”,就会出现数据竞争。我当时渲染到一半发现球面上出现随机亮点,排查半天,最后发现是一个全局计数器在并行时被多线程搞乱了,顺手写进像素颜色里做可视化。所以并行化做得越早,越要早一点把“所有状态都收进局部变量”这个习惯培养起来。
5. 实操中容易踩的坑与排查技巧
5.1 交点浮点误差:epsilon偏移与自相交
这是做光线追踪第一个真正让新手心态崩溃的问题:明明物体几何没问题,着色器也没问题,画面上却布满了一闪一闪的黑点和亮斑,尤其是球体和平面相接的地方最严重。原因我用一句话总结就是“浮点数骗了你”:你求得的交点本质上是一个近似值,它可能与真实表面存在一点点偏差,于是后续从P出发的反射光线、阴影光线的起点要么在表面里,要么在表面外,和自己所在的几何体发生本不该有的相交。
规模比较小的解决方案是:在生成反射光线和阴影光线时,把起点沿法线方向偏移一个小量。这个值我记得在单精度浮点下常用1e-4或1e-3,具体大小最好根据场景尺寸确定。场景很大的时候,1e-4可能不够;场景很小时,1e-3又可能把起点推到物体外部太远,反而造成漏交。课程里几位助教答疑时也反复提过:这不是一个理论公式能解决的,是一个实践参数,得针对场景调。
5.2 法线、光线方向归一化与向量叉积方向
很多求交和着色代码里用了cross,但cross的结果方向和两条向量所在平面的“正面”有关,具体遵循右手定则。如果你在构造三角形时顶点顺序不统一——有时候顺时针、有时候逆时针——计算出的法线就会时而朝外、时而朝内。朝内的法线会让光照变暗甚至变黑,并且这个现象通常是“有的面亮有的面暗”,看起来就像棋盘格。
我后期用了一个土办法:把场景里所有三角形的法线用可视化模式画出来,一眼就能看出哪些三角形顶点的绕序反了。另外,每次从交互器输入模型时,尽量先跑一遍“统一顶点绕序”的预处理,别把这个问题留到渲染阶段。
5.3 阴影偏移与“阴影 acne”
阴影测试光线最容易出现的视觉问题,就是你会在原本该平滑的阴影边界附近看到密密麻麻的小黑点,这个现象英文叫shadow acne(阴影痤疮),形象到不用解释。它本质上也是相交自干扰问题,和5.1节说的是一样的原因,只是表现形态不同。
处理方式除了加偏移之外,还可以在阴影测试中把命中区间设为(epsilon, distanceToLight - epsilon)。我个人的习惯是,起点的偏移做粗调,命中区间的截断做微调。毕竟阴影光线上只关心有没有被挡,不关心精确的交点位置,所以直接在区间两端都放宽一点,效果往往更好。
5.4 常见的灰屏/全黑/亮斑问题速查表
做这个课后小作业时,我所有踩过的坑基本可以归成下面这几类。与其在代码里瞎试,不如对着这张表现清单逐条对照:
| 现象 | 常见原因 | 排查顺序 |
|---|---|---|
| 全黑画面 | 光线方向没归一化,导致所有t算错 | 先在特定像素打印光线方向和t |
| 部分物体全黑 | 法线方向反了 | 可视化法线,检查顶点绕序 |
| 画面有锯齿但结构正确 | 每像素只打了一条光线 | 开2x2超采样验证 |
| 阴影区域充满噪点 | 阴影光线起点没加偏移 | 给阴影光线加epsilon偏移 |
| 反射面上面有条纹 | 交点自相交 | 检查反射起点偏移和深度限制 |
| 渲染速度慢到无法接受 | 暴力遍历所有三角形 | 先缩场景规模,确认正确后再考虑加速结构 |
| 玻璃物体内部全黑 | 折射方向或折射率处理错误 | 用可视化光路检查法线翻转逻辑 |
对这些问题的通用排查方式是“单像素调试”:选图像中央一个像素,把它的光线参数、命中物体、递归深度全部打印出来,一步步人工推演结果。这个方法笨,但比在屏幕上瞎猜要快得多。我每写一个递归层次,都会先在画面最中间的像素上做断点调试,等它对了,再放开全图渲染。
最后再分享一点个人的复习心得
这套课的知识密度决定了它不适合“一遍看懂”,而是需要配合代码才能真正消化。我复习第十三讲时,最大的收获不是背下了Möller-Trumbore公式,而是理解了为什么课程要从“光线-物体求交”讲到“递归追踪”,最后还要预告下一讲的加速结构——因为它本质上是一个从“正确”迈向“快速”的过程。如果你现在做的实现又慢又不完美,完全不用担心,先把画面做到“逻辑上正确”,再去追求性能,这条路子是顺的。
另外,我强烈建议你做一个这样的实验:把反射深度从1改到5,把每像素采样从1改到4,渲染同一张场景,然后对比每一档的差异。很多人体会不到Whitted风格的精妙之处,就是因为在现行教程里只跑了一遍默认参数,压根没看过参数变化带来的效果对比。这个实验做完,我相信你对递归光线追踪的理解会比只看课件深得多。