总结一下要点(刚好最近要复习):
- 保证对称性,画一条线段不能依赖于点的起点和终点,(a, b)画出的线段和(b, a)画出的线段应该是一样的。
- 如果k大于1,画出的线会有了类似于“孔洞”的效果。
- 优化
Line()
的性能。因为画线会通过一个for循环
,解决方法是加入增量(for循环中移除了除法运算),因为除法
比较占用资源。 - 消除浮点数。经过Breas算法的改进,代码中除了
error
的计算以外,没有一个×或者÷的运算。
其他博客的精华:
- 由于图形学所有的渲染都是依靠无数线段的渲染来完成的,所以直线的光栅化算法的效率显得尤为重要。
- 在显示器上由于像素呈现四边形,理论上无法完全模拟线段(因为在数学的观点来看线段是笔直的,没有宽度的)。只能用近似的方法来让它“看起来”是一条线段,这就是直线的光栅化。
- 数值微分算法(DDA算法)引进了图形学中很重要的增量思想(为了消除乘法
k * x
)。但是DDA算法的2个问题:
- 每次递增x时不能斜率过大(k不能大于1)。斜率过大会导致屏幕上显示的点少而且稀疏。(可以在斜率小于1的时候采用递增x的方式,在斜率大于1的时候采用递增y的方式来画直线。)
- 效率仍然比较低(虽然已经没有了乘法,但是加法是一个浮点数的加法)
- 中点画线算法达到了和DDA算法一样的效率(浮点数加法),并且避开了浮点数加法。因此,
中点画线算法
已经把直线光栅化的效率推至极限(整数加法)。 -
Bresenham算法
扩展了中点画线算法
的适用范围,它可以根据任何形式的直线方程都可以画出直线,并且保持效率最佳。 -
Bresenham算法
的思想是将像素中心构造成虚拟网格线,按照直线起点到终点的顺序,计算直线与各垂直网格线的交点,然后根据误差项的符号确定该列像素中与此交点最近的像素。 -
Bresenham算法总结了DDA算法和中点画线算法的优点,应用更加广泛。
最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render
我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。
首先,我们来康康,渲染器实现后的成效是什么样的?
like this ——
一、 首次尝试
大家的第一个目标可以定为渲染出金属丝网。
所以,第一步,应该学习如何画线段。
画线段用到了算法:Bresenham's line algorithm,不知道的可以看这个链接:Bresenham's line algorithm
这个算法讲到了如何画一个从(x0, y0)到(x1, y1)的直线段,code如下:
void line(int x0, int x1, int y0, int y1, TGAImage &image, TGAColor color)
{
// for (float t = 0.; t < 1; t+=0.1)
for (float t = 0.; t < 1.; t += .01) // 上句code没有将所有写成float类型
{
int x = x0 + t * (x1 - x0);
int y = y0 + t * (y1 - y0);
image.set(x, y, color);
}
}
上述code中,image使用了引用reference, 对color没有表示。
t表示斜率,直接定义。
最后一句code使用了接口set来完成。核心就是for循环来不断的画线。
画出的图是这样:
二、第二次尝试
上述code的问题在于低效,还有一个就是常数的选择,上面取它为0.01——
如果将常数取为0.1,上面code所画的图则会变为下图——
通过这个,大家可以发现,上述代码的核心部分就是:将要绘制的像素数量,那么有人会将代码改成下面这样——
void line(int x0, int x1, int y0, int y1, TGAImage &image, TGAColor color)
{
// for (int x = x0; x <= x1; x += .1)
for (int x = x0; x <= x1; x++)
{
// float t = (x - x0)/(float)(x1 - x);
float t = (x - x0)/(float)(x1 - x0);
int y = y0 * (1. - t) + y1 * t;
image.set(x, y, color);
}
}
上述code我写的有两处错误:
- 第一个是line3中的条件3是x++
- 第二个是line5中分母应该是(x1 - x0)
- 代码中的错误: 整数除法
- 像这种(x - x0)/(x1 - x0)
第二个代码中的首要错误就是上述整数除法的这种错误。
三、 第三次尝试
3.1 出现问题
大家可以先用上述的两段code来画三条Lines。调用函数line:
line(13, 20, 80, 40, image, white);
line(20, 13, 40, 80, image, red);
line(80, 40, 13, 20, image, red);
有同学可能要问了,明明三条线的code,怎么只显示了2条线。
因为第一句和第三句的命令是一样的,只不过是不同的起终点和不同的方向。也就是说,第三条线画出来后,可以覆盖第一条线,因为第一条线是白色的,第三条线是红色的。
理论上,白色呢条线是要被红色覆盖的。
可是没有,那么怎么改?
可以说,这3行代码是对对称性的一个测试。
也就是说,画一条线不应该依赖于点的起点或者终点,
(a, b)画出的线段和(b, a)画出的线段应该是一样的。
3.2 修正直线
好,接下来,咱们来想办法让消失的红线重新浮现出来。
大家可以通过交换点的位置来改变,所以x0是永远低于x1的。
再加上,高度远大于宽度的原因,所以画出的线上会有孔洞。
但是,很多人会这样改code——
if (dx > dy)
{
for (int x)
}
else
{
for (int y)
}
这上面2行核心代码,我属实没看懂。而且是不对的。
应该按下面这样改:
void line (int x0, int x1, int y0, 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 (x1 < x0) //因为这个是在改左右,所以关注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 = (1. - t) * y0 + t * y1;
if (steep)
{
image.set(y, x, color);
}
else
{
image.set(x, y, color);
}
}
}
// 就是第二个if判断那里,少写了个 std::swap(y0, y1)
- 这个代码的逻辑是什么呢?
- 逻辑是这样,更改了line()函数。
- 在line()函数中,加入了bool型变量steep,初值定为false,表示没有斜率。
- 比较(x1 - x0)和(y1 - y0)的绝对值大小,如果x坐标的绝对值小,那就把x和y上的坐标对换,也就是x0和y0换,x1和y1换,换完后,将steep置为true,表示有斜率。例如:原先坐标是(2, 4)和(5, 9),这样子,y轴的绝对值是5,x轴的绝对值是3,这样子,y=kx中,斜率k是大于1。我们要做的就是让斜率不要大于1,所以交换x0和y0的值,交换x1和y1的值。原坐标就会变为(4, 2)和(9, 5),这样x轴的绝对值就是5,y轴的绝对值是3,这样,y=kx的斜率k就比1小了。
- 比较x1和x0的大小,如果x1比x0小,就交换x1和x0的值。注意:此时y1和y0也要换,要保持同步,不然坐标点的值就变了。例如:原先坐标是(8, 5)和(2, 7),交换后,就变成(2, 7)和(8, 5)。
- 接下来的逻辑差不多,重新盘一下:枚举x坐标,初值x0,终值x1。定义一个float型的斜率t。y的大小就由斜率t和y0、y1来计算。
- 如果steep是true,说明我们原先交换过x和y,在画图的时候需要转回来。所以set函数的命令是(y, x, color); 如果steep是false,说明没有交换过x和y,那就原先的(x, y, color)。
改了后,结果就是这样:
那么也许有同学会问了,更改后的代码,前面交换x和y的意义在哪里?
意义就在于我们要完成:画一条线是不应该依赖于坐标的起点和终点的。这样子,增强了代码的对称性,这样,像上一节的情况就不会出现。我们重新画了一条线,只是改了颜色和坐标起点,那么结果就是线不变颜色会变。
四、计时:第四次尝试
当我们修改好了code,让他可以看起来work fine以后,我们需要提高它的性能,想办法优化代码。
那么优化之前,大家可以猜猜,上述code中的哪个步骤是最占用资源的STEP?
答案是:
- line(int, int, int, int, TGAImage&, TGAColor)
它占用了70%的资源,所以这句code就是大家需要优化的步骤。
五、优化line()
大家一定都知道,上述的除法
(x-x0)/(x1-x0)
中都有相同的因子。因为x1和x0是不变的。
因为这个语句是在for循环中的,我们可以把它从for循环中取出来。
这个误差变量给了从当前(x, y)像素到最佳直线的距离。
每一次的误差都会大于一个像素。
对此,我可以给出的解决方案是:
- 将y增加1,相应地将误差也减少1。
改正后的代码如下:
这次调整的思路是:
- 加入了差分dx dy derror
- 之前算斜率t是放在for loop中,斜率t的计算是除法(除法中分子分母还都是减法)。现在换成了derror,derror的计算是加法。(提高性能)
- 初始化了error和y
- 取而代之的是在for循环中加入了error的递推式,和error与y之间的对应增减关系(消除误差变量)
void line (int x0, int x1, int y0, int y0, TGAImage &image, TGAColor color)
{
bool steep = false;
// if (std::abs(y0-x0) > std::abs(y1-x1))
// {
// std::swap(x0, x1);
// std::swap(y0, y1);
// steep = true;
//}
if (std::abs(x0 - x1) < std::abs(y0 - y1))
{
std::swap(x0, y0);
std::swap(x1, y1);
steep = true;
}
if (x1 < x0)
{
std::swap(x1, x0);
std::swap(y1, y0);
}
int dx = x1 - x0;
int dy = y1 - y0;
//float derror = dy / (float)(dx);
////float derror = std::abs(dy/(float)(dx));
float derror = std::abs(dy/float(dx));
float error = 0;
int y = y0;
for (int x = x0; x <= x1; x++)
{
if (steep)
{
image.set(y, x, color);
}
else
{
image.set(x, y, color);
}
error += derror;
//if (error > 5)
if (error > .5)
{
y += (y1 > y0 ? 1 : -1);
error -= 1;
}
}
}
// 这次写代码碰到了3个错误:
// 1. 第一段代码中x0 y0、x1 y1互换,但是比较是x0 x1的差值和y0 y1的差值比较。
// 2. 算derror的时候,要加abs,float可以不带括号,float(dx)
// 3. 判断y是+1还是-1的时候,条件是error 是否比 .5 大。 大的话,y + 1 or -1(取决于y1是否比y0大), 然后对应地,error 也 -1。
这次写代码碰到了3个错误:
- 第一段代码中x0 y0、x1 y1互换,但是比较是x0 x1的差值和y0 y1的差值比较。
- 算derror的时候,要加abs,float可以不带括号,float(dx)
- 判断y是+1还是-1的时候,条件是error 是否比 .5 大。 大的话,y + 1 or -1(取决于y1是否比y0大), 然后对应地,error 也 -1。
上面说到,line的资源占用率达到了70%,这次代码的优化后,差不多下降到了40%。
原因就是在for循环中移除了除法运算的代码。
所以+ - 还可以接受,但是/就比较占用资源了。
并且在code语句中,优化前和优化后的代码区别是——
- 将斜率和y分开运算了。优化前,y的运算包含了t,而且是×运算。
- 而且t是斜率,error是微分斜率,解读为误差。
- 优化后,error是error,通过derror累加。y只是—-+1的操作,对应的error也-1。
- 唯一的y和error对应的是 当error大于.5的时候,y才开始+1或-1。
结果图:
为什么画出来的是蓝色的?因为代码的这里改了:
- image.set(y, x, TGAColor(255, 1));
调用了另一个构造函数,1个字节数,颜色值为255,显示出来是蓝色。
六、浮点数存在的必要性?
大家应该发现了,一直对于error和斜率steep均用的是float型变量。
- 那么使用float型变量的原因是什么?
唯一的原因就是除法中的一个因子为dx和在for循环体中的比较(error > .5)这两个操作。
- 那么如何消除浮点数?
大家可以通过使用另一个变量来替代原先的误差变量来消除浮点数。
可以称这个变量为error2, 假设它为error × dx × 2。
改进的code如下:
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);
}
//这次的error就直接是只跟dy相关了,去掉了dx,因为dx带有浮点数。
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;//erro2的叠加还是之前呢个样子
if (error2 > dx)//但是比较就不再是之前的与.5比较了,而是dx比大小
{
y += (y1 > y0 ? 1 : -1);
error2 -= dx * 2;//相应的,y加减1,error2和dx进行运算。
//我猜测原因应该是derror2的初始化,是直接通过dy生成的。所以error2在for循环中的累加,与dx进行运算不会收到影响。
}
}
}
这次的error就直接是只跟dy相关了。
去掉了float型的dx,变成了int型的dx。之前derror的计算是这样:
- float derror = std::abs(dy / float(dx));
derror是浮点型的,dx也被强制转化了,而且derror的值由dy和dx一起计算得到。
error2的叠加还是之前呢个样子。
但是比较就不再是之前的与.5比较了,而是dx比大小
相应的,y加减1,error2和dx进行运算。
我猜测原因应该是derror2的初始化,是直接通过dy生成的。所以error2在for循环中的累加,与dx进行运算不会收到影响。
现在,我们对code的改进,已经去除掉了函数调用中对color进行引用传递的不必要的copies。(或者只是启用编译标志-O3)
代码中除了error的计算以外,没有一个×或者÷的运算。
line的执行时间也降低了(从2.95将到了0.64)。
七、线框渲染
在对code进行优化完后,大家需要做的就是对线框进行渲染。
模型的保存使用Wavefront.obj,OBJ是一种几何定义文件格式。
详细的介绍看这里Wavefront.obj file
这里,也提供了一个OBJ文件,内容长这样。
渲染所需要的就是从文件中读取以下类型的顶点数组
v 0.608654 -0.568839 -0.416318
上面这三个是x, y, z坐标,这个v表示顶点数组
f 1193/1240/1193 1180/1227/1180 1179/1226/1179
每个文件行和面都有一个顶点。上面这行则是说明其中一个三角形的构成是分别由1193, 1180,1179个顶点构成的。
v -0.000581696 -0.734665 -0.623267; //这个v表示顶点数组,后面3个数字表示的是x, y, z坐标
vt 0.438 0.333 0.000;
vn 0.556 0.801 -0.221;
f 175/155/175 214/194/214 213/193/213; //每个文件行和面都有一个顶点
大家需要对第4行中的代码多加注意:
关注每一个空格后的第一个数字。这个第一个数字是我们上面第一行代码表示V的呢个数组中顶点总共的数量。
所以,这个顶点第4行代码的意思是175、214和213个顶点构成一个三角形。
在model.cpp中包含了一个简单的解析器。将下面的for loop写进main.cpp,大家的线框渲染就大功告成了。
for (int i=0; infaces(); i++)
{
std::vector face = model->face(i);
for (int j=0; j<3; j++)
{
Vec3f v0 = model->vert(face[j]);
Vec3f v1 = model->vert(face[(j+1)%3]);
int x0 = (v0.x+1.)*width/2.;
int y0 = (v0.y+1.)*height/2.;
int x1 = (v1.x+1.)*width/2.;
int y1 = (v1.y+1.)*height/2.;
line(x0, y0, x1, y1, image, white);
}
}
线框渲染后的效果是这样的,大家可以康康:
重新打开了一遍,成白色了:
学习之初,我整理了每节课中代码的变化,现在看来用处不大,但是删了可惜,大家有需要的可以看看哈~~
这篇博客用到的代码文件的变化是这样的:
- tgaimage.h
(初始导入)
- tgaimage.cpp
(初始导入)
- african_head.obj
(线框渲染)
- geometry.h
(线框渲染)
- main.cpp
(朴素线段追踪)->(线段追踪、减少划分的次数)->(线段追踪:all integer Bresenham)->(线框渲染)
- model.cpp
(线框渲染)
- model.h
(线框渲染)
解释一下上述文件括号中的文字——
只有tgaimage.h/.cpp这两个文件是从初始导入到Lesson1最后的线框渲染过程中没有变化过的,所以一直显示是初始导入。
main.cpp变化较多,从一开始的简单的线段追踪,到减少划分次数的线段追踪,再到所有integer Bresenham的线段追踪,直到最后的线段渲染。
剩下的,.obj文件、geometry.h文件、model.h/.cpp文件都是在线框渲染时才一起出来的。
其中model时用来test测试的。