实现OpenGL渲染器原理篇(一)——Bresenham直线生成算法和线框渲染

总结一下要点(刚好最近要复习):

  1. 保证对称性,画一条线段不能依赖于点的起点和终点,(a, b)画出的线段和(b, a)画出的线段应该是一样的。
  2. 如果k大于1,画出的线会有了类似于“孔洞”的效果。
  3. 优化Line()的性能。因为画线会通过一个for循环,解决方法是加入增量(for循环中移除了除法运算),因为除法比较占用资源。
  4. 消除浮点数。经过Breas算法的改进,代码中除了error的计算以外,没有一个×或者÷的运算。

其他博客的精华:

  1. 由于图形学所有的渲染都是依靠无数线段的渲染来完成的,所以直线的光栅化算法的效率显得尤为重要。
  2. 在显示器上由于像素呈现四边形,理论上无法完全模拟线段(因为在数学的观点来看线段是笔直的,没有宽度的)。只能用近似的方法来让它“看起来”是一条线段,这就是直线的光栅化
  3. 数值微分算法(DDA算法)引进了图形学中很重要的增量思想(为了消除乘法k * x)。但是DDA算法的2个问题:
  • 每次递增x时不能斜率过大(k不能大于1)。斜率过大会导致屏幕上显示的点少而且稀疏。(可以在斜率小于1的时候采用递增x的方式,在斜率大于1的时候采用递增y的方式来画直线。)
  • 效率仍然比较低(虽然已经没有了乘法,但是加法是一个浮点数的加法)
  1. 中点画线算法达到了和DDA算法一样的效率(浮点数加法),并且避开了浮点数加法。因此,中点画线算法已经把直线光栅化的效率推至极限(整数加法)。
  2. Bresenham算法扩展了中点画线算法的适用范围,它可以根据任何形式的直线方程都可以画出直线,并且保持效率最佳。
  3. Bresenham算法的思想是将像素中心构造成虚拟网格线,按照直线起点到终点的顺序,计算直线与各垂直网格线的交点,然后根据误差项的符号确定该列像素中与此交点最近的像素。
  4. Bresenham算法总结了DDA算法和中点画线算法的优点,应用更加广泛。


    Bresenham算法流程

最近在Github上复现了一个渲染器render的项目:
Github链接:tiny render

我希望在博客上可以记录自己的学习过程,博客主要分为两大类:《原理篇》和《语法篇》。
原理篇则主要讲的是——实现渲染器过程中所需要的算法知识。
语法篇则主要讲的是——实现渲染器过程中使用到的C++的语法知识。


首先,我们来康康,渲染器实现后的成效是什么样的?
like this ——


TinyRender

一、 首次尝试

大家的第一个目标可以定为渲染出金属丝网

所以,第一步,应该学习如何画线段

画线段用到了算法: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循环来不断的画线。

画出的图是这样:


常数0.01

二、第二次尝试

上述code的问题在于低效,还有一个就是常数的选择,上面取它为0.01——

常数取0.01

如果将常数取为0.1,上面code所画的图则会变为下图——

常数取0.1

通过这个,大家可以发现,上述代码的核心部分就是:将要绘制的像素数量,那么有人会将代码改成下面这样——

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);
写了3条线的代码,但只显示了2条线

有同学可能要问了,明明三条线的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()函数。
    1. 在line()函数中,加入了bool型变量steep,初值定为false,表示没有斜率。
    1. 比较(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小了。
    1. 比较x1和x0的大小,如果x1比x0小,就交换x1和x0的值。注意:此时y1和y0也要换,要保持同步,不然坐标点的值就变了。例如:原先坐标是(8, 5)和(2, 7),交换后,就变成(2, 7)和(8, 5)。
    1. 接下来的逻辑差不多,重新盘一下:枚举x坐标,初值x0,终值x1。定义一个float型的斜率t。y的大小就由斜率t和y0、y1来计算。
    1. 如果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个错误:

  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。

上面说到,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); 
    } 
}

线框渲染后的效果是这样的,大家可以康康:

线框渲染

重新打开了一遍,成白色了:


线框渲染2

学习之初,我整理了每节课中代码的变化,现在看来用处不大,但是删了可惜,大家有需要的可以看看哈~~

这篇博客用到的代码文件的变化是这样的:

  • 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测试的。

你可能感兴趣的:(实现OpenGL渲染器原理篇(一)——Bresenham直线生成算法和线框渲染)