【Games101】C++ 基础软光栅化器:光栅化三角形(Cmake 配置 Eigen OpenCV 库)
相关代码已上传至 github 链接MZRHnbnb/Software-Rasterizer如对图形学基础的齐次坐标和矩阵变换不了解和不熟悉可查看《Games101》或者前往阅读我对此推导和解析的博客链接【线性代数】图形学基础齐次坐标与矩阵变换-CSDN博客配置环境这里我利用了Eigen 库来进行点和向量的表示并计算比较方便利用 OpenCV 库来显示图像。安装好 Eigen 和 OpenCV 后我们可以用Cmake进行配置Cmake也要先上网自行下载。在VS2022下载的时候应该就已经内置好了插件但是如果是使用vscode还是需要下载相关插件启用 Cmake 。但我个人感觉 VS2022 更好用因为 vscode 我怎么写这个 CMakeLists.txt 这个 OpenCV 都一直报错。要自行创建一个名为 CMakeLists.txt 的文件一个字也不能改否则 Cmake 无法识别。在文件里写要配置的库和要编译的代码文件我个人的 CMakeLists.txt 内容在 GitHub 仓库可查看大家可自行查看借鉴或者自行编写。实现思路配置好环境后我们要用到的头文件基本如下。#include iostream #include vector #include cmath #include Eigen/Dense//Eigen头文件 #include opencv2/opencv.hpp//OpenCV头文件既然要画出一个三角形我们可以要先输入三角形的三个坐标点用于后续计算。输入的坐标都是三维坐标但为了方便计算我们最好是以齐次坐标方式存储。在这里我们存储坐标点可以利用 Eigen 库的Vector3f类存储三维坐标 (x,y,z) 和Vector4f类来存储齐次坐标 (x,y,z,w) 利用一个 vector 数组存储好三角形的三个顶点。//输入 std::vectorEigen::Vector3f s;//存(x,y,z) for(int i1;i3;i){ float x,y,z; std::cinxyz; s.push_back({x,y,z}); } //转齐次坐标 std::vectorEigen::Vector4f qs;//存(x,y,z,w)齐次坐标 for (int i 0; i 3; i) { qs.push_back({ s[i].x(),s[i].y(),s[i].z(),1 }); }接下来为了方便我个人先暂且不写 View 变换先默认摄像机位于原点上方是 Y 轴方向始终朝向 -Z 方向。大家如有需要可自行编写 View 变换。下面直接可以写 P 变换即透视投影。在 Games101 有十分具体的对于透视投影矩阵的推导这里就不再赘述了。在代码使用中我们需要给相机 camera 的视锥体定义好四个参数1. FOV 视场角控制镜头能看到多大的角度如 45 度 90 度等。2. Aspect 宽高比即渲染窗口的宽度和高度之比。3. near 指镜头最近能看到多远。4. far 指镜头最远能看到多远。由此可得到的透视投影矩阵如下右手系且相机朝向 -Z 在对三角形的每一个点进行透视投影矩阵变换后要记得进行归一化的透视除法获得 NDC 坐标随后再进行下一步处理。//透视投影 float fov, aspect, near, far; std::cin fov aspect near far; fov fov * 3.14159f / 180.0f;//转为弧度制利用cmath的tan函数计算 Eigen::Matrixfloat, 4, 4 P;//Eigen矩阵 P.setZero();//初始化为0 //输入矩阵里的数字 P(0, 0) 1.0f / (aspect * tan(fov * 0.5f)); P(1, 1) 1.0f / tan(fov * 0.5); P(2, 2) -(near far) / (far - near); P(2, 3) -2 * near * far / (far - near); P(3, 2) -1; //对三角形每个点乘上透视投影矩阵 for (int i 0; i 3; i) { qs[i] P * qs[i]; //透视除法每个xyz都要除以对应w值 qs[i].x() / qs[i].w(); qs[i].y() / qs[i].w(); qs[i].z() / qs[i].w(); }做完透视投影后下一步是视口变换将我们处理好的x 和 y 都在 [-1,1] 区间的坐标映射到我们屏幕上的坐标。这里其实可以利用 OpenCV 库函数的功能直接获取个人设备上的分辨率如1920×1080 和 2560×1440 等等。但是这里为了方便我直接定义屏幕长为 800 宽为 600。这一步比较简单只需要对每个三角形变换后的 x 和 y 进行如下操作即可x(1x)÷2×长y(1-y)÷2×宽OpenCV 中 y 轴朝下//视口变换 int width 800; int hight 600; cv::Mat image(hight, width, CV_8UC3, cv::Scalar(0, 0, 0));//顺便创建好显示的屏幕 for (int i 0; i 3; i) { qs[i].x() (1.0f qs[i].x()) * 0.5f * width; qs[i].y() (1.0f - qs[i].y()) * 0.5f * hight; }做完这些变换之后接下来就是光栅化三角形了。我们现在需要做的就是判断一个像素的中心是否在三角形内如果是就涂上红色否则不管。判断一个像素是否在三角形内应该用的是叉积计算判断我们可以将其封装成一个 bool 类型函数。bool chek(const std::vectorEigen::Vector4f arr, float x, float y) { std::vectorfloat z(arr.size());//用于记录每个点计算叉积的z坐标 //按顺序遍历每个点算叉积设其z坐标都为0方便判断 for (int i 0; i arr.size(); i) { Eigen::Vector3f a(arr[(i 1) % arr.size()].x() - arr[i].x(), arr[(i 1) % arr.size()].y() - arr[i].y(), 0); Eigen::Vector3f b(x-arr[i].x(), y-arr[i].y(), 0); Eigen::Vector3f ans a.cross(b); z[i] ans.z(); } //只有在三个点算的叉积都同向这个像素点就是在三角形内否则不在 return (z[0] 0 z[1] 0 z[2] 0) || (z[0] 0 z[1] 0 z[2] 0); }随后为了性能问题我们不能遍历一整个屏幕的像素而是通过三角形的三个顶点坐标计算其包围盒遍历包围盒内的像素点。//计算包围盒 float minx 1e9, miny 1e9, maxx -1e9, maxy -1e9; for (int i 0; i 3; i) { minx std::min(minx, qs[i].x()); miny std::min(miny, qs[i].y()); maxx std::max(maxx, qs[i].x()); maxy std::max(maxy, qs[i].y()); } //检查像素是否在三角形内遍历包围盒 for (int i miny - 1; i maxy 1; i) { for (int j minx - 1; j maxx 1; j) { if (j0 || jwidth - 1 || i0 || ihight - 1) continue;//超出屏幕坐标点范围不用算 //对xy都加0.5找到像素中心 float rx j 0.5f; float ry i 0.5f; //通过刚刚的函数判断是否在三角形内是则给像素设置为红色 if (chek(qs, rx, ry)) { image.atcv::Vec3b(i, j) cv::Vec3b(0, 0, 255); } } } //最后输出屏幕的图像 cv::imshow(666, image); cv::waitKey(0);至此一个简易的光栅化一个彩色三角形的软光栅化器就完成了。我们可以尝试如下输入-0.5 -0.5 -2 0.5 -0.5 -2 0 0.5 -2 45 1.333 0.1 100预期可以得到但目前尚未加入 V 变换因为把相机固定在了原点并朝向 -Z 而且 Z-Buffer 深度缓冲也尚未实现但我们可以先实现出来这个简易版后续需要再自行加入新功能。新增Z_Buffer深度缓冲目前最近的一次更新中我优化了一下我的一些变量命名让它们看起来更加直观而且加入了Z_Buffer 深度缓冲可以实现输入多个三角形并指定其 RGB 颜色渲染出来其遮挡关系并对整个代码进行了一个比较大的重构具体可自行查看 GitHub 仓库。Z_Buffer 深度缓冲实际上就是利用一个二维数组维护一个一整个屏幕内每一个像素的深度值只显示最小深度的颜色实现类似 Games101 提到的“画家算法”。计算深度值我们需要计算三角形内每个像素的重心坐标从而通过线性插值计算出深度值。重心坐标其实就是三角形内每个像素点点相对于三个顶点的一个比例对此我们可以利用三角形的面积通过比例计算如下图α S_2 / Sβ S_3 / Sγ S_1 / S我们可以利用 Vector3f 来存储重心坐标用二维叉积就能算出三角形面积封装成如下函数//计算重心坐标 Eigen::Vector3f calculate_bary(const std::vectorEigen::Vector4f triangle,float x, float y) { Eigen::Vector3f ans; float temp[5] { 0 }; Eigen::Vector2f ab(triangle[1].x() - triangle[0].x(), triangle[1].y() - triangle[0].y()); Eigen::Vector2f ac(triangle[2].x() - triangle[0].x(), triangle[2].y() - triangle[0].y()); float whole_triangle ac.cross(ab); for (int i 0; i triangle.size(); i) { Eigen::Vector2f a(triangle[(i 1) % triangle.size()].x() - triangle[i].x(), triangle[(i 1) % triangle.size()].y() - triangle[i].y()); Eigen::Vector2f b(x - triangle[i].x(), y - triangle[i].y()); temp[i] b.cross(a) / whole_triangle; } //注意三角形的对应关系不要弄混 ans.x() temp[1]; ans.y() temp[2]; ans.z() temp[0]; return ans; }得到重心坐标后我们就可以进行深度值的计算了。这里我们直接对 NDC 坐标里的 Z 值进行线性插值计算可以得出精确的深度值所以我们封装好的函数之后在后续光栅化再调用即可。//计算Z-Buffer深度缓冲 float calculate_Z(const std::vectorEigen::Vector4f triangle,Eigen::Vector3f bary) { //进行线性插值计算 float Z_depth bary.x() * triangle[0].z() bary.y() * triangle[1].z() bary.z() * triangle[2].z(); return Z_depth; }随后我们在光栅化阶段再进行深度缓冲计算另外由于重心坐标的特性如果一个像素点的重心坐标每一个值的范围都在 [0,1] 则在三角形内反之不在。可据此判断像素点是否在三角形内不再用叉积法过于冗余因为重心是我们在深度缓冲时候必算的顺手就可以判断一个像素点是否在三角形内。bool chek_point_inbary(Eigen::Vector3f bary) { return bary.x() 0 bary.x() 1 bary.y() 0 bary.y() 1 bary.z() 0 bary.z() 1; }随后进行光栅化为了方便我们先封装好一个光栅化一个三角形的函数要记得引用传入 Z_Buffer 二维数组来进行深度缓冲。实际上逻辑和原本都差不多不过是加入一个深度缓冲计算而已。//光栅化 void Paint_Triangle(const std::vectorEigen::Vector4f triangle,std::vectorstd::vectorfloat Z_Buffer, cv::Mat image,int width,int hight,int R,int G,int B) { //计算包围盒 float min_x 1e9, min_y 1e9, max_x -1e9, max_y -1e9; for (int i 0; i 3; i) { min_x std::min(min_x, triangle[i].x()); min_y std::min(min_y, triangle[i].y()); max_x std::max(max_x, triangle[i].x()); max_y std::max(max_y, triangle[i].y()); } for (int i min_y - 1; i max_y 1; i) { for (int j min_x - 1; j max_x 1; j) { if (j0 || jwidth - 1 || i0 || ihight - 1) continue; float rx j 0.5f; float ry i 0.5f; Eigen::Vector3f bary calculate_bary(triangle, rx, ry);//计算重心坐标 float Z_depth calculate_Z(triangle, bary);//计算深度值 //只有点在三角形内且当前深度值比Z_Buffer深度值小时才对像素涂色 if (chek_point_inbary(bary) Z_depth Z_Buffer[i][j]) { Z_Buffer[i][j] Z_depth;//更新当前最小的深度即不被遮挡的那个深度 image.atcv::Vec3b(i, j) cv::Vec3b(B, G, R);//OpenCV采用的是BGR } } } }然后在 main 函数调用光栅化我们输入的每一个三角形。//Z_Buffer深度缓冲二维数组 std::vectorstd::vectorfloat Z_Buffer(hight 5, std::vectorfloat(width 5, 1e9)); //光栅化每一个三角形 for (int i 0; i triangle_cnt; i) { //输入三角形的颜色 int R, G, B; std::cin R G B; Paint_Triangle(triangle_point_NDC[i], Z_Buffer, image, width, hight, R, G, B); }目前对于深度缓冲的修改就完成了现在可以很好的处理三角形之间的遮挡关系。而现在的输入数据要改成首先输入三角形数量然后输入每个三角形的三个顶点输入 Fov , Aspect , near , far 最后输入每个三角形的颜色 RGB 值。如下示例数据2 -2.0 1.5 -4 2.0 -1.5 -8 -2.0 -1.5 -6 2.0 1.5 -4 -2.0 -1.5 -8 2.0 -1.5 -6 45 1.33 0.1 50 255 0 0 0 0 255预期则可得到做完深度缓冲下一步就可以加入适当的光照模型或者加入抗锯齿功能了。如有兴趣可通过链接继续阅读【Games101】C软光栅化器Blinn Phong光照模型及简易MSAA抗锯齿实现Cmake 配置 Eigen OpenCV 库-CSDN博客