FEATURED · 精选文章

从零实现光线追踪:用C++构建基础渲染器,理解图形学底层原理

发布时间 / 2026/8/1 2:46:08
来源 / 创域科博编辑部
栏目 / 资讯中心
从零实现光线追踪:用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 x00, double y00, double z00) : x(x0), y(y0), z(z0) {} // 向量运算加减乘除、点积、叉积、长度、归一化 Vec3 operator(const Vec3 v) const { return Vec3(xv.x, yv.y, zv.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 光线类RayRay类非常简单就是封装一个起点和一个方向。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 场景物体基类与球体类HittableSphere我们需要一个统一的接口来处理所有能被光线击中的物体。定义一个抽象基类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值用于优化。接下来实现球体Sphereclass 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-1j从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格式中的PPMPortable PixMap文本格式。它没有压缩格式简单可以直接用代码生成。void write_color(std::ostream out, const Vec3 pixel_color) { // 将[0,1]的浮点颜色值转换到[0,255]的整数 int r static_castint(255.999 * std::clamp(pixel_color.x, 0.0, 1.0)); int g static_castint(255.999 * std::clamp(pixel_color.y, 0.0, 1.0)); int b static_castint(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物体表面让球体拥有木纹、大理石等质感。体积渲染模拟雾、烟、云等参与介质光线在其中会发生散射和吸收。蒙特卡洛积分更严谨地实现路径追踪支持多种材质金属、电介质如玻璃/水、毛发、布料等。直接照明与全局照明区分直接来自光源的光线和经过多次反弹的间接光并用不同的采样策略优化。亲手实现这个算法最大的收获不是代码本身而是那种对“光如何形成图像”的深刻直觉。你会发现屏幕上每一个像素的颜色背后都是一段光线在虚拟世界中穿梭、碰撞、衰减的旅程。这种从第一性原理出发的理解是使用任何现成渲染引擎都无法替代的。拿起你的编译器从发射第一条光线开始吧。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻