实现OpenGL渲染器原理篇(三)——隐藏面移除(z缓冲区)

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

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


一、介绍

大家后,这节课,我们学习的是如何去除上一章(在对隐藏面部进行移除后)遗留的视觉伪像(z-buffer)

去除视觉伪像

理论上我们可以绘制所有三角形而不丢弃任何三角形。
如果我们正确地从后往前做,前面的平面会擦去后面的。它被称为画家的算法

然而不幸的是,它伴随着很高的计算成本:对于每个摄像机的移动,我们需要重新排序所有的场景

接下来,则是动态场景……这甚至不是主要问题。

主要的问题是并非总是能够确定正确的顺序。


二、渲染一个简单的场景

想象一个由三个三角形组成的简单场景:摄像机看起来是从上到下的;

我们将彩色的三角形投射到白色屏幕上。

彩色三角形投射到白色屏幕后

渲染后应该像是这样:

换个视角看

看着上图,我想问一下大家:蓝色面是在红色小面的前面还是后面?
之前讲的画家算法对于这种情况而言就不奏效了。

  • 那么该怎么办呢?

可以将蓝色小面分成两个(一个在红色小面前面,一个在红色小面后面)

然后在红色三角形前面的那部分蓝色三角形也要一分为二——一部分在绿色三角形前面,一部分在绿色三角形后面

我想你现在应该明白问题所在了:在有数百万个三角形的场景中,这种形式的计算是非常昂贵的。

其实,可以使用BSP树来完成它

顺便说一下,这个数据结构对于移动的相机来说是恒定的,但是它真的很乱。

所以得想想别的法子。


三、Y-buffer的引出,学会丢弃一个维度

好,为了解决上述的问题。

让我们先暂时丢掉一个维度沿着黄色的平面裁剪上面的场景。

二维

我的意思是,现在我们的场景是由三个线段组成的(黄色平面和每个三角形的交点),最后的渲染有一个正常的宽度,但高度为1像素

注意那条水平的,未被阴影化的,窄窗口

因为现在这个场景已经从三维变成了二维的,所以很容易使用大家在第一课中已经编写好的line()函数来绘制它。

{
    // just dumping the 2d scene (yay we have enough dimensions!)
    TGAImage scene(width, height, TGAImage::RGB);
    
    // scene "2d mesh"——2d网络
    line (Vec2i(20, 34), Vec2i(744, 400), scene, red);
    line (Vec2i(120, 434), Vec2i(444, 400), scene, green);
    line (Vec2i(330, 463), Vec2i(594, 200), scene, blue);
    
    // screen line
    line (Vec2i(10, 10), Vec2i(790, 10), scene, white);
    
    scene.flip_vertically(); // 这一行可以让原点在图像的左下角
    scene.write_tga_file("scene.tga");
}

如果我们从侧面看,这就是2D场景的样子:

从侧面看,投影到黄色面板上

接下来,让我们渲染它。
大家回想一下,要注意,渲染是1像素的高度

但是在我的源代码中,我创建了16像素高的图像,以便在高分辨率屏幕上阅读。

下面的rasterize()函数仅写在图像的第一行进行渲染——

TGAImage render(width, 16, TGAImage::RGB);

int ybuffer[width];
for (int i = 0; i < width; i++)
{
    ybuffer[i] = std::numeric_limits:min(); // numeric_limits是什么?
}

rasterize(Vec2i(20, 34), Vec2i(744, 400), render, red, ybuffer); // 这个render是函数吗还是什么?
rasterize(Vec2i(120, 434), Vec2i(444, 400), render, green, ybuffer);
rasterize(Vec2i(330, 463), Vec2i(594, 200), render, blue, ybuffer);

我们可以看到,ybuffer[]中的每一个值,都被赋予了相同的值,也就是std::numeric_limits::min()。那么下面这句代码:

for (int i = 0; i < width; i++)
{
    ybuffer[i] = std::numeric_limits::min();
}

是一个赋初值的过程。就是先定义了ybuffer[]数组,然后给它赋int类型最小的初值
接下来,在rasterize()函数中,对于ybuffer[]数组,以x为下标,给每个以x为下标的ybuffer[]变量,赋予新的y值。
我的理解就是,ybuffer[]数组就像一元方程一样,取每一个特定的x值为下标,存储一个对应的特定的y值

void rasterize(Vec2i p0, Vec2i p1, TGAImage& image, TGAColor color, int ybuffer[])
{
    for (int x = p0.x; x <= p1.x; x++)
    {
        int y = p0.y * (1. - t) + p1.y * t + .5;

         if (ybuffer[x] < y)
        {
            ybuffer[x] = y;          // 这个ybuffer是个什么啊?用x的值做下标
            image.set(x, 0, color);  // 应用了ybuffer,y的值为0
        }

显而易见,我声明了一个具有尺寸为 的神奇数组 。

这个数组的初值为负无穷,然后通过ybuffer[]数组的加入来调用rasterize()函数,并将新定义的TGAImage类型的变量作为参数传入进去。

void rasterize (Vec2i p0, Vec2i p1, TGAImage &image, TGAColor color, int ybuffer[])
{
    if (p0.x > p1.x)
        std::swap(p0, p1);
    
    for (int x = p0.x; x <= p1.x; x++)
    {
        float t = (x - p0.x) / (float) (p1.x - p0.x);
        int y = (1. - t) * p0.y + t * p1.y;
        
        if (ybuffer[x] < y)
        {
            ybuffer[x] = y; // ybuffer[x]和y有什么关系?有ybuffer[y]吗?
            image.set(x, 0, color); 
        }
    }
}

下来我来解释下上述的代码:

  • 遍历p0.x和p1.x之间的所有x坐标,并计算相应的线段的y坐标
  • 检查数组ybuffer[]的下标是当前的x索引后,最终得到了什么。
  • 如果通过Bresham直线算法得到的当前y值比ybuffer[]中原先的y值更小,意味着更接近相机,那么就将其绘制在屏幕上,并更新ybuffer

好,我们现在一步一步看一下这个渲染的过程
在调用第一行(红色)段的rasterize()后,下图就是我们的内存——

screen-red:


screen-red

ybuffer-red:

ybuffer-red

ybuffer-red图中两边的品红色表示负无穷大,这些品红色的部分和screen-red图中的黑色部分是相对应的,黑色的部分就是画红色线段没有触及到的区域

其余所有部分以灰色阴影显示——

  • 如果这个灰色比较清晰,比较淡,则说明靠近相机
  • 如果这个灰色颜色比较深,则说明离相机比较远。

好,那么在进入到画绿色线段之前,我们给大家一个彩蛋——上图screen-red是经过放大过的,那么放大之前是什么样的呢——

未放大的screen-red图,注意看呢条细细的红线

放大像素的代码——

        for (int i = 0; i < width; i++)
        {
            for (int j = 1; j < 16; j++) // 这个j为何从1开始?
            {
                render.set(i, j, render.get(i, 0)); // 抽空得好好看看set()和get()函数
            }
        }

下来大家还需要画绿色线段:

screen-green:

screen-red+green

ybuffer-green:

ybuffer-green

最后则是画蓝色线段:

screen-blue:

screen-red-green-blue

ybuffer-blue:

ybuffer-blue

通过上面的3张y-buffer图我们可以发现,或者说我的问题,就是中间灰色区域深浅不定,也就是离camera的距离忽远忽近,不知道是什么意思。

以上,我们是在1D的屏幕上画了一个2D的场景

我们来分析一下这个2D场景和1D屏幕分别由什么生成——

  • 1D屏幕我理解的就是只有x坐标,没有y,所以它的生成是rasterize()函数中的image.set()。
  • 2D场景我理解的则是有x和y坐标,这个就是main()函数中的TGAImage render(width, 16, TGAImage::RGB)
    不过值得注意的是在放大像素嵌套for循环的代码中,render.get(i, 0)中的y也设置为了0,而且get()是返回一个color值的。我个人不理解为什么呢个要设置为0,姑且先猜测为为了符合对应rasterize()函数中的y=0的条件吧!
  • 而且为什么说是在1D屏幕上画了一个2D场景
  • 而不是说在一个2D屏幕上画了一个1D场景

四、回到3D

为了在二维屏幕上绘图,z缓冲区必须是二维的。

int *zbuffer = new int[width * height];

我将二维缓冲区打包为一维转换的操作是很简单的:

int idx = x + y * width;

好,idx定义完后,看看怎么定义x和y

int x = idx % width;
int y = idx / width;

然后,在代码中,我简单地遍历所有三角形,并使用当前三角形和对z缓冲区的引用调用光栅化器函数rasterizer()

唯一的困难如何计算我们想要绘制的像素的z值

大家可以先回想一下如何在y缓冲区的示例中计算y值

int y = p0.y * (1. - t) + p1.y * t;

上面code中,t变量的本质是什么?

事实证明:(1-t, t)是点(x,y)关于线段p0、p1的重心坐标:

因此,一个比较好的想法是采用三角形栅格化重心坐标版本,对于想要绘制的每个像素,简单地将其重心坐标乘以我们栅格化后的三角形顶点的z值

triangle (screen_coords, float *zbuffer, image, TGAColor(intensity * 255, intensity * 255, intensity * 255, 255));

[...]

void triangle (Vec3f *pts, float *zbuffer, TGAImage &image, TGAColor color)
{
    // buffer和points之间到底是个什么样的关系呢?
    Vec2f bboxmin(std::numeric_limits::max, std::numeric_limits::max());
    Vec2f bboxmax(-std::numeric_limits::max, -std::numeric_limits::max());
    
    Vec2f clamp(image.get_width() - 1, image.get_height() - 1); // 这个clamp函数到底是干嘛的?
    
    for (int i = 0; i < 3; i++)
    {
        for (int j = 0; j < 2; j++)
        {
            bboxmin[j] = std::max(0.f,      std::min(bboxmin[j], pts[i][j]));
            bboxmax[j] = std::min(clamp[j], std::max(bboxmax[j], pts[i][j]));
            // 0.f 是什么鬼啊?
            // bboxmax和pts比较大小
        }
    }
    Vec3f P;
    // for (P.x = bboxmin; P.x <= bboxmax; P.x++)
    // 边界值bboxmin和bboxmax还分x和y吗?
    for (P.x = bboxmin.x; P.x <= bboxmax.x; P.x++)
    {
        for (P.y = bboxmin.y; P.y <= bboxmax.y; P.y++)
        {
            Vec3f bc_screen = barycentric(pts[0], pts[1], pts[2], P);
            if (bc_screen.x < 0 || bc_screen.y < 0 || be_screen.z < 0)
                continue;
            // bc_screen也有x y z三个方向的分量
            
            P.z = 0; // 为什么把P点的坐标的z值分量设为0?而且P是什么型的?int还是float?
            
            for (int i = 0; i < 3; i++)
            {
                P.z += pts[i][2] * bc_screen[i]; // pts[i][2]中的2对应的就是z值向量
                                          // 这里面bc_screen是个数组?但为啥没xyz分量
            }
            
            if (zbuffer[int(P.x + P.y * width)] < P.z) // P.x不乘任何东西,P.y乘width
                                                       // 在zbuffer中的下标[]进行操作
            {
                zbuffer[int(P.x + P.y * width)] = P.z; // 前面ybuffer也这么整的
                image.set(P.x, P.y, color);
            }
        }
    }
}

上述的代码大家可以看到。对上一课的源代码所做的更改非常少,从而就实现了抛弃隐藏的部分,这非常棒!


渲染效果如下:

去除隐藏部分后

五、除了插入Z值,还能做什么?

在.obj文件中,具有以“ vt u v”开头的行,它们给出了纹理坐标数组。小平面线“ f x / x / x x / x / x x / x / x”中中间(斜线之间)的数字是此三角形此顶点的纹理坐标。将其插入三角形内,乘以纹理图像的宽度-高度,您将获得要放入渲染中的颜色。

漫反射纹理的.tga文件可在这里下载。

得到的结果如下:

加入质地后的效果

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

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

  • tgaimage.h

(初始导入)->(新光栅化器+z-buffer)

  • tgaimage.cpp

(初始导入)->(新光栅化器+z-buffer)

  • african_head.obj

(线框渲染)->(新光栅化器+z-buffer)

  • geometry.h

(线框渲染)->(新光栅化器+z-buffer)

  • main.cpp

(朴素线段追踪)->(线段追踪、减少划分的次数)->(线段追踪:all integer Bresenham)->(线框渲染)->(better test triangles)->(三角形绘制routine)->(背面剔除 + 高洛德着色)->(y-buffer!)->(新光栅化器+z-buffer)

  • model.cpp

(线框渲染)->(新光栅化器+z-buffer)

  • model.h

(线框渲染)

解释一下上述文件括号中的文字——
model.h一直没变,还是线框渲染
model.cpp、geometry.h、.obj文件、tgaimage.h/.cpp均由线框渲染or初始导入变成了新光栅化器+z-buffer
main.cpp变化是两次,第一次先是更新成了y-buffer,最后则是变成了新光栅化器+z-buffer
其中model时用来test测试的。

你可能感兴趣的:(实现OpenGL渲染器原理篇(三)——隐藏面移除(z缓冲区))