光线追踪渲染器从零实现:核心代码、调参与避坑指南
简介这是一份基于OpenGL实现光线追踪渲染算法的完整工程代码面向计算机图形学学生、游戏引擎开发者及对渲染原理感兴趣的进阶读者。压缩包内共38个文件整体仅166KB以17个cpp源文件与9个头文件为核心覆盖材质与光源计算、光线方程、BVH加速结构、Phong光照模型、反射折射递归追踪、阴影检测、抗锯齿、运动模糊等关键模块同时提供工程文件与可执行演示程序便于直接编译运行并对照验证渲染结果。通过研究这套代码可以系统理解光线追踪从数学定义、场景组织到像素输出的完整技术链路代码中还包含环境贴图、全局光照及GPU并行优化等进阶处理思路。已有1324人浏览学习适合需要兼顾算法原理与工程参考的中高级开发者。1. 光线追踪程序代码先用最短路径看懂你在写什么光线追踪程序代码说穿了是给每个像素发一条射线让它去场景里碰出颜色。可网上那些最小示例一换成自己的场景黑屏、颠倒、噪点全来了。问题多半不在算法而在坐标约定和浮点精度。我见过不少半途放弃的人卡在最前面的射线生成上。写通一个最小可运行的渲染器你对这套方向的判断会完全不同。这个闭环并不长但每一步都不能跳过。下面先讲射线方程和坐标系再给一份能编译的 C 代码之后说采样、递归深度怎么调最后是排查清单。想判断这个方向值不值得投入看这份路径就够。2. 光线方程与场景坐标系算不对求交后面全是白忙对光线追踪程序代码而言真正影响渲染结果的往往不是某一句话而是几个叠在一起的约定。本节把这些约定拆开讲清楚后面你复制代码时才不用靠猜。2.1 光线方程 p(t)ot·d 的三个隐藏约定一条光线在三维空间里可以写成一个带参数 t 的向量函数 p(t)ot·d其中 o 是起点d 是方向t 是沿光线方向的标量参数。只要 d 的长度被归一化t 就代表从起点到交点的距离。这里的三个隐藏约定每一条都会跑出错细节。第一个约定direction 必须归一化。很多初次实现为了省事直接用“从相机指向某像素”的原始向量做光线方向。如果 d 不是单位向量球体求交里的二次方程仍然能解出交点但带入到阴影射线时你拿 t 值和光源距离去比较结果就会整体错位。我一般会在光线生成时就调用 normalized()让后续所有代码都建立在“单位方向”这个前提上。第二个约定求交时 t 的边界不是 0 到无穷大而是 tMin 到 tMax。tMin 取一个很小的正数比如 1e-4。原因是浮点计算会让着色点表面自带一层“误差皮”如果用 t0 判定这条光线很可能再次击中原表面生成的阴影和反射区域出现颗粒噪点。这个颗粒不是采样不足是自相交问题。第三个约定所有求交函数共用一份 HitRecord 接口。不管球、平面还是三角形hit 函数都返回最近的 t 和法线。上层代码只需要调用一次“遍历所有物体求最近的交点”不需要关心物体种类。这样代码结构非常清晰新增物体类型时也不用改主循环。典型接口长这样struct HitRecord { double t; // 最近的交点参数 Vec3 point; // 交点坐标 Vec3 normal; // 交点处的法线 int material_id; // 材质编号 };参数说明material_id 用来在着色阶段索引材质数组比在每个物体里保存指针更简单也方便以后把材质换成外部配置。t 和 point 看起来冗余但保留 t 可以少算一次 point对阴影判断这类高频操作有实际收益。平时维护时我习惯只把 point 算给需要它的路径比如反射和折射。2.2 从像素坐标到世界坐标宽高比和 y 轴翻转一起解决像素循环是最容易写错的地方。约定是这样的图像坐标 (i,j) 中 i 向右、j 向下左上角为 (0,0)世界坐标中 x 向右、y 向上、z 向屏幕里。两种坐标系的 y 方向相反因此从图像到世界必须做一次翻转。double aspect double(imageWidth) / imageHeight; double scale tan(fov * 0.5 * M_PI / 180.0); for (int j 0; j imageHeight; j) { for (int i 0; i imageWidth; i) { double x (2.0 * (i 0.5) / imageWidth - 1.0) * aspect * scale; double y (1.0 - 2.0 * (j 0.5) / imageHeight) * scale; Vec3 dir(x, y, -1.0); dir.normalize(); Ray ray(Vec3(0, 0, 0), dir); // 交给求交与着色 } }逻辑说明(i 0.5) / imageWidth把像素中心映射到 [0,1]乘 2 减 1 变成 [-1,1]。x 方向上乘 aspect为了让横向和纵向的可见范围保持一致。y 方向上写成(1.0 - 2.0*(j0.5)/imageHeight)这样当 j 从 0顶部增大时y 从正上方变成负符合世界坐标向上为正。参数说明scale 由视野角度 fov 换算而来fov 单位是角度所以乘以 M_PI/180。fov 越大同一个物体在画面里越小fov60 时大约能看到一个“标准镜头”的视野。aspect 不乘的话圆形会渲染成椭圆这是最常见的画面异常之一肉眼就能发现。注意最终方向向量的 z 分量是 -1相机默认看向 -z这套约定在大多数图形引擎里都能通用。2.3 球体求交和平面求交渲染器里的最小几何集合球体求交的推导我在这里写一遍免得你对着代码猜。将 p(t)ot·d 代入 |p-c|²r²得到关于 t 的二次方程(d·d)t² 2(o-c)·d·t (o-c)·(o-c) - r² 0bool hitSphere(const Sphere s, const Ray r, double tMin, double tMax, HitRecord rec) { Vec3 oc r.origin - s.center; double a dot(r.direction, r.direction); double b 2.0 * dot(oc, r.direction); double c dot(oc, oc) - s.radius * s.radius; double disc b * b - 4.0 * a * c; if (disc 0.0) return false; double sqrtD sqrt(disc); double t (-b - sqrtD) / (2.0 * a); if (t tMin || t tMax) { t (-b sqrtD) / (2.0 * a); if (t tMin || t tMax) return false; } rec.t t; rec.point r.origin r.direction * t; rec.normal (rec.point - s.center) / s.radius; return true; }逻辑说明先算判别式 disc小于 0 说明光线与球没有交点。先取较小的根 t0因为它是靠近相机一侧的交点若它超界再取大根 t1。法线的计算是用交点减球心再除以半径得到单位球面法线。这个法线在反射和光照计算中都会用到。参数说明如果方向已经归一化a 就等于 1但代码里保留一般式子对性能的影响几乎可忽略却能让后续支持缩放变换更方便。很多项目在实际代码中还会加一条“若 disc 非常接近 0 说明光线擦边”的判断不过光线追踪场景里擦边的概率极小省略了也不影响画面。平面求交同样重要因为最常用的地面就是平面bool hitPlane(const Vec3 center, const Vec3 normal, const Ray r, double tMin, double tMax, HitRecord rec) { double denom dot(normal, r.direction); if (fabs(denom) 1e-8) return false; double t dot(normal, center - r.origin) / denom; if (t tMin || t tMax) return false; rec.t t; rec.point r.origin r.direction * t; rec.normal normal; return true; }参数说明denom 接近 0 表示光线几乎平行于平面这种情况视为不相交否则求交。这里返回的法线没有归一化因为调用方在光照计算时会对法线做一次 normalize省一次运算。注意法线的朝向若场景中平面所有法线都朝上背面光就不会计算而球体的法线在背面是自然反向的这个差异会让平面背面的球体渲染得偏暗。若需要双面渲染可以在着色时对法线取反一次。这套最小几何集合已经能搭出不少好看场景一个球做主体、一个无限平面当地面、一个方向光当太阳。下一节就把它们串进真正可运行的工程里。3. 最小可运行的光线追踪程序代码从 main() 到一张 PPM 图片3.1 场景数据结构和材质参数一套两百行代码的骨架我在这里给出一份能直接编译运行的骨架代码风格偏“能说明问题就好”没有引入外部依赖。先看基础类型和场景结构#include cmath #include cstdio #include vector using namespace std; struct Vec3 { double x, y, z; Vec3(double x_ 0, double y_ 0, double z_ 0) : x(x_), y(y_), z(z_) {} Vec3 operator(const Vec3 v) const { return Vec3(x v.x, y v.y, z v.z); } Vec3 operator-(const Vec3 v) const { return Vec3(x - v.x, y - v.y, z - v.z); } Vec3 operator*(double k) const { return Vec3(x * k, y * k, z * k); } Vec3 operator/(double k) const { return Vec3(x / k, y / k, z / k); } }; double dot(const Vec3 a, const Vec3 b) { return a.x * b.x a.y * b.y a.z * b.z; } Vec3 normalize(const Vec3 v) { return v / sqrt(dot(v, v)); } struct Ray { Vec3 origin, direction; Ray(const Vec3 o, const Vec3 d) : origin(o), direction(normalize(d)) {} }; struct Material { Vec3 color; double kd; // 漫反射系数0.2 ~ 0.9 double reflect; // 反射率0 表示不反射1 表示全反射 }; struct Sphere { Vec3 center; double radius; int material_id; }; struct Plane { Vec3 center; Vec3 normal; int material_id; };逻辑说明这里把材质和几何体分开用 material_id 关联。Ray 的构造函数里直接对方向调 normalize相当于给所有后续求交代码上了保险。代价是每条光线多一次 sqrt但相比“忘归一化导致的整张黑图”这点开销非常值。参数说明kd 控制漫反射强度我通常把 0.5 当作中间值reflect 控制反射强度的比例0 到 1。实现时注意材质参数不要卡在纯黑或纯白否则反射叠加后画面会两极分化。Sphere 和 Plane 的字段都保持最小以后再加纹理坐标、发光颜色都容易扩展。3.2 递归 trace 函数颜色在这条路径上被算出来trace 函数是整个渲染器的核心它做三件事找最近交点、算直接光照和阴影、递归计算反射。下面这段代码为了可读性只遍历了球体平面遍历逻辑完全一样留空位置替换即可。Vec3 trace(const Ray r, const vectorSphere spheres, const vectorMaterial mats, int depth) { if (depth 0) return Vec3(0.4, 0.6, 0.8); // 深度耗尽时返回接近天空的颜色 // 1. 找最近交点 double tMinHit 1e20; Vec3 hitPoint, hitNormal; int hitMat -1; for (const auto s : spheres) { HitRecord rec; if (hitSphere(s, r, 1e-4, 1e20, rec)) { if (rec.t tMinHit) { tMinHit rec.t; hitPoint rec.point; hitNormal rec.normal; hitMat s.material_id; } } } // 平面遍历与球体遍历结构相同省略 if (hitMat 0) return Vec3(0.4, 0.6, 0.8); // 未击中任何物体 const Material m mats[hitMat]; // 2. 环境光 平行光 Vec3 lightDir normalize(Vec3(-1.0, 2.0, 1.5)); Vec3 color m.color * 0.15; // 环境光 // 3. 阴影射线从交点沿光源方向探测遮挡 Ray shadowRay(hitPoint hitNormal * 1e-4, lightDir); bool inShadow false; for (const auto s : spheres) { HitRecord tmp; if (hitSphere(s, shadowRay, 1e-4, 1e20, tmp)) { inShadow true; break; } } if (!inShadow) { double diff max(0.0, dot(hitNormal, lightDir)); color color m.color * m.kd * diff; } // 4. 镜面反射递归 if (m.reflect 0.0 depth 1) { Vec3 viewDir normalize(r.origin - hitPoint); Vec3 reflectDir viewDir - hitNormal * 2.0 * dot(viewDir, hitNormal); Vec3 reflectColor trace( Ray(hitPoint hitNormal * 1e-4, reflectDir), spheres, mats, depth - 1); color color reflectColor * m.reflect; } return color; }逻辑说明环境光是一个常数项保证背光面不至于全黑平行光用来模拟方向光shadowRay 从交点沿 lightDir 方向探测只要命中任何球体就判定在阴影里。反射计算用标准反射公式 reflectDir viewDir - 2*(viewDir·normal)*normal然后递归调用深度减一。参数说明hitPoint hitNormal * 1e-4这一步极其关键它把阴影射线的起点沿法线方向推离表面避免自相交。1e-4 这个偏移量不是越大越好太大会让阴影看起来“浮起来”太小又压不住浮点误差我通常先固定 1e-4出现问题再放大到 5e-4。3.3 编译运行与输出图像一条命令出 PPM用 main 函数把像素循环、场景放置、文件输出串起来代码结构如下int main() { const int imageWidth 640; const int imageHeight 480; const double fov 60.0; vectorMaterial mats; mats.push_back({Vec3(0.9, 0.3, 0.3), 0.6, 0.3}); // 红色微反射 mats.push_back({Vec3(0.3, 0.9, 0.3), 0.6, 0.0}); // 绿色不反射 vectorSphere spheres; spheres.push_back({Vec3(0.0, 0.0, -5.0), 1.0, 0}); spheres.push_back({Vec3(2.0, 0.5, -4.0), 0.6, 1}); // 输出 PPM 文件 FILE *fp fopen(output.ppm, wb); fprintf(fp, P6\n%d %d\n255\n, imageWidth, imageHeight); for (int j 0; j imageHeight; j) { for (int i 0; i imageWidth; i) { double aspect double(imageWidth) / imageHeight; double scale tan(fov * 0.5 * M_PI / 180.0); double x (2.0 * (i 0.5) / imageWidth - 1.0) * aspect * scale; double y (1.0 - 2.0 * (j 0.5) / imageHeight) * scale; Vec3 dir(x, y, -1.0); Ray ray(Vec3(0, 0, 0), dir); Vec3 color trace(ray, spheres, mats, 8); // 简单 Gamma 校正 color.x pow(color.x, 1.0 / 2.2); color.y pow(color.y, 1.0 / 2.2); color.z pow(color.z, 1.0 / 2.2); unsigned char r (unsigned char)(min(1.0, color.x) * 255); unsigned char g (unsigned char)(min(1.0, color.y) * 255); unsigned char b (unsigned char)(min(1.0, color.z) * 255); fputc(r, fp); fputc(g, fp); fputc(b, fp); } } fclose(fp); return 0; }逻辑说明PPM 的 P6 格式是二进制头三行分别是魔数、宽高、最大颜色值之后直接写 RGB 字节。这里先写头部再逐像素写数据顺序与像素循环一致。Gamma 校正用的是 2.2 次幂目的是让屏幕上的颜色接近人眼感知否则画面会整体偏暗。编译和运行命令g -O2 -stdc17 -o rt rt.cpp ./rt output.ppm逻辑说明-O2 会优化掉不少重复计算尤其对像素循环更明显。运行后用图片查看工具打开 output.ppm看到两个球体、一个带阴影的地面渐变就说明这套最小渲染器已经完整跑通。如果你用的是 Windows 命令行输出重定向会写文件记得先打开二进制模式或者在 main 里直接 fwrite示例代码已经写成直接写文件不依赖重定向。4. 渲染质量与性能的调参平衡采样、递归深度与并行取舍4.1 每像素采样数从锯齿边缘到平滑过渡的关键参数前面那个版本每个像素只发一条射线所以物体边缘会有明显锯齿。要改善最常见的做法是引入每像素采样数 samples_per_pixel让每个像素在微小范围内抖动出多条射线再对颜色取平均。int samplesPerPixel 16; for (int j 0; j imageHeight; j) { for (int i 0; i imageWidth; i) { Vec3 sumColor(0, 0, 0); for (int s 0; s samplesPerPixel; s) { double u (i rand01()) / imageWidth; double v (j rand01()) / imageHeight; // 从 u, v 生成射线并 trace累加到 sumColor } Vec3 avgColor sumColor / samplesPerPixel; // 写像素 } }参数说明rand01() 返回 [0,1] 之间的随机数作用是把原本固定在像素中心的射线偏移到像素内部。samplesPerPixel1 就是无抗锯齿4 能明显改善斜线锯齿16 在 640×480 下已经比较平滑64 以上适合最终出图不适合调试。我的习惯是先固定场景分别用 1、4、16、64 各渲一张肉眼对比边缘。如果 4 和 16 差别不大说明你的场景本来就走低噪声路线如果 16 和 64 差别仍然明显那就要考虑场景里是否存在高频细节导致误差收敛慢。采样数并不是越高越好每翻一倍渲染时间也跟着翻一倍。4.2 递归深度8 层还是 50 层取决于反射衰减递归深度决定一条光线最多反射多少次。我的经验值是漫反射为主场景设 4 到 5强镜面场景设 8 左右就足够。每个材质都有 reflect 系数反射率越接近 1光线携带的能量越不容易衰减深度不足时画面会明显偏黑。这里用一个表格说明不同深度下的画面表现深度画面表现适用场景1只有直接光照和一次反射玻璃/镜子明显发暗快速预览4反射两次后仍然能看到主要细节大多数室内和静物8反射链接近收敛肉眼与更高深度差异极小镜面长廊等连续反射场景50几乎每个像素都在不停反射性能开销巨大极度追求反射完整性时参数说明设深度为 8 并不是让每条光线真的反射 8 次。反射链通常会因为击中背景而提前终止只有在两个镜子互相对照时才会真正耗到最大深度。所以我建议代码里用if (depth 0) return ...控制递归终点而不是在调用时传一个 0这样便于观察实际反射层数。注意反射率与深度是耦合关系。reflect0.2 时第 2 次反射的能量只有 4%几乎看不见reflect0.9 时第 8 次反射仍有约 43% 能量不可忽略。因此调参时先从反射最强的材质入手把深度定在 8再看暗部是否需要改善。4.3 多线程与场景规模先拆像素行再想加速结构当场景只有几十个球体时性能瓶颈根本不在求交而在主循环的串行遍历。最简单的提速方式是把像素循环拆给多个线程。下面是一个用 std::thread 把行拆开写的示意#include thread void renderRows(int yBegin, int yEnd, ...) { for (int j yBegin; j yEnd; j) { for (int i 0; i imageWidth; i) { // 和单线程版本完全相同的像素计算 } } } // 启动 4 个线程每个线程处理 1/4 的行 int rowsPerThread imageHeight / 4; thread t0(renderRows, 0, rowsPerThread, ...); thread t1(renderRows, rowsPerThread, 2 * rowsPerThread, ...); // 依此类推 t0.join(); t1.join(); ...逻辑说明像素行的计算彼此独立没有任何共享状态因此拆分非常安全。每线程的行数是连续区间这样写简单且缓存友好。真正要注意的是不要在函数内部重复初始化场景数据和材质数组应把 vector 的引用传进来否则复制开销会抵消多线程收益。参数说明线程数通常设为 CPU 核数。比如 8 核机器开 8 个线程再往上提升效果很小。场景规模达到几万三角形时这种粗粒度拆分就不够用了需要引入 BVH 或网格加速结构但那已经超出“最小可运行代码”的范畴适合在第二版扩展。对现在的代码来说先保证线程不写出竞争再考虑更复杂的加速。5. 光线追踪程序常见问题与避坑指南黑屏、噪点与错位的排查清单5.1 渲染结果全黑先查光线方向再查 tMin现象输出图片全黑但场景定义看起来完全正常。原因最常见的是相机看向反方向。相机在原点光线方向指向 z 正方向而场景物体在 z 负方向射线永远击不中物体。另一种可能是 tMin 设置过大把有效交点也过滤掉了。解决在光线生成后加一行调试打印输出 (0,0) 像素的光线方向。如果 direction.z 为正说明相机看向屏幕外。把方向向量改成Vec3(x, y, -1.0)后重跑。tMin 则从 1e-4 开始若画面出现细小碎块再逐步增大。5.2 图片上下颠倒一个负号引发的错位现象球体、地面位置完全正确但整个画面是倒过来的。原因图像坐标系 y 向下世界坐标系 y 向上像素循环里少了1.0 -这一项。解决将水平方向的映射从2.0*(j0.5)/imageHeight-1.0改为1.0-2.0*(j0.5)/imageHeight。这种问题肉眼一眼就认出修完后上下关系立刻正常。顺带检查坐标轴是否把 y 当成了深度轴那是另一个常见混淆。5.3 球体变成椭圆宽高比丢失的典型案例现象场景里所有球体都是横向或纵向拉伸的椭圆距离相机越远越明显。原因像素坐标转换到世界坐标时没有乘以宽高比 aspect导致 x 方向的可见范围被压缩或拉伸。解决在生成光线时把 x 乘上double(imageWidth)/imageHeight。如果图像是 640×480aspect1.333不乘的话球体横向偏扁。我习惯把这个值提升为全局常量避免在多个函数里各写一遍导致不一致。5.4 阴影区域全是颗粒噪点浮点误差与自相交角力现象物体表面有大量随机亮点或灰斑尤其集中在阴影交界处附近。原因阴影射线的起点在交点表面由于浮点误差射线刚出发就和自己所在的表面相交产生错误遮挡。解决给阴影射线起点加上法线方向的偏移hitPoint hitNormal * 1e-4。如果偏移后阴影边界出现空洞说明偏移过大改为 1e-5 再试。这条经验同样适用于反射射线和折射射线是所有光线追踪代码里最容易忽略的细节。5.5 反射几层之后颜色发黑递归深度与能量衰减的组合问题现象镜子或抛光材质的反射区域前一两层正常再往里就黑成一团。原因递归深度太少反射链提前终止或者材质 reflect 系数过低能量衰减太快。解决先把深度提高到 8单独测试一个纯镜面球。如果仍然发黑检查材质 reflect 是否是 0.9 以上的强反射而不是 0.3。还有一个常被忽视的点反射后的颜色是reflectColor * m.reflect如果某层材质 reflect0后续颜色直接就丢掉了这是设计如此不是 bug。若想让暗部更丰富可以把最低亮度钳制在 0.02 左右但不要用“加常数”的方式那会让反射失真。6. 用一张测试图验证渲染器把调参从玄学变成工程习惯我养成的习惯是准备一套固定的测试场景一个带反射的红色球、一个不反射的绿色球、一块灰色地面、一盏从左上角打下来的平行光。每次改动渲染器都先用这个场景渲染 16 采样存成 baseline.ppm之后所有改动都和这张图对比。这个做法能解决一个让人头疼的问题你很难判断“画面变好”还是“画面只是变亮”。比如你把环境光从 0.15 提到 0.3整张图都会变亮但不代表渲染更正确。对比 baseline 时我会重点看三个位置球体边缘是否保持平滑、阴影区域是否有新增噪点、地面反射是否出现异常亮点。这三个位置基本覆盖了射线生成、自相交、递归反射三个容易翻车的地方。调试时还有一个替代手段针对某个像素单独打印中间值。在 trace 函数里临时加一个像素坐标参数命中球体后输出 t、normal、reflect 三个量跑一次就能看出问题出在求交阶段还是着色阶段。这个办法比盯着整张图猜效率高得多。我之前吃过亏一次性调了采样、深度和偏移量三个参数结果画面糊了却不知道哪个参数起的作用。后来改成每次只改一个参数固定其他值问题立刻暴露。技术方向积累到今天真正拉开差距的往往不是你会不会写求交函数而是你有没有一套稳定的验证流程。希望这套流程能帮你少走些弯路把光线追踪程序代码从“能跑”推到“可维护、可调参、可信赖”的状态。本文还有配套的精品资源点击获取