1. 项目概述
图形学领域有个奇特的现象:不少概念听上去玄乎,真做起来反而比想象中简单。Whitted-Style 光线追踪就属于这一类。这个项目标题看上去像是某个图形学课程的大作业,实际上它是现代渲染器的地基,从游戏引擎的实时反射到离线影视渲染,都能看到它的影子。
先把这个项目说清楚。你要做的,是拿一颗“虚拟眼睛”(即相机)往场景里发射光线,光线撞到物体表面后,按照反射定律和折射定律继续弹射,同时向光源发射一条阴影测试光线,判断当前着色点是否被遮挡。因为光路可逆,你站在相机视角追踪光线,等价于从光源追踪光子到相机,只是方向反了,计算效率高得多。项目最终要输出的是一张JPEG或PNG图像,每个像素的颜色由光线在场景中反复弹射后累积得到。
这个项目非常适合两类人:一类是刚学完光栅化、想理解光线追踪和光栅化本质差异的学生;另一类是准备入行渲染引擎开发的工程师,Whitted-Style模型虽然“老”,但它是理解路径追踪、光子映射等现代算法的跳板。在我个人看来,用两周时间把Whitted-Style完整实现一遍,比稀里糊涂跑通一个现成引擎有价值得多。
2. 核心原理拆解:三大基础操作
2.1 光线生成:从像素到世界坐标的变换
光线的数学定义其实非常简单,一条射线就是origin + t * direction,t大于0。难的地方在于“怎么让光线准确穿过像素中心”,这就是光线生成(Ray Generation)。
大多数实现采用针孔相机模型。你要明确几个参数:相机位置、朝向、视场角(FOV)以及图像的宽高比。实际操作时,第一步是把像素坐标映射到标准化设备坐标(NDC),范围从-1到1。假设图像宽度为W,高度为H,像素坐标为(i, j),那么:
x_ndc = (i + 0.5) / W * 2 - 1 y_ndc = (j + 0.5) / H * 2 - 1这里加0.5是为了取像素中心,不加会导致采样点偏移,渲染出的边缘线条会有明显的锯齿状。
接着根据FOV计算光线方向。假设相机朝向为-Z方向,那么:
direction = normalize(vec3(x_ndc * tan(FOV/2) * aspect, y_ndc * tan(FOV/2), -1))其中aspect为宽高比。这个方向的-1代表相机朝-Z看,如果场景坐标系不同,需要先做一次视图变换矩阵把相机摆正。我见过不少初学者直接把像素坐标当世界坐标用,结果画面里的物体变形得完全认不出来,问题就出在漏了这一步。
2.2 光线与物体求交:数学基本功的实战检验
求交是光线追踪中最核心、最频繁的操作,项目80%以上的时间都在处理求交。Whitted-Style项目通常要求支持球体(Sphere)和三角形网格(Mesh),这两种图元的求交公式是基础中的基础。
球体求交相对简单。球体方程是(p - center)^2 = r^2,把光线方程代入后整理成一元二次方程:
at^2 + bt + c = 0其中a = dot(d, d),b = 2 * dot(origin - center, d),c = dot(origin - center, origin - center) - r^2。判别式大于0则有交点,取较小的正根。
三角形求交则是另一套思路,主流实现是Möller–Trumbore算法。它把交点表示为重心坐标插值形式,同时求解t和两个重心坐标u、v。核心思想是:如果在三角形所在平面内,用三个顶点和法线构造一个线性方程组,一次解出参数。这个算法效率高,且能直接得到重心坐标,方便后续做纹理坐标插值和法线插值。
实操中有一个容易出错的点:光线方向和三角形法线的夹角。如果光线的direction和法线dot大于0,说明光线从背面射入,要不要在求交时忽略背面?这个取决于场景需求。比如渲染一个封闭的水晶球,内部不应该被看到,那就要做背面剔除;但渲染水面时,背面和正面都要算折射,就不能剔。项目中建议用参数控制,方便切换调试。
2.3 递归模型与光线类型划分
Whitted-Style的核心是:像素的最终颜色不是一次求交就能决定的,而是递归地表征为多处光照贡献的叠加。每一根光线在场景中弹射时,会产生三类衍生光线。
第一类是反射光线(Reflection Ray),根据反射定律,reflect_dir = reflect(ray_dir, normal)。反射光线会继续在场景中寻找下一个交点。第二类是折射光线(Refraction Ray),根据Snell定律计算,需要考虑介质的折射率(如玻璃1.5),以及是否发生全反射。第三类是阴影光线(Shadow Ray),从着色点连向光源,如果中间有其他物体遮挡,证明该点处于阴影中。
一个Whitted-Style渲染器的核心伪代码逻辑如下:
function castRay(ray): if (hit = scene.intersect(ray)): color = local_illumination(hit, shadow_ray) if (hit.material.is_reflective): color += reflect_coef * castRay(reflection_ray) if (hit.material.is_refractive): color += refract_coef * castRay(refraction_ray) return color else: return backgroundColor这里的递归深度通常控制在3到5层。太深了计算量爆炸,而且物理上没有必要——现实世界中光线弹射无限次,但大多数能量衰减在几次内就已经完成。超过5层的贡献量通常小于0.1%,直接截断在视觉上无感知,但性能差距是成倍的。
3. 从理论到代码:搭建一个最小渲染框架
3.1 数据结构与场景组织
写Whitted-Style之前,先把数据模型设计好,否则后期加功能会改得痛不欲生。我会按以下结构组织代码。
class Ray: origin, direction class HitRecord: t, position, normal, material, u, v class Material: diffuse_color, specular_exponent, reflectivity, refractivity, ior class Sphere: center, radius, material intersect(ray) -> HitRecord class Triangle: v0, v1, v2, material intersect(ray) -> HitRecordMaterial建议使用统一结构体,而不是分Diffuse、Metal、Glass三个类。Whitted-Style本身就是简化模型,用参数化材料反而更灵活。比如一个材质要同时具备反射和折射属性(像汽车挡风玻璃),分开写就显得别扭。
场景用vector<Object*>存放所有图元,每根光线遍历所有对象找最近交点。这里记录一个关键操作:不要在每个图元的intersect里更新全局最近交点,而是让每个图元返回自身交点,由外层统一比较t值取最小。这个习惯对后续换BVH加速结构至关重要,区域内剔除由加速结构统一处理。
3.2 坐标系设置与相机参数选择
很多项目教程喜欢用一个固定的视图矩阵矩阵,代码写起来简单,但换场景时得手动改矩阵参数,非常痛苦。我建议实现一个build_camera(camera_pos, look_at, up_vec, fov, aspect)函数,动态构造相机的正交基。
正交基构造方法是:
forward = normalize(look_at - camera_pos) right = normalize(cross(forward, up)) up = cross(right, forward)然后光线的方向就可以表达为:
dir = normalize(forward + right * x_ndc * tan_half_fov * aspect + up * y_ndc * tan_half_fov)这个方案有个隐藏好处:后续加景深(Depth of Field)时,只需要在相机位置附近随机抖动origin,方向重新指向原来的焦点即可,正交基让这个操作天然成立。如果没有正交基,景深实现就只能在屏幕空间做后处理模糊,效果差别很大。
3.3 直射光照计算:Blinn-Phong模型的选用与局限
Whitted-Style项目通常搭配Blinn-Phong局部光照模型。所谓“局部”,是指只考虑光源直接照射到着色点的那部分能量,不考虑物体间相互反射。
Blinn-Phong把光照分解为三个分量:环境光(Ambient)、漫反射(Diffuse)和高光(Specular)。
ambient = ambient_color * ambient_strength diffuse = diffuse_color * max(0, dot(normal, light_dir)) * light_intensity specular = specular_color * pow(max(0, dot(normal, half_vector)), shininess) * light_intensity其中half_vector是光线方向和视线方向的中间向量,即normalize(light_dir + view_dir)。用half向量替代传统的反射向量,计算上更高效,效果也没有视觉差异。
这里要重点说一个易踩的坑:在Whitted-Style框架中,局部光照直接决定了最终图像是否“脏”。阴影部分变暗的幅度、高光的锐利程度、环境光的大小,都依赖于这几个参数。建议先固定一套参数跑通流程,再慢慢调。不要一上来就追求PBR级别的光照物理正确性,那会陷入参数海洋。
3.4 阴影测试与软阴影的近似处理
阴影测试在Whitted-Style里非常朴素:对每个着色点,向光源方向发射一条Shadow Ray,如果该ray与场景中任意物体相交,且交点的t值小于光源距离,则该点在阴影中,直接跳过漫反射和高光计算。
硬阴影的边缘会有明显的锯齿,这在渲染领域叫aliasing。要消除这个,可以对光源表面做区域采样(Area Light)——在光源平面上随机采样多个点,分别测阴影,取平均值。比如一个方形光源,随机采样16个点,阴影就能呈现从全暗到全亮的过渡,这就是软阴影。实际应用时,16个采样点已经能获得不错的视觉效果,再往上增加到64个,图像差距就不太容易分辨了。
不过需要注意,递归反射光线产生的阴影不能简单用16个点平均处理。反射光线的来源是一个点,它对光源面积积分的要求更高,通常需要提升到128个采样点才能去除噪点。这个问题在离线渲染中常被忽视,因为单帧渲染时间都在分钟级,多出的时间可以接受。
4. 实操过程与核心环节实现
4.1 渲染主循环与像素遍历顺序
整个渲染器的入口其实就是一个两层嵌套的for循环,外层遍历y,内层遍历x,对每个像素做多采样(SSAA)。一个典型的主循环是:
for (int j = 0; j < height; j++) { for (int i = 0; i < width; i++) { vec3 color(0, 0, 0); for (int s = 0; s < samples_per_pixel; s++) { // 抖动采样点的uv坐标 float u = float(i + random01()) / width; float v = float(j + random01()) / height; Ray ray = camera.generate_ray(u, v); color += cast_ray(ray); } color /= samples_per_pixel; pixels[j * width + i] = gamma_correct(color); } }外层遍历顺序建议按y从高到低,x从左到右。某些职业习惯讲究图像从下往上渲染,看到结果像“翻页”,这种输出形式方便在命令行终端观察进度。实际上,先渲染图像上半部分有一个好处:物体通常位于视口中央,上半部分的求交测试能更早暴露场景摆放问题。调试期间这样节省时间。
4.2 材质系统设计:金属、玻璃与漫反射材质
材质是本项目的灵魂。我习惯把材质抽象成一个评价函数,输入交点信息,输出该点的基础颜色和反射属性。Whitted-Style中材质并不需要复杂的BRDF模型,用参数组合就能模拟常见效果。
漫反射材质(Diffuse):反射率低,光滑度为零,只受直接光照影响。它的颜色就是albedo(基础色)。
金属材质(Metal):反射率较高,反射光线保持镜面方向,高光锐利。实现时给反射光线方向加一点随机扰动,就能模拟磨砂金属的效果。扰动量为reflect_dir + roughness * random_in_hemisphere(normal),roughness为0时是光滑镜面,调高后反射能量变模糊。
玻璃材质(Glass):同时有反射和折射,折射率设为1.5左右。真实玻璃会同时产生反射和折射,两个方向都要递归计算。关键点在Snell定律的代码实现:
vec3 refract(vec3 incident, vec3 normal, float ior_ratio) { float cos_theta = dot(-incident, normal); vec3 r_out_perp = ior_ratio * (incident + cos_theta * normal); vec3 r_out_parallel = -sqrt(abs(1 - dot(r_out_perp, r_out_perp))) * normal; return r_out_perp + r_out_parallel; }当1 - dot(r_out_perp, r_out_perp)为负时,说明发生全反射,此时应返回反射方向。很多教程提过全反射,但很少有人指出这个代码细节——检测全反射的位置在refract函数的sqrt处,而不是调用处,许多初学者会在这里花半天排查阴影噪点。
4.3 递归深度的设定与能量守恒问题
递归深度的选择直接决定渲染质量和速度。深度为1时,只有直接光照,反射和折射都拿背景色填充,效果勉强及格;深度为3时,能看到两层反射叠加的镜像效果,这已经可以达到课程作业的基准线;深度为5时,画面细节基本收敛,再往上渲染时间成倍增加,收益趋近于零。
有个细节需要强调:递归衰减因子与能量守恒。Whitted-Style模型里,如果镜面反射系数为0.8,那么反射光线对最终颜色的贡献就是0.8 * cast_ray(reflection_ray)。但要保证能量守恒,反射系数和折射系数之和不能超过1。比如玻璃材质反射率0.1,折射率0.9,加起来等于1。如果设成0.5和0.8,那么玻璃球边缘会出现诡异的高亮,这违背物理直觉。
这个问题的根源在于,Whitted-Style本质上是一个简化的能量模型,没有BRDF的归一化条件约束。你可以在材质参数上手动校正,或者使用免费的物理参数库,从源头避开这个问题。
4.4 包围盒加速:没有加速结构的灾难现场
如果你准备渲染一个包含几万个三角形的模型,必须先在求交部分加BVH(Bounding Volume Hierarchy)加速结构,否则性能惨不忍睹。我实测过一个包含5万个三角形的场景,裸遍历需要约1200毫秒每帧,而加上BVH后直接降到15毫秒。
BVH的工作原理简单理解就是“分球树”。先为整个场景构造一个包围盒(AABB),然后把所有物体按空间位置分成两组,分别构造子包围盒,递归直到叶子节点只包含少量三角形。求交时先检测光线是否穿过AABB,不穿过则整棵子树跳过。
AABB求交的核心算法是Slab Method,即把光线与盒子的相交分解到三个坐标轴维度,取各维度t区间的交集。
float tmin = -INF; float tmax = INF; for each axis i: float t1 = (box_min[i] - origin[i]) / direction[i]; float t2 = (box_max[i] - origin[i]) / direction[i]; if (t1 > t2) swap(t1, t2); tmin = max(tmin, t1); tmax = min(tmax, t2); if (tmax < tmin) return no_hit;要注意除法方向为0的情况(光线与某个轴平行),此时如果origin在盒子范围之外,即不存在交点,需要特殊处理。用if (direction[i] == 0) { ... }判断即可。不少实现直接忽略了这个分支,后续出现渲染图像里物体“消失”的诡异bug。
5. 常见问题与排查技巧实录
5.1 画面全是黑的 / 太暗
这个现象的根本原因通常是阴影测试出错。排查顺序是:
- 先关掉阴影光线,只保留局部光照,确认几何形状正确显示。
- 打开阴影后,如果画面出现明显的横条纹或黑色噪点,检查阴影光线是否和原光线共线。正确的Shadow Ray应该从着色点出发,方向指向光源,起点应该沿法线方向做一个微小偏移(如
hit_point + normal * 0.001)。如果不做偏移,Shadow Ray和物体表面自身相交就成了必然,画面自然全黑。 - 检查光源方向是否归一化。没有归一化会导致光照强度离谱地偏大或偏小。
5.2 边缘出现断裂的白线 / 黑线
这类问题多半是浮点精度引发的自相交(Shadow Acne)。特效电影里那些物体表面闪烁着细密的雪花噪点,基本都是这个原因。补救方法是给交点做一个偏移量,习惯上取1e-3 * t或者干脆固定0.001。但是这个偏移不能太大,否则会让物体显得“悬浮”在场景中,阴影边缘出现偏移。
比较好的处理策略是:反射光线和阴影光线使用不同的偏移量。反射光线偏移大一些(1e-2),因为反射是在表面之上的重新发射;阴影光线偏移可以小一些(1e-3),因为阴影测试的遮挡物体通常离表面有一定距离。
5.3 物体靠近屏幕边缘时严重拉伸变形
这种情况不是Whitted模型的问题,而是相机参数计算错误。最典型的原因是FOV和aspect两个量的关系写反了。正确逻辑是:aspect = width / height,如果aspect小于1(竖屏输出),光线方向的x分量会被压缩,图像左右拉伸;反之被压缩。调试时准备一个球体放在屏幕正中央,用各种宽高比渲染,看是否始终是正圆。一测便知。
5.4 总体性能过慢:先查求交逻辑
Whitted-Style的耗时大头在于求交测试。如果每像素采样数高,场景物体多,那么裸求交会直接让程序卡死。性能优化断言的顺序是:先加BVH加速结构,再做多线程并行,最后再考虑向量化指令优化。顺序不能颠倒——在单线程的裸求交上加SIMD没有意义,至少先让基础流量降低十倍,再做底层优化。
多线程优化的实现思路也简单:把图像按行分块,每块交给一个线程处理。示例中我用OpenMP加一行#pragma omp parallel for schedule(dynamic)就实现了近线性加速。注意一定要用schedule(dynamic),因为不同像素的光线复杂度差异很大,静态调度会让某些线程忙等、某些线程空闲,动态调度则自动均衡负载。
6. 从Whitted-Style到现代渲染的跃迁
整套项目做完之后,我的体会是:Whitted-Style渲染器像是一把刀,刀柄是数学原理,刀刃是工程实现。它冰冷而直接,但也正因为这样的直接,才让后续理解复杂的全局光照算法有了抓手。
然后是个人经验。实现Whitted-Style项目过程中,我复盘了几个重要的认知升级。
认知一:光栅化和光线追踪不是对立的,而是管道中不同阶段的优化手段。现在的实时渲染器以光栅化为主,甚至很多游戏引擎的反射功能里内置了简化版光线追踪。这两者的关系不是替代,而是分工——光栅化适合做深度测试和基础着色,光线追踪适合做全局效果。
认知二:Whitted-Style天生不适合漫反射间照。它的模型假设,物体之间的漫反射间接光贡献被忽略。所有Whitted-Style渲染出的角落阴影都是死黑一片。而路径追踪正是通过随机采样改进这一点。如果你用Whitted-Style渲染一个纯白球依靠墙壁反射而显现出墙壁颜色的场景,效果会彻底失败。这不是算法bug,而是模型假设的边界。
认知三:工程实现比数学公式重要得多。从数学上来说,Whitted-Style的知识点半天就能学完;但从一个能跑通的实现,到一个稳定高效、能出好图的渲染器,中间隔着无数细节。这些细节无法靠背公式掌握,只能在调试中积累。
最后分享一个针对后续扩展的建议:如果你学完Whitted-Style想做点什么,可以往两个方向发展。一是把折射部分替换为有体积参与的可视化渲染(参与介质/烟雾效果),从Whitted-Style到体积渲染的路线相对平滑。二是把递归结构重构为路径追踪结构,核心改动在采样策略——把反射和折射采样改为随机方向采样,配合方差缩减就变成了一个最简路径追踪器。我在实际操作中发现,从Whitted-Style到路径追踪的迁移,改动量比我预想中更小,这也反向证明了Whitted模型的正确性。一个模型如果连迁移到更复杂的框架都顺畅,说明它的骨架搭得足够稳固。