1. 从像素为什么这么假说起光线追踪到底在解决什么问题很多人第一次接触三维渲染脑子里都会有个疑问为什么有些画面一眼看上去就是游戏感而有些画面却像照片一样真实这个差距很大程度上就卡在光照计算这一步。传统的光栅化渲染管线本质上是把三维物体投影到二维屏幕上然后逐像素填充颜色光照效果靠各种经验公式去骗眼睛。而光线追踪走的是另一条路——它反过来从摄像机出发往场景里发射光线去追问这条光线最终打到了什么、又反射到了哪里。这个思路的转变是计算机图形学里一个非常关键的分水岭。光栅化关心的是这个三角形覆盖了哪些像素光线追踪关心的是这个像素对应的那条光线在场景里经历了什么。前者是物体驱动后者是像素驱动。听起来只是顺序调换但带来的效果差异是巨大的软阴影、镜面反射、折射、焦散、全局光照这些高级感十足的效果在光线追踪里几乎是自然涌现的而在光栅化里往往要靠一堆技巧去模拟。我写这篇东西不是要给你复述一遍教科书上的渲染方程而是想把光线追踪这套算法从能跑起来到跑得对、跑得快的完整链路拆开讲清楚。适合谁看如果你已经写过基础的图形程序知道向量、矩阵、射线求交大概是怎么回事但一到自己动手实现光线追踪就卡壳或者写出来了但画面噪点一堆、速度慢得离谱那这篇内容应该能帮到你。我会把原理、代码结构、性能优化、常见坑都过一遍尽量做到你照着就能复现。先说结论光线追踪的核心难点从来不是发射光线这个动作本身而是三件事——求交的效率、递归的深度控制、采样的质量。这三件事决定了你的渲染器是玩具还是能用的工具。下面我按这个逻辑一层层展开。2. 光线追踪的数学骨架一条光线如何被描述和求交2.1 光线的参数方程与它的几何直觉光线在数学上就是一个射线标准写法是P(t) O t * D其中 O 是起点originD 是方向向量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 ≥ 0v ≥ 0u 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 NoneMö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 的威力最常用的加速结构是 BVHBounding Volume Hierarchy包围体层次结构。它的思想是先用一个大的包围盒把整个场景框住然后递归地把场景里的物体分成两组每组再各自算包围盒一直分下去形成一棵二叉树。求交的时候先测光线和大包围盒如果不相交整棵子树直接跳过如果相交再往下测子包围盒逐层缩小范围。这个结构能把求交复杂度从 O(N) 降到 O(log N) 级别。实测下来10 万三角形的场景用 BVH 之后渲染速度能提升几十倍甚至上百倍。BVH 的构建有两种主流策略按物体数量中位数分割median split和按表面积启发式分割SAHSurface 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 分阶段推进别想一口吃成胖子我建议按这个顺序推进每一步都能看到成果不容易半途而废第一阶段只支持球体只做直接光照输出一张有明暗的图。目标是跑通发射光线-求交-着色的主循环。第二阶段加入平面和三角形支持多个物体实现最近交点选择。这时候你应该能看到一个简单的三维场景。第三阶段加入阴影射线实现硬阴影。画面立刻就有立体感了。第四阶段加入反射和折射实现递归追踪。玻璃球和镜面球是这一阶段的标志性成果。第五阶段加入 BVH 加速把渲染时间从分钟级降到秒级。第六阶段加入软阴影、景深、抗锯齿追求画质。每个阶段都单独验证出问题了容易定位。我见过有人一上来就想写完整的路径追踪器结果卡在某个细节上整个项目就搁置了。6.2 材质系统的设计别把逻辑写死新手容易把材质逻辑硬编码在着色函数里比如if 是玻璃 then 折射 else if 是镜子 then 反射。这样写一开始快但后面想加新材质就得改核心代码很痛苦。正确的做法是把材质抽象成一个结构体包含颜色、反射率、折射率、粗糙度等参数着色函数只负责读取参数、执行通用逻辑。这样加新材质只需要改数据不用动代码。class Material: def __init__(self, color, reflectivity0.0, transparency0.0, ior1.5, roughness0.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 学习路径的几点个人体会如果你是想系统学图形学我的建议是先动手写一个软光栅化器理解顶点变换、裁剪、光栅化、插值这一套。然后再写光线追踪理解光照和全局效果。顺序反过来也行但先光栅化后光线追踪知识衔接会更顺。数学基础方面线性代数是重中之重向量点乘叉乘、矩阵变换、齐次坐标这些必须熟练。微积分和概率论在做路径追踪的时候会用到但入门阶段可以先放一放等需要了再补。最后说一句光线追踪这个方向看十篇文章不如自己写一遍。很多坑只有亲手踩过才知道疼很多优化只有亲手调过才有感觉。上面这些内容你照着搭一个最小实现跑出第一张图再逐步加功能基本上就能把核心脉络摸清楚了。后面想深入路径追踪、重要性采样、双向路径追踪、光子映射一路走下去够你玩很久。
阅读完成 · 觉得有帮助?