C++实现光线追踪相机:薄透镜模型与景深效果原理详解

📅 2026/7/20 10:43:14
C++实现光线追踪相机:薄透镜模型与景深效果原理详解
1. 项目概述从“纸片感”到“电影感”的跨越在计算机图形学的世界里渲染一张图像最核心的目标之一就是“真实感”。我们见过太多基于光栅化的实时渲染虽然速度飞快但总感觉少了点什么——物体边缘过于锐利阴影边界清晰得像刀切整个画面缺乏那种物理世界中的柔和与深度。这就是常说的“纸片感”或“游戏感”。而光线追踪正是打破这层壁垒的关键技术。它模拟光线在场景中的物理传播路径能天然地生成逼真的阴影、反射和折射。但今天我们要聊的是让真实感再上一个台阶的“杀手锏”虚焦模糊也叫景深效果。你拿起单反相机对准一个主体对焦背景会自然地模糊成光斑前景也可能虚化这种效果能极大地引导观众的视觉焦点营造出强烈的空间感和电影感。在光线追踪中实现虚焦模糊不仅仅是加一个后期滤镜而是要从物理层面改造我们的相机模型。这个项目就是带你用C从零开始构建一个支持虚焦模糊的光线追踪相机。我们会摒弃那些过于简化、只能产生“纸片感”图像的针孔相机模型转而实现一个更贴近真实相机的薄透镜模型。我将附上完整的、可编译运行的代码你不仅能得到酷炫的渲染图更能彻底理解光线追踪相机的工作原理以及虚焦模糊背后的光学原理。无论你是图形学新手想深入了解光线追踪还是有一定基础的开发者想完善自己的渲染器这篇文章都将是一份详实的实战指南。2. 核心原理拆解薄透镜模型如何工作要理解虚焦模糊首先得抛弃我们习以为常的“针孔相机”思维。在针孔模型中场景中任意一点发出的光线只有唯一一条能通过那个无限小的针孔到达成像平面我们的胶片或传感器。这导致了所有物体都是完全清晰的没有景深概念。这显然不是真实相机的工作方式。真实相机有一个镜头组但为了简化模型我们使用“薄透镜近似”。这个模型的核心参数有三个焦距、透镜半径和对焦距离。焦距指的是透镜中心到焦平面的距离。平行光例如来自无限远的光线经过透镜后会汇聚在焦平面上。在相机中这大致对应着镜头的焦距参数如50mm。对焦距离这是你想要让场景中哪个平面清晰成像的距离。当你旋转相机镜头对焦环时本质上是在调整透镜与成像平面传感器之间的相对位置使得特定距离的物体恰好成像在传感器上。透镜半径这模拟了镜头光圈的大小。半径越大光圈越大进入的光线越多同时景深越浅虚化效果越强。其工作原理可以这样理解在成像平面传感器上我们有一个像素点(i, j)。在薄透镜上我们不再只取中心点而是在透镜圆盘内随机采样一个点(lens_x, lens_y)。这个随机过程是虚焦模糊自然感的关键它模拟了光线可以从镜头不同部位进入。我们计算这条光线的方向它始于透镜采样点并指向对焦平面上的一个目标点。这个目标点是由像素点反向投影到对焦平面上的位置决定的。这样传感器上一点接收到的光线不再是来自场景中的单一方向而是一个锥形光束。只有当这个光束在场景物体上交汇于对焦距离附近的点时该点在传感器上的成像才是清晰的如果交汇点远离对焦平面它在传感器上就会形成一个模糊圆多个这样的模糊圆叠加就形成了虚化效果。注意这里的“随机采样”通常使用低差异序列如Halton序列或在单位圆内均匀采样以避免产生难看的噪声图案。在代码中我们会用一个简单的在单位圆盘内均匀取样的方法并配合多次采样每像素多条光线来平滑噪声。2.1 为何选择C实现你可能会问Python不是更简单吗对于光线追踪这种计算密集型任务C有着无可比拟的优势。每条光线都需要与场景中所有几何体进行求交测试这是一个O(N)复杂度的操作并且每像素需要发射数十甚至数百条光线抗锯齿景深。C的极致性能能让我们在可接受的时间内得到高质量图像。此外图形学的工业标准如游戏引擎、离线渲染器底层几乎都是C/C掌握用C实现核心算法对理解整个图形管线大有裨益。3. 相机类设计与关键数据结构让我们开始动手。首先我们需要设计相机类。这个类将封装所有相机参数和生成光线的核心方法。#ifndef CAMERA_H #define CAMERA_H #include “vec3.h” // 假设你有一个三维向量类 #include “ray.h” // 光线类包含原点(origin)和方向(direction) class camera { public: // 构造函数使用薄透镜模型 camera( point3 lookfrom, // 相机位置 point3 lookat, // 观察目标点 vec3 vup, // 世界空间的上向量 (通常为(0,1,0)) double vfov, // 垂直视野角 (degrees) double aspect_ratio, // 宽高比 double aperture, // 光圈半径 (透镜半径) double focus_dist // 对焦距离 ) { // 1. 计算成像平面视口尺寸 auto theta degrees_to_radians(vfov); auto h tan(theta/2); auto viewport_height 2.0 * h; auto viewport_width aspect_ratio * viewport_height; // 2. 建立相机坐标系 (w, u, v) w unit_vector(lookfrom - lookat); // 指向相机后方的z轴 u unit_vector(cross(vup, w)); // 右向量 (x轴) v cross(w, u); // 上向量 (y轴) // 3. 计算相机平面上的向量 origin lookfrom; horizontal focus_dist * viewport_width * u; vertical focus_dist * viewport_height * v; // 左下角坐标 lower_left_corner origin - horizontal/2 - vertical/2 - focus_dist*w; // 4. 存储透镜参数 lens_radius aperture / 2; } // 核心方法为给定像素坐标(u,v)生成一条光线 ray get_ray(double s, double t) const { // 在透镜圆盘内随机采样一个点 vec3 rd lens_radius * random_in_unit_disk(); vec3 offset u * rd.x() v * rd.y(); // 计算光线起点透镜上的点和方向 // 方向指向对焦平面上的目标点 return ray( origin offset, lower_left_corner s*horizontal t*vertical - origin - offset ); } private: point3 origin; // 相机/透镜中心 point3 lower_left_corner; // 成像平面左下角 vec3 horizontal; // 成像平面水平向量 vec3 vertical; // 成像平面垂直向量 vec3 u, v, w; // 相机坐标系的基向量 double lens_radius; // 透镜半径 }; #endif关键数据结构解析vec3/point3这是图形学的基石用于表示点、向量、颜色。你需要实现基本的向量运算加减、点乘、叉乘、归一化等。ray光线类通常包含一个起点origin和一个方向向量direction。它是光线追踪中的基本查询单元。相机坐标系 (u, v, w)这是理解相机朝向的关键。w指向相机观察方向的反方向因为我们的习惯是相机看向-z方向。u相机空间的右向量x轴由世界空间上向量vup与w叉乘得到。v相机空间的上向量y轴由w和u叉乘得到保证坐标系是正交的。random_in_unit_disk()这是一个工具函数用于在半径为1的单位圆盘内生成一个均匀的随机点。实现时要注意拒绝采样法确保点在圆内而非正方形内以保证采样的均匀性。vec3 random_in_unit_disk() { while (true) { auto p vec3(random_double(-1,1), random_double(-1,1), 0); if (p.length_squared() 1) continue; // 如果点在单位圆外则拒绝并重试 return p; } }4. 完整渲染流程与核心代码实现有了相机我们需要将它嵌入一个完整的、简化的光线追踪渲染循环中。这个循环主要包括为每个像素发射多条光线每条光线与场景求交计算交点颜色最后平均所有光线结果。下面是一个高度概括的主渲染循环伪代码展示了相机如何被调用// 图像尺寸 const int image_width 800; const int image_height static_castint(image_width / aspect_ratio); const int samples_per_pixel 100; // 每像素采样数用于抗锯齿和景深平滑 // 初始化相机 camera cam(lookfrom, lookat, vup, vfov, aspect_ratio, aperture, focus_dist); // 遍历每个像素 for (int j image_height-1; j 0; --j) { for (int i 0; i image_width; i) { color pixel_color(0, 0, 0); // 每像素内多次采样 for (int s 0; s samples_per_pixel; s) { // 计算像素内的随机偏移 [0,1) auto u (i random_double()) / (image_width-1); auto v (j random_double()) / (image_height-1); // 从相机获取一条光线 ray r cam.get_ray(u, v); // 追踪这条光线获取颜色 pixel_color ray_color(r, world, max_depth); } // 平均颜色并应用伽马校正通常取sqrt write_color(std::cout, pixel_color, samples_per_pixel); } }核心函数ray_color的实现思路这个函数递归地追踪光线是光线追踪的核心。求交判断光线r是否与场景world中的任何物体相交。命中处理如果命中记录最近的交点信息交点位置、法线、材质。根据交点材质如漫反射、金属、电介质生成一条新的散射光线。递归调用ray_color追踪这条新光线并将返回的颜色与材质本身的颜色衰减相乘。设置递归深度max_depth以防止无限递归通常5-10次。未命中处理如果光线没有击中任何物体则返回背景色如渐变的天空色。color ray_color(const ray r, const hittable world, int depth) { hit_record rec; // 如果递归深度耗尽不再贡献光线 if (depth 0) return color(0,0,0); // 与场景求交t_min避免自相交t_max设为无穷大 if (world.hit(r, 0.001, infinity, rec)) { ray scattered; color attenuation; // 根据材质类型计算散射光线和衰减 if (rec.mat_ptr-scatter(r, rec, attenuation, scattered)) { // 递归追踪并乘上衰减系数 return attenuation * ray_color(scattered, world, depth-1); } // 如果材质不散射如发光体返回其自发光颜色 return rec.mat_ptr-emitted(); } // 背景色简单的线性插值天空 vec3 unit_direction unit_vector(r.direction()); auto t 0.5*(unit_direction.y() 1.0); return (1.0-t)*color(1.0, 1.0, 1.0) t*color(0.5, 0.7, 1.0); }4.1 场景构建与材质定义为了测试我们的虚焦相机我们需要一个简单的场景。经典的“三球场景”是个好选择一个漫反射地面一个金属球一个玻璃球。你需要实现基本的几何体如球体sphere和材质lambertian漫反射,metal金属,dielectric电介质。材质散射函数的实现示例漫反射class lambertian : public material { public: lambertian(const color a) : albedo(a) {} virtual bool scatter(const ray r_in, const hit_record rec, color attenuation, ray scattered) const override { // 在交点法线所在的半球空间内随机生成一个散射方向 vec3 scatter_direction rec.normal random_unit_vector(); // 防止随机向量与法线正好反向抵消导致零向量 if (scatter_direction.near_zero()) scatter_direction rec.normal; scattered ray(rec.p, scatter_direction); attenuation albedo; return true; } private: color albedo; // 反射率/颜色 };5. 参数调优与效果对比从清晰到虚化现在一切就绪。让我们看看如何通过调整相机参数来控制虚化效果。关键在于aperture光圈半径和focus_dist对焦距离。测试场景假设我们有三个球沿z轴排列。地面在y-0.5一个球在(0, 0, -1)另一个在(0.5, 0, -0.6)。针孔相机模式无虚化double aperture 0.0; // 光圈半径为0即针孔 double focus_dist (lookfrom - lookat).length(); // 对焦距离设为到观察点的距离 camera cam(..., aperture, focus_dist);渲染结果所有物体都清晰画面缺乏层次感。浅景深效果强烈虚化double aperture 0.1; // 较大的光圈半径 double focus_dist 1.0; // 将对焦平面对准z-1处的球 camera cam(..., aperture, focus_dist);渲染结果z-1处的球非常清晰它前面z-0.6和后面地面的物体会出现明显的虚化模糊。光圈值0.1已经能产生非常显著的景深效果。调整对焦平面double aperture 0.1; double focus_dist 0.6; // 将对焦平面对准z-0.6处的球渲染结果现在z-0.6处的球变得清晰而z-1处的球和地面变得模糊。这直观地演示了“对焦”的过程。控制虚化强度增大aperture(如从0.05到0.2)虚化效果模糊圆会更明显背景光斑更大更柔和。但注意更大的光圈也需要更多的每像素采样数samples_per_pixel来压制因此带来的噪声。减小aperture(如从0.1到0.02)虚化效果减弱图像更接近全清晰状态。实操心得采样数与噪点的权衡虚焦模糊引入了额外的随机性透镜采样这必然会增加图像的噪点。samples_per_pixel这个参数变得至关重要。对于无景深的效果可能每像素50次采样就能得到平滑的图像。但对于大光圈浅景深你可能需要200甚至500次采样才能获得足够干净的结果。渲染时间会线性增长。在实践中这是一个在质量与时间之间的经典权衡。对于最终成品图建议使用较高的采样数如500在测试和调整阶段可以降低到50-100以快速预览效果。6. 常见问题、调试技巧与性能优化即使按照步骤实现你也可能会遇到一些问题。这里记录一些常见的坑和解决思路。6.1 图像全黑或全白检查光线方向确保在camera::get_ray中光线方向的计算是正确的。特别是lower_left_corner的计算公式。一个快速的调试方法是暂时将lens_radius设为0并让ray_color函数直接返回光线方向的单位向量作为颜色。你应该看到一个从中心蓝色/青色向四周白色渐变的有趣图案而不是纯色块。检查颜色值范围在write_color函数中确保将浮点颜色值通常范围在[0,1]正确转换到[0, 255]的整数范围。忘记伽马校正取平方根会导致图像暗淡错误地乘以采样数会导致颜色过曝全白。6.2 虚化效果不对称或奇怪检查透镜采样random_in_unit_disk()函数必须生成均匀的单位圆盘内随机点。如果采样是在正方形内进行的会导致虚化光斑呈方形失真。确保使用了拒绝采样法。检查相机坐标系确保u,v,w是两两正交的单位向量。u和v应该分别代表成像平面的右方和上方。一个错误的vup向量会导致整个画面倾斜。6.3 渲染速度太慢光线追踪是计算密集型的。除了升级硬件代码层面的优化空间巨大包围盒加速结构这是最大的性能提升点。实现一个BVH层次包围盒可以将与数百万三角形的求交测试从O(N)降低到O(logN)。对于任何复杂场景都是必选项。并行化渲染每个像素是独立的完美并行任务。使用C的thread库或OpenMP可以轻松地将渲染任务分配到多个CPU核心上。例如将图像分成若干水平条带每个线程处理一个条带。减少递归深度对于漫反射主导的场景深度超过5次后光线贡献已经微乎其微。将max_depth设为5-10。重要性采样在材质散射函数中更智能地生成散射光线如朝向光源采样可以更快地收敛减少噪点从而在相同采样数下获得更好质量。6.4 玻璃球或金属球内部有黑色噪点这通常是由于光线在相交点附近因浮点数精度问题再次与自身相交导致的“阴影痤疮”或“自相交”。注意我们在world.hit()调用中传入的t_min 0.001一个很小的值而不是0。这避免了在交点处新发射的光线立刻再次击中同一表面。如果问题依然存在可以尝试稍微增大这个值或者将散射光线的原点沿着法线方向向外偏移一个小量rec.p 1e-5*rec.normal。7. 完整代码获取与扩展方向由于完整的渲染器代码较长通常超过1000行包含向量、光线、几何体、材质、BVH等多个类我无法在此全部贴出。但你可以基于上述核心的camera类和框架结合经典的“Ray Tracing in One Weekend”系列教程的代码进行整合。我的实现正是以此为蓝本并强化了相机模块。扩展方向当你掌握了基础的光线追踪和虚焦相机后可以尝试以下方向让你的渲染器更加强大运动模糊原理与景深类似。不再是在透镜上采样空间位置而是在曝光时间上采样时间点让物体或相机在采样时间内运动从而产生动态模糊。复杂镜头系统薄透镜模型是简化。真实镜头有像差色差、球差。你可以尝试模拟这些效果让虚化光斑不再是完美的圆形而是带有颜色边缘或特定形状如镜头光圈叶片形成的多边形。光圈形状将random_in_unit_disk()替换为在其他形状如多边形模拟光圈叶片内的采样可以创造出星芒等独特的焦外成像效果。与光栅化管线结合在现代实时渲染中可以通过后处理模拟景深如分离前景、背景进行高斯模糊。但理解物理正确的光线追踪景深会让你对这些近似技术的局限和优化方向有更深的认识。实现一个带虚焦模糊的光线追踪相机远不止是添加一个特效。它是一次从“计算机图形”到“计算机成像”的思维转变让你真正开始以物理镜头的方式去思考光线、场景和成像。当你调出第一张拥有漂亮焦外光斑的渲染图时那种成就感远比调用一个现成的API要强烈得多。希望这份指南和代码框架能成为你探索图形学深处美妙世界的一块坚实垫脚石。动手调试那些参数观察画面的变化这是学习过程中最有趣的部分。