资讯详情

资讯详情

光线追踪从数学原理到代码实现:递归求交、反射折射与性能优化实战

光线追踪这四个字很多刚接触图形学的朋友第一次听到会觉得挺玄乎其实说白了就一句话从你的眼睛或者摄像机出发往屏幕上的每个像素方向射一条光线看它撞到什么、弹到哪里、最后带什么颜色回来。这套思路跟光栅化那种“把三角形投影到屏幕上涂色”的做法完全是两条路。我最早做离线渲染那会儿用光栅化做出来的画面总觉得“假”——阴影硬邦邦、反射全靠贴图糊弄、透明物体更是没法看。后来咬牙啃了光线追踪才发现很多物理上正确的效果用这套算法几乎是“免费”出来的。这篇文章我打算把光线追踪从数学原理到代码落地完整捋一遍。不管你是刚学完线性代数想找个练手项目还是已经在做渲染相关工作想系统补一下理论都能从中找到能直接抄作业的东西。我会重点讲清楚为什么光线追踪能做出光栅化做不了的效果、求交运算到底怎么推导、递归深度和采样数怎么权衡、以及实际写代码时那些文档里不会告诉你的坑。整篇内容基于我自己的项目实践和踩坑记录代码用Python伪代码为主方便你迁移到任何语言。1. 光线追踪到底在解决什么问题1.1 从“画得像”到“算得对”的思维转变光栅化的核心逻辑是“把物体往屏幕上搬”。你有一个三角形算出它在屏幕空间的投影然后逐像素判断是否被覆盖覆盖了就填色。这个过程非常高效GPU就是为它设计的所以实时渲染几乎全是光栅化。但它有个根本缺陷它只关心“这个像素被哪个三角形盖住了”不关心“这个像素上的光是从哪来的”。光线追踪反过来它从屏幕出发问的是“这个像素对应的视线最终看到了什么”。这个视角的转换带来一个巨大的好处你可以沿着光路一直追下去。光线打到镜面就反射一条新光线继续追打到玻璃就折射一条继续追遇到遮挡就朝光源再射一条“阴影光线”判断是否被挡。所有这些在光栅化里需要各种trick阴影贴图、反射探针、屏幕空间反射才能近似的东西在光线追踪里是算法天然的一部分。我个人的体会是光栅化像是一个很会画画的画师靠经验和技巧把画面“画得像”光线追踪更像是一个物理模拟器它不关心像不像它只负责算算对了自然就像。这也是为什么电影级渲染几乎清一色用光线追踪因为导演要的是“对”不是“快”。1.2 核心应用场景与适用边界光线追踪最典型的应用场景有这么几类。第一类是离线渲染比如动画电影、建筑效果图、产品广告图这些场景对时间不敏感一张图渲几个小时甚至几天都行但对画质要求极高。第二类是实时预览现在很多GPU已经支持硬件光线追踪游戏和设计软件里可以做到每秒几十帧的实时光追虽然采样数低、噪点多但配合降噪算法已经能看了。第三类是科学可视化比如光学仿真、声波传播模拟这些场景里“光线”不一定是可见光但数学形式是一样的。不过光线追踪也不是万能的。它最大的问题是计算量大。一个1080p的画面有200多万个像素每个像素至少射一条主光线每条主光线可能还要递归出反射、折射、阴影光线算下来轻松上亿次求交运算。所以实际项目里必须做优化用包围盒层次结构BVH加速求交、用重要性采样减少噪点、用降噪算法弥补低采样。这些优化手段的取舍直接决定了你的渲染器是“能用”还是“好用”。注意光线追踪和路径追踪不是一回事。光线追踪是一个框架路径追踪是它的一种具体实现用蒙特卡洛方法求解渲染方程。初学者容易混淆后面我会专门讲这个区别。2. 数学基础光线、求交与着色2.1 光线的参数方程与屏幕映射一条光线在数学上就是一个射线用参数方程表示P(t) O t * D其中O是起点originD是方向directiont是参数表示沿着方向走了多远。t必须大于等于0因为光线只能往前走不能往回走。这个形式看起来简单但它是一切求交运算的基础。屏幕映射这一步很多人一开始会懵。我们最终要生成一张W×H的图每个像素(i, j)对应一条光线。怎么从像素坐标算出光线方向通常的做法是先把像素坐标归一化到[-1, 1]或者[0, 1]的范围然后根据摄像机的视场角FOV和宽高比把归一化坐标映射到摄像机前方的一个平面上最后从摄像机位置指向这个平面上的点就得到了光线方向。具体来说假设视场角是fov宽高比是aspect那么scale tan(fov * 0.5) x (2 * (i 0.5) / W - 1) * aspect * scale y (1 - 2 * (j 0.5) / H) * scale D normalize(x * right y * up forward)这里加0.5是为了取像素中心不加的话画面会偏半个像素。这个细节很小但如果你发现渲染出来的图整体偏移了八成就是这里的问题。2.2 球体与平面求交的推导过程球体求交是最经典的入门案例。球心C半径r光线P(t) O tD。球面上的点满足|P - C|² r²。把P代入|O tD - C|² r²令L O - C展开(D·D)t² 2(L·D)t (L·L - r²) 0这是一个标准的一元二次方程at² bt c 0其中a D·Db 2(L·D)c L·L - r²。判别式Δ b² - 4ac。如果Δ 0无交点如果Δ 0相切如果Δ 0两个交点取较小的正t值就是光线最先撞到的点。这里有个优化技巧如果D是单位向量那么a 1可以省掉一次点积。另外b可以写成2h的形式用h L·D这样判别式变成h² - c少算一次乘法。这些优化在单条光线上看不出来但乘以几亿条光线就很可观了。平面求交更简单。平面方程是N·P d 0N是法向量d是距离原点的偏移。代入光线N·(O tD) d 0 t -(N·O d) / (N·D)分母N·D如果接近0说明光线平行于平面无交点。t必须大于0否则交点在光线背后。2.3 法向量、材质与着色模型求交之后我们需要知道交点处的法向量才能算着色。球体的法向量就是(P - C) / r平面的法向量就是N本身。法向量必须归一化否则光照计算会出错。着色模型我推荐从Phong模型入手它虽然不物理但足够简单能让你快速看到效果。Phong模型分三部分环境光、漫反射、镜面反射。color ka * ambient kd * max(0, N·L) * lightColor ks * pow(max(0, R·V), shininess) * lightColor其中ka、kd、ks是材质系数L是光源方向V是视线方向R是反射方向。R可以用2(N·L)N - L算出来。shininess控制高光的锐利程度值越大高光越集中。实操心得Phong模型里最容易搞错的是R的方向。R是光线反射方向不是视线反射方向。如果你发现高光位置不对先检查R有没有算反。3. 递归光线追踪的完整实现3.1 主循环与光线生成整个渲染器的主循环非常直白遍历每个像素生成主光线调用trace函数把返回的颜色写入图像。for j in range(H): for i in range(W): ray generate_ray(i, j) color trace(ray, depth0) image[j][i] colorgenerate_ray就是前面讲的屏幕映射。trace函数是核心它负责求交、着色、递归。这里有个工程上的选择用递归还是用迭代递归写起来直观但深度大了会爆栈。迭代需要自己维护一个栈或者队列代码复杂但更安全。我一般先用递归把逻辑跑通如果深度超过10层再考虑改成迭代。实际项目里递归深度通常控制在5到8层再深对画质的提升微乎其微但计算量指数增长。3.2 求交函数的组织与加速结构最朴素的求交是遍历场景里所有物体逐个算交点取最近的。这叫暴力求交复杂度O(n)n是物体数量。物体少的时候没问题物体一多就卡死了。解决办法是建加速结构最常用的是BVH包围盒层次结构。思路很简单把物体用包围盒框起来包围盒再两两合并成更大的包围盒形成一棵树。求交时先测光线和根包围盒不撞就直接返回撞了再递归测子节点。这样大部分光线在树的上层就被剔除了复杂度降到O(log n)。BVH的构建有两种策略按空间划分比如中位数切分和按物体划分比如表面积启发式。表面积启发式效果更好但构建慢适合静态场景。如果场景是动态的每帧都要重建BVH那就得用更快的构建策略或者干脆用网格加速结构。def intersect_bvh(node, ray, t_min, t_max): if not intersect_aabb(node.bbox, ray, t_min, t_max): return None if node.is_leaf: return intersect_objects(node.objects, ray, t_min, t_max) left intersect_bvh(node.left, ray, t_min, t_max) right intersect_bvh(node.right, ray, t_min, t_max) return closer(left, right)注意AABB求交要用slab方法分别算光线和三个轴对齐平面的交点区间取交集。如果区间为空或者t_max t_min说明没撞到。3.3 反射、折射与阴影光线的递归逻辑trace函数的核心逻辑是这样的求交找到最近的交点。没交点就返回背景色。计算交点处的法向量和材质。对每个光源射一条阴影光线判断是否被遮挡。被遮挡就不加这个光源的贡献。如果材质有反射属性递归调用trace算反射颜色按反射系数混合。如果材质有折射属性递归调用trace算折射颜色按折射系数混合。返回最终颜色。反射方向的计算前面讲过R D - 2(D·N)N其中D是入射方向N是法向量。注意D要指向交点N要朝向光线来的那一侧否则符号会错。折射方向用斯涅尔定律eta n1 / n2 cos_i -D·N sin_t² eta² (1 - cos_i²) if sin_t² 1: 全反射 cos_t sqrt(1 - sin_t²) T eta * D (eta * cos_i - cos_t) * N全反射是折射里最容易漏掉的情况。当光线从密介质射向疏介质入射角超过临界角时折射光线消失全部反射。如果你不处理这个渲染玻璃时会出现黑边或者错误颜色。阴影光线就是从交点朝光源方向射一条光线起点稍微偏移一点避免自交如果撞到任何物体且距离小于到光源的距离说明被遮挡。偏移量一般是法向量方向乘以一个很小的epsilon比如1e-4。这个epsilon太大会导致漏光太小会导致自交噪点需要根据场景尺度调。3.4 递归深度控制与颜色混合递归不能无限进行必须设一个最大深度。每次递归深度加1超过阈值就返回黑色或者环境色。这个阈值一般取5到8。深度越大画面越亮越真实但计算量也越大。颜色混合的公式是final local_color * (1 - reflectivity - transparency) reflected_color * reflectivity refracted_color * transparencyreflectivity和transparency是材质的反射率和透射率两者之和不能超过1。如果超过1颜色会溢出画面会过曝。这个约束在物理上对应能量守恒虽然Phong模型本身不物理但保持这个约束能让画面看起来更舒服。4. 性能优化与画质提升的实战技巧4.1 采样策略从单点到超采样单点采样就是每个像素射一条光线出来的图锯齿严重。解决办法是超采样每个像素射多条光线取平均。最简单的超采样是网格采样比如每个像素射4条、9条、16条均匀分布在像素内。但均匀采样对边缘锯齿的改善有限更好的方法是抖动采样在像素内随机取点这样锯齿会变成噪点人眼对噪点的容忍度比锯齿高。如果再配合多帧累积噪点会逐渐消失。def sample_pixel(i, j, samples): color [0, 0, 0] for _ in range(samples): offset_x random() offset_y random() ray generate_ray(i offset_x, j offset_y) c trace(ray, 0) color[0] c[0] color[1] c[1] color[2] c[2] return [c / samples for c in color]采样数是画质和速度的直接权衡。我一般先用每像素4个采样快速预览构图确认没问题再提到16或64出正式图。如果场景里有大量镜面和玻璃采样数还要再翻倍因为反射和折射会放大噪点。4.2 重要性采样与降噪思路重要性采样是蒙特卡洛方法里的概念核心思想是“在贡献大的方向上多采样”。比如光源很小但很亮你随机朝半球采样大部分光线都打不到光源浪费了。如果改成朝光源方向采样效率会高很多。在光线追踪里重要性采样主要用于路径追踪。对于简单的递归光线追踪重要性采样体现在光源采样上不要随机朝半球射阴影光线而是直接朝光源上的随机点射。这样阴影边缘会更柔和噪点也更少。降噪是另一个大话题。实时渲染里常用的有SVGF、OpenImageDenoise等思路都是利用帧间信息和空间邻域来平滑噪点。离线渲染里可以用简单的双边滤波但要注意别把细节也滤掉了。我的经验是降噪只能锦上添花不能雪中送炭。如果原始采样数太低降噪出来的图会糊成一团还不如不加。4.3 多线程与GPU加速的取舍CPU多线程是最容易实现的加速手段。把像素循环按行分块每个线程处理一块最后合并。Python里用multiprocessingC里用std::thread或者OpenMP。加速比接近核心数但要注意负载均衡别让某个线程处理特别复杂的区域。GPU加速是另一个量级。用CUDA或者OpenCL把trace函数写成kernel每个像素一个线程速度能快几十倍甚至上百倍。但GPU编程有几个坑递归在GPU上支持不好需要改成迭代内存访问要合并否则带宽成瓶颈分支发散会严重拖慢速度因为同一warp里的线程如果走不同分支要串行执行。我的建议是先用CPU把算法跑通确认逻辑没问题再考虑移植到GPU。直接上GPU调试成本太高一个逻辑错误可能要花半天才能定位。5. 常见问题与排查实录5.1 画面全黑或全白的排查路径画面全黑是最常见的问题原因通常有这么几个。第一光线方向算错了导致所有光线都射向虚空。检查方法把背景色设成红色如果画面还是黑说明trace函数根本没被调用或者返回值被覆盖了。第二求交函数有bug永远返回无交点。检查方法在求交函数里打印交点信息看是否有输出。第三颜色计算溢出或者下溢。检查方法把光照计算暂时去掉直接返回法向量映射到[0,1]的颜色如果能看到法向量图说明求交没问题问题在着色。画面全白通常是颜色累加没有归一化或者递归没有终止条件导致无限反射。检查递归深度是否有限制颜色混合系数之和是否超过1。5.2 阴影瑕疵与自交问题的解决阴影瑕疵最典型的表现是物体表面出现条纹状的黑斑这叫“阴影痤疮”。原因是阴影光线的起点在物体表面由于浮点精度误差光线可能从表面内侧射出导致自己遮挡自己。解决办法是给起点加一个沿法向量方向的偏移偏移量要大于浮点误差但小于场景尺度。shadow_origin hit_point normal * 1e-4偏移量太大会导致“漏光”物体和地面接触的地方会出现亮缝。这个值需要根据场景大小调一般取场景尺度的千分之一到万分之一。另一个常见问题是反射和折射的噪点。如果反射面上出现随机黑点通常是反射光线的起点也有自交问题同样需要偏移。折射的噪点更麻烦因为折射光线可能进入物体内部需要跟踪光线是在物体内还是外用不同的折射率。5.3 性能瓶颈的定位与优化性能瓶颈的定位可以用“分而治之”的方法。先测求交耗时再测着色耗时再测递归耗时。如果求交占了大头就优化加速结构如果着色占了大头就简化光照模型如果递归占了大头就降低最大深度。一个实用的技巧是加计时器统计每条光线的平均求交次数。如果平均求交次数接近物体总数说明加速结构没起作用需要检查BVH的构建和遍历逻辑。如果平均求交次数远小于物体总数说明加速结构工作正常瓶颈可能在别处。问题现象可能原因排查方法解决方案画面全黑光线方向错误背景色设红检查屏幕映射画面全黑求交永远失败打印交点检查求交公式阴影黑斑自交加偏移沿法向量偏移反射噪点反射起点自交加偏移沿法向量偏移折射黑边未处理全反射检查sin_t²加全反射分支性能差加速结构失效统计求交次数重建BVH画面过曝颜色系数和1检查材质系数归一化系数实操心得调试渲染器最好的工具是“可视化中间结果”。把法向量、深度、交点位置分别渲染成图一眼就能看出哪一步出了问题。比盯着最终画面瞎猜高效得多。6. 从光线追踪到路径追踪的进阶方向6.1 渲染方程与蒙特卡洛积分光线追踪的递归形式其实是在近似求解渲染方程。渲染方程描述了场景中某一点朝某个方向发出的辐射亮度等于该点自发光加上它对半球方向上所有入射光线的反射积分。这个积分没有解析解只能用数值方法近似。蒙特卡洛积分就是用随机采样来估计积分值。在路径追踪里每个像素射多条光线每条光线在交点处随机选择一个方向继续追踪最后把所有光线的贡献平均起来。采样数越多结果越接近真实值但噪点也越明显。路径追踪和递归光线追踪的区别在于递归光线追踪在每个交点处同时算反射、折射、阴影是确定性的路径追踪在每个交点处只随机选一个方向是随机性的。路径追踪更通用能处理复杂材质和全局光照但噪点也更多。6.2 全局光照与软阴影的实现思路全局光照是指光线在场景中多次反弹后形成的间接照明。递归光线追踪只能处理镜面反射和规则折射对漫反射的间接光无能为力。路径追踪天然支持全局光照因为光线在漫反射表面也会随机反弹。软阴影是指光源有面积时产生的半影效果。递归光线追踪里阴影光线是朝光源中心射的所以阴影边缘是硬的。要做出软阴影需要朝光源上的随机点射阴影光线或者用路径追踪让光线自然地在光源上采样。这两个效果是光线追踪和路径追踪的分水岭。如果你的项目需要电影级画质路径追踪是必经之路。如果只是做产品预览或者教学演示递归光线追踪已经足够。6.3 实时渲染中的降噪与帧累积实时路径追踪的采样数通常只有每像素1到4个噪点非常严重。解决办法是降噪加帧累积。帧累积利用时间上的冗余把前一帧的结果和当前帧混合静止画面下噪点会逐渐消失。降噪则利用空间上的冗余用邻域像素的信息平滑当前像素。这两项技术是实时路径追踪能落地的关键。没有它们实时路径追踪只能做demo没法做产品。但它们的实现也很复杂涉及运动向量、深度缓冲、法线缓冲等多个G-Buffer还需要处理运动物体的鬼影问题。我个人的建议是先把离线渲染做扎实理解光线追踪的每一个细节再去碰实时渲染。实时渲染的复杂度是离线渲染的好几倍基础不牢很容易迷失在工程细节里。最后分享一个我踩过的坑早期做光线追踪时我为了追求“真实”把递归深度设到了20层结果渲染一张图要几个小时画质却只比8层好一点点。后来才明白递归深度超过一定值后贡献已经衰减到可以忽略不计了。与其堆深度不如把采样数提上去或者把加速结构做好。渲染这件事平衡比极致更重要。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →