Whitted-Style光线追踪从原理到实现:反射折射与阴影的递归求解

发布时间:2026/10/16 1:02:43
Whitted-Style光线追踪从原理到实现:反射折射与阴影的递归求解
我记得有段时间在调一个软件渲染Demo光栅化管线已经能跑得有模有样了模型、深度缓冲、Blinn-Phong着色、阴影贴图都能出图。但一遇到镜面反射就开始抓瞎——屏幕空间反射在这个Demo里根本没法处理屏幕外的物体玻璃球后面的东西也完全看不到折射。后来我干脆把整套光栅化丢掉用Whitted-Style光线追踪重写了一遍效果直接上升了一个档次。这篇文章就从原理到实现细节把Whitted-Style光线追踪完整拆一遍适合正在学计算机图形学、或者已经写完光栅化想进阶效果的读者参考。Whitted-Style光线追踪也就是经典的递归光线追踪是1980年被提出的算法它的核心思路并不复杂从相机出发对每个像素发射一根主光线遇到物体后就递归地计算反射、折射并对每个光源发射一根阴影光线。整套流程走完把各级着色贡献叠加起来就是最终像素颜色。理解了这套东西后面再去看路径追踪、辐射度这些高级算法会顺很多。1. 为什么需要Whitted-Style光栅化解决不了的问题清单1.1 光栅化的工作方式与三个致命短板先捋一下光栅化是怎么工作的把场景里的三角形逐个投影到屏幕上用深度测试决定屏幕上每个像素最终能看到哪个三角形的哪个点然后对这个可见点做局部光照着色。问题就出在每个像素只对场景查询一次——我们看到一个光滑球面它反射了身后的红色窗帘但光栅化管线在计算球面颜色时并不知道窗帘的存在所以球面上就缺了那抹红色。具体来说有三个典型场景是光栅化很难处理的。一是镜面反射链两面镜子对着放光栅化想模拟这种效果只能靠环境贴图或反射探针但反射探针本质上是预烘焙场景动态变化就崩了。二是透明物体折射玻璃球后面的景象会弯折、扭曲这在光栅化里通常需要渲染多遍或者用近似技术效果都一般。三是阴影判断Shadow Map虽然能出阴影但存在自阴影偏移、分辨率不足导致的阴影锯齿等问题而且难以做出高质量的大面积区域阴影。这三个短板本质上都是同一个根源光栅化只做了一次视线查询光线没有在场景里继续传播下去。想模拟反射、折射这类依赖视线多次弹跳的效果就必须把视线传播这件事本身做出来。1.2 Whitted方案的核心定位不是更快而是更全Whitted在1980年提出的方案主旨就是补上光栅化缺失的视线传播能力。当光线打到物体表面时除了做一次着色计算还继续往反射方向发一根反射光线往折射方向发一根折射光线同时向每个光源发一根阴影光线。如果反射光线又碰到另一个物体同样再来一次递归处理。这种算法也被称为反向光线追踪——现实中光从光源出发经过无数次弹射最终进入眼睛但如果我们正向追踪绝大多数光线根本进不了相机纯属浪费计算。反向追踪则保证每一根光线最终都贡献到某个像素上效率高得多。从算法史角度看Whitted-Style是所有现代全局光照算法的起点。后面的分布式光线追踪用大量采样替代单一递归路径、路径追踪用蒙特卡洛方法逼近渲染方程都是在Whitted框架上加概率和能量守恒衍生出来的。所以哪怕你最终的目标是学路径追踪先把Whitted这条线彻底吃透后面工程量会小很多。2. 光线生成的数学准备从相机到像素的一束光2.1 光线方程与相机坐标系搭建光线在数学上用一个起点加一个方向表示P(t) O t * D, t 0O是起点D是单位方向向量t是沿方向走的距离。t0保证我们不计算起点本身。这个方程就是整个光线追踪的地基求交、阴影、递归反射全部复用这个表达。接下来要解决的是相机输出的每个像素如何转换成一条三维空间中的光线。首先需要给相机建一个坐标系通常包括三个正交单位向量前向F相机看向的方向、右向R、上向U。相机还有一个关键参数视野角FOV垂直方向的角度以及画面的宽高比aspect。如果你写过投影矩阵这些概念应该不陌生。光线追踪里更简单不需要矩阵乘法直接利用这几个向量构造出空间中的一条射线。但有一个细节容易搞错像素坐标和屏幕坐标的方向。图像坐标中y轴通常向下而三维空间中相机的y轴向上这一步翻转漏掉的话渲染出的画面会上下颠倒。2.2 从像素坐标到世界空间光线的完整推导假设图像宽为width、高为height像素坐标为(i, j)。先把像素坐标归一化到[0, 1]区间注意要用像素中心也就是加0.5偏移避免采样到像素边缘u (i 0.5) / width v (j 0.5) / height再把它们映射到[-1, 1]区间并和视野角挂钩。垂直方向用FOV计算半高水平方向用半高乘以宽高比计算半宽halfH tan(fov / 2) halfW halfH * aspect x (2 * u - 1) * halfW y (1 - 2 * v) * halfH这里y用了1 - 2v而不是2v - 1就是因为图像坐标y轴向下稍微不注意就会得到上下翻转的画面。有了这个屏幕坐标后主光线的方向就是D normalize(R * x U * y - F)或者说相机前方的焦点距离可以根据需求缩放屏幕坐标比如放大焦距产生望远镜效果。实际项目中我一般直接把上面这个方向归一化就行焦距的控制通过缩放x、y实现。一个常见的工程细节透视投影中光线的起点是相机位置所有光线共用一个O方向按透视锥体展开而正交投影中像素光线的方向都相同起点才根据像素位置偏移。大多数软渲染Demo用透视模型更直观。3. 求交运算Whitted光线追踪的命中率核心3.1 隐式曲面与二次方程求交从相机发出的光线要判断撞到了什么最基础的是和隐式曲面求交。以球体为例球面方程为||P - C||² R²把P(t)代入得到||O tD - C||² R²展开后是一个关于t的二次方程at² bt c 0。D如果归一化a就等于1。判别式小于0说明光线没碰到球等于0说明擦边大于0则有两个实根取最小的正根作为最近的交点。这段逻辑写出来很直观但有一个隐藏前提解出的t必须大于epsilon后面专门讲而且球体这样需要处理多个实根的情况在三角形网格里同样存在——最近交点才是我们看到的阴影光线判断则只需要知道有没有交点不需要最近的。3.2 三角网格的Möller–Trumbore算法与重心坐标实际场景大多是三角网格每条光线遍历所有三角形做一次交运算是性能瓶颈所在。在OpenGL里我们用光栅化处理三角形光线追踪里则常用Möller–Trumbore算法用线性代数求解光线与三角形的交点同时得到重心坐标。简化的实现如下bool rayTriangleIntersect(const Ray ray, const Vertex v0, const Vertex v1, const Vertex v2, float t, float u, float v) { Vector3f edge1 v1 - v0; Vector3f edge2 v2 - v0; Vector3f pvec cross(ray.direction, edge2); float det dot(edge1, pvec); if (fabs(det) 1e-8) return false; // 光线与三角形平行 float invDet 1.0f / det; Vector3f tvec ray.origin - v0; u dot(tvec, pvec) * invDet; if (u 0 || u 1) return false; Vector3f qvec cross(tvec, edge1); v dot(ray.direction, qvec) * invDet; if (v 0 || u v 1) return false; t dot(edge2, qvec) * invDet; return t 0; }这里的u、v是重心坐标第三重心坐标w 1 - u - v。重心坐标的价值在于做插值顶点法线、顶点颜色、纹理坐标都可以用同样的u、v、w加权插值出来这样表面上每个点都有连续的材质信息而不是三角面片内一个值。3.3 自相交、epsilon偏移与浮点精度处理这条要特别注意。光线求交本身看似简单但在浮点运算下有一个非常隐蔽的坑理论上你从球面上的某个交点发一条反射光线这个交点应该正好落在球面上但浮点误差会让这个点稍微陷入球面内部一点点。反射光线从球面内侧出发立刻又和球面自身相交产生一个极小t值的错误交点导致球面上出现大量不规则黑点或亮斑。解决思路有两个层面。第一所有求交时只接受t epsilon的命中一般的epsilon取1e-4到1e-6具体要看场景尺度。注意这个epsilon不能取太大否则贴近表面的小物体会被光线直接穿透。第二在递归阴影和反射时维护一个命中物体ID集合当阴影光线和反射光线判断遮挡时跳过当前所在物体自身的交点。两招一起上自相交现象基本能杜绝。另一种三角形网格特有的问题是裂缝。顶点共享时如果三角形的顶点坐标在精度上有一点点差异光线可能从两个相邻三角面之间的缝里漏过去。遇到这种情况习惯做法是沿着表面法线方向把交点做一个微小的偏移相当于把起点从几何表面推到实际表面的上面再做后续追踪。4. 递归追踪的完整链路反射、折射与阴影判断4.1 镜面反射方向推导与递归着色流程假设光线从相机出发沿方向d打到物体表面表面法线为单位向量N。把d看作入射光线的传播方向反射光线方向可以通过向量分解得到reflectDir d - 2 * dot(d, N) * N可以这么理解d在法线方向的分量dot(d, N) * N会在反射时反向而垂直法线的分量保持不变组合起来就是这个公式。要小心法线的方向约定——如果法线不在入射光线的同一侧结果会完全错乱所以求交时最好统一规定法线方向和入射方向夹角小于90度必要时对法线做翻转。递归着色流程如下function shade(point, rayIn, depth): if depth maxDepth: return 0 hit nearestIntersect(point, rayIn) if not hit: return backgroundColor color localShade(hit) // Blinn-Phong 或者基于光源的直接光照 if hit.material.isMirror: reflectDir computeReflectDir(rayIn.direction, hit.normal) color hit.material.ks * shade(hit.position eps*hit.normal, reflectDir, depth1) if hit.material.isTransparent: refractDir computeRefractDir(rayIn.direction, hit.normal, hit.material.etaRatio) color hit.material.kt * shade(hit.position eps*hit.normal, refractDir, depth1) return color这个递归函数是Whitted风格光追的心脏。主循环对每个像素发射光线调用shade最终返回的颜色就是该像素的最终颜色。4.2 折射定律、临界角与菲涅尔近似折射比反射麻烦一些要遵守斯涅尔定律η1 * sinθ1 η2 * sinθ2这里θ1是入射角相对法线θ2是折射角η1和η2分别是两种介质的折射率。空气的折射率约为1.0水的折射率约为1.33玻璃约1.5——这些参数决定了筷子插入水里看起来折断了的程度。计算折射方向时的关键一步是检查临界角。当光从折射率高的介质射向折射率低的介质且入射角足够大时折射角的正弦会超过1这时不再有折射光线发生全反射。代码里的表现就是cosTheta -dot(rayIn.direction, hit.normal) // 注意符号约定 sinThetaSq eta * eta * (1 - cosTheta * cosTheta) if sinThetaSq 1: return reflectDir // 全反射 else: return refractDir然后在反射和折射的权重分配上Whitted当年混的是材质里的镜面反射系数和透射系数但真实世界的玻璃球从正面看透光更多从侧面看反光更多。工程上常用Schlick菲涅尔近似来计算反射比例R0 ((η1 - η2) / (η1 η2))² R(θ) R0 (1 - R0) * (1 - cosθ)^5这个公式给的效果已经足够真实掠射角视线几乎平行于表面时反射接近100%这也是湖面在傍晚夕阳下像是镜子一样的原因。4.3 Shadow Ray硬阴影与遮挡判定Whitted风格的阴影判断非常直接对每个可见光源从击中点打一条光线到光源位置。如果中途被其他物体挡住就认为当前点处于阴影中不参与该光源的直接光照。这里的实现细节在于距离判断Vector3f toLight light.position - hitPoint; float lightDist length(toLight); if (nearestIntersect(hitPoint eps*hit.normal, toLight, lightDist)) { // 被遮挡不计算该光源贡献 } else { // 可见进行直接光照着色 }有一个经常翻车的地方阴影光线的起点仍然要做epsilon偏移否则又会和当前表面自相交。同时遍历求交时一定要求交点满足t lightDist且t eps否则光源前面自己的表面会导致所有像素都变成阴影。Whitted-Style产生的阴影天然是硬阴影因为光源被当成一个点。想实现软阴影一个自然扩展是对面光源区域发射多个阴影光线统计可见比例然后把多个光源采样结果做平均。这个思路已经是分布式光线追踪的雏形了——你完全可以在Whitted框架里慢慢加这类扩展。5. 让程序活着跑完加速结构与越界防护5.1 BVH加速结构与AABB包围盒朴素实现中每根光线要和场景里所有三角形求交而Whitted风格每像素至少要发一根主光线加上反射、折射、阴影可能在十条以上。没有加速结构的话渲染一张1920×1080的图在模型上会直接卡死。最常用的加速结构是BVHBounding Volume Hierarchy。思路很简单给每个三角形算一个AABB包围盒把所有三角形放在一个大盒子里不断按某条轴切分成两堆递归建树。求交时先测光线是否穿过节点的包围盒穿不过就直接裁掉整个子树穿过了再往下递归。最理想情况下每次求交复杂度从O(N)降到O(logN)。AABB求交用slab方法把包围盒看作三个方向的区间[xmin,xmax]、[ymin,ymax]、[zmin,zmax]光线进入盒子需要同时满足三个轴的区间约束最后取全局进入t和离开t的判断合并。代码形式如下bool rayAABBIntersect(const Ray ray, const Bounds3f bbox, float tNear, float tFar) { tNear -INFINITY; tFar INFINITY; for (int i 0; i 3; i) { float inv 1.0f / ray.direction[i]; float t1 (bbox.min[i] - ray.origin[i]) * inv; float t2 (bbox.max[i] - ray.origin[i]) * inv; if (t1 t2) swap(t1, t2); tNear max(tNear, t1); tFar min(tFar, t2); if (tNear tFar) return false; } return tNear tFar; }构建BVH时一个简单有效的策略是统计所有三角形包围盒的中心点沿最长轴排序取中位数把三角形分为左右两组递归建树。这样建出来的树虽然不是最优的SAH方案但对于教学和中小型Demo完全够用。等你把效果调满意了再研究表面积启发式构建法也不迟。5.2 递归深度限制与能量衰减截断递归不能无限跑不然两面平行镜子之间的光线会无限反复反射直到进程栈溢出。工程上通常设置最大递归深度常见值是5到8层。为什么这个深度够用因为每次反射/折射都会乘一个权重系数反射系数ks或菲涅尔反射比例这些系数小于1几层之后能量贡献已经衰减到肉眼不可见的范围继续递归纯粹浪费算力。更精细的做法是跟踪当前路径上的累计能量衰减系数当衰减值低于某个阈值比如0.01就提前终止递归。这个阈值判断和深度限制可以同时使用效果比单一深度限制更好因为粗糙表面的反射路径衰减很快光滑镜面则可以多跑几层。缓冲区溢出之外还有一个性能陷阱每个交点的阴影光线数量乘以光源数量会在场景中放大。如果有几十个光源每像素几十根阴影光线是常见的事。优化方案是主动限制光源的可见范围或者预先对高贡献光源做排序。最简单的做法是把光源数量控制在个位数以内跑通流程再逐步增加。6. 渲染结果的验证思路与常见画错误判6.1 分通道检查法把每个贡献拆开调试光线追踪代码写完后最容易犯的错误是一股脑把所有贡献混在一起最后画面不正常时根本不知道是几何错了、法线错了、还是递归逻辑错了。我的习惯是给渲染器加几个Debug输出模式把单通道单独导出来看。第一个是深度图t通道把每个像素最近交点的t值映射成灰度输出。这个图能快速验证光线求交是否正确——球体应该呈现由中心向外逐渐变亮的圆形渐变如果出现奇怪的条纹或空洞说明求交有问题。第二个是法线可视化通道把交点法线的xyz分量映射到rgb输出。这个图可以直观看到法线的朝向是否一致常用于排查反射光线的扭曲问题。第三个是材质通道给不同材质赋纯色直接看哪个像素命中哪种材质这对验证递归调用的命中位置特别有效。分通道检查之所以有效是因为它把连续依赖的渲染管线解耦了。几何错误会让反射方向本身就不对这时再看整体画面只会更糊涂。我强烈建议在接入材质和光源之前先用这几个通道把几何部分彻底验证一遍。6.2 几个典型Bug黑点、裂缝、阴影遗漏我在调试Demo时遇到的黑点问题最终定位是epsilon太小导致的自相交。表面反射光线在t极小处命中了自身表现为球面上出现密集的暗色噪点。调到1e-5级别后黑点消失但也不能无限调大——有些细小的几何细节会被eps穿透出现光线下潜的现象。三角形网格上的裂缝问题则和求交的三角形索引顺序相关。相邻三角形的顶点顺序不一致时法线方向会不同光照效果会出现补丁感。检查方法很简单把渲染结果切换到法线通道如果相邻面片的法线可视化颜色跳变明显就去检查建模或导入阶段是否统一了顶点绕序。阴影方面最常见的错误是整片场景没有阴影或者阴影全黑。没有阴影通常是因为阴影光线的求交范围没有限制在光源距离内或者忽略了当前物体自身。一次忘写t lightDist的判断导致所有物体之间都在互相遮挡画面惨不忍睹。我现在的做法是把阴影判断封装成一个独立函数单独做一个白色灯光、纯色物体的测试场景验证通过了再接复杂场景。6.3 性能调优的实用底线如果你的光追程序能在中等规模场景几千三角形下渲染出图像就算基本成功。继续优化时有一个性价比很高的手段以像素为单位做超采样抗锯齿即每个像素发射多根主光线方向微微偏移最后平均。这在Whitted框架里几乎不加成本效果却非常明显能消除物体边缘的锯齿感。采样数可以选择1、4、9、16配合随机抖动或者均匀分布在像素内的采样点画面质量提升是本质级别的。另外反射/折射权重系数建议从0.8和0.7起步根据实际材质微调。真实玻璃的反射率通常没有那么高系数太大会让场景过曝太小又会让反射、折射效果不明显。调参时记得同时观察反射通道和折射通道的单独输出避免被整体曝光欺骗。从零实现一遍Whitted-Style光线追踪之后我对图形学那些抽象概念的理解扎实了很多。它不复杂核心也就一个递归函数加求交运算但正是这份简洁让后来的路径追踪、光子映射能够继续往上盖楼。如果你正在写自己的光追渲染器千万别急着追求酷炫特效先把主光线的深度通道和法线通道调对再一层层叠反射和折射。这样每加一个特性画面都能给出明确的反馈整个调试过程会顺很多。