上一篇我们聊了光栅化——它快,是因为"每个三角形各画各的"。但阴影、反射、折射这些跨物体的光学效果,它天生做不好,只能靠各种 hack 勉强糊弄。

那光线追踪是怎么解决这个问题的?答案很简单:把整个流程翻过来。

光栅化是从物体出发,问"这个三角形覆盖了哪些像素";光线追踪是从像素出发,问"这个像素看到了什么"。

这一篇,我们就跟着一条光线,走完它从相机到场景的完整旅程。

从像素出发:发射一条"视线"

想象你站在一个房间的窗前,透过窗户看外面的世界。你看到某个点上的颜色,是因为从那个点出发的光线穿过了窗户、进入了你的眼睛。

光线追踪"作弊"了——它反着走:从你的眼睛出发,穿过窗户上每一个小格子(像素),射向场景,看它撞到什么东西。

具体来说,对于屏幕上 1920×1080 个像素中的每一个,光线追踪做三件事(12):

  1. 从相机位置发出一条射线,穿过这个像素的中心,射向场景
  2. 找到这条射线在场景中 最先碰到 的物体
  3. 根据这个碰点的材质、光照等信息,算出这个像素的颜色

这条从相机出发的射线叫 主光线(primary ray)。它的方向很好算:从相机位置到像素中心连一条线,延伸出去就是。

用伪代码表示就是:

1for (每个像素 in 屏幕) {
2    发射一条主光线穿过这个像素的中心
3    for (每个物体 in 场景) {
4        检测光线是否与这个物体相交
5    }
6    取最近的交点,计算颜色,写入像素
7}
光线追踪的基础示意:从相机位置通过像素发射光线进入场景

两层循环——外层是像素,内层是场景中所有物体——正好和光栅化反过来。1

找到"最先碰到"的那一个

一条光线射出去,可能穿过好几个物体——比如先穿过一个玻璃杯,再撞到后面的墙。但每个像素只能显示离相机 最近 的那个物体的颜色。

所以光线追踪在做相交测试时,不是"碰到第一个就算",而是 遍历场景中所有物体,记录下最近的交点。这个"最近"的判定,靠的是光线参数方程里的参数 tt

P(t)=O+tDP(t) = O + t \cdot D

其中 OO 是光线起点,DD 是方向,tt 是沿着光线走的距离。tt 越小,交点越近。遍历所有物体后,取 tt 最小的那个交点,就是像素真正应该显示的东西。3

这个机制和光栅化里的深度缓冲(z-buffer)做的事一样——解决"谁在前面"的问题。但光线追踪用一个统一的框架就能搞定,不需要额外的深度缓冲。

在交点处"着色":本地光照

找到交点之后,要算出这个点应该是什么颜色。这涉及三个因素(24):

  • 物体的材质(颜色、粗糙度、是否反光等)
  • 光源的位置和强度
  • 观察者的方向

最简单的着色模型是 Phong 模型,它把光照分成三部分:环境光(ambient,一个常数,让暗部不至于全黑)、漫反射(diffuse,粗糙表面的散射)、镜面高光(specular,光滑表面的亮斑)。

但到这里为止,光线追踪还没有展现出比光栅化高明的地方——上面的计算,光栅化也能做。

真正的杀手锏,在下一步。

递归追踪:反射、折射、阴影

这是 Whitted 在 1980 年那篇经典论文里引入的核心思想(5):在交点处,不只算本地光照,还要继续发射新的光线,去"问"周围的环境。

具体来说,从交点出发会发射三种 次级光线(secondary rays)

反射光线(reflection ray):如果物体表面是光滑的(比如镜子、金属、水面),就在交点处按反射定律弹出一条新光线,继续追踪它撞到什么。那条新光线的颜色,会成为这个反射点颜色的一部分。

折射/透射光线(transmission/refraction ray):如果物体是透明的(比如玻璃、水),就按斯涅尔定律算出光线穿过物体后的方向,继续追踪。这就能产生玻璃球后面物体扭曲变形的效果。

阴影光线(shadow ray):从交点向每一个光源发射一条光线。如果这条光线在半路被其他物体挡住了,说明这个交点处于阴影中——光源的贡献就不计算。25

这些次级光线自己又会碰到新的物体,在那些新交点上继续发射次级光线……递归下去,直到达到设定的最大深度(比如 5 层、10 层),或者光线射出了场景。

主光线与次级光线(反射、折射、阴影)的递归追踪示意

为什么这能"自然产生"真实光影?

对比一下光栅化和光线追踪,你就明白了。

在光栅化里,要做一个镜面反射,程序员得额外渲染一张"环境贴图"贴在镜子上——而且这张图是预先烘焙好的,场景里动态移动的物体反射不出来。

但在光线追踪里,反射是 自然发生 的:主光线打到镜面,反射光线弹出去,撞到场景里的另一个物体——那个物体的颜色就直接被反射回来了。不需要预烘焙,不需要额外 hack,场景里任何物体动了,反射内容自动跟着更新。12

同理,阴影在光线追踪里就是"从交点向光源射一条光线,看它有没有被挡住"——简单、直接、物理正确。玻璃折射也是,光线穿过玻璃时按折射定律偏折,再在另一面继续追踪,扭曲效果自然产生。

这就是为什么光线追踪渲染的画面会有那种"突然变真"的感觉:阴影不是硬邦邦的一团黑,反射不是贴上去的假图片,玻璃不是半透明的塑料片。 所有效果都是同一个物理模拟过程的自然产物。

那代价呢?

代价就是计算量,而且是巨大的计算量。

想想看:每个像素要发射一条主光线,每条主光线如果打到反射面,又要发射反射光线,反射光线再打到物体,可能还要发射阴影光线……场景有 100 万个三角形,每一条光线都要和这些三角形做相交测试。3

1980 年 Whitted 用他的算法渲染一张图,分辨率 512×512,花了 好几十分钟。直到 2018 年英伟达推出 RTX 系列显卡,才第一次让光线追踪在实时游戏里跑起来——靠的是专门为光线/三角形相交测试设计的硬件加速单元。

但那是下一篇的内容了。今天你只需要记住光线追踪的核心理念:从像素出发,沿着光线的路径反向追踪,在每一个交点问"你看到了什么",然后递归地追问下去。 这种"一条光线的旅程",让阴影、反射、折射这些跨物体的光学效果,从"需要额外 hack 的难题"变成了"自然发生的副产品"。