拓冰建站拓冰建站
首页 / 资讯中心 / 正文

基于Qt+C++的简易光线追踪渲染器实现与工程实践

简介一套基于Qt和C实现的简易光线追踪渲染器源码拥有GUI操作界面采用部分C11特性核心架构参考《Ray Tracing from the Ground Up》一书非常适合图形学入门者学习也可直接作为毕业设计、课程设计或项目开发的参考工程。压缩包共76个文件以35个C头文件与34个C源文件为主涵盖几何对象、材质、光照、采样器、Tracer、相机等完整模块另含Qt界面文件.ui、工程文件.pro和两份Markdown说明文档分别说明项目结构与使用方式目录功能划分清楚便于对照理解或二次开发。资源包仅75KB轻量便携已有164人浏览学习。源码经过严格测试从基础的平面、球体渲染到Phong、Emissive、PathTracer等进阶效果均有对应的类实现与接口注释读者既可按步骤追踪代码运行逻辑也能沿用现有框架替换场景、添加新材质快速完成课程设计或毕业设计作品亦可作为扩展光源、材质和渲染算法的起点。1. 为什么用 QtC 写一个“最小可运行”的光线追踪渲染器一个能显示阴影和镜面反射的光线追踪渲染器核心代码往往不足 500 行真正把初学者挡在门外的反而是图像输出和工程组织。如果把像素渲染结果丢在纯 C 控制台里最后只能看到一串数字而 Qt 提供的 QImage 可以把计算结果直接变成窗口上的一帧画面QtConcurrent 又能让 800×600 的渲染不至于把界面拖死。这篇文章就按课程设计的常见要求搭一个基于 QtC 的简易光线追踪渲染器场景里有球体、平面、点光源能调节反射深度带 CMake 源码和文档组织。读者需要具备 C 的类、指针、STL 基础不需要预先读过图形学教材。读完可以照着敲出一份能截图、能演示、能通过验收的工程。2. 搭工程Whitted 模型、CMake 和 Vec3/Ray 骨架2.1 Whitted 光线追踪的“三种光线”到底在算什么事对入门项目来说光线追踪不需要一上来就接触 PBR 那套能量守恒。常见做法是先把 Whitted 模型跑通它只做三件事主光线负责找到每个像素能看到的最近物体阴影光线负责判断该物体是否被光源照亮反射/折射光线负责让镜面物体把周围环境倒映出来。主光线从相机出发穿过图像平面。对图像上第(x, y)个像素先在[0,1]范围内算出归一化坐标u, v再映射到图像平面上的一个点生成一条从origin指向该点的射线。找到最近的交点后用 Blinn-Phong 模型计算直接光照并递归叠加反射光线回去的颜色。整体递归公式可以写成color ambient Σ(diffuse specular) * lightColor reflectCoeff * trace(reflectedRay, depth 1)这也就是初学者常听说的“反向追踪”我们没有模拟光源发出的所有光子而是为每个像素只发射一条射线到场景里代价小得多。渲染800×600的图像递归深度取 35 层在普通 CPU 上只需要几秒如果深度加到 10计算量会迅速膨胀界面卡顿、内存抖动都会出现。2.2 用 CMake 而不是 qmake 搭 Qt 工程现在网上的 Qt 教程很多还在用 qmake如果你只是打开一个窗口.pro文件确实简单。但课程设计项目通常要加 QtConcurrent、QtTest甚至后面还想接第三方库CMake 的表达能力明显更强。Qt 6 官方也把 CMake 作为推荐构建方式find_package能一键拿到 Widgets、Concurrent、Test 等模块。对比项qmakeCMakeQt 6 支持兼容但非主流官方推荐多模块引入手动写 QT widgets concurrentfind_package 明确声明第三方库依赖 .pri麻烦find_package 或 FetchContent语法复杂程度简单适合纯 Qt 小项目稍复杂但项目越大越好用下面这个CMakeLists.txt可以直接抄cmake_minimum_required(VERSION 3.16) project(SimpleRayTracer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) find_package(Qt6 COMPONENTS Widgets Concurrent REQUIRED) add_executable(SimpleRayTracer src/main.cpp src/MainWindow.cpp src/MainWindow.h src/Renderer.cpp src/Renderer.h ) target_link_libraries(SimpleRayTracer PRIVATE Qt6::Widgets Qt6::Concurrent)这里CMAKE_AUTOMOC必须打开否则带Q_OBJECT的头文件不会被预处理。如果本机装的是 Qt 5.15把find_package(Qt6 ...)改成find_package(Qt5 ...)链接库名改成Qt5::Widgets、Qt5::Concurrent即可。下载并安装 Qt 时注意编译器要和 Qt 构建版本匹配Windows 上选 MSVC 还是 MinGW决定了 CMake 里要用哪套工具链vscode 配置 C/C 环境时也不要混用两个编译器的库。2.3 Vec3 与 Ray光线的数学结构渲染器里到处是点、向量、方向不需要依赖 Qt 的QVector3D。自己实现一个Vec3反而更自由可以加叉积、点积、归一化而且不把数学逻辑绑死在 Qt 类型上。// src/raytracer/Vec3.h #pragma once #include cmath class Vec3 { public: double x{0.0}, y{0.0}, z{0.0}; Vec3() default; Vec3(double x, double y, double z) : x(x), y(y), z(z) {} Vec3 operator(const Vec3 v) const { return {x v.x, y v.y, z v.z}; } Vec3 operator-(const Vec3 v) const { return {x - v.x, y - v.y, z - v.z}; } Vec3 operator*(double s) const { return {x * s, y * s, z * s}; } Vec3 operator/(double s) const { return {x / s, y / s, z / s}; } double dot(const Vec3 v) const { return x * v.x y * v.y z * v.z; } Vec3 cross(const Vec3 v) const { return { y * v.z - z * v.y, z * v.x - x * v.z, x * v.y - y * v.x }; } Vec3 normalize() const { double len std::sqrt(x * x y * y z * z); return {x / len, y / len, z / len}; } double length() const { return std::sqrt(x * x y * y z * z); } };这个类完全没有动态内存按值拷贝非常快。实现上有一个隐藏风险如果传入(0,0,0)normalize()会出现除零。渲染循环里一旦方向向量计算错误结果会出现 NaN图像上表现为大片黑色噪点后面可以统一加一个std::isfinite检查来兜底。Ray 类更简单只保存起点和方向并提供at(t)计算光线行走t步后的位置也就是交点坐标// src/raytracer/Ray.h #pragma once #include Vec3.h class Ray { public: Vec3 origin; Vec3 direction; Ray() default; Ray(const Vec3 o, const Vec3 d) : origin(o), direction(d.normalize()) {} Vec3 at(double t) const { return origin direction * t; } };Ray构造函数里直接归一化方向之后求交时就可以默认方向长度为 1。这个约定能让球体求交里的二次方程系数更清晰方向长度非 1 时a dot(dir, dir)不再是 1t参数会被整体缩放排查起来很费时间。3. 在 C 中实现球体/平面求交和 Blinn-Phong 光照3.1 用 Hittable 接口统一场景物体后续要添加球体、平面甚至网格三角形渲染器的主循环不应该为每一种物体写一遍分支。通常会先定义一个抽象接口Hittable场景中所有可被光线打中的物体都继承它。// src/raytracer/Hittable.h #pragma once #include Ray.h #include Material.h struct HitRecord { double t 0.0; Vec3 p; Vec3 normal; Material material; }; class Hittable { public: virtual ~Hittable() default; virtual bool hit(const Ray ray, double tMin, double tMax, HitRecord record) const 0; };tMin和tMax有什么作用不是所有交点都要接受。阴影光线从交点出发寻找光源时需要把tMax限制为光源距离否则会打到光源后面的物体而反射光线为了避免和自己相交需要把tMin设为 0.001 左右。把区间判断直接放进hit()里是实现 Whitted 风格最省心的方式。3.2 球体和平面的求交公式与 C 实现球体求交是隐式曲面。设球心为C半径为r光线上一点为P(t) O tD点满足|P - C|² r²。展开后得到一个关于t的二次方程a dot(D, D) b 2 * dot(O - C, D) c dot(O - C, O - C) - r²判别式小于 0 说明光线没碰到球大于 0 时取较小的正根作为最近交点。常见优化是把b写成2 * dot(oc, D)然后用(-half_b - sqrt(disc)) / a求根少做一次乘法。// src/raytracer/Sphere.h #pragma once #include Hittable.h class Sphere : public Hittable { public: Vec3 center; double radius; Material material; Sphere() default; Sphere(const Vec3 c, double r, const Material m) : center(c), radius(r), material(m) {} bool hit(const Ray ray, double tMin, double tMax, HitRecord rec) const override { Vec3 oc ray.origin - center; double a ray.direction.dot(ray.direction); double halfB oc.dot(ray.direction); double c oc.dot(oc) - radius * radius; double disc halfB * halfB - a * c; if (disc 0.0) { return false; } double sqrtD std::sqrt(disc); double root (-halfB - sqrtD) / a; if (root tMin || root tMax) { root (-halfB sqrtD) / a; if (root tMin || root tMax) { return false; } } rec.t root; rec.p ray.at(root); rec.normal (rec.p - center) / radius; rec.material material; return true; } };注意rec.normal是单位向量因为(P - C)的长度正好等于半径。但这里有个法线方向陷阱当光线从球体内部射出法线会指向光线来的方向而不是指向反弹后的外部。颜色也要求法线和视角方向一致一个简便处理是在着色阶段加判断如果ray.direction.dot(rec.normal) 0就把法线取反。平面也可以照这个思路实现// src/raytracer/Plane.h #pragma once #include Hittable.h class Plane : public Hittable { public: Vec3 normal; double offset 0.0; Material material; bool hit(const Ray ray, double tMin, double tMax, HitRecord rec) const override { double denom ray.direction.dot(normal); if (std::abs(denom) 1e-8) { return false; // 光线与平面平行 } double t (offset - ray.origin.dot(normal)) / denom; if (t tMin || t tMax) { return false; } rec.t t; rec.p ray.at(t); rec.normal normal; if (rec.normal.dot(ray.direction) 0.0) { rec.normal -normal; } rec.material material; return true; } };这里的offset满足normal · P offset当normal (0,1,0)、offset 0时就是一个常见的 xz 平面地板。平行场景中法线取反能避免着色时出现“一半亮一半暗”的缝合线。3.3 阴影、反射和材质参数有了求交函数就可以写渲染核心trace()。它的输入是一条光线和当前递归深度输出是一个Vec3颜色。第一步做场景最近交点判断第二步遍历每个点光源发射阴影光线第三步根据材质把反射光线结果叠加回去。// src/raytracer/Renderer.cpp核心部分 Vec3 Renderer::trace(const Ray ray, int depth) const { HitRecord rec; if (!scene.hit(ray, 0.001, INFINITY, rec)) { return Vec3(0.3, 0.45, 0.6); // 背景色 } Vec3 color ambientColor * rec.material.color; for (const Light light : lights) { Vec3 toLight light.position - rec.p; double lightDist toLight.length(); toLight toLight / lightDist; // 阴影光线偏移避开自身交点 Ray shadowRay(rec.p rec.normal * shadowBias, toLight); HitRecord shadowRec; if (scene.hit(shadowRay, 0.001, lightDist - 0.001, shadowRec)) { continue; // 被遮挡不计算漫反射和高光 } double diff std::max(rec.normal.dot(toLight), 0.0); Vec3 viewDir ray.direction * (-1.0); Vec3 halfVec (toLight viewDir).normalize(); double spec std::pow(std::max(rec.normal.dot(halfVec), 0.0), rec.material.shininess); color color light.color * (diff * rec.material.color spec * rec.material.specularColor); } if (depth maxDepth rec.material.reflectivity 0.0) { Vec3 reflectDir ray.direction - rec.normal * (2.0 * ray.direction.dot(rec.normal)); Ray reflectRay(rec.p rec.normal * shadowBias, reflectDir); color color trace(reflectRay, depth 1) * rec.material.reflectivity; } return color; }这段代码的每一行都对应一个新手常犯的坑shadowBias设置为 0.001 左右太小会出现黑色自相交噪点。反射光线起点同样要加偏移否则反射光线会立刻击中表面本身。镜面高光用半程向量计算避免了 Phong 模型中反射方向求反的很多繁琐处理。递归深度用maxDepth控制默认给 5 足够。材质参数建议按下面这个表起步既简单又能看到明显层次材质漫反射颜色高光指数反射率蓝色塑料球(0.1, 0.3, 0.8)320.15银色金属球(0.65, 0.65, 0.65)2560.85地板(0.8, 0.8, 0.8)80.1红色哑光球(0.8, 0.1, 0.1)40.04. 接 Qt 界面QImage 缓冲区、QtConcurrent 后台渲染和相机控制4.1 用 QImage::Format_RGB32 高效写像素光把trace()跑通还不行得让 Qt 把计算结果画出来。最直接的思路是渲染函数直接向QImage写入像素QImage Renderer::render(QSize size) { QImage image(size, QImage::Format_RGB32); for (int y 0; y size.height(); y) { uchar* line image.scanLine(y); for (int x 0; x size.width(); x) { Vec3 pixelColor trace(...); // 简单 Gamma 校正 auto toByte [](double c) { return static_castint(255.0 * std::pow(std::clamp(c, 0.0, 1.0), 1.0 / 2.2)); }; uint* pixel reinterpret_castuint*(line) x; *pixel qRgb(toByte(pixelColor.x), toByte(pixelColor.y), toByte(pixelColor.z)); } } return image; }初学者很容易在完全用setPixelColor写像素它的性能极低每像素都要构造 QColor还要处理颜色空间转换600×500 的图就要调用 30 万次。scanLineqRgb是课程设计里最常见的写法。注意QImage::Format_RGB32的像素布局是0xffRRGGBB和qRgb完全匹配不要为了稳妥去猜Format_ARGB32两种格式的字节序不一样稍微换错就会得到一张红蓝互换的图。4.2 用 QtConcurrent::run 跑后台渲染不阻塞界面如果直接在主界面槽函数里调用render()点击按钮后窗口会卡住直到渲染完毕才能拖动。常见做法是把渲染工作丢给线程池通过QFutureWatcher在完成后回到 GUI 线程刷新 QLabel。// MainWindow.h 中的相关声明 class RenderWindow : public QMainWindow { Q_OBJECT public: explicit RenderWindow(QWidget* parent nullptr); private slots: void startRender(); void onRenderFinished(); private: QLabel* imageLabel; QFutureWatcherQImage* watcher; };void RenderWindow::startRender() { if (watcher-isRunning()) { return; } Renderer renderer buildScene(); // 预先配置好的场景 QFutureQImage future QtConcurrent::run([renderer]() { return renderer.render(QSize(800, 600)); }); watcher-setFuture(future); statusLabel-setText(QStringLiteral(渲染中...)); } void RenderWindow::onRenderFinished() { QImage image watcher-result(); imageLabel-setPixmap(QPixmap::fromImage(image)); statusLabel-setText(QStringLiteral(完成)); }这里QFutureWatcherQImage必须在构造函数里 connect 到onRenderFinishedconnect(watcher, QFutureWatcherQImage::finished, this, RenderWindow::onRenderFinished);如果还想加一个进度条通用的做法是让Renderer内部维护一个std::atomicint行号界面用QTimer每 100ms 读一次直接写在 lambda 里往进度条组件更新是线程不安全的容易引发崩溃。核心只有在异步里运行的renderer是拷贝或者不共享可变状态线程问题才可控。4.3 透视相机与主光线生成有了求交和颜色还需要一个相机模型。最简单的透视相机用三根向量表示左下角、水平向量、垂直向量。渲染每个像素时按u, v在图像平面上插值主光线从相机位置指向该点// Camera.h struct Camera { Vec3 origin; Vec3 lowerLeft; Vec3 horizontal; Vec3 vertical; Camera(double fovDegrees, double aspect) { double theta fovDegrees * M_PI / 180.0; double halfH std::tan(theta / 2.0); origin Vec3(0, 0, 0); lowerLeft Vec3(-halfH * aspect, -halfH, -1); horizontal Vec3(2 * halfH * aspect, 0, 0); vertical Vec3(0, 2 * halfH, 0); } Ray rayAt(double u, double v) const { return Ray(origin, lowerLeft horizontal * u vertical * v - origin); } };渲染循环里u (x 0.5) / widthv (y 0.5) / height加 0.5 是让光线对准像素中心。默认相机位置在原点看向-Z方向如果想移动视角可以在项目文档里把origin、lookAt和up做一次正交基映射但这部分对“简易”版来说不是必须的。最开始先用固定相机把其他部分跑到能看到阴影图再谈视角变换。5. 调试光线追踪器和整理“源码文档”项目的 5 个技巧5.1 全黑、黑斑、摩尔纹的排查对照表渲染出图后常见现象往往能直接对应到一行代码现象最可能原因排查位置整张图全黑球体在相机背后或方向向量未归一化Camera.rayAt、Ray 构造球体有密集黑斑阴影偏移太小光线击中自身shadowBias 调大到 0.001图像上下颠倒遍历的 y 坐标没有和图像坐标系反转imageY height - 1 - y出现大片白色噪点颜色值无限大除以零或 NaNstd::isfinite(color.x)过滤反射画面断断续续递归深度不够或反射起点进入物体内部rec.p normal * bias偏移量加大提示全黑但能隐约看到轮廓时优先怀疑相机朝向黑色噪点时优先怀疑阴影偏移而不是去改光源位置。5.2 在 Qt Creator 环境里做最小化复现写光线追踪很容易在“概念觉得对”的情况下输出错图。我之前调试时常做的一个动作是在trace()里针对指定像素(123, 456)打印交点信息if (debugX x debugY y) { qDebug() t rec.t p rec.p.x rec.p.y rec.p.z normal rec.normal.x rec.normal.y rec.normal.z; }只在单线程、单像素里打开 debug而不是开全局日志否则背景光的成百上千行输出会瞬间淹没真正有用的信息。配合 Qt Creator 的断点可以逐层看反射光线的depth和方向变化比用 printf 猜快很多。5.3 验证渲染器是否正确的“对照法”与源码文档结构课程设计和毕业设计最怕“跑通了但说不出为什么对”。建议做一份“参数对照表”同一个场景分别以递归深度 0、2、5 渲染三次保存三张图。深度 0 应该只有直接光照深度 5 时金属球上会出现清晰的周围环境倒影。把这组截图放进文档里比任何文字说明都更能证明你自己理解了反射递归。源码和文档的目录结构可以维持一个很朴素但完整的形态SimpleRayTracer/ ├─ CMakeLists.txt ├─ README.md ├─ docs/ │ ├─ 设计说明.md │ ├─ 测试对比.png │ └─ 部署运行.md ├─ src/ │ ├─ app/main.cpp │ ├─ ui/MainWindow.h │ ├─ ui/MainWindow.cpp │ └─ raytracer/ │ ├─ Vec3.h │ ├─ Ray.h │ ├─ Sphere.h │ ├─ Plane.h │ ├─ Camera.h │ └─ Renderer.h/cpp即使只用 800×600 的分辨率调通这套流程你已经完成了课程设计中最核心的“渲染算法 GUI 线程 文档”四件事。把自己从“抄代码”的状态里拔出来最有效的方法是给球体和平面的相交函数各写一段注释推导一遍求交公式把一个 Hello World 光线追踪器改成自己的参数化场景调试出第一张带阴影、反射和地面渐变的图片那份成就感才是继续深入图形学最实在的起点。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门