前言

光栅化(Rasterization),简而言之就是将一张图片映射到屏幕上的一格格像素中去,如果想要深入了解的话我个人十分推荐闫令琪老师的图形学入门课程GAMES101-现代计算机图形学入门-闫令琪Lecture 05 Rasterization 1Lecture 06 Rasterization 2这一章里面讲的非常详细。

tinyrenderer
此次的起点则是tinyrenderer,一个由大佬开源的C++软渲染教程,由画点-画线-画面-…的循序渐进目的更好的理解图形是怎么出现在我们的电脑屏幕上的。

配合以下文章食用更好,但有一些原教程中没有提及的东西我也会加上

知乎链接:
从零构建光栅器,tinyrenderer笔记(上)
从零构建光栅器,tinyrenderer笔记(下)

初始工程:
初始工程

把初始工程拉取下来,就可以开始愉快的学习如何编写一个光栅器了!

初始化工程

拉取下来的工程目录结构如上

我们打开一手Visual Studio ⬇
继续但无需代码 ⬇
左上角文件选项 ⬇
新建 ⬇
从现有代码创建项目 ⬇
项目类型选择C++ 下一步 ⬇
项目文件位置选择对应课时的文件夹,项目名称随便起个喜欢的名字 下一步 ⬇
项目类型选择控制台应用程序项目 完成 ⬇
左上角视图选项选择解决方案资源管理器 (已经有的可以跳过)

如果上图中的这个显示所有文件没有选上的话就选上,这样我们就得到了包含三个文件的初始工程,点进main.cpp可能会遇到中文编码问题,反正我就遇到了,但这并不重要。

tgaimage.cpptgaimage.h这两个文件是辅助我们构建一个tga文件的,所以我们并不是从0开始,我们可以当他是一个图片格式,跟png,jpg的类似,用正常的图片软件都能打开,我自己用的是bandview。

01 Draw a line

画线初体验

我们一开始所能倚仗的唯一一个函数是

1
image.set(x, y, color);

它的作用是在范围内给一个对应坐标的像素写入对应的颜色值,原点在右下角,那么我们怎么利用这个函数在(x0, y0)(x1, y1)之间画出一条线呢,很简单,随着(x0, y0)每往(x1, y1)走一步(t)的距离,就分别对x和y做插值处理得到一个新的点(x, y),在该点上写入颜色值,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void line(int x0, int y0, int x1, int y1, TGAImage &image, TGAColor color)
{
for (float t = 0.; t < 1.; t += .1f)
{
int x = x0 * (1. - t) + x1 * t;
int y = y0 * (1. - t) + y1 * t;
image.set(x, y, color);
}
}

int main(int argc, char** argv)
{
TGAImage image(100, 100, TGAImage::RGB);
//画线
line(13, 20, 80, 40, image, white);
image.flip_vertically();
image.write_tga_file("output.tga");
return 0;
}

也就是工程直接打开的样子,我们反手一个f5运行一下,接着打开main.cpp所在文件夹,打开output.tga这个图片

示例图片

感觉不太对,这个点的分布是离散的,让我们进一步提高采样的点,每一次循环t只增加0.01再试试,重复上面的操作再把output.tga打开

示例图片

终于我们屏幕上终于出现了一条线段!

做出一点小优化

但是仔细观察可以发现,这个循环执行了一百次,但实际上如果我们将x坐标的差值当作循环的次数,然后在对应的y上着色,则只需要80 - 13 + 1 = 68次循环即可,不错的想法,试一试

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

再次点开output.tga,我们发现效果也很不错:

示例图片

好!到现在为止我们成功画出了一条线

酒吧 工程师 炒饭

吗?

来尝试一下不同的输入吧!

1
2
3
4
5
6
//1号线
line(13, 20, 80, 40, image, white);
//2号线
line(20, 13, 40, 80, image, red);
//3号线
line(80, 40, 13, 20, image, red);
示例图片

这对吗?这不对

经过对比我们会发现,1号线是比较符合我们预期的,2号线怎么又变成离散的了,3号线则直接消失不见了!

先看看2号线吧,我们可以发现当线段斜率大于1的时候,我们每一次的循环,宽度的增加都赶不上高度的增加,在x + 1的情况下y可能增长2 3 4,把情况极端化,如果我们想从(1, 0)(1, 2)画一条竖线

1
for (int x = x0; x <= x1; x++)

x == x1的时候,x的坐标根本不会增长,我们连循环都进不去,对于这种情况,tinyrenderer作者的学生是这样建议的:

1
2
3
4
5
6
if (dx > dy) 
for (int x)
...
else
for (int y)
...

而tinyrenderer作者的评价是

再看看3号线,其实发现3号线不是消失了,而是应该和1号线重合了,两者只是将坐标反过来画线罢了,但还是不太对,我们是先执行的1号线再执行3号线,正常来说3号线的像素点应该覆盖掉1号线的像素点才对,回头看看line函数,实际上在x0 > x1的情况下这个循环根本不会执行!

针对上面的问题我们做出一点调整吧

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
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))
{
//当斜率<1的时候,我们把每个点的xy都翻转一下,就得到了一条比较平缓的线段
std::swap(x0, y0);
std::swap(x1, y1);
steep = true;
}
//当线段需要从右往左画的时候把两点的x和y都互换一下,意味着我们的线段永远都是从左边往右边画
if (x0 > x1)
{
std::swap(x0, x1);
std::swap(y0, y1);
}
for (int x = x0; x <= x1; x++)
{
float t = (x - x0) / (float)(x1 - x0);
int y = y0 * (1. - t) + y1 * t;

if(steep)
//这一步相当于把求出的点按照y = x这条直线镜像到正确的点上
//其实就是先把斜率大于1的情况下的线段先按照y = x做一个镜像,在计算完点的位置之后再镜像回去
image.set(y, x, color);
else
image.set(x, y, color);
}
}

完成上述操作后让我们来f5执行一下代码再看看结果

示例图片

好!至此为止我们就算是正式迈出了画线的第一步!