实现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<int>:min(); // numeric_limits<int>是什么?
}

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<int>::min()。那么下面这句代码:

for (int i = 0; i < width; i++)
{
    ybuffer[i] = std::numeric_limits<int>::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
        }

显而易见,我声明了一个具有尺寸为 (width, 1)的神奇数组 ybuffer

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

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的重心坐标: (x,y) = p0*(1-t) + p1*t

因此,一个比较好的想法是采用三角形栅格化重心坐标版本,对于想要绘制的每个像素,简单地将其重心坐标乘以我们栅格化后的三角形顶点的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<float>::max, std::numeric_limits<float>::max());
    Vec2f bboxmax(-std::numeric_limits<float>::max, -std::numeric_limits<float>::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测试的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容