☰
光线追踪从入门到实践:求交、递归与性能优化全解析
2026/10/12 16:08:14 网站建设 项目流程

1. 从"像素为什么这么假"说起:光线追踪到底在解决什么问题

很多人第一次接触三维渲染,脑子里都会有个疑问:为什么有些画面一眼看上去就是"游戏感",而有些画面却像照片一样真实?这个差距,很大程度上就卡在光照计算这一步。传统的光栅化渲染管线,本质上是把三维物体投影到二维屏幕上,然后逐像素填充颜色,光照效果靠各种经验公式去"骗"眼睛。而光线追踪走的是另一条路——它反过来,从摄像机出发,往场景里发射光线,去追问"这条光线最终打到了什么、又反射到了哪里"。

这个思路的转变,是计算机图形学里一个非常关键的分水岭。光栅化关心的是"这个三角形覆盖了哪些像素",光线追踪关心的是"这个像素对应的那条光线,在场景里经历了什么"。前者是物体驱动,后者是像素驱动。听起来只是顺序调换,但带来的效果差异是巨大的:软阴影、镜面反射、折射、焦散、全局光照这些"高级感"十足的效果,在光线追踪里几乎是自然涌现的,而在光栅化里往往要靠一堆技巧去模拟。

我写这篇东西,不是要给你复述一遍教科书上的渲染方程,而是想把光线追踪这套算法从"能跑起来"到"跑得对、跑得快"的完整链路拆开讲清楚。适合谁看?如果你已经写过基础的图形程序,知道向量、矩阵、射线求交大概是怎么回事,但一到自己动手实现光线追踪就卡壳,或者写出来了但画面噪点一堆、速度慢得离谱,那这篇内容应该能帮到你。我会把原理、代码结构、性能优化、常见坑都过一遍,尽量做到你照着就能复现。

先说结论:光线追踪的核心难点从来不是"发射光线"这个动作本身,而是三件事——求交的效率、递归的深度控制、采样的质量。这三件事决定了你的渲染器是玩具还是能用的工具。下面我按这个逻辑一层层展开。

2. 光线追踪的数学骨架:一条光线如何被描述和求交

2.1 光线的参数方程与它的几何直觉

光线在数学上就是一个射线,标准写法是:

P(t) = O + t * D

其中 O 是起点(origin),D 是方向向量(direction,通常归一化),t 是一个非负的标量参数。t 的物理意义就是"沿着这个方向走了多远"。这个式子看着简单,但它是整个光线追踪的基石。你后面所有的求交、着色、递归,都是围绕这个 t 展开的。

为什么用参数方程而不是直接用两个点?因为参数方程天然适合做"最近交点"的判断。场景里可能有多个物体和这条光线相交,每个交点对应一个 t 值,我们只要取最小的那个正 t,就是光线第一个撞上的东西。这个"取最小正 t"的操作,是光线追踪里出现频率最高的逻辑,没有之一。

方向向量一定要归一化,这点新手特别容易忽略。如果你不归一化,t 的数值就不再是真实距离,后面做阴影判断、衰减计算、深度比较的时候全都会出问题。我见过有人调了半天阴影不对,最后发现就是方向向量没归一化,t 值被缩放了好几倍,导致阴影射线还没走到遮挡物就"超时"了。

2.2 球体求交:最经典的入门案例

球体求交是光线追踪的"Hello World",因为它有解析解,推导干净。球心 C、半径 r,光线要满足:

|P(t) - C|² = r²

把 P(t) = O + tD 代进去,展开整理成一个关于 t 的一元二次方程:

a = D·D b = 2 * D·(O - C) c = (O - C)·(O - C) - r²

判别式 Δ = b² - 4ac。Δ < 0 说明光线和球没交点;Δ = 0 是相切;Δ > 0 有两个交点,取较小的那个正根。这里有个细节:如果 D 已经归一化,那么 a 恒等于 1,可以省掉一次点乘,代码里直接写死。这种小优化在单条光线上看不出来,但一帧几百万条光线的时候,省下的就是实打实的时间。

代码大概长这样:

def intersect_sphere(origin, direction, center, radius): oc = origin - center a = direction.dot(direction) b = 2.0 * oc.dot(direction) c = oc.dot(oc) - radius * radius discriminant = b * b - 4 * a * c if discriminant < 0: return None sqrt_d = math.sqrt(discriminant) t1 = (-b - sqrt_d) / (2 * a) t2 = (-b + sqrt_d) / (2 * a) if t1 > 1e-4: return t1 if t2 > 1e-4: return t2 return None

注意那个1e-4的 epsilon。这是为了避免"自相交"问题——光线从物体表面出发去打别的物体时,如果起点正好在表面上,可能会立刻和出发的那个物体再次相交,t 接近 0。加一个小的偏移量能规避掉大部分这类伪交点。这个 epsilon 取多大有讲究,太小挡不住浮点误差,太大又会让近距离的物体被"穿透",一般取 1e-4 到 1e-3 之间,具体看你的场景尺度。

2.3 三角形求交:为什么它是工业界的核心

球体好看,但真实场景里绝大多数模型都是三角网格。三角形求交才是真正干活的部分。最常用的方法是 Möller–Trumbore 算法,它的巧妙之处在于用重心坐标直接判断交点是否在三角形内部,不需要先求平面交点再判断。

思路是这样的:三角形三个顶点 V0、V1、V2,三角形内任意一点可以表示为:

P = V0 + u*(V1 - V0) + v*(V2 - V0)

其中 u ≥ 0,v ≥ 0,u + v ≤ 1。同时这个点又在光线上,所以 P = O + tD。两个式子联立,得到三个未知数 t、u、v 的线性方程组,用克拉默法则解出来。如果解出的 u、v 满足上述条件,且 t > 0,就是有效交点。

def intersect_triangle(origin, direction, v0, v1, v2): edge1 = v1 - v0 edge2 = v2 - v0 h = direction.cross(edge2) a = edge1.dot(h) if abs(a) < 1e-8: return None # 光线平行于三角形平面 f = 1.0 / a s = origin - v0 u = f * s.dot(h) if u < 0.0 or u > 1.0: return None q = s.cross(edge1) v = f * direction.dot(q) if v < 0.0 or u + v > 1.0: return None t = f * edge2.dot(q) if t > 1e-4: return t return None

Möller–Trumbore 的好处是快,而且不需要预先算平面方程。但它的数值稳定性在极端情况下(比如光线几乎平行于三角形)会退化,所以那个abs(a) < 1e-8的判断不能省。我实测下来,对于一般场景这个算法足够稳,但如果你的模型有大量细长三角形,可能需要考虑双精度或者换用更稳的算法。

2.4 求交之后:法线、材质与着色

求交只是第一步,拿到交点之后要算这个点的法线。球体的法线就是 (P - C) 归一化,三角形的法线是两条边的叉积归一化。如果模型带顶点法线,还要用重心坐标做插值,这样曲面看起来才平滑,不然会有一块块的"面片感"。

法线拿到之后,进入着色阶段。最基础的是 Lambert 漫反射加 Phong 高光,但既然都上光线追踪了,通常我们会直接上更物理的模型。这里的关键是:光线追踪的着色不是一次性的,而是递归的。光线打到镜面物体上,要反射出去继续追;打到透明物体上,要折射进去继续追。这个递归结构,才是光线追踪真正的威力所在。

3. 递归光线追踪:反射、折射与阴影是怎么串起来的

3.1 递归结构的设计与终止条件

一个完整的光线追踪函数,大致是这样的骨架:

def trace_ray(origin, direction, depth): if depth > MAX_DEPTH: return background_color hit = find_closest_hit(origin, direction) if hit is None: return background_color color = local_shading(hit) if hit.material.reflective: reflect_dir = reflect(direction, hit.normal) reflect_color = trace_ray(hit.point + epsilon * hit.normal, reflect_dir, depth + 1) color += hit.material.reflectivity * reflect_color if hit.material.transparent: refract_dir = refract(direction, hit.normal, hit.material.ior) refract_color = trace_ray(hit.point - epsilon * hit.normal, refract_dir, depth + 1) color += hit.material.transparency * refract_color return color

这个递归结构看着优雅,但有两个必须处理好的点。第一是终止条件,也就是 MAX_DEPTH。如果不设上限,两个镜面物体面对面放,光线就会无限来回反射,直接栈溢出。一般设 5 到 10 层就够了,再深的效果人眼基本分辨不出来,纯属浪费算力。第二是递归的代价,每多一层,光线数量就翻倍,指数级增长。所以实际工程里,深度控制是性能调优的第一杠杆。

3.2 反射向量的推导与实现

反射向量的公式是:

R = D - 2*(D·N)*N

其中 D 是入射方向(指向表面),N 是归一化法线。这个公式的几何意义是:把入射向量分解成法线方向和切线方向两部分,法线分量反向,切线分量不变,合起来就是反射方向。推导过程用向量投影就能得到,不难,但一定要确保 N 是归一化的,否则反射方向会偏。

有个容易踩的坑:反射光线的起点。如果你直接用交点作为起点,由于浮点精度问题,光线可能立刻和原物体再次相交,产生"自反射"的黑点或者噪点。正确做法是沿着法线方向偏移一个 epsilon,也就是hit.point + epsilon * hit.normal。这个偏移方向要和光线出射方向一致,反射是往外走,折射是往里走,所以折射的时候要减 epsilon。

3.3 折射与斯涅尔定律

折射稍微麻烦一点,因为涉及折射率。斯涅尔定律说:

n1 * sin(θ1) = n2 * sin(θ2)

其中 n1、n2 是两种介质的折射率,θ 是光线与法线的夹角。写成向量形式,需要先判断光线是从外部进入还是从内部射出,因为法线方向要相应翻转。如果发生全反射(入射角超过临界角),折射就不存在了,全部能量反射回去。

def refract(direction, normal, ior): cos_i = -direction.dot(normal) if cos_i < 0: normal = -normal cos_i = -cos_i eta = ior else: eta = 1.0 / ior sin2_t = eta * eta * (1.0 - cos_i * cos_i) if sin2_t > 1.0: return None # 全反射 cos_t = math.sqrt(1.0 - sin2_t) return eta * direction + (eta * cos_i - cos_t) * normal

全反射这个分支一定要处理,不然会出现数值错误。玻璃、水面这些材质,边缘处的全反射效果恰恰是真实感的重要来源,处理好了会很出彩。

3.4 阴影射线:硬阴影与软阴影的分野

阴影的判断逻辑很直接:从交点向光源方向发一条射线,如果中途撞到任何物体,说明这个点被遮挡,处于阴影中。这条射线叫阴影射线(shadow ray),它只关心"有没有交点",不关心最近的是哪个,所以一旦发现交点就可以立刻返回,效率很高。

但这样得到的是硬阴影,边缘锐利,看起来不真实。真实世界的光源都有面积,阴影边缘是渐变的,这叫软阴影。软阴影的做法是在光源上采样多个点,每个点发一条阴影射线,最后统计被遮挡的比例,作为阴影的浓度。采样点越多,阴影越柔和,但代价是光线数量成倍增加。实际项目里通常用抖动采样,比如在光源面上随机取 16 个点,配合降噪,效果和性能能取得不错的平衡。

提示:阴影射线不需要做完整的求交,只要判断"是否相交"即可,所以求交函数可以写一个只返回布尔值的快速版本,省掉计算最近交点的开销。

4. 性能这道坎:从能跑到跑得动的优化路径

4.1 朴素实现的性能瓶颈在哪

如果你按上面的思路写一个最朴素的光线追踪器,渲染一张 800x600 的图,每个像素一条主光线,加上反射折射和阴影,可能要跑好几分钟甚至更久。瓶颈非常明确:每条光线都要和场景里所有物体求交。假设场景有 10 万个三角形,一张图 48 万像素,每个像素平均发 5 条光线,那就是 240 万条光线乘以 10 万次求交,2400 亿次运算。这个量级,再快的 CPU 也扛不住。

所以优化的核心思路只有一个:减少不必要的求交。一条光线从摄像机出发,其实只可能和场景里很小一部分物体相交,大部分物体离这条光线十万八千里,根本不用测。问题是怎么快速排除掉这些"不可能相交"的物体。

4.2 包围盒与层次结构:BVH 的威力

最常用的加速结构是 BVH(Bounding Volume Hierarchy,包围体层次结构)。它的思想是:先用一个大的包围盒把整个场景框住,然后递归地把场景里的物体分成两组,每组再各自算包围盒,一直分下去,形成一棵二叉树。求交的时候,先测光线和大包围盒,如果不相交,整棵子树直接跳过;如果相交,再往下测子包围盒,逐层缩小范围。

这个结构能把求交复杂度从 O(N) 降到 O(log N) 级别。实测下来,10 万三角形的场景,用 BVH 之后渲染速度能提升几十倍甚至上百倍。BVH 的构建有两种主流策略:按物体数量中位数分割(median split)和按表面积启发式分割(SAH,Surface Area Heuristic)。SAH 构建慢一点,但查询效率更高,工业级渲染器基本都用 SAH。

包围盒求交用的是平板法(slab method),对每个轴计算光线进入和离开包围盒的 t 值,取所有轴进入 t 的最大值和离开 t 的最小值,如果最大值小于最小值,说明光线穿过了包围盒。

def intersect_aabb(origin, inv_dir, bbox_min, bbox_max): t_min = (bbox_min - origin) * inv_dir t_max = (bbox_max - origin) * inv_dir t_near = max(min(t_min.x, t_max.x), min(t_min.y, t_max.y), min(t_min.z, t_max.z)) t_far = min(max(t_min.x, t_max.x), max(t_min.y, t_max.y), max(t_min.z, t_max.z)) return t_near <= t_far and t_far > 0

注意这里用了inv_dir,也就是方向向量的倒数。预先算好倒数,可以避免在每次求交时做除法,这是个很实用的小优化。但要小心方向分量为 0 的情况,会产生无穷大,需要特殊处理或者用 epsilon 兜底。

4.3 并行化:多线程与 GPU

光线追踪天然适合并行,因为每条光线之间是独立的,互不依赖。CPU 上可以用多线程,把图像按行或者按块分给不同线程,每个线程负责自己那块像素。Python 因为有 GIL,多线程效果有限,建议用多进程或者直接上 NumPy 做向量化。C++ 的话,OpenMP 一行#pragma omp parallel for就能把主循环并行化,简单粗暴有效。

再往上就是 GPU。GPU 有几千个核心,天生适合这种"同一套逻辑作用在海量数据上"的任务。用 CUDA 或者计算着色器把光线追踪搬到 GPU 上,速度能再提升一到两个数量级。不过 GPU 编程的门槛在于内存管理和线程调度,BVH 的遍历在 GPU 上要做成无栈或者短栈的形式,不然递归深度一大就爆栈。这块内容展开能写一整篇,这里先点到为止。

4.4 采样与降噪:少发光线也能出好图

除了减少求交,另一个思路是减少光线数量。每个像素只发一条光线,噪点会很多,尤其是软阴影和景深效果。但如果每个像素发几百条,又太慢。折中方案是:每个像素发少量光线(比如 4 到 16 条),然后用降噪算法把噪点抹掉。

降噪的思路是利用图像的空间相关性——相邻像素的颜色通常接近,噪点是高频随机信号,可以用滤波或者机器学习的方法去掉。工业界现在流行用神经网络降噪,输入是低采样数的噪点图加上法线、深度等辅助信息,输出是干净的结果。这套方法能把采样数降低一到两个数量级,是实时渲染的关键技术之一。

注意:降噪不是万能的,它会把一些真实的细节也一起抹掉。如果场景里有高频纹理或者细小几何,降噪参数要调保守一点,不然画面会"糊"。

5. 那些文档里不写、但一定会踩的坑

5.1 浮点精度引发的"自相交"与黑点

这是光线追踪新手遇到的第一个大坑,没有之一。现象是:物体表面出现随机分布的黑点,或者反射面上有奇怪的噪点。原因就是光线从表面出发时,由于浮点精度,起点可能落在表面内侧,导致光线立刻和原物体相交,t 值接近 0,着色就错了。

解决办法前面提过,就是加 epsilon 偏移。但 epsilon 的取值是个经验活。太小(比如 1e-8)挡不住误差,太大(比如 1e-2)会让近距离物体之间出现缝隙。我的经验是,根据场景尺度来定,如果场景单位是米,物体尺寸在 1 左右,epsilon 取 1e-4 比较合适。另外,偏移方向一定要沿着法线,而不是随便加个随机向量,不然会引入新的偏差。

5.2 法线方向搞反导致的诡异光照

法线方向必须指向物体外部,这是着色的基本前提。但如果你从三角网格读数据,顶点顺序不一致,算出来的法线可能朝里。表现就是:本该亮的地方是黑的,本该有高光的地方没有。排查方法很简单,把法线可视化出来看一眼,朝外的法线应该是彩色的,朝里的会明显不对。

修复方式有两种:一是统一三角形顶点顺序(通常逆时针为正面),二是着色时判断法线和视线方向的点积,如果为负就翻转法线。第二种更鲁棒,推荐用。

5.3 递归深度与栈溢出

递归光线追踪如果不控制深度,遇到镜面走廊这种场景,光线会无限反射,程序直接崩。除了设 MAX_DEPTH,还要注意递归函数的栈空间消耗。每一层递归都要保存局部变量,深度一大,栈就爆了。C++ 里默认栈大小是 1MB 到 8MB,深度几百层就可能出问题。解决办法是把递归改成迭代,用一个显式的栈来管理,或者干脆限制深度在 10 以内。

5.4 阴影射线的"漏光"与"过度遮挡"

阴影射线有两个经典问题。一是漏光:由于 epsilon 偏移,阴影射线可能穿过薄物体,导致本该有阴影的地方出现亮斑。二是过度遮挡:如果阴影射线起点偏移方向不对,可能被原物体自己挡住,导致整个物体全黑。这两个问题的根源都是偏移量和方向没处理好。我的做法是,阴影射线的起点偏移量比反射光线稍大一点,因为阴影射线通常要跨越更长的距离,精度要求更高。

5.5 性能陷阱:在循环里做重复计算

这个坑很隐蔽,但影响巨大。比如在求交循环里,每次都重新计算方向向量的倒数、重新归一化法线、重新算包围盒的变换矩阵。这些量在一条光线的生命周期里是不变的,应该提前算好。我见过一个实现,把1/a放在三角形求交函数里每次算,改成预计算之后,整体速度提升了将近 30%。这种优化不需要改算法,纯粹是代码组织的问题,但收益非常可观。

6. 从玩具到作品:一个可复现的最小实现路线

6.1 分阶段推进,别想一口吃成胖子

我建议按这个顺序推进,每一步都能看到成果,不容易半途而废:

  1. 第一阶段:只支持球体,只做直接光照,输出一张有明暗的图。目标是跑通"发射光线-求交-着色"的主循环。
  2. 第二阶段:加入平面和三角形,支持多个物体,实现最近交点选择。这时候你应该能看到一个简单的三维场景。
  3. 第三阶段:加入阴影射线,实现硬阴影。画面立刻就有立体感了。
  4. 第四阶段:加入反射和折射,实现递归追踪。玻璃球和镜面球是这一阶段的标志性成果。
  5. 第五阶段:加入 BVH 加速,把渲染时间从分钟级降到秒级。
  6. 第六阶段:加入软阴影、景深、抗锯齿,追求画质。

每个阶段都单独验证,出问题了容易定位。我见过有人一上来就想写完整的路径追踪器,结果卡在某个细节上,整个项目就搁置了。

6.2 材质系统的设计:别把逻辑写死

新手容易把材质逻辑硬编码在着色函数里,比如if 是玻璃 then 折射 else if 是镜子 then 反射。这样写一开始快,但后面想加新材质就得改核心代码,很痛苦。正确的做法是把材质抽象成一个结构体,包含颜色、反射率、折射率、粗糙度等参数,着色函数只负责读取参数、执行通用逻辑。这样加新材质只需要改数据,不用动代码。

class Material: def __init__(self, color, reflectivity=0.0, transparency=0.0, ior=1.5, roughness=0.0): self.color = color self.reflectivity = reflectivity self.transparency = transparency self.ior = ior self.roughness = roughness

这个结构看着简单,但它决定了你的渲染器能不能扩展。粗糙度参数可以用来做磨砂金属、磨砂玻璃,只要在反射方向上加一点随机扰动就行。

6.3 调试手段:把中间结果可视化出来

光线追踪的调试比普通程序难,因为出问题的时候你看到的只是一张不对的图,不知道是哪一步错了。我的经验是,把中间结果都存成图:法线图、深度图、交点位置图、阴影测试结果图。哪一步不对,一眼就能看出来。

比如画面全黑,先看法线图,如果法线图正常,说明求交没问题,问题在着色;如果法线图也是黑的,说明求交就错了。这种二分排查的思路,能省下大量时间。

6.4 参考实现与验证方法

写完一个功能,怎么知道对不对?最直接的办法是拿一个已知结果的场景去测。比如一个漫反射球放在平面上,光源在正上方,那么球的正上方应该最亮,边缘渐暗,平面上应该有球的投影。如果结果符合预期,说明基本逻辑对了。

再进阶一点,可以用解析解验证。比如两个镜面球互相反射,理论上反射次数越多能量越弱,你可以检查颜色是不是按预期衰减。这种验证比"看着差不多"靠谱得多。

7. 光线追踪之外:它和路径追踪、光栅化的关系

7.1 光线追踪只是起点,路径追踪才是终点

严格来说,我上面讲的叫 Whitted 风格的光线追踪,它是递归的、确定性的。而现代渲染器用的多是路径追踪(Path Tracing),它是蒙特卡洛方法,每个像素随机采样多条路径,最后求平均。路径追踪能正确处理全局光照——比如颜色溢出(红墙旁边的白球会泛红),这是 Whitted 光线追踪做不到的。

但路径追踪的噪点更多,需要更多采样和降噪。所以实际项目里,往往是两者结合:用光线追踪做直接光照和镜面反射,用路径追踪做漫反射全局光照,各取所长。

7.2 和光栅化的分工

光栅化快,适合实时;光线追踪准,适合离线。现在这两条路线在融合,实时渲染里用光栅化画主体,用光线追踪做反射和阴影,这就是所谓的混合渲染。硬件上,新一代显卡都带了光线追踪加速单元,专门做包围盒求交和光线遍历,把最耗时的部分硬件化了。

对学习者来说,我的建议是:光栅化和光线追踪都要懂。光栅化帮你理解渲染管线的基本流程,光线追踪帮你理解光照的物理本质。两者不是替代关系,而是互补。

7.3 学习路径的几点个人体会

如果你是想系统学图形学,我的建议是先动手写一个软光栅化器,理解顶点变换、裁剪、光栅化、插值这一套。然后再写光线追踪,理解光照和全局效果。顺序反过来也行,但先光栅化后光线追踪,知识衔接会更顺。

数学基础方面,线性代数是重中之重,向量点乘叉乘、矩阵变换、齐次坐标这些必须熟练。微积分和概率论在做路径追踪的时候会用到,但入门阶段可以先放一放,等需要了再补。

最后说一句,光线追踪这个方向,看十篇文章不如自己写一遍。很多坑只有亲手踩过才知道疼,很多优化只有亲手调过才有感觉。上面这些内容,你照着搭一个最小实现,跑出第一张图,再逐步加功能,基本上就能把核心脉络摸清楚了。后面想深入,路径追踪、重要性采样、双向路径追踪、光子映射,一路走下去,够你玩很久。

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

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

立即咨询