0%

从零开始的软渲染生活-第一话

开个新坑,Follow了Sokolov大佬的TinyRenderer,纯c++没有任何其他图形api,真正的从零开始。
我只是拾人牙慧,有不清楚的地方还请移步原作

准备工作

最终我们需要输出为TGA图像(游戏贴图的常见格式),所以这里先得准备一个图像输出的类,图像类的实现与软渲染器关系不大,故这里暂且不表,如果用到一些有必要解释的函数再来细说,我们只需要用它来输出一张图片看看效果:

1
2
3
4
5
6
7
int main(int argc, char** argv) {
TGAImage image(100, 100, TGAImage::RGB);
image.set(52, 41, red);
image.flip_vertically(); // i want to have the origin at the left bottom corner of the image
image.write_tga_file("output.tga");
return 0;
}

这里做了一个图像的flip操作,于是我们可以认为坐标原点位于图的左下角,方便理解,但是与其他图片坐标的表示可能就没那么一致。

Bresenham算法绘制线段

线段是最基本的东西了,一般在图像上画一条线我们考虑Bresenham算法,它的原理是简单的递推步进,每次从起始位置向目标位置步进一个像素。最简单的写法:

1
2
3
4
5
6
7
void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color){
for(float t=0.0; t<1.0; t += 0.01){
int x = x0 + (x1-x0)*t;
int y = y0 + (y1-y0)*t;
image.set(x,y,color);
}
}

这里的0.01类似于采样率,这个数设置得越小,画出来的像素点就越密集,当我们设为0.01,那么就会有100次循环,也即画了100个点。
这个方法很直白,但是用到了很多次乘法,而且对于一条直线来说,100个点中其实会有很多点位置重复了(因为像素坐标取整的缘故),于是效率很低,做好优化是图形学的精髓,所以我们可以想办法。
像素坐标是整数的,所以斜着的线段表现出来应该是不连续的一段一段组成的,具体的Bresenham算法思想是要分斜率讨论的(传送门)。斜率较大的,每条小线段趋于垂直,斜率较小的,每条小线段趋于水平。以此来保证画出来的直线尽可能的直。

设dx=x1-x0; dy=y1-y0;斜率即为dy/dx,有了这个值之后,我们只需要对于两点之间x轴方向上的像素点进行遍历,x方向每移动一格,我们便考虑y是否变化或保持不变。我们可以先只考虑斜率为0到1之间的情况,因为其他情况都可以通过坐标变换来得到,考虑在斜率0到1之间从左下向右上画线,我们用一个增量error来判断此时的y是否要上移一格,每一次循环x+1,error都加上斜率的值,如果error大于0.5了(也就是实际线已经越过了这个像素点水平位置的一半了),说明要往上走了(可以理解成累积偏移量),则y+1。这样一来,我们的循环便只有x1-x0次,且循环内只用到了加减法。再优化一点,我们可以把与0.5作比较改为与整数1作比较(毕竟像素点都是整数值),因为error = dy/dx; error*dx = dy;只要把刚刚的增量改为2dy, 我们就可以愉快地拿error2与dx做比较了,和原来的比较是等价的,不信你试试看。最终:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color) { 
bool steep = false;
if (std::abs(x0-x1)<std::abs(y0-y1)) {
std::swap(x0, y0);
std::swap(x1, y1);
steep = true;
}
if (x0>x1) {
std::swap(x0, x1);
std::swap(y0, y1);
}
int dx = x1-x0;
int dy = y1-y0;
int derror2 = std::abs(dy)*2;
int error2 = 0;
int y = y0;
for (int x=x0; x<=x1; x++) {
if (steep) {
image.set(y, x, color);
} else {
image.set(x, y, color);
}
error2 += derror2;
if (error2 > dx) {
y += (y1>y0?1:-1);
error2 -= dx*2;
}
}
}

那么我们就可以利用线来绘制三维模型的mesh网格了
这里就涉及到obj文件的读取,问题不大

定义好存储obj的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
class Obj {
public:
int vn = 0;
int fn = 0;
std::vector<float> vx_list; //顶点x坐标
std::vector<float> vy_list;//顶点y坐标
std::vector<int> f_list;//三角面片信息

Obj();
void read_obj(std::string filename);//读取的函数

};

读取obj

接下来我们就要分析一下obj文件的构成,来决定怎么读取。

记事本打开一个obj,可以看到有这样的

1
2
3
4
5
6
v -0.000581696 -0.734665 -0.623267
v 0.000283538 -1 0.286843
v -0.117277 -0.973564 0.306907
v -0.382144 -0.890788 0.221243
v -0.247144 -0.942602 0.276051
v -0.656078 -0.718512 -0.109025

前面的v表示这是一个顶点的三维坐标

还有这样的

1
2
3
4
5
6
7
8
9
10
f 1091/1258/1091 1209/1261/1209 1131/1266/1131
f 1258/1339/1258 1206/1252/1206 1205/1255/1205
f 1258/1339/1258 1208/1256/1208 1206/1252/1206
f 1258/1339/1258 1215/1270/1215 1214/1267/1214
f 1189/1236/1189 1138/1184/1138 1160/1207/1160
f 1185/1231/1185 1138/1184/1138 1189/1236/1189
f 1202/1248/1202 1220/1281/1220 1200/1246/1200
f 1200/1246/1200 1220/1281/1220 1198/1247/1198
f 1201/1249/1201 1200/1246/1200 1199/1245/1199
f 1201/1249/1201 1202/1248/1202 1200/1246/1200

f是表示这一行就是一个三角面片的信息了,每一行三组,分别是三个顶点,每一组三个数分别是顶点索引/纹理坐标索引/法线索引,我们只需要画mesh线框,所以只关心顶点就好了,索引就是比如1091 1209 1131 说明这个面是由我们前面读取的第1091 第1209 第1131这三个点构成的。

那么就可以上代码了,简单的文件流读取,中间遇到我们暂时不需要的信息直接忽略就好了,只把需要的存到前面的数据结构里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void Obj::read_obj(std::string file_name){
std::ifstream in;
in.open(file_name.c_str(), std::ios::in);
std::string line;
std::string str;
float x,y,z;
int ix,iy,iz;

while(!in.eof())
{
getline(in,line);
if(line.size() == 0) continue;
if(line[0] == '#') continue;
else if(line[0] == 'v'){
if(line[1] == 't'){
continue;
}
else if(line[1] == 'n'){
continue;
}
else{
std::istringstream IL(line);
IL >> str >> x >> y >> z;
vx_list.push_back(x);
vy_list.push_back(y);
}

}
else if(line[0] == 'f'){
std::istringstream IL(line);
char c;
IL >> str;
// while(IL >> ix >> c>> iy >> c >>iz){
// ix--;
// f_list.push_back(ix);
// std::cout<<ix<<std::endl;
// }
for(int i = 0;i < 3;i++){
IL >> ix >> c>> iy >> c >>iz ;
f_list.push_back(ix-1);
std::cout<<c<<std::endl;

}

}
}
vn = vx_list.size();
fn = f_list.size()/3;
in.close();
}

然后主函数里我们就可以画线了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void draw_wireframe(std::string obj_path,TGAImage &image){
Obj obj;
obj.read_obj(obj_path);
for(int i=0;i<obj.f_list.size(); i+=3){

float x0 = obj.vx_list[obj.f_list[i]];
//std::cout<<obj.f_list[i]<<" "<<obj.f_list[i+1]<<std::endl;
x0 = (x0+1.)*400/2.;
float y0 = obj.vy_list[obj.f_list[i]];
y0 = (y0+1.)*400/2.;
float x1 = obj.vx_list[obj.f_list[i+1]];
x1 = (x1+1.)*400/2.;
float y1 = obj.vy_list[obj.f_list[i+1]];
y1 = (y1+1.)*400/2.;
float x2 = obj.vx_list[obj.f_list[i+2]];
x2 = (x2+1.)*400/2.;
float y2 = obj.vy_list[obj.f_list[i+2]];
y2 = (y2+1.)*400/2.;


//std::cout<<x0<<" "<<x1<<" "<<x2<<std::endl;

line(x0,y0,x1,y1,image,white);
line(x2,y2,x1,y1,image,white);
line(x0,y0,x2,y2,image,white);

}

}

值得一提的是,这里我用的obj是已经归一化好的,那么只需要映射到图片大小的范围就好了,如果是其他一些没有归一化的obj就还得再处理一下。