本文是一个完整的图形学入门实践课程,目前还在更新中,GitHub已开源。理论上本文项目需要20-30个小时完成。不知道为啥我的网站统计字数也有问题。
主要内容是完全手撸一个光栅化渲染器。本文会从头复习图形学以及C++的相关知识,包括从零构造向量模版库、光栅化原理解释、图形学相关基础算法解释等等内容。
另外原作者的的透视矩阵部分是经过一定程度的简化的,与虎书等正统做法不同。我会先按照原文ssloy老师的思想表达关键内容,最后按照我的想法完善本文。并且,原项目中的数学向量矩阵库写得不是很好,我专门开了一章一步步重构这个库。
简易的光栅化渲染器0 简单的开始1.1 画线第一关:实现画线第二关:发现BUG第三关:解决BUG第四关:优化前言第五关:Bresenham's 优化第六关:注意流水线预测第七关:浮点数整型化1.2 三维画线第一关:加载.obj第二关:绘制第三关:优化2.1 三角形光栅化第一关:线框三角形第二关:请你自己画实心的三角形第三关:扫描线算法第四关:包围盒逐点扫描第五关:重心坐标2.2 平面着色Flat shading render第一关:回顾第二关:绘制随机的颜色第三关:根据光线传播绘制颜色3.1 表面剔除第一关:画家算法(Painters' Algorithm)第二关:了解z-buffer第三关:创建Z-Buffer第四关:整理当前代码3.2 上贴图第一关:思路第二关:加载纹理文件第三关:获取纹理坐标第四关:通过纹理坐标uv获取对应颜色第五关:在光栅化三角形函数中增加贴贴图的功能第六关:为模板函数添加更多重载符号操作4.1 透视视角第一关:线性变换第二关:齐次坐标 Homogeneous coordinates第三关:三维世界第四关:具体代码实现4.2 项目代码分析第一关:model类第二关:geometry第三关:main5.1 移动摄像机第一关:定义摄像机第二关:相机代码6.1 优化/重写代码6.2 重写模版向量类第一关:需求分析第二关:实现Vec2模版以及四个算数符第三关:实现Vec3模版以及四个算数符第四关:用模版构建不同大小的向量第五关:进一步完善向量功能第六关:构建矩阵第七关:继续完善矩阵库6.3 整合光栅化代码特别节目1之:main代码之旅特别节目2之:细说GouraudShader特别节目3之:开始绘画-片元着色器🌈 彩虹着色器📺 模拟老电视效果🔥 火焰效果🌌 星空效果6.4 升级Shader-支持UV纹理🎬 Shader类的角色列表🎭 vertex函数:多面手🌈 fragment函数:艺术家🎨 那么,这个Shader类都能做什么?6.5 学习法线贴图第一关:纹理第二关:全局坐标系与Darboux坐标系第三关:经常见到的Uniform第四关:光照计算6.6 实现Phong模型7.1 阴影第一关:目前的问题第二关:第一趟渲染-从光源出发第三关:第二趟渲染-从主相机出发8.1 环境光遮蔽 - 模拟全局光照效果特别节目:知识脉络第一关:啥是AO?如何结合Phong使用?第二关:做梦第三关:屏幕空间环境遮挡 (SSAO)附录1. c++模版类 - 从入门到入土第一关:为什么需要模版类?第二关:「函数模版」第三关:「类模版」第四关:「多模板参数」与「非类型参数」第五关:「模板特化」第六关:「类型推断」1. auto & decltype2. 模板中的基本类型推断3. 自动构造模版类型4. 尾返回类型第七关:「变量模板」第八关:「模板类型别名」第九关:模板的SFINAE原则第十关:模板与友元第十一关:折叠表达式第十二关:模板概念(Concepts) - C++20第十三关: std::enable_if
和 SFINAE第十四关:类模板偏特化第十五关:constexpr
和模板第十六关:模板中的嵌套类型第十七关:模板参数包与展开第十八关:Lambda 表达式与模板第十九关:模板递归第二十关:带有模板的继承1. 模版基类2. 模版子类3. 在模板类中继承模板基类第二十一关:std::type_trait
的工具集第二十二关:模板与动态多态性备注/声明
五星上将曾经说过,懂的越少,懂的越多。我接下来将提供一个tgaimage的模块,你说要不要仔细研究研究?我的评价是不需要,如学。毕竟懂的越多,懂的越少。
在这里提供一个最基础的框架🔗,他只包含了tgaimage模块。该模块主要用于生成.TGA文件。以下是一个最基本的框架代码:
xxxxxxxxxx
141// main.cpp
2
3
4const TGAColor white = TGAColor(255, 255, 255, 255);
5const TGAColor red = TGAColor(255, 0, 0, 255);
6const TGAColor blue = TGAColor(0, 0, 255, 255);
7
8int main(int argc, char** argv) {
9 TGAImage image(100, 100, TGAImage::RGB);
10 // TODO: Draw sth
11 image.flip_vertically(); // i want to have the origin at the left bottom corner of the image
12 image.write_tga_file("output.tga");
13 return 0;
14}
上面代码会创建一个100*100的image图像,并且以tga的格式保存在硬盘中。我们在TODO中添加代码:
xxxxxxxxxx
11image.set(1, 1, red);
代码作用是在(1, 1)的位置将像素设置为红色。output.tga的图像大概如下所示:
这一章节的目标是画线。具体而言是制作一个函数,传入两个点,在屏幕上绘制线段。
给定空间中的两个点,在两点(x0, y0)(x1, y1)之间绘制线段。
最简单的代码如下:
xxxxxxxxxx
71void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
2 for (float t=0.; t<1.; t+=.01) {
3 int x = x0 + (x1-x0)*t;
4 int y = y0 + (y1-y0)*t;
5 image.set(x, y, color);
6 }
7}
上面代码中的.01其实是错误的。不同的分辨率对应的绘制步长肯定不一样,太大的步长会导致:
所以我们的逻辑应该是:需要画多少像素点就循环Draw多少次。最简单的想法可能是绘制x1-x0个像素或者是y1-y0个像素:
xxxxxxxxxx
71void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
2 for (int x=x0; x<=x1; x++) {
3 float t = (x-x0)/(float)(x1-x0);
4 int y = y0*(1.-t) + y1*t;
5 image.set(x, y, color);
6 }
7}
上面代码是最简单的插值计算。但是这个算法是错误的。画三条线:
xxxxxxxxxx
31line(13, 20, 80, 40, image, white);
2line(20, 13, 40, 80, image, red);
3line(80, 40, 13, 20, image, blue);
白色线看起来非常好,红色线看起来断断续续的,蓝色线直接看不见了。于是总结出以下两个问题:
理论上说白色线和蓝色线应该是同一条线,只是起点与终点不同
太“陡峭”的线效果不对
接下来就解决这个两个问题。
此处“陡峭”的意思是(y1-y0)>(x1-x0)
下文“平缓”的意思是(y1-y0)<(x1-x0)
为了解决起点终点顺序不同导致的问题,只需要在算法开始时判断两点x分量的大小:
xxxxxxxxxx
41if (x0>x1) {
2 std::swap(x0, x1);
3 std::swap(y0, y1);
4}
为了画出没有空隙的“陡峭”线,只需要将“陡峭”的线变成“平缓”的线。最终的代码:
xxxxxxxxxx
241void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
2 if(std::abs(x0-x1)<std::abs(y0-y1)) { // “陡峭”线
3 if (y0 > y1) { // 确保从下到上画画
4 std::swap(x0, x1);
5 std::swap(y0, y1);
6 }
7 for (int y = y0; y <= y1; y++) {
8 float t = (y - y0) / (float) (y1 - y0);
9 int x = x0 * (1. - t) + x1 * t;
10 image.set(x, y, color);
11 }
12 }
13 else { // “平缓”线
14 if (x0 > x1) { // 确保从左到右画画
15 std::swap(x0, x1);
16 std::swap(y0, y1);
17 }
18 for (int x = x0; x <= x1; x++) {
19 float t = (x - x0) / (float) (x1 - x0);
20 int y = y0 * (1. - t) + y1 * t;
21 image.set(x, y, color);
22 }
23 }
24}
如果你想测试你自己的代码是否正确,可以尝试绘制出以下的线段:
xxxxxxxxxx
141line(25,25,50,100,image,blue);
2line(25,25,50,-50,image,blue);
3line(25,25,0,100,image,blue);
4line(25,25,0,-50,image,blue);
5
6line(25,25,50,50,image,red);
7line(25,25,50,0,image,red);
8line(25,25,0,0,image,red);
9line(25,25,0,50,image,red);
10
11line(25,25,50,36,image,white);
12line(25,25,50,16,image,white);
13line(25,25,0,16,image,white);
14line(25,25,0,36,image,white);
目前为止,代码运行得非常顺利,并且具备良好的可读性与精简度。但是,画线作为渲染器最基础的操作,我们需要确保其足够高效。
性能优化是一个非常复杂且系统的问题。在优化之前需要明确优化的平台和硬件。在GPU上优化和CPU上优化是完全不同的。我的CPU是Apple Silicon M1 pro,我尝试绘制了9,000,000条线段。
发现在line()函数内,image.set();
函数占用时间比率是38.25%,构建TGAColor对象是19.75%,14%左右的时间花在内存拷贝上,剩下的25%左右的时间花费则是我们需要优化的部分。下面的内容我将以运行时间作为测试指标。
我们注意到,for循环中的除法操作是不变的,因此我们可以将除法放到for循环外面。并且通过斜率估计每向前走一步,另一个轴的增量error。dError是一个误差积累,一旦误差积累大于半个像素(0.5),就对像素进行一次修正。
xxxxxxxxxx
401// 第一次优化的代码
2void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
3 if(std::abs(x0-x1)<std::abs(y0-y1)) { // “陡峭”线
4 if (y0>y1) {
5 std::swap(x0, x1);
6 std::swap(y0, y1);
7 }
8 int dx = x1 - x0;
9 int dy = y1 - y0;
10 float dError = std::abs(dx / float(dy));
11 float error = 0;
12 int x = x0;
13 for (int y = y0; y <= y1; y++) {
14 image.set(x, y, color);
15 error += dError;
16 if (error>.5) {
17 x += (x1>x0?1:-1);
18 error -= 1.;
19 }
20 }
21 }else { // “平缓”线
22 if (x0>x1) {
23 std::swap(x0, x1);
24 std::swap(y0, y1);
25 }
26 int dx = x1 - x0;
27 int dy = y1 - y0;
28 float dError = std::abs(dy / float(dx));
29 float error = 0;
30 int y = y0;
31 for (int x = x0; x <= x1; x++) {
32 image.set(x, y, color);
33 error += dError;
34 if (error>.5) {
35 y += (y1>y0?1:-1);
36 error -= 1.;
37 }
38 }
39 }
40}
没有优化用时:2.98s
第一次优化用时:2.96s
在很多教程当中,为了方便修改,会用一些trick将“陡峭”的线和“平缓”的线的for循环代码整合到一起。即先将“陡峭”线两点的xy互换,最后再image.set()的时候再换回来。
xxxxxxxxxx
301// 逆向优化的代码
2void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
3 bool steep = false;
4 if (std::abs(x0-x1)<std::abs(y0-y1)) {
5 std::swap(x0, y0);
6 std::swap(x1, y1);
7 steep = true;
8 }
9 if (x0>x1) {
10 std::swap(x0, x1);
11 std::swap(y0, y1);
12 }
13 int dx = x1-x0;
14 int dy = y1-y0;
15 float dError = std::abs(dy/float(dx));
16 float error = 0;
17 int y = y0;
18 for (int x=x0; x<=x1; x++) {
19 if (steep) {
20 image.set(y, x, color);
21 } else {
22 image.set(x, y, color);
23 }
24 error += dError;
25 if (error>.5) {
26 y += (y1>y0?1:-1);
27 error -= 1.;
28 }
29 }
30}
没有优化用时:2.98s
第一次优化用时:2.96s
合并分支用时:3.22s
惊奇地发现,竟然有很大的性能下降!背后的原因之一写在了这一小节的标题中。这是一种刚刚我们的操作增加了控制冒险(Control Hazard)。合并分支后的代码每一次for循环都有一个分支,可能导致流水线冒险。这是现代处理器由于预测错误的分支而导致的性能下降。而第一段代码中for循环没有分支,分支预测可能会更准确。
简而言之,减少for循环中的分支对性能的提升帮助非常大!
值得一提的是,如果在Tiny-Renderer中使用本文的操作,速度将会进一步提升。这在Issues中也有相应讨论:链接🔗。
为什么我们必须用浮点数呢?在循环中我们只在与0.5做比较的时候用到了。因此我们完全可以将error乘个2再乘个dx(或dy),将其完全转化为int。
xxxxxxxxxx
391// 第二次优化的代码
2void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) {
3 int error2 = 0;
4 if(std::abs(x0-x1)<std::abs(y0-y1)) { // “陡峭”线
5 if (y0>y1) {
6 std::swap(x0, x1);
7 std::swap(y0, y1);
8 }
9 int dx = x1 - x0;
10 int dy = y1 - y0;
11 int dError2 = std::abs(dx) * 2;
12 int x = x0;
13 for (int y = y0; y <= y1; y++) {
14 image.set(x, y, color);
15 error2 += dError2;
16 if (error2>dy) {
17 x += (x1>x0?1:-1);
18 error2 -= dy * 2;
19 }
20 }
21 }else { // “平缓”线
22 if (x0>x1) {
23 std::swap(x0, x1);
24 std::swap(y0, y1);
25 }
26 int dx = x1 - x0;
27 int dy = y1 - y0;
28 int dError2 = std::abs(dy) * 2;
29 int y = y0;
30 for (int x = x0; x <= x1; x++) {
31 image.set(x, y, color);
32 error2 += dError2;
33 if (error2>dx) {
34 y += (y1>y0?1:-1);
35 error2 -= dx*2;
36 }
37 }
38 }
39}
没有优化用时:2.98s
第一次优化用时:2.96s
合并分支用时:3.22s
第二次优化用时:2.96s
优化程度也较为有限了,原因是在浮点数化整的过程中增加了计算的次数,与浮点数的计算压力相抵消了。
在前面的内容中,我们完成了Line()函数的编写。具体内容是给定屏幕坐标上的两个点就可以在屏幕中绘制线段。
首先,我们创建model类作为物体对象。我们在model加载的.obj文件里可能会有如下内容:
xxxxxxxxxx
11v 1.0 2.0 3.0
v表示3D坐标,后面通常是三个浮点数,分别对应空间中的x, y, z。上面例子代表一个顶点,其坐标为 (1.0, 2.0, 3.0)
。
当定义一个面(f
)时,你引用的是先前定义的顶点(v
)的索引。
xxxxxxxxxx
21f 1 2 3
2f 1/4/1 2/5/2 3/6/3
上面两行都表示一个面,
第一行表示三个顶点的索引
第二行表示顶点/纹理坐标/法线的索引
在这里我提供一个简单的 .obj 文件解析器 model.cpp 。你可以在此处找到当前项目链接🔗。以下是你可能用到的model类的信息:
模型面数量:i<model->nfaces()
获取第n个面的三个顶点索引:model->face(n)
通过索引获取顶点三维坐标:model->vert()
本项目使用的.obj文件的所有顶点数据已做归一化,也就是说v后面的三个数字都是在[-1, 1]之间。
在这里我们仅仅考虑三维顶点中的(x, y),不考虑深度值。最终在main.cpp中通过model解析出来的顶点坐标绘制出所有线框即可。
xxxxxxxxxx
121for (int i=0; i<model->nfaces(); i++) {
2 std::vector<int> face = model->face(i);
3 for (int j=0; j<3; j++) {
4 Vec3f v0 = model->vert(face[j]);
5 Vec3f v1 = model->vert(face[(j+1)%3]);
6 int x0 = (v0.x+1.)*width/2.;
7 int y0 = (v0.y+1.)*height/2.;
8 int x1 = (v1.x+1.)*width/2.;
9 int y1 = (v1.y+1.)*height/2.;
10 line(x0, y0, x1, y1, image, blue);
11 }
12}
这段代码对所有的面进行迭代,将每个面的三条边都进行绘制。
将不必要的计算设置为const,避免重复分配释放内存。
xxxxxxxxxx
241const float halfWidth = screenWidth / 2.0f;
2const float halfHeight = screenHeight / 2.0f;
3
4int nfaces = model->nfaces();
5for (int i = 0; i < nfaces; ++i) {
6 const std::vector<int>& face = model->face(i);
7 Vec3f verts[3];
8
9 for (int j = 0; j < 3; ++j) {
10 verts[j] = model->vert(face[j]);
11 }
12
13 for (int j = 0; j < 3; ++j) {
14 const Vec3f& v0 = verts[j];
15 const Vec3f& v1 = verts[(j + 1) % 3];
16
17 int x0 = (v0.x + 1.0f) * halfWidth;
18 int y0 = (v0.y + 1.0f) * halfHeight;
19 int x1 = (v1.x + 1.0f) * halfWidth;
20 int y1 = (v1.y + 1.0f) * halfHeight;
21
22 line(x0, y0, x1, y1, image, blue);
23 }
24}
接下来,绘制完整的三角形,不光是一个个三角形线框,更是要一个实心的三角形!为什么是三角形而不是其他形状比如四边形?因为三角形可以任意组合成为所有其他的形状。基本上,在OpenGL中绝大多数都是三角形,因此我们的渲染器暂时无需考虑其他的东西了。
当绘制完一个实心的三角形后,完整渲染一个模型也就不算难事了。
在Games101的作业中,我们使用了AABB包围盒与判断点是否在三角形内的方法对三角形光栅化。你完全可以用自己的算法绘制三角形,在本文中,我们使用割半法处理。
利用上一章节完成的line()函数,进一步将其包装成绘制三角形线框的triangleLine()函数。
xxxxxxxxxx
71void triangleLine(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage &image, TGAColor color){
2 line(v0.u, v0.v, v1.u, v1.v, image, color);
3 line(v0.u, v0.v, v2.u, v2.v, image, color);
4 line(v1.u, v1.v, v2.u, v2.v, image, color);
5}
6...
7triangleLine(Vec2i(0,0),Vec2i(25,25),Vec2i(50,0),image,red);
这一部分最好由你自己花费大约一个小时完成。一个好的三角形光栅化算法应该是简洁且高效的。你目前的项目大概是这样的:链接🔗。
【此处省略一小时】
当你完成了你的算法之后,不妨来看看其他人是怎么做的。为了光栅化一个实心三角形,一种非常常见的方法是使用扫描线算法:
按 v
(或 y
)坐标对三角形的三个顶点进行排序,使得 v0
是最低的,v2
是最高的。
对于三角形的每一行(从 v0.v
到 v2.v
),确定该行与三角形的两边的交点,并绘制一条从左交点到右交点的线。
x1void triangleRaster(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage &image, TGAColor color) {
2 if (v0.v > v1.v) std::swap(v0, v1);
3 if (v0.v > v2.v) std::swap(v0, v2);
4 if (v1.v > v2.v) std::swap(v1, v2);
5
6 // Helper function to compute the intersection of the line and a scanline
7 auto interpolate = [](int y, Vec2i v1, Vec2i v2) -> int {
8 if (v1.v == v2.v) return v1.u;
9 return v1.u + (v2.u - v1.u) * (y - v1.v) / (v2.v - v1.v);
10 };
11
12 for (int y = v0.v; y <= v2.v; y++) {
13 // Intersect triangle sides with scanline
14 int xa = interpolate(y, v0, v2); // Intersection with line v0-v2
15 int xb = (y < v1.v) ? interpolate(y, v0, v1) : interpolate(y, v1, v2); // Depending on current half
16
17 if (xa > xb) std::swap(xa, xb);
18
19 // Draw horizontal line
20 for (int x = xa; x <= xb; x++) {
21 image.set(x, y, color);
22 }
23 }
24}
介绍另一个非常有名的方法,包围盒扫描方法。将需要光栅化的三角形框上一个矩形的包围盒子内,在这个包围盒子内逐个像素判断该像素是否在三角形内。如果在三角形内,则绘制对应的像素;如果在三角形外,则略过。伪代码如下:
1triangle(vec2 points[3]) {
2 vec2 bbox[2] = find_bounding_box(points);
3 for (each pixel in the bounding box) {
4 if (inside(points, pixel)) {
5 put_pixel(pixel);
6 }
7 }
8}
想要实现这个方法,主要需要解决两个问题:找到包围盒、判断某个像素点是否在三角形内。
第一个问题很好解决,找到三角形的三个点中最小和最大的两个分量两两组合。
第二个问题似乎有些棘手。我们需要学习什么是重心坐标 (barycentric coordinates )。
利用重心坐标,可以判断给定某个点与三角形之间的位置关系。
给定一个三角形ABC和任意一个点P
我们把上面的式子解开,得到关于
然后将点P挪到同一边,得到下面的式子:
然后将上面的向量分为x分量与y分量,写成两个等式。接下来用矩阵表示他们:
两个向量点积是0,说明两个向量垂直。右边这俩向量都与
梳理一下,当务之急是判断给定的一个点与一个三角形的关系。直接给出结论,如果点在三角形内部,则这三个系数都属于(0,1)之间。直接给出光栅化一个三角形的代码:
xxxxxxxxxx
371Vec3f barycentric(Vec2i v0, Vec2i v1, Vec2i v2, Vec2i pixel){
2 // v0, v1, v2 correspond to ABC
3 Vec3f u = Vec3f(v1.x-v0.x,// AB_x
4 v2.x-v0.x,// AC_x
5 v0.x-pixel.x)// PA_x
6 ^
7 Vec3f(v1.y-v0.y,
8 v2.y-v0.y,
9 v0.y-pixel.y);
10 if (std::abs(u.z)<1) return Vec3f(-1,1,1);
11 return Vec3f(1.f-(u.x+u.y)/u.z, u.y/u.z, u.x/u.z);
12}
13// 重心坐标的方法 - 光栅化三角形
14void triangleRaster(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage &image, TGAColor color){
15 // Find The Bounding Box
16 Vec2i* pts[] = {&v0, &v1, &v2};// Pack
17 Vec2i boundingBoxMin(image.get_width()-1, image.get_height()-1);
18 Vec2i boundingBoxMax(0, 0);
19 Vec2i clamp(image.get_width()-1, image.get_height()-1);
20 for (int i=0; i<3; i++) {
21 boundingBoxMin.x = std::max(0, std::min(boundingBoxMin.x, pts[i]->x));
22 boundingBoxMin.y = std::max(0, std::min(boundingBoxMin.y, pts[i]->y));
23
24 boundingBoxMax.x = std::min(clamp.x, std::max(boundingBoxMax.x, pts[i]->x));
25 boundingBoxMax.y = std::min(clamp.y, std::max(boundingBoxMax.y, pts[i]->y));
26 }
27
28 // For Loop To Iterate Over All Pixels Within The Bounding Box
29 Vec2i pixel;
30 for (pixel.x = boundingBoxMin.x; pixel.x <= boundingBoxMax.x; pixel.x++) {
31 for (pixel.y = boundingBoxMin.y; pixel.y <= boundingBoxMax.y; pixel.y++) {
32 Vec3f bc = barycentric(v0, v1, v2, pixel);
33 if (bc.x<0 || bc.y<0 || bc.z<0 ) continue;
34 image.set(pixel.x, pixel.y, color);
35 }
36 }
37}
barycentric()函数可能比较难理解,可以暂时抛弃研究其数学原理。并且上面这段代码是经过优化的,如果希望了解其原理可以看我这一篇文章:链接🔗。
xxxxxxxxxx
41const int screenWidth = 250;
2const int screenHeight = 250;
3...
4triangleRaster(Vec2i(10,10), Vec2i(100, 30), Vec2i(190, 160),image,red);
你可以在下面的链接中找到当前项目的代码:链接🔗。
在「1.2 三维画线」中绘制了模型的线框,即空三角形模型。在「2.1 三角形光栅化」中,介绍了两种方法绘制一个“实心”的三角形。现在,我们将使用“平面着色”来渲染小人模型,其中平面着色使用随机的RGB数值。
首先将加载模型的相关代码准备好:
xxxxxxxxxx
301
2
3
4
5
6
7...
8Model *model = NULL;
9const int screenWidth = 800;
10const int screenHeight = 800;
11...
12
13// 光栅化三角形的代码
14void triangleRaster(Vec2i v0, Vec2i v1, Vec2i v2, TGAImage &image, TGAColor color){
15 ...
16}
17
18int main(int argc, char** argv) {
19 const float halfWidth = screenWidth / 2.0f;
20 const float halfHeight = screenHeight / 2.0f;
21 TGAImage image(screenWidth, screenHeight, TGAImage::RGB);
22 model = new Model("../object/african_head.obj");
23
24 ...// 在此处编写接下来的代码
25
26 image.flip_vertically();
27 image.write_tga_file("output.tga");
28 delete model;
29 return 0;
30}
下面是遍历获得模型的每一个需要绘制的三角形的代码:
xxxxxxxxxx
41for (int i=0; i<model->nfaces(); i++) {
2 std::vector<int> face = model->face(i);
3 ...
4}
当我们获得了所有的面,在每一趟遍历中,将face
的三个点取出来并转换到屏幕坐标上,最后传给三角形光栅化函数:
xxxxxxxxxx
51for (int j=0; j<3; j++) {
2 Vec3f world_coords = model->vert(face[j]);
3 screen_coords[j] = Vec2i((world_coords.x+1.)*width/2., (world_coords.y+1.)*height/2.);
4}
5triangleRaster(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(rand()%255, rand()%255, rand()%255, 255));
刚才的随机颜色远远满足不了我们,现在我们根据光线与三角形的法线方向绘制不同的灰度。什么意思呢?看下面这张图,当物体表面的法线方向与光线方向垂直,物体接受到了最多的光;随着法线与光线方向的夹角越来越大,收到光的照射也会越来越少。当法线与光线方向垂直的时候,表面就接收不到光线了。
将这个特性添加到光栅化渲染器中。
xxxxxxxxxx
181Vec3f light_dir(0,0,-1); // define light_dir
2
3for (int i=0; i<model->nfaces(); i++) {
4 std::vector<int> face = model->face(i);
5 Vec2i screen_coords[3];
6 Vec3f world_coords[3];
7 for (int j=0; j<3; j++) {
8 Vec3f v = model->vert(face[j]);
9 screen_coords[j] = Vec2i((v.x+1.)*width/2., (v.y+1.)*height/2.);
10 world_coords[j] = v;
11 }
12 Vec3f n = (world_coords[2]-world_coords[0])^(world_coords[1]-world_coords[0]);
13 n.normalize();
14 float intensity = n*light_dir;
15 if (intensity>0) {
16 triangle(screen_coords[0], screen_coords[1], screen_coords[2], image, TGAColor(intensity*255, intensity*255, intensity*255, 255));
17 }
18}
上面代码需要注意的点:
三角形法线n
的计算
判断点积正负
intensity
小于等于0的意思是这个面(三角形)背对着光线,摄像机肯定看不到,不需要绘制。
注意到嘴巴的地方有些问题,本应在嘴唇后面的嘴巴内部区域(像口腔这样的空腔)却被画在嘴唇的上方或前面。这表明我们对不可见三角形的处理方式不够精确或不够规范。“dirty clipping”方法只适用于凸形状。对于凹形状或其他复杂的形状,该方法可能会导致错误。在下一章节中我们使用 z-buffer 解决这个瑕疵(渲染错误)。
这里给出当前步骤的代码链接🔗。
上一章的末尾我们发现嘴巴部分的渲染出现了错误。本章先介绍画家算法(Painters' Algorithm),随后引出 Z-Buffer ,插值计算出需渲染的像素的深度值。
这个算法很直接,将物体按其到观察者的距离排序,然后从远到近的顺序绘制,这样近处的物体自然会覆盖掉远处的物体。
但是仔细想就会发现一个问题,当物体相互阻挡时算法就会出错。也就是说,画家算法无法处理相互重叠的多边形。
如果画家算法行不通,应该怎么解决物体相互重叠的问题呢?我们初始化一张表,长宽与屏幕像素匹配,且每个像素大小初始化为无限远。每一个像素存储一个深度值。当要渲染一个三角形的一个像素时,先比较当前欲渲染的像素位置与表中对应的深度值,如果当前欲渲染的像素深度比较浅,说明欲渲染的像素更靠近屏幕,因此渲染。
而这张表,我们称之为:Z-Buffer。
理论上说创建的这个 Z-Buffer 是一个二维的数组,例如:
xxxxxxxxxx
101float **zbuffer = new float*[screenWidth];
2for (int i = 0; i < screenWidth; i++) {
3 zbuffer[i] = new float[screenHeight];
4}
5...
6// 释放内存
7for (int i = 0; i < screenWidth; i++) {
8 delete[] zbuffer[i];
9}
10delete[] zbuffer;
但是,我认为这太丑陋了,不符合我的审美。我的做法是将二维数组打包变成一个一维的数组:
xxxxxxxxxx
11int *zBuffer = new int[screenWidth*screenHeight];
最基本的数据结构,取用的时候只需要:
xxxxxxxxxx
31int idx = x + y*screenWidth;
2int x = idx % screenWidth;
3int y = idx / screenWidth;
初始化zBuffer可以用一行代码解决,将其全部设置为负无穷:
xxxxxxxxxx
11for (int i=screenWidth*screenHeight; i--; zBuffer[i] = -std::numeric_limits<float>::max());
要给当前的triangleRaster()
函数新增 Z-Buffer 功能。
我们给pixel
增加一个维度用于存储深度值。另外,由于深度是float类型,如果沿用之前的函数可能会出现问题,原因是之前传入的顶点都是经过取舍得到的整数且不包含深度信息。而且需要注意整数坐标下的深度值往往不等于取舍之前的深度值,这个精度的损失带来的问题是在复杂精细且深度值波动很大的位置会出现渲染错误。但是目前可以直接忽略,等到后面进行超采样、抗锯齿或者其他需要考虑像素内部细节的技术时再展开讲解。
因此,为了后期拓展的方便,我们将之前涉及pixel的Vec2i代码换为Vec3f类型,并且每一个点都增加一个维度用于存储深度值。
xxxxxxxxxx
431Vec3f barycentric(Vec3f A, Vec3f B, Vec3f C, Vec3f P) {
2 Vec3f s[2];
3 for (int i=2; i--; ) {
4 s[i][0] = C[i]-A[i];
5 s[i][1] = B[i]-A[i];
6 s[i][2] = A[i]-P[i];
7 }
8 Vec3f u = cross(s[0], s[1]);
9 if (std::abs(u[2])>1e-2)
10 return Vec3f(1.f-(u.x+u.y)/u.z, u.y/u.z, u.x/u.z);
11 return Vec3f(-1,1,1);
12}
13// 重心坐标的方法 - 光栅化三角形
14void triangleRaster(Vec3f v0, Vec3f v1, Vec3f v2, float *zBuffer, TGAImage &image, TGAColor color){
15 Vec3f* pts[] = {&v0, &v1, &v2};// Pack
16 // Find The Bounding Box
17 Vec2f boundingBoxMin( std::numeric_limits<float>::max(), std::numeric_limits<float>::max());
18 Vec2f boundingBoxMax(-std::numeric_limits<float>::max(), -std::numeric_limits<float>::max());
19 Vec2f clamp(image.get_width()-1, image.get_height()-1);
20 for (int i=0; i<3; i++) {
21 boundingBoxMin.x = std::max(0.f, std::min(boundingBoxMin.x, pts[i]->x));
22 boundingBoxMin.y = std::max(0.f, std::min(boundingBoxMin.y, pts[i]->y));
23 boundingBoxMax.x = std::min(clamp.x, std::max(boundingBoxMax.x, pts[i]->x));
24 boundingBoxMax.y = std::min(clamp.y, std::max(boundingBoxMax.y, pts[i]->y));
25 }
26
27 // For Loop To Iterate Over All Pixels Within The Bounding Box
28 Vec3f pixel;// 将深度值打包到pixel的z分量上
29 for (pixel.x = boundingBoxMin.x; pixel.x <= boundingBoxMax.x; pixel.x++) {
30 for (pixel.y = boundingBoxMin.y; pixel.y <= boundingBoxMax.y; pixel.y++) {
31 Vec3f bc = barycentric(v0, v1, v2, pixel);// Screen Space
32 if (bc.x<0 || bc.y<0 || bc.z<0 ) continue;
33 // HIGHLIGHT: Finished The Z-Buffer
34 //image.set(pixel.x, pixel.y, color);
35 pixel.z = 0;
36 pixel.z = bc.x*v0.z+bc.y+v1.z+bc.z+v2.z;// 通过重心坐标插值计算当前Shading Point的深度值
37 if(zBuffer[int(pixel.x+pixel.y*screenWidth)]<pixel.z) {
38 zBuffer[int(pixel.x + pixel.y * screenWidth)] = pixel.z;
39 image.set(pixel.x, pixel.y,color);
40 }
41 }
42 }
43}
将世界坐标转化到屏幕坐标的函数打包:
xxxxxxxxxx
31Vec3f world2screen(Vec3f v) {
2 return Vec3f(int((v.x+1.)*width/2.+.5), int((v.y+1.)*height/2.+.5), v.z);
3}
另外,对tgaimage、model和geometry做了一些修改,主要是优化了一些细节。具体项目请查看当前项目分支链接🔗。
啥是贴图呢?就是类似这种奇奇怪怪的图片。
目前我们已经完成了三角形的重心坐标插值得出了三角形内某点的深度值。接下来我们还可以用插值操作计算对应的纹理坐标。
本章基于「3.1 表面剔除」最后的项目完善,本章主要是c++ STL相关操作。
请首先下载「3.1 表面剔除」最后的项目链接🔗。
首先从硬盘中加载纹理贴图,然后传到三角形顶点处,通过对应的纹理坐标从texture获取颜色,最后插值得到各个像素的颜色。
另外,项目框架的代办清单:
增加model模块中对vt标签的解析
完善model模块中对f标签的解析,具体是获取纹理坐标索引
完善geometry模块的操作符,具体是实现Vec<Dom, f>与float相乘等操作
从硬盘中加载纹理texture,用TGAImage存储。
xxxxxxxxxx
71TGAImage texture;
2if(texture.read_tga_file("../object/african_head_diffuse.tga")){
3 std::cout << "Image successfully loaded!" << std::endl;
4 // 可以做一些图像处理
5} else {
6 std::cerr << "Error loading the image." << std::endl;
7}
在 model.h 中,在class Model上方创建一个Face结构体用于存储解析后obj中的f标签。f标签有三个值,这里只存储前两个。f标签的三个值分别是顶点索引/纹理索引/法线索引,等后面用到了法线坐标再拓展即可。
xxxxxxxxxx
51struct Face {
2 std::vector<int> vertexIndices;
3 std::vector<int> texcoordIndices;
4 ...
5};
然后将model的模版私有属性:
xxxxxxxxxx
11std::vector< std::vector<int> > faces_;
改为:
xxxxxxxxxx
11std::vector<Face> faces_;
同时也修改 model.cpp 下获取 face 的函数:
xxxxxxxxxx
31Face Model::face(int idx) {
2 return faces_[idx];
3}
实际解析时的函数:
xxxxxxxxxx
201else if (!line.compare(0, 2, "f ")) {
2// std::vector<int> f;
3// int itrash, idx;
4// iss >> trash;
5// while (iss >> idx >> trash >> itrash >> trash >> itrash) {
6// idx--; // in wavefront obj all indices start at 1, not zero
7// f.push_back(idx);
8// }
9// faces_.push_back(f);
10 Face face;
11 int itrash, idx, texIdx;
12 iss >> trash;
13 while (iss >> idx >> trash >> texIdx >> trash >> itrash) {
14 idx--; // in wavefront obj all indices start at 1, not zero
15 texIdx--; // similarly for texture indices
16 face.vertexIndices.push_back(idx);
17 face.texcoordIndices.push_back(texIdx);
18 }
19 faces_.push_back(face);
20 }
接下来解析纹理坐标索引texcoords_。
xxxxxxxxxx
111// model.h
2...
3class Model {
4private:
5 ...
6 std::vector<Vec2f> texcoords_;
7public:
8 ...
9 Vec2f& getTexCoord(int index);
10};
11...
xxxxxxxxxx
161// model.cpp
2...
3Model::Model(const char *filename) : verts_(), faces_(), texcoords_(){
4 ...
5 else if (!line.compare(0, 3, "vt ")) {
6 iss >> trash >> trash;
7 Vec2f tc;
8 for (int i = 0; i < 2; i++) iss >> tc[i];
9 texcoords_.push_back(tc);
10 }
11 ...
12}
13...
14Vec2f& Model::getTexCoord(int index) {
15 return texcoords_[index];
16}
最后就可以通过对应的索引得到纹理坐标了。
xxxxxxxxxx
11tex_coords[j] = model->getTexCoord(face.texcoordIndices[j]);
获得了纹理坐标后就可以用texture.get(x_pos, y_pos)获取图片(贴图/纹理)的对应像素。注意最后TGAColor使用的是BGRA通道,而不是RGBA通道。
xxxxxxxxxx
121TGAColor getTextureColor(TGAImage &texture, float u, float v) {
2 // 纹理坐标限制在(0, 1)
3 u = std::max(0.0f, std::min(1.0f, u));
4 v = std::max(0.0f, std::min(1.0f, v));
5 // 将u, v坐标乘以纹理的宽度和高度,以获取纹理中的像素位置
6 int x = u * texture.get_width();
7 int y = v * texture.get_height();
8 // 从纹理中获取颜色
9 TGAColor color = texture.get(x, y);
10 // tga使用的是BGRA通道
11 return TGAColor(color[2],color[1],color[0], 255);
12}
增加了四个传参,分别是三个三角形的纹理坐标与纹理。实现细节直接看代码比较直接。
xxxxxxxxxx
261// 带贴图 - 光栅化三角形
2void triangleRasterWithTexture(Vec3f v0, Vec3f v1, Vec3f v2,
3 Vec2f vt0, Vec2f vt1, Vec2f vt2,// 纹理贴图
4 float *zBuffer, TGAImage &image,
5 TGAImage &texture){
6 ...
7 // Find The Bounding Box
8 ...
9
10 // For Loop To Iterate Over All Pixels Within The Bounding Box
11 Vec3f pixel;// 将深度值打包到pixel的z分量上
12 for (pixel.x = boundingBoxMin.x; pixel.x <= boundingBoxMax.x; pixel.x++) {
13 for (pixel.y = boundingBoxMin.y; pixel.y <= boundingBoxMax.y; pixel.y++) {
14 Vec3f bc = barycentric(v0, v1, v2, pixel);// Screen Space
15 if (bc.x<0 || bc.y<0 || bc.z<0 ) continue;
16 // HIGHLIGHT: Finished The Z-Buffer
17 pixel.z = 0;
18 pixel.z = bc.x*v0.z+bc.y+v1.z+bc.z*v2.z;
19 Vec2f uv = bc.x*vt0+bc.y*vt1+bc.z*vt2;
20 if(zBuffer[int(pixel.x+pixel.y*screenWidth)]<pixel.z) {
21 zBuffer[int(pixel.x + pixel.y * screenWidth)] = pixel.z;
22 image.set(pixel.x, pixel.y,getTextureColor(texture, uv.x, 1-uv.y));
23 }
24 }
25 }
26}
在上面的代码中,你可能会发现乘号竟然报错了,这个问题在下一关马上得到解决。最终在 main() 函数中这样调用:
xxxxxxxxxx
161// main.cpp
2...
3for (int i=0; i<model->nfaces(); i++) {
4 Face face = model->face(i);
5 Vec3f screen_coords[3], world_coords[3];
6 Vec2f tex_coords[3];
7 for (int j=0; j<3; j++) {
8 world_coords[j] = model->vert(face.vertexIndices[j]);
9 screen_coords[j] = world2screen(world_coords[j]);
10 tex_coords[j] = model->getTexCoord(face.texcoordIndices[j]);
11 }
12 triangleRasterWithTexture(screen_coords[0], screen_coords[1], screen_coords[2],
13 tex_coords[0],tex_coords[1],tex_coords[2],
14 zBuffer, image, texture);
15}
16...
在写纹理坐标的时候,我们会用到一些操作比如说 Vec2i 类型与 float 浮点数相乘和相除。将下面的代码添加到 geometry.h 的中间部分:
xxxxxxxxxx
411...
2
3template <typename T> vec<3,T> cross(vec<3,T> v1, vec<3,T> v2) {
4 return vec<3,T>(v1.y*v2.z - v1.z*v2.y, v1.z*v2.x - v1.x*v2.z, v1.x*v2.y - v1.y*v2.x);
5}
6
7// -------------添加内容-------------
8template<size_t DIM, typename T> vec<DIM, T> operator*(const T& scalar, const vec<DIM, T>& v) {
9 vec<DIM, T> result;
10 for (size_t i = 0; i < DIM; i++) {
11 result[i] = scalar * v[i];
12 }
13 return result;
14}
15
16template<size_t DIM, typename T> vec<DIM, T> operator*(const vec<DIM, T>& v, const T& scalar) {
17 vec<DIM, T> result;
18 for (size_t i = 0; i < DIM; i++) {
19 result[i] = v[i] * scalar;
20 }
21 return result;
22}
23
24template<size_t DIM, typename T> vec<DIM, T> operator/(const vec<DIM, T>& v, const T& scalar) {
25 vec<DIM, T> result;
26 for (size_t i = 0; i < DIM; i++) {
27 result[i] = v[i] / scalar;
28 }
29 return result;
30}
31
32// -------------添加内容结束-------------
33
34template <size_t DIM, typename T> std::ostream& operator<<(std::ostream& out, vec<DIM,T>& v) {
35 for(unsigned int i=0; i<DIM; i++) {
36 out << v[i] << " " ;
37 }
38 return out ;
39}
40
41...
这样就完全没问题了,大功告成。当然你也可以在这个链接🔗中找到完整的代码。
上文的内容全部都是正交视角下的渲染,这显然算不上酷,因为我们仅仅是将z轴“拍扁”了。这一章节的目标是学习绘制透视视角。
https://stackoverflow.com/questions/36573283/from-perspective-picture-to-orthographic-picture
缩放可以表示为:
拉伸可以表示为:
旋转可以表示为:
为什么要引入齐次坐标呢?因为想要表示一个二维变换的平移并不能仅仅使用一个2x2的矩阵。平移并不在这个二维矩阵的线性空间中。因此,我们拓展一个维度帮助我们表示平移。
在计算机图形学中我们使用齐次坐标(Homogeneous Coord)。比如说一个二维的
这样,我们就可以通过
在常规的笛卡尔坐标中,很难从数学表示上区分一个点和一个向量,因为它们都可能使用相同的形式如 vec2(x,y)。但在齐次坐标中,通过最后一个坐标值(这里的z)可以明确区分它们。当z=0时,它是一个向量;当z≠0时,它是一个点。较为数学一点的表示方法:
上面公式中,无论
齐次坐标是一个大大的好啊,当你进行数学操作时,结果的类型(向量或点)是明确的:
向量 + 向量 = 向量:两个向量相加的结果仍然是一个向量。
向量 - 向量 = 向量:两个向量相减的结果仍然是一个向量。
点 + 向量 = 点:一个点和一个向量相加的结果是一个新的点。
点 + 点 = ??:两个点坐标的中点。
这使得数学操作更加直观和有意义。
一段来自屏幕外的声音🔊:齐次坐标最下面那行有啥用??这个问题非常关键。
家喻户晓的,
但是,这个
变换矩阵不做其他线性变换,仅仅将pq随便设为一个数:
我们发现,这个变换有点奇怪。随着(x,y)越来越大,这个“缩放因子”就会越来越小。
有没有一种可能,这个就是近大远小?
没错,这就是一种透视的现象。至此,上面齐次坐标矩阵的最后一朵乌云已经攻破。
随着最后一朵乌云散去,必然会迎来更多的乌云。新的乌云,名字叫做三维。
上文所述都是二维下的,现在进入三维的世界。三维的齐次坐标自然就是用四维的矩阵表示。
缩放:
平移:
绕x,z,y轴旋转:
透视:
为什么只有z方向上才有r?因为我们默认摄像机摆在z轴,物体随着z轴透视缩放的。
现在,将一个三维坐标通过透视缩放,得到:
上图中,横轴向左是z的正方向,纵轴向上是y的正方向。
根据相似三角形法则,y1/By=(c-z1)/c,最后得到:
因此得到透视矩阵:
大家可能发现,如果有接触过图形学的朋友们可能会对之前的学习产生怀疑。为什么这里顶点变换的透视变换矩阵和其他教材都不一样呢?比方说虎书上的透视矩阵是这样的:
fovy:垂直视场角, 通常表示为度数。
aspect : 宽高比, 即视口宽度除以视口高度。
near : 近裁剪面的距离。
far: 远裁剪面的距离。
所以我们刚才推导出来的矩阵并不是常见的透视投影矩阵,但是他确实表达了投影的思想,因此我们暂时用着。
来自屏幕外的声音🔊:停停停,理论说了这么多,能不能搞点实践的!
在上一关中,我们得到了不那么正规但是能用的透视矩阵,现在要做的就是将世界坐标的顶点转换到齐次坐标,然后乘上透视矩阵、视口矩阵。视口矩阵其实就是用一个简洁的矩阵把下面归一化设备坐标 NDC [-1,1]转换到了屏幕空间[0,width]。看下面这一段代码就是那个被ViewPort矩阵淘汰的家伙:
xxxxxxxxxx
31Vec3f world2screen(Vec3f v) {
2 return Vec3f(int((v.x+1.)*screenWidth/2.+.5), int((v.y+1.)*screenHeight/2.+.5), v.z);
3}
接下来,把顶点坐标乘上我们下面两个矩阵(顺序要注意):
xxxxxxxxxx
81//初始化透视矩阵
2Matrix Projection = Matrix::identity(4);
3//初始化视角矩阵
4Matrix ViewPort = viewport(width/2, height/2, width/2, height/2);
5//投影矩阵[3][2]=-1/c,c为相机z坐标
6Projection[3][2] = -1.f/camera.z;
7...
8screen_coords[j] = m2v(ViewPort * Projection * v2m((world_coords[j])));
一段来自屏幕外的声音🔊:等等等等,v2m和m2v是什么?viewport()具体实现方法是什么?
v2m是将向量变成矩阵(齐次坐标),m2v反之。
xxxxxxxxxx
241Vec3f m2v(Matrix m){
2 return Vec3f(m[0][0]/m[3][0], m[1][0]/m[3][0], m[2][0]/m[3][0]);
3}
4
5Matrix v2m(Vec3f v) {
6 Matrix m(4, 1);
7 m[0][0] = v.x;
8 m[1][0] = v.y;
9 m[2][0] = v.z;
10 m[3][0] = 1.f;
11 return m;
12}
13
14Matrix viewport(int x, int y, int w, int h) {
15 Matrix m = Matrix::identity(4);
16 m[0][3] = x+w/2.f;
17 m[1][3] = y+h/2.f;
18 m[2][3] = depth/2.f;
19
20 m[0][0] = w/2.f;
21 m[1][1] = h/2.f;
22 m[2][2] = depth/2.f;
23 return m;
24}
然后还需要完善geometry的模块,在geometry.h中添加如下代码:
xxxxxxxxxx
241//////////////////////////////////////////////////////////////////////////////////////////////
2
3const int DEFAULT_ALLOC=4;
4
5class Matrix {
6 std::vector<std::vector<float> > m;
7 int rows, cols;
8public:
9 Matrix(int r=DEFAULT_ALLOC, int c=DEFAULT_ALLOC);
10 inline int nrows();
11 inline int ncols();
12
13 static Matrix identity(int dimensions);
14 std::vector<float>& operator[](const int i);
15 Matrix operator*(const Matrix& a);
16 Matrix transpose();
17 Matrix inverse();
18
19 friend std::ostream& operator<<(std::ostream& s, Matrix& m);
20};
21
22/////////////////////////////////////////////////////////////////////////////////////////////
23...
24// typedef mat<4,4,float> Matrix;
然后添加文件 geometry.cpp
xxxxxxxxxx
1081//
2// Created by remoooo on 2023/9/6.
3//
4
5
6
7
8
9
10
11Matrix::Matrix(int r, int c) : m(std::vector<std::vector<float> >(r, std::vector<float>(c, 0.f))), rows(r), cols(c) { }
12
13int Matrix::nrows() {
14 return rows;
15}
16
17int Matrix::ncols() {
18 return cols;
19}
20
21Matrix Matrix::identity(int dimensions) {
22 Matrix E(dimensions, dimensions);
23 for (int i=0; i<dimensions; i++) {
24 for (int j=0; j<dimensions; j++) {
25 E[i][j] = (i==j ? 1.f : 0.f);
26 }
27 }
28 return E;
29}
30
31std::vector<float>& Matrix::operator[](const int i) {
32 assert(i>=0 && i<rows);
33 return m[i];
34}
35
36Matrix Matrix::operator*(const Matrix& a) {
37 assert(cols == a.rows);
38 Matrix result(rows, a.cols);
39 for (int i=0; i<rows; i++) {
40 for (int j=0; j<a.cols; j++) {
41 result.m[i][j] = 0.f;
42 for (int k=0; k<cols; k++) {
43 result.m[i][j] += m[i][k]*a.m[k][j];
44 }
45 }
46 }
47 return result;
48}
49
50Matrix Matrix::transpose() {
51 Matrix result(cols, rows);
52 for(int i=0; i<rows; i++)
53 for(int j=0; j<cols; j++)
54 result[j][i] = m[i][j];
55 return result;
56}
57
58Matrix Matrix::inverse() {
59 assert(rows==cols);
60 // augmenting the square matrix with the identity matrix of the same dimensions a => [ai]
61 Matrix result(rows, cols*2);
62 for(int i=0; i<rows; i++)
63 for(int j=0; j<cols; j++)
64 result[i][j] = m[i][j];
65 for(int i=0; i<rows; i++)
66 result[i][i+cols] = 1;
67 // first pass
68 for (int i=0; i<rows-1; i++) {
69 // normalize the first row
70 for(int j=result.cols-1; j>=0; j--)
71 result[i][j] /= result[i][i];
72 for (int k=i+1; k<rows; k++) {
73 float coeff = result[k][i];
74 for (int j=0; j<result.cols; j++) {
75 result[k][j] -= result[i][j]*coeff;
76 }
77 }
78 }
79 // normalize the last row
80 for(int j=result.cols-1; j>=rows-1; j--)
81 result[rows-1][j] /= result[rows-1][rows-1];
82 // second pass
83 for (int i=rows-1; i>0; i--) {
84 for (int k=i-1; k>=0; k--) {
85 float coeff = result[k][i];
86 for (int j=0; j<result.cols; j++) {
87 result[k][j] -= result[i][j]*coeff;
88 }
89 }
90 }
91 // cut the identity matrix back
92 Matrix truncate(rows, cols);
93 for(int i=0; i<rows; i++)
94 for(int j=0; j<cols; j++)
95 truncate[i][j] = result[i][j+cols];
96 return truncate;
97}
98
99std::ostream& operator<<(std::ostream& s, Matrix& m) {
100 for (int i=0; i<m.nrows(); i++) {
101 for (int j=0; j<m.ncols(); j++) {
102 s << m[i][j];
103 if (j<m.ncols()-1) s << "\t";
104 }
105 s << "\n";
106 }
107 return s;
108}
接下来,渲染器启动!
来自甲方的声音🔊:效果非常好,下次不要做了。
看得出来,画面出现了一点问题。但是值得注意的是,顶点的位置已经基本正确了。但是贴图出现了错误。
借此机会,调整一下贴图加载的逻辑。我们原先在main函数粗暴加载,现在我们将物体对应的贴图当作model对象的一个属性,自动读取。在model.h中加入字段:
xxxxxxxxxx
11TGAImage diffusemap_;
构造函数就可以根据文件名字存入对应的贴图了:
xxxxxxxxxx
11load_texture(filename, "_diffuse.tga", diffusemap_);
然后通过以下函数得到对应的uv坐标:
xxxxxxxxxx
41Vec2i Model::uv(int iface, int nvert) {
2 int idx = faces_[iface][nvert][1];
3 return Vec2i(uv_[idx].x*diffusemap_.get_width(), uv_[idx].y*diffusemap_.get_height());
4}
改动部分比较多直接阅读项目吧,项目链接🔗在这里,我们在「4.2 代码分析」中详细讨论整个项目,力求搞懂每一行代码与设计思路,尤其是C++ STL细节。下面是一个最终结果:
目前的代码链接🔗有较大的改动,但是技术原理是不变的。本章节可以选择性阅读,也可以直接跳到「5.1 移动摄像机」。
项目结构:
xxxxxxxxxx
111├── object
2│ ├── african_head.obj
3│ ├── african_head_diffuse.tga
4├── CMakeLists.txt
5├── geometry.cpp
6├── geometry.h
7├── main.cpp
8├── model.cpp
9├── model.h
10├── tgaimage.cpp
11└── tgaimage.h
model.h
xxxxxxxxxx
191class Model {
2private:
3 std::vector<Vec3f> verts_; // 模型的顶点
4 std::vector<std::vector<Vec3i> > faces_; // this Vec3i means vertex/uv/normal
5 std::vector<Vec3f> norms_; // 存储模型的法线
6 std::vector<Vec2f> uv_; // 存储模型的 UV 纹理坐标
7 TGAImage diffusemap_; // 模型的漫反射纹理图像
8 // load_texture() 在加载模型的时候会用到,用于加载纹理。
9 void load_texture(std::string filename, const char *suffix, TGAImage &img);
10public:
11 Model(const char *filename); // 构造函数,从给定文件名加载模型
12 ~Model(); // 析构函数,用于释放模型所占用的资源
13 int nverts(); // 返回模型的顶点数量
14 int nfaces(); // 返回模型的面数量
15 Vec3f vert(int i); // 返回指定索引的顶点
16 Vec2i uv(int iface, int nvert); // 返回指定面和指定顶点的 UV 坐标
17 TGAColor diffuse(Vec2i uv); // 根据给定的 UV 坐标,从纹理图中获取颜色
18 std::vector<int> face(int idx); // 返回指定索引的面信息(可能是顶点/纹理坐标/法线的索引)
19};
Model.cpp
xxxxxxxxxx
891/*
2 * 构造函数 Model::Model(const char *filename)
3 * - 构造函数使用了初始化列表来对 verts_, faces_, norms_ 和 uv_ 进行初始化。
4 *
5 */
6Model::Model(const char *filename) : verts_(), faces_(), norms_(), uv_() {
7 std::ifstream in;
8 in.open (filename, std::ifstream::in);
9 if (in.fail()) return;
10 std::string line;
11 /* 循环读取并解析每一行内容。
12 * 根据行的开头字符来决定行的类型(例如顶点、法线、纹理坐标或面)。
13 * 根据这些信息更新相应的成员变量。
14 */
15 while (!in.eof()) {
16 std::getline(in, line);
17 std::istringstream iss(line.c_str());
18 char trash;
19 if (!line.compare(0, 2, "v ")) {
20 iss >> trash;
21 Vec3f v;
22 for (int i=0;i<3;i++) iss >> v[i];
23 verts_.push_back(v);
24 } else if (!line.compare(0, 3, "vn ")) {
25 iss >> trash >> trash;
26 Vec3f n;
27 for (int i=0;i<3;i++) iss >> n[i];
28 norms_.push_back(n);
29 } else if (!line.compare(0, 3, "vt ")) {
30 iss >> trash >> trash;
31 Vec2f uv;
32 for (int i=0;i<2;i++) iss >> uv[i];
33 uv_.push_back(uv);
34 } else if (!line.compare(0, 2, "f ")) {
35 std::vector<Vec3i> f;
36 Vec3i tmp;
37 iss >> trash;
38 while (iss >> tmp[0] >> trash >> tmp[1] >> trash >> tmp[2]) {
39 for (int i=0; i<3; i++) tmp[i]--; // in wavefront obj all indices start at 1, not zero
40 f.push_back(tmp);
41 }
42 faces_.push_back(f);
43 }
44 }
45 std::cerr << "# v# " << verts_.size() << " f# " << faces_.size() << " vt# " << uv_.size() << " vn# " << norms_.size() << std::endl;
46 load_texture(filename, "_diffuse.tga", diffusemap_);// 调用 load_texture 函数加载相应的纹理文件
47}
48
49Model::~Model() {
50}
51
52// 返回模型的顶点数量
53int Model::nverts() {
54 return (int)verts_.size();
55}
56// 返回模型的面数量。
57int Model::nfaces() {
58 return (int)faces_.size();
59}
60
61// 接收一个面的索引并返回这个面的所有顶点/纹理/法线坐标
62std::vector<int> Model::face(int idx) {
63 std::vector<int> face;
64 for (int i=0; i<(int)faces_[idx].size(); i++) face.push_back(faces_[idx][i][0]);
65 return face;
66}
67// 返回指定索引的顶点。
68Vec3f Model::vert(int i) {
69 return verts_[i];
70}
71// 加载纹理。
72void Model::load_texture(std::string filename, const char *suffix, TGAImage &img) {
73 std::string texfile(filename);
74 size_t dot = texfile.find_last_of(".");
75 if (dot!=std::string::npos) {
76 texfile = texfile.substr(0,dot) + std::string(suffix);
77 std::cerr << "texture file " << texfile << " loading " << (img.read_tga_file(texfile.c_str()) ? "ok" : "failed") << std::endl;
78 img.flip_vertically();
79 }
80}
81// 返回给定 UV 坐标的漫反射颜色。
82TGAColor Model::diffuse(Vec2i uv) {
83 return diffusemap_.get(uv.x, uv.y);
84}
85// 返回指定面和顶点的 UV 坐标。
86Vec2i Model::uv(int iface, int nvert) {
87 int idx = faces_[iface][nvert][1];
88 return Vec2i(uv_[idx].x*diffusemap_.get_width(), uv_[idx].y*diffusemap_.get_height());
89}
geometry.h中,分为两个部分:模版向量类,矩阵类
xxxxxxxxxx
1011/* --向量类定义-- */
2// t -> 任意类型的数据,比如说 int float double等等
3template <class t> struct Vec2 {
4 t x, y;// 创建了两个t类型的数据成员
5 Vec2<t>() : x(t()), y(t()) {} // 使用类型t的默认构造函数来初始化x和y。
6 Vec2<t>(t _x, t _y) : x(_x), y(_y) {} // 接受两个参数,初始化x,y
7// Vec2<t>(const Vec2<t> &v) : x(t()), y(t()) { *this = v; } // 模板类的拷贝构造函数
8 Vec2<t>(const Vec2<t> &v) : x(v.x), y(v.y) {} // 我认为上面的代码不太好,改了一下
9 Vec2<t> & operator =(const Vec2<t> &v) { // 重载了等号,改变符号左边对象的数值
10 if (this != &v) {
11 x = v.x;
12 y = v.y;
13 }
14 return *this;
15 }
16 Vec2<t> operator +(const Vec2<t> &V) const { return Vec2<t>(x+V.x, y+V.y); }
17 Vec2<t> operator -(const Vec2<t> &V) const { return Vec2<t>(x-V.x, y-V.y); }
18 Vec2<t> operator *(float f) const { return Vec2<t>(x*f, y*f); }
19 // 重载[]符号,这里官方写错了,if(x<=0)是错误的。
20 t& operator[](const int i) { if (i<=0) return x; else return y; }
21 // 重载输出流
22 template <class > friend std::ostream& operator<<(std::ostream& s, Vec2<t>& v);
23};
24
25template <class t> struct Vec3 {
26 t x, y, z;
27 Vec3<t>() : x(t()), y(t()), z(t()) { }
28 Vec3<t>(t _x, t _y, t _z) : x(_x), y(_y), z(_z) {}
29 template <class u> Vec3<t>(const Vec3<u> &v);
30 Vec3<t>(const Vec3<t> &v) : x(t()), y(t()), z(t()) { *this = v; }
31 Vec3<t> & operator =(const Vec3<t> &v) {
32 if (this != &v) {
33 x = v.x;
34 y = v.y;
35 z = v.z;
36 }
37 return *this;
38 }
39 Vec3<t> operator ^(const Vec3<t> &v) const { return Vec3<t>(y*v.z-z*v.y, z*v.x-x*v.z, x*v.y-y*v.x); }
40 Vec3<t> operator +(const Vec3<t> &v) const { return Vec3<t>(x+v.x, y+v.y, z+v.z); }
41 Vec3<t> operator -(const Vec3<t> &v) const { return Vec3<t>(x-v.x, y-v.y, z-v.z); }
42 Vec3<t> operator *(float f) const { return Vec3<t>(x*f, y*f, z*f); }
43 t operator *(const Vec3<t> &v) const { return x*v.x + y*v.y + z*v.z; }
44 float norm () const { return std::sqrt(x*x+y*y+z*z); }
45 Vec3<t> & normalize(t l=1) { *this = (*this)*(l/norm()); return *this; }
46 t& operator[](const int i) { if (i<=0) return x; else if (i==1) return y; else return z; }
47 template <class > friend std::ostream& operator<<(std::ostream& s, Vec3<t>& v);
48};
49
50// 为常用类型提供了类型别名
51typedef Vec2<float> Vec2f;
52typedef Vec2<int> Vec2i;
53typedef Vec3<float> Vec3f;
54