Lesson 2 : Triangle rasterization and back-face culling
本节内容:实现三角形光栅化算法,以及进行简单的背面剔除(Back face culling)。
在上节内容中,我们实现了画直线的算法,帮助我们实现了线框模式的绘制;然而,我们都知道图形渲染的基本图元是三角形。每个模型都可以被分解成无数个小三角形;而光栅化的操作即是将这一个个小三角形以像素形式绘制在屏幕上的过程。作为这一切最基本的操作,我们首先要搞明白将一个三角形光栅化的方法。
Filling triangles
首先我们要明白一个事情:本质上三角形不过是由三条直线组成的。因此,想要画出一个空心的三角形实际上很简单,我们只需要绘制三条直线:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
//画三条直线
line3(t0, t1, image, color);
line3(t1, t2, image, color);
line3(t2, t0, image, color);
}
这样我们可以得到一个空心的三角形。然而,摆在我们面前的问题是,我们要怎么将这个三角形填满颜色呢??
本节我们将用到以下测试用例:
Vec2i t0[3] = {Vec2i(10, 70), Vec2i(50, 160), Vec2i(70, 80)};
Vec2i t1[3] = {Vec2i(180, 50), Vec2i(150, 1), Vec2i(70, 180)};
Vec2i t2[3] = {Vec2i(180, 150), Vec2i(120, 160), Vec2i(130, 180)};
triangle(t0[0], t0[1], t0[2], image, red);
triangle(t1[0], t1[1], t1[2], image, white);
triangle(t2[0], t2[1], t2[2], image, green);
如果我们最后成功,这三个三角形应该长这样:
下面我们开始探索三角形光栅化算法吧。
Old-school method: Line sweeping
我们来探讨一个很朴素的想法:我们去确定三角形的边界,然后根据这个边界一行一行去扫描整个三角形,将每一个在里面的像素都填上颜色。这可不可行?
可行!这个想法具体化之后大概是下面的意思:
- 将三角形的三个顶点按y坐标从小到大排序
- 绘制出三角形“左边”的边界和“右边”的边界
- 在边界之内,从下到上绘制一条条直线
给出三个按y坐标排序好的点t0、t1、t2,我们定义从t0到t2的那条边为“左边”,t0到t1和t1到t2的边为“右边”。
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
//冒泡排序的思想。
if (t0.y>t1.y) std::swap(t0, t1);
if (t0.y>t2.y) std::swap(t0, t2);
if (t1.y>t2.y) std::swap(t1, t2);
line(t0, t1, image, green);
line(t1, t2, image, green);
line(t2, t0, image, red);
}
画出了一张这样的图:
红色的是“左边”,绿色的是"右边"。我们观察到"右边"是由两条边组成的,所以我们应该将整个三角形的光栅化分成两个部分:上部和下部。下面是只画出下半部分的代码:
void triangle(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage &image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
if (t0.y>t1.y) std::swap(t0, t1);
if (t0.y>t2.y) std::swap(t0, t2);
if (t1.y>t2.y) std::swap(t1, t2);
int total_height = t2.y-t0.y;
for (int y=t0.y; y<=t1.y; y++) {
int segment_height = t1.y-t0.y+1;
float alpha = (float)(y-t0.y)/total_height;
float beta = (float)(y-t0.y)/segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2-t0)*alpha;
Vec2i B = t0 + (t1-t0)*beta;
image.set(A.x, y, red);
image.set(B.x, y, green);
}
}
画出的图:
可能会注意到有几条边出现了离散化的现象,但是这无伤大雅,因为当我们将三角形内部也光栅化的时候,这些边界自然会消失,所以无须担心。
那么,接下来的事情就很简单了,我们只需要将上半部分也绘制出来,然后再在每个部分的绘制过程中,加上将每一行都填满像素的代码,就完成了一个三角形的光栅化。代码如下:
//old-school method: line sweeping
//alpha边不需要分段,只需要按着beta边绘制的同时绘制一次即可
//rst是我给光栅化算法的命名空间
void rst::triangle_line_sweeping(Vec2i t0, Vec2i t1, Vec2i t2, TGAImage& image, TGAColor color) {
// sort the vertices, t0, t1, t2 lower−to−upper (bubblesort yay!)
if (t0.y > t1.y) std::swap(t0, t1);
if (t0.y > t2.y) std::swap(t0, t2);
if (t1.y > t2.y) std::swap(t1, t2);
int total_height = t2.y - t0.y;
//绘制下半部分
for (int y = t0.y; y <= t1.y; y++) {
int segment_height = t1.y - t0.y + 1;
float alpha = (float)(y - t0.y) / total_height;
float beta = (float)(y - t0.y) / segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2 - t0) * alpha;
Vec2i B = t0 + (t1 - t0) * beta;
image.set(A.x, y, red);
image.set(B.x, y, green);
if (A.x > B.x) std::swap(A, B);
//绘制横向的直线,填满三角形内部
for (int j = A.x; j <= B.x; j++) {
image.set(j, y, color); // attention, due to int casts t0.y+i != A.y
}
}
//绘制上半部分
for (int y = t1.y; y <= t2.y; y++) {
int segment_height = t2.y - t1.y + 1;
float alpha = (float)(y - t0.y) / total_height;
float beta = (float)(y - t1.y) / segment_height; // be careful with divisions by zero
Vec2i A = t0 + (t2 - t0) * alpha;
Vec2i B = t1 + (t2 - t1) * beta;
if (A.x > B.x) std::swap(A, B);
//绘制横向的直线,填满三角形内部
for (int j = A.x; j <= B.x; j++) {
image.set(j, y, color); // attention, due to int casts t0.y+i != A.y
}
}
}
结果如下:
我们成功了。
作者在这个算法之后还进行了一次简化,但是以我个人的观点来看,既然是old-school方法,我们不该把太多的精力放在实现&优化这个算法上。因此关于这个算法的优化版,可以自行查阅原文。本文在此结束对line-sweeping算法的讨论。
The method I adopt for my code (bounding-box)
终于,我们来讨论一点up-to-date的东西吧!请看下面这段伪代码:
triangle(vec2 points[3]) {
vec2 bbox[2] = find_bounding_box(points);
for (each pixel in the bounding box) {
if (inside(points, pixel)) {
put_pixel(pixel);
}
}
}
这个算法的思想是:
- 先找出包围这个三角形的盒子(即Bounding box);
- 对于每个在包围盒内的像素,判断它在不在三角形内,如果在就进行绘制,反之不绘制。
就是这么简单。大道至简,这就是目前广泛使用的光栅化方法!关于这个思想,更详细的解释可以参考:计算机图形学三:直线光栅化的数值微分算法,中点Brensenham算法和三角形的光栅化中的三角形光栅化部分。
那么,要实现这个算法,我们自然要解决如上的两个问题。
首先第一个问题,如何找到包围盒?这太简单了,只要我们获取了三个点的坐标,就能得到最小的xy值和最大的xy值,由这两个点我们就已经形成了包围盒。当然,包围盒本身有更复杂的求法,比如凸包(Convex Hull),我们这样求得的包围盒应该算是AABB(Axis-Aligned Bounding Box);但是我们目前就使用这个包围盒来进行三角形的光栅化。
下一步,我们要判断一个点是否处在三角形内。要做到这点也有很多方法,比如说我们可以用GAMES101中首先提到的方法:
对于三角形的每个点,我们都使用两个向量:一个是和下一个点的连线(如AB向量),一个是和所求点的连线(AP)。我们计算这两个向量的点积,如果有任何一个点积小于0,就说明这个点在三角形外。注意围成三角形的向量一定要是顺时针或者逆时针排列,不能出现箭头指到一起的情况。
这是一个很朴素的思想。不过我们在这里要用一个更tricky一点的想法:重心坐标(barycentric coordinates)。关于重心坐标的基本概念请参考下面的博客。
在此就假设我们已经了解了重心坐标的基本概念。那么,我们怎么用这个东西来判断点在不在三角形内呢?
很简单:我们求出对应点的重心坐标,只要三个值中有任意一个值小于0,就说明这个点不在三角形内(是不是和上面的方法感觉很像?实际上的原理是差不多的)。关于重心坐标的求解,上面的博客也有详细解释,这里直接给出求解的公式:
对于
P
=
α
A
+
β
B
+
γ
C
P = \alpha A + \beta B + \gamma C
P=αA+βB+γC
有
由此我们就可以求出重心坐标了。给出代码如下:
//求解重心坐标
//输入:数组pts[3],指向一个顺序为点A、B、C的Vec2i数组
//
// P点为所求的重心坐标对应的点
Vec3f rst::barycentric(Vec2i pts[3], Vec2i P) {
int Xa = pts[0].x;
int Xb = pts[1].x;
int Xc = pts[2].x;
int Ya = pts[0].y;
int Yb = pts[1].y;
int Yc = pts[2].y;
float u1 = (float)Xa * Yb - Xb * Ya;
float u = ((Ya - Yb) * P.x + (Xb - Xa) * P.y + u1) / ((Ya - Yb) * Xc + (Xb - Xa) * Yc + u1);
float v1 = (float)Xa * Yc - Xc * Ya;
float v = ((Ya - Yc) * P.x + (Xc - Xa) * P.y + v1) / ((Ya - Yc) * Xb + (Xc - Xa) * Yb + v1);
float a = 1 - u - v;
return Vec3f(1 - u - v, u, v);
}
不得不说作者给出的代码我是完全用不起来,各种数组下标问题,指针问题都出现了,同时感觉也不是很直观,有兴趣的可以自己去看作者的实现,这段代码是我自己的理解,可能实现上比较暴力,但是至少我自己看得懂嘛。
有了这个函数,我们得以实现整个光栅化算法:
//标准的三角形光栅化算法
void rst::triangle(Vec2i pts[3], TGAImage& image, TGAColor color) {
//先求出bounding box 偷懒了直接用两个min\max嵌套
int minx = min(pts[0].x, min(pts[1].x, pts[2].x));
int maxx = max(pts[0].x, max(pts[1].x, pts[2].x));
int miny = min(pts[0].y, min(pts[1].y, pts[2].y));
int maxy = max(pts[0].y, max(pts[1].y, pts[2].y));
//两个for循环嵌套 这就是大规模并行计算的暴力解法?
for (int i = minx; i <= maxx; ++i) {
for (int j = miny; j <= maxy; ++j) {
Vec2i P(i, j);
Vec3f coord = barycentric(pts, P);
if (coord.x < 0 || coord.y < 0 || coord.z < 0) continue;
image.set(P.x, P.y, color);
}
}
}
这个算法也是自己实现的,以我自己能理解的方式呈现出来。想看作者源代码的可以参阅原文。
输入以下测试用例,我们得到的结果如下:
Vec2i pts[3] = { Vec2i(10,10), Vec2i(190, 160), Vec2i(100, 30) };
rst::triangle(pts, image, red);
简直完美啊。
Flat shading render
下面,我们尝试利用我们刚刚写出来的光栅化算法,来渲染整个模型!首先我们可以用随机的颜色来填充每一个三角形面片,看看我们的算法究竟能不能起效。代码如下:
//随机色彩的flat shading
for (int i = 0; i < model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec2i screen_coords[3];
for (int j = 0; j < 3; j++) {
Vec3f world_coords = model->vert(face[j]);
screen_coords[j] = Vec2i((world_coords.x + 1.) * width / 2., (world_coords.y + 1.) * height / 2.);
}
rst::triangle(screen_coords, image, TGAColor(rand() % 255, rand() % 255, rand() % 255, 255));
}
效果图:
我们的方法是有效的。
顺带一提,我们目前采取的这种着色方法称为Flat Shading,也就是我们对整个三角形面片都上一样的颜色;与之相对的还有Gouraud Shading、Phong Shading等方法,在此先不涉及。
下一步,我们尝试把这个模型变得更生动些。我们给他一个光照!终于有点渲染的感觉了!
我们给他一个方向为(0, 0, -1)的平行光,来展示模型上出现的明暗变化。值得说明的是,我们将在代码中用0-255的RGB值来近似表示亮度(Luminance)这个概念,但是实际上两者是不同的,需要经过一定的转换;而且,色彩的明暗还与色彩空间的相关概念有关。但是但是,我们在这里都先忽略,毕竟图形学嘛,看起来是对的就是对的,没有什么比看到自己的成果更激动人心了!
代码如下:
//设置平行光源进行简单的Flat shading & back face culling
void simpleShading(Model* model, int width, int height, Vec3f light_dir, TGAImage& image) {
for (int i = 0; i < model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec2i screen_coords[3];
Vec3f world_coords[3];
for (int j = 0; j < 3; j++) {
Vec3f v = model->vert(face[j]);
screen_coords[j] = Vec2i((v.x + 1.) * width / 2., (v.y + 1.) * height / 2.);
world_coords[j] = v;
}
//获取平面法向量
Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
n.normalize();
//计算法向量和光照的点乘
float intensity = n * light_dir;
//如果Intensity < 0,说明面片处于背面(摄像机看不到的位置),直接discard(不做渲染)
if (intensity > 0) {
rst::triangle(screen_coords, image, TGAColor(intensity * 255, intensity * 255, intensity * 255, 255));
}
}
}
可以看到我们顺便把背面剔除的工作也做了,仅仅需要加一个if语句,就能省下可能有50%的开销,而不会对最终成像有任何影响。多么神奇!
得出的图片如下:
很好的效果。到这里Lesson 2可以说结束了。
End…?
还没有。我本来也以为这完美了,直到我放大了这张图片,然后看到了这张图片不完美的瑕疵:
它有很多黑色的小洞!!!!
这里就不放放大图了,因为我真的觉得很恶心,浑身起鸡皮疙瘩…
对于这个引起我生理不适的问题,是一定要解决的。所幸,我很快在Issue#88页面上找到了我的答案:
Yup, floating point precision issues, no biggie. Try comparing with a small negative value (like -.01) here instead of zero:
https://github.com/atomicapple0/tinyrenderer/blob/7f51718971f009840851276bb449eecd3cb8ec60/main.cpp#L102
This will make the traingles a bit thicker, and hopefully filling the gaps.
总结一下,就是浮点数的精度问题,导致某些点意外地被认为在三角形外,所以没有做光栅化,形成了这种小黑点。解决方法是将我们比较的值设成一个小的负值,如-0.1。改进后的光栅化代码如下:
//标准的三角形光栅化算法
void rst::triangle(Vec2i pts[3], TGAImage& image, TGAColor color) {
//先求出bounding box 偷懒了直接用两个min\max嵌套
/* for (int i = 0; i < 2; ++i) {
if (pts[i].x > pts[i + 1].x) {
swap(pts[i].x, pts[i + 1].x)
}
}*/
int minx = min(pts[0].x, min(pts[1].x, pts[2].x));
int maxx = max(pts[0].x, max(pts[1].x, pts[2].x));
int miny = min(pts[0].y, min(pts[1].y, pts[2].y));
int maxy = max(pts[0].y, max(pts[1].y, pts[2].y));
//两个for循环嵌套 这就是大规模并行计算的暴力解法?
for (int i = minx; i <= maxx; ++i) {
for (int j = miny; j <= maxy; ++j) {
Vec2i P(i, j);
Vec3f coord = barycentric(pts, P);
//optimization for small black holes (REALLY DISGUSTING)
if (coord.x < -.01 || coord.y < -.01 || coord.z < -.01) continue;
image.set(P.x, P.y, color);
}
}
}
结果如下:
心情舒畅!!!
Lesson 2到此完成。