从零实现光线追踪:用C++构建基础渲染器,理解图形学底层原理
1. 项目概述:从像素到光线,亲手构建视觉奇观
“光线追踪”这个词,听起来像是电影工业里那些造价百万美元的渲染农场才玩得转的黑科技。确实,从《玩具总动员》到《阿凡达》,它一直是创造逼真视觉特效的基石。但今天,我们不谈那些复杂的商业引擎,就聊聊怎么用几百行代码,在自己的电脑上实现一个最基础、最核心的光线追踪算法。这就像学画画,我们不一开始就挑战《蒙娜丽莎》,而是从画一个球体开始,理解光影如何塑造形体。
这个项目的核心目标很简单:不用任何图形API(如OpenGL或DirectX),仅凭纯粹的数学和算法,在内存中生成一张包含三维场景(比如几个球体)的图片,并模拟出光线与物体交互产生的阴影、反射等基础效果。最终,你会得到一个.ppm格式的图片文件,打开它,你就能看到自己“创造”的光影世界。这个过程会让你深刻理解计算机图形学的底层逻辑——颜色不是画上去的,而是算出来的。无论你是对图形学充满好奇的学生,还是想夯实基础的开发者,亦或是算法爱好者,亲手实现一遍这个“轮子”,都会让你对渲染、光照乃至更广泛的模拟技术有脱胎换骨的认识。
2. 核心原理拆解:光线如何“看见”世界
在开始敲代码之前,我们必须把光线追踪的核心思想掰开揉碎讲清楚。它和我们眼睛看东西的原理在思路上是相反的。
2.1 逆向思维:从像素出发寻找光线
传统的光栅化渲染(比如游戏里常用的方法)是“从物体到像素”:把三维模型的三角形转换到屏幕上,决定每个像素涂什么颜色。而光线追踪是“从像素到物体”:为图像上的每一个像素,发射一条虚拟的光线(称为“视线”或“主光线”),穿过这个像素,射入虚拟的三维场景中,去追踪这条光线最终遇到了什么,从而决定该像素的颜色。
想象一下,你的电脑屏幕是一个窗户,窗外是你用代码搭建的虚拟世界。光线追踪就是为窗户上的每一块玻璃(像素)派遣一名侦察兵(光线),让他报告从你这个观察点看出去,透过这块玻璃能看到世界的哪个角落,那个角落的物体是什么颜色、有多亮。
2.2 光线与物体的“碰撞检测”
侦察兵(光线)出发后,首要任务就是判断它是否击中了场景中的物体。这本质上是一个几何求交问题。在我们的简易实现里,物体通常从最简单的球体开始,因为球体的求交计算在数学上非常简洁。
一条光线可以用一个起点(通常是相机位置)和一个方向向量来定义:P(t) = origin + t * direction,其中t > 0表示沿着光线方向的某个距离点。一个球体可以用球心坐标和半径来定义。判断光线是否击中球体,就等价于求解关于t的二次方程:是否存在一个正的t值,使得点P(t)到球心的距离等于球的半径。这是我们整个算法的数学基石。
注意:这里的计算大量依赖于向量运算(点积、叉积、归一化)。如果你对线性代数感到生疏,建议先复习一下向量的基本操作,它们是图形学的“语言”。
2.3 着色计算:当光线击中目标之后
当我们的侦察兵报告“击中了一个红色的球体”时,工作只完成了一半。我们还需要知道这个点在当前光照下看起来是什么样子。这就是着色模型。最基础、最经典的是朗伯余弦定律(Lambertian)漫反射模型。
它的直观理解是:一个表面点接收到的光强,取决于光线方向与该点法线方向夹角的余弦值。光线垂直照射时最亮,擦着表面照射时最暗。计算时,我们通常假设有一个或多个“光源”(比如一个位于场景上方的点光源)。对于击中点,我们计算从该点指向光源的方向向量,并将其与击中点的表面法线(对于球体,法线就是从球心指向击中点的向量)做点积。点积结果(在0到1之间)乘以物体自身的颜色,再乘以光源强度,就得到了该点的漫反射颜色。
如果还想模拟光滑物体(如台球、金属)的镜面反射,算法就需要递归:在击中点,根据入射光线和法线,计算出反射光线的方向,然后发射一条新的反射光线,继续追踪它击中了什么,并将其颜色贡献叠加到当前像素上。递归深度需要控制,否则会无限循环。
3. 工程框架与核心类设计
理解了原理,我们就可以着手搭建代码的骨架了。一个好的设计能让后续的实现和调试清晰很多。我们将采用面向对象的思想,设计几个核心类。
3.1 基础数学类:Vec3
一切图形学计算的基础是三维向量。我们将实现一个Vec3类,它不仅仅表示一个点或方向,还要封装所有必要的运算。
class Vec3 { public: double x, y, z; // 构造函数 Vec3(double x0=0, double y0=0, double z0=0) : x(x0), y(y0), z(z0) {} // 向量运算:加减乘除、点积、叉积、长度、归一化 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 d) const { return Vec3(x*d, y*d, z*d); } // 标量乘 Vec3 operator*(const Vec3& v) const { return Vec3(x*v.x, y*v.y, z*v.z); } // 逐分量乘(用于颜色混合) double dot(const Vec3& v) const { return x*v.x + y*v.y + z*v.z; } Vec3 cross(const Vec3& v) const { return Vec3(y*v.z - z*v.y, z*v.x - x*v.z, x*v.y - y*v.x); } double length() const { return sqrt(x*x + y*y + z*z); } Vec3 normalize() const { double len = length(); return Vec3(x/len, y/len, z/len); } };同时,我们可以用Vec3来存储颜色(RGB),其三个分量分别代表红、绿、蓝通道,取值范围通常在 [0, 1]。
3.2 光线类:Ray
Ray类非常简单,就是封装一个起点和一个方向。
class Ray { public: Vec3 origin; Vec3 direction; Ray(const Vec3& o, const Vec3& d) : origin(o), direction(d) {} // 根据参数t计算光线上的点 Vec3 at(double t) const { return origin + direction * t; } };3.3 场景物体基类与球体类:Hittable&Sphere
我们需要一个统一的接口来处理所有能被光线击中的物体。定义一个抽象基类Hittable。
struct HitRecord { Vec3 point; // 击中点坐标 Vec3 normal; // 击中点法线(单位向量) double t; // 光线参数t bool front_face; // 光线是从外部击中还是内部击中(用于决定法线方向) // ... 后续可以添加材质指针 }; class Hittable { public: virtual bool hit(const Ray& r, double t_min, double t_max, HitRecord& rec) const = 0; };t_min和t_max参数非常重要,它们定义了有效的相交区间。t_min通常设为一个很小的正数(如0.001),以避免光线在起点附近与自身相交(数值精度问题);t_max初始可以设为无穷大,在遍历物体时,会更新为最近击中点的t值,用于优化。
接下来实现球体Sphere:
class Sphere : public Hittable { public: Vec3 center; double radius; Sphere(const Vec3& cen, double rad) : center(cen), radius(rad) {} virtual bool hit(const Ray& r, double t_min, double t_max, HitRecord& rec) const override { Vec3 oc = r.origin - center; double a = r.direction.dot(r.direction); double half_b = oc.dot(r.direction); // 注意,这里用half_b简化计算 double c = oc.dot(oc) - radius*radius; double discriminant = half_b*half_b - a*c; if (discriminant < 0) return false; double sqrtd = sqrt(discriminant); // 寻找在[t_min, t_max]区间内的最小根 double root = (-half_b - sqrtd) / a; if (root < t_min || t_max < root) { root = (-half_b + sqrtd) / a; if (root < t_min || t_max < root) return false; } rec.t = root; rec.point = r.at(rec.t); Vec3 outward_normal = (rec.point - center) * (1.0 / radius); // 设置法线方向:始终指向光线射来的反方向(即外部) rec.front_face = (r.direction.dot(outward_normal) < 0); rec.normal = rec.front_face ? outward_normal : outward_normal * (-1.0); return true; } };这段代码是核心中的核心。它求解了关于t的二次方程a*t^2 + 2*half_b*t + c = 0。注意我们使用了half_b来简化计算和提高数值稳定性。找到根后,我们选择在有效区间内、且值最小的那个根(因为光线先碰到近处的表面)。最后,我们通过判断光线方向与物体表面法线的点积符号,来确定光线是从外部还是内部击中物体,并据此保证法线始终指向光线来源的反方向(这对于后续的光照计算至关重要)。
3.4 场景管理与相机类
我们需要一个列表来管理场景中的所有物体。一个简单的HittableList类就可以胜任,它本身也继承自Hittable,其hit函数遍历所有物体,记录下最近的碰撞点。
相机类Camera负责将3D世界坐标映射到2D图像像素坐标。在最简单的设置中,我们假设相机位于原点 (0,0,0),看向负Z轴方向。图像平面位于z = -1处。图像宽度和高度决定了视野(FOV)。相机类需要计算从相机原点出发,穿过每个像素中心的光线方向。
class Camera { public: double viewport_height, viewport_width; Vec3 origin; Vec3 horizontal; Vec3 vertical; Vec3 lower_left_corner; // 视口左下角坐标 Camera(double aspect_ratio = 16.0/9.0) { double focal_length = 1.0; // 焦距,即视口平面到相机的距离 viewport_height = 2.0; viewport_width = aspect_ratio * viewport_height; origin = Vec3(0, 0, 0); horizontal = Vec3(viewport_width, 0, 0); vertical = Vec3(0, viewport_height, 0); // 视口左下角 = 原点 - 水平/2 - 垂直/2 - 焦距*朝向 lower_left_corner = origin - horizontal*0.5 - vertical*0.5 - Vec3(0, 0, focal_length); } Ray get_ray(double u, double v) const { // u, v 是像素的归一化坐标,范围[0,1] return Ray(origin, lower_left_corner + horizontal*u + vertical*v - origin); } };4. 核心渲染循环与着色实现
框架搭好,现在进入最激动人心的部分——渲染主循环。我们将逐像素计算颜色。
4.1 像素坐标到光线方向
假设我们的输出图片宽为image_width,高为image_height。对于每一个像素(i, j)(其中i从0到width-1,j从0到height-1),我们需要计算其对应的归一化坐标(u, v)。
for (int j = image_height - 1; j >= 0; --j) { // 通常从上到下写入文件 for (int i = 0; i < image_width; ++i) { double u = double(i) / (image_width - 1); double v = double(j) / (image_height - 1); Ray r = cam.get_ray(u, v); Vec3 pixel_color = ray_color(r, world); // 核心着色函数 write_color(std::cout, pixel_color); // 将颜色值写入PPM文件 } }注意循环中j是从上到下递减的,这是因为PPM等格式的图片数据通常是从上到下存储的。
4.2ray_color函数:光线追踪的入口
这个函数是算法的灵魂。它接收一条光线和场景,返回该光线对应的颜色。
Vec3 ray_color(const Ray& r, const Hittable& world) { HitRecord rec; if (world.hit(r, 0.001, infinity, rec)) { // 如果击中物体,进行着色计算 Vec3 light_dir = (Vec3(10, 10, 10) - rec.point).normalize(); // 假设光源位置 double diffuse_strength = std::max(0.0, rec.normal.dot(light_dir)); // 假设物体是红色的漫反射材质 return Vec3(1.0, 0.2, 0.2) * diffuse_strength; } // 如果没有击中任何物体,返回背景色(简单的渐变色天空) Vec3 unit_direction = r.direction.normalize(); double t = 0.5 * (unit_direction.y + 1.0); // 将y从[-1,1]映射到[0,1] return Vec3(1.0, 1.0, 1.0) * (1.0 - t) + Vec3(0.5, 0.7, 1.0) * t; // 线性插值 }这个版本实现了最基本的漫反射着色和天空盒背景。背景使用了一个从白色到淡蓝色的垂直渐变,模拟简单的天空效果。
4.3 输出图片:PPM格式
我们需要将计算出的RGB颜色(每个分量是0到1的浮点数)写入一个图像文件。最简单的方法是使用Netpbm格式中的PPM(Portable PixMap)文本格式。它没有压缩,格式简单,可以直接用代码生成。
void write_color(std::ostream& out, const Vec3& pixel_color) { // 将[0,1]的浮点颜色值转换到[0,255]的整数 int r = static_cast<int>(255.999 * std::clamp(pixel_color.x, 0.0, 1.0)); int g = static_cast<int>(255.999 * std::clamp(pixel_color.y, 0.0, 1.0)); int b = static_cast<int>(255.999 * std::clamp(pixel_color.z, 0.0, 1.0)); out << r << ' ' << g << ' ' << b << '\n'; }在主程序开头,需要先输出PPM文件头:
std::cout << "P3\n" << image_width << ' ' << image_height << "\n255\n";P3表示这是ASCII编码的PPM文件,然后是宽度、高度和最大颜色值255。
5. 效果优化与高级特性初探
有了基础版本,渲染出的可能只是一个有阴影的红色球体在蓝色天空下。这很棒,但我们可以让它更真实、更丰富。
5.1 抗锯齿:让边缘更平滑
目前每个像素只发射一条中心光线,当物体边缘位于像素内部时,会产生明显的锯齿。抗锯齿(Anti-aliasing)的经典方法是超采样(Super Sampling):在每个像素内,随机发射多条光线,然后取颜色的平均值。
int samples_per_pixel = 100; // 每个像素采样次数 for (int i = 0; i < image_width; ++i) { for (int j = 0; j < image_height; ++j) { Vec3 pixel_color(0, 0, 0); for (int s = 0; s < samples_per_pixel; ++s) { // 在像素范围内增加随机偏移 double u = (i + random_double()) / (image_width - 1); double v = (j + random_double()) / (image_height - 1); Ray r = cam.get_ray(u, v); pixel_color += ray_color(r, world); } // 计算平均颜色,并做伽马校正(取平方根近似) pixel_color *= (1.0 / samples_per_pixel); pixel_color = Vec3(sqrt(pixel_color.x), sqrt(pixel_color.y), sqrt(pixel_color.z)); write_color(std::cout, pixel_color); } }这里引入了random_double()函数来生成 [0,1) 之间的随机数。同时,我们进行了简单的伽马校正(取平方根),因为人眼对暗部变化更敏感,而显示器是线性响应的,校正后视觉效果更符合人眼感知。
5.2 漫反射材质的真实模拟
之前的漫反射计算是“理想”的,光线击中点直接看向光源。现实中,粗糙表面的反射光线是向四面八方散射的。一个更物理正确的漫反射模型是:在击中点,沿着以法线为中心的半球方向,随机选择一个方向作为新的光线方向。这意味着光线可能弹向任何地方,包括阴影区,从而自然形成柔和的阴影和颜色混合。
// 在ray_color的击中分支中,替换原来的直接光照计算 if (world.hit(r, 0.001, infinity, rec)) { Vec3 target = rec.point + rec.normal + random_in_unit_sphere(); // 随机半球方向 return ray_color(Ray(rec.point, target - rec.point), world) * 0.5; // 每次反射吸收一半能量 }random_in_unit_sphere()返回一个单位球体内的随机点,将其与法线相加,得到的新方向大致在以法线为中心的半球内。由于每次反射都会衰减能量(这里乘以0.5),递归多次后光线贡献会趋于0,递归会自动终止。这种算法称为路径追踪(Path Tracing),是光线追踪的一种形式,它能非常自然地模拟全局光照、颜色渗透(如红球旁边的白墙会微微泛红)等复杂效果。
5.3 添加更多几何体与材质
现在,你可以轻松地向HittableList中添加更多球体,赋予它们不同的位置、大小和颜色。你还可以实现其他几何体,如平面(Plane)、三角形(Triangle)等,只需实现各自的hit函数。
要实现镜面反射材质,可以在HitRecord中添加一个材质类指针。材质类定义一个scatter函数,输入入射光线和碰撞记录,输出衰减颜色和散射后的光线。对于镜面材质,scatter函数会根据反射定律计算新的光线方向。
6. 调试技巧与性能优化
第一次实现光线追踪,肯定会遇到各种问题:图片全黑、全白、颜色怪异、物体形状不对等。以下是一些实用的调试技巧:
- 可视化光线方向:在
ray_color函数中,如果未击中物体,不要直接返回背景色,而是将光线的方向向量(各分量在-1到1之间)映射到0-1范围并作为颜色输出。这能帮你确认相机和光线生成是否正确。 - 简化场景:从一个位于原点的白色球体和一个从左上角打来的简单光源开始。确保阴影方向正确。
- 检查法线:将击中点的法线(各分量在-1到1之间)映射为颜色输出。球体的法线图应该是一个平滑的彩色渐变球。
- 逐步增加复杂度:先关掉抗锯齿和递归反射,让单个球体的漫反射工作正常。然后再一步步开启高级特性。
关于性能,我们的简易实现在加入抗锯齿和漫反射递归后可能会非常慢。这是因为计算量是像素数×采样数×递归深度×物体数量的乘积。一些优化思路:
- 包围盒(Bounding Volume Hierarchy, BVH):这是加速光线追踪最核心的数据结构。它将场景中的物体组织成一棵树,每个树节点对应一个包围盒。光线先与包围盒求交,如果连包围盒都没击中,则无需检查其内的所有物体。这能将复杂度从 O(N) 降为 O(logN)。
- 并行计算:每个像素的颜色计算是完全独立的,非常适合并行化。可以使用
std::thread或 OpenMP 将图像分成块,并行渲染。 - 重要性采样:在漫反射中,我们均匀地随机采样半球。但我们可以根据BRDF(双向反射分布函数)或光源位置来偏向于对最终颜色贡献更大的方向进行采样,从而用更少的采样数获得更低的噪声。
7. 从简易实现到深入探索
当你成功渲染出第一个带有柔和阴影和颜色混合的三球场景时,恭喜你,你已经掌握了光线追踪最核心的思想。这个简易实现是一个强大的起点,你可以沿着多个方向深入:
- 更多几何图元:实现三角形网格,从而可以导入复杂的3D模型(如.obj文件)。
- 纹理映射:将2D图片贴到3D物体表面,让球体拥有木纹、大理石等质感。
- 体积渲染:模拟雾、烟、云等参与介质,光线在其中会发生散射和吸收。
- 蒙特卡洛积分:更严谨地实现路径追踪,支持多种材质(金属、电介质如玻璃/水、毛发、布料等)。
- 直接照明与全局照明:区分直接来自光源的光线和经过多次反弹的间接光,并用不同的采样策略优化。
亲手实现这个算法,最大的收获不是代码本身,而是那种对“光如何形成图像”的深刻直觉。你会发现,屏幕上每一个像素的颜色,背后都是一段光线在虚拟世界中穿梭、碰撞、衰减的旅程。这种从第一性原理出发的理解,是使用任何现成渲染引擎都无法替代的。拿起你的编译器,从发射第一条光线开始吧。
