最近在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
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:
ybuffer-red:
ybuffer-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:
ybuffer-green:
最后则是画蓝色线段:
screen-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测试的。