本文主要内容为讲解tiny renderer的第三课,Z-buffer(深度缓冲)。
稍微观察一下上一篇文章经过填充和光照处理的人脸模型,总感觉看起来哪里不对劲。在眼部和嘴巴的地方存在错误的遮挡关系,口腔内部居然覆盖了嘴唇,这看上去非常的不真实。幸运的是,Z-buffer可以完美的解决这种不正确的现象。
画家算法
从最简单的原始方法开始。我们可以想象一下在画家在创造一幅画的时候是怎么处理这种前后关系的:画家一般会从远方的景象开始绘制,由远及近,近处的景象会直接覆盖远处的景象,这样一层一层的绘制,直到最终完成。我们当然也可以在计算机里模拟这种方法,虽然有着不错的效果,但是不幸的是,这种算法需要耗费巨大的计算量。每当我们换一个角度观察物体的时候,我们都需要对当前的场景从头到尾重新绘制一边(可以理解为在不同的角度,画家都需要从零开始,画一张新的画)。
在现在这个需要动态绘制大量场景的背景下,这种算法显然过于消耗计算成本。
从简单场景开始
假设有以下三个不同颜色三角形相交的模型:
我们从上往下进行投影,看到的会是下图这个样子。如果我们继续使用画家算法,从远到近进行绘制,那么问题来了,以红色的三角形举例,它是在蓝色的前面还是后面呢?也就是说作为一名画家,我也不知道应该先画红色还是蓝色的三角形。
更简单的例子
Z-buffer听名字好像是要考虑三个维度的方法,我们不妨再简化一下,只考虑两个维度,把这个简化后的称为Y-buffer:
现在我们不在考虑对所有三角形的平面进行投影,而是只考虑过三个三角形顶点的这个平面(也可以称之为线)。这样,我们将原来的一个三维空间,仅仅保留黄色的这一个二维平面上。
而这个二维平面上的线段,我们可以使用line函数非常方便地绘制在我们地屏幕上。
// 三角形简化后的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);
// 投影线(屏幕)
line(Vec2i(10, 10), Vec2i(790, 10), scene, white);
现在我们的任务是将红绿蓝三条线段投影到白色地线段上,并且保持正确的前后(上下)关系。 (为了方便观看,我们将投影后的屏幕宽度稍微拉伸一下)在主函数中,我们先定义一个渲染的成图render,然后定义一个Y-buffer,大小为width×1,初始化为无限小。然后用rasterize栅格化我们的红绿蓝三条线段:
TGAImage render(width, 16, TGAImage::RGB);
int ybuffer[width];
for (int i = 0; i < width; i++) {
ybuffer[i] = std::numeric_limits<int>::min();
}
rasterize(Vec2i(20, 34), Vec2i(744, 400), render, red, ybuffer);
rasterize(Vec2i(120, 434), Vec2i(444, 400), render, green, ybuffer);
rasterize(Vec2i(330, 463), Vec2i(594, 200), render, blue, ybuffer);
我们从遍历p0和p1点之间的所有x坐标,并计算出x点对应的y坐标,然后检查当前x位置对应的Y-buffer存储的值,如果当前的y值更大,说明该点距离视点更近,所以需要将这个点绘制在屏幕上并且更新Y-buffer的值。
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 = p0.y * (1. - t) + p1.y * t + .5;
if (ybuffer[x] < y) {
ybuffer[x] = y;
image.set(x, 0, color);
}
}
}
最后得到的绘制结果如下图,可以看到完美的保证了线段的前后关系。
回到三维情况
如果想从绘制线段扩展到绘制三角形,那么Z-buffer必须是二维的,用来记录投影后的二维平面上所有点的深度值
int *zbuffer = new int[width*height];
但是为了方便地操作Z-buffer,一般将索引定义为一维
int idx = x + y*width;
如果想变换回来也很简单
int x = idx % width;
int y = idx / width;
现在的问题是,如何记录Z-buffer中记录的z值呢?z值代表当前点在三维空间中的深度,在已经知道三角形三个顶点深度值的情况下,可以使用重心坐标求出其他任意点的z值。
// 求重心坐标
Vec3f barycentric(Vec3f A, Vec3f B, Vec3f C, Vec3f P) {
Vec3f s[2];
for (int i = 2; i--; ) {
s[i][0] = C[i] - A[i];
s[i][1] = B[i] - A[i];
s[i][2] = A[i] - P[i];
}
Vec3f u = cross(s[0], s[1]);
if (std::abs(u[2]) > 1e-2) // dont forget that u[2] is integer. If it is zero then triangle ABC is degenerate
return Vec3f(1.f - (u.x + u.y) / u.z, u.y / u.z, u.x / u.z);
return Vec3f(-1, 1, 1); // in this case generate negative coordinates, it will be thrown away by the rasterizator
}
// 绘制三角形
void triangle(Vec3f* pts, float* zbuffer, TGAImage& image, TGAColor color) {
// 获取三角形的最小外接矩形
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);
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]));
}
}
Vec3f P;
// 遍历外接矩形内部的所有点
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 || bc_screen.z < 0) continue;
P.z = 0;
for (int i = 0; i < 3; i++) P.z += pts[i][2] * bc_screen[i]; // 计算z值
if (zbuffer[int(P.x + P.y * width)] < P.z) {
zbuffer[int(P.x + P.y * width)] = P.z;
image.set(P.x, P.y, color);
}
}
}
}
作者在GitHub对应章节中的代码使用的是随机颜色渲染,结果应该是下面这个样子的。
但是在文章中的结果图很明显是考虑了光照强度的因素,因此这里对主函数中的代码进行稍微修改,增加了光线以及三角形的法向量,用于计算每个像素的亮度
for (int i = 0; i < model->nfaces(); i++) {
std::vector<int> face = model->face(i);
Vec3f screen_coords[3]; // 屏幕坐标
Vec3f world_coords[3]; // 世界坐标
for (int j = 0; j < 3; j++) {
Vec3f v = model->vert(face[j]);
screen_coords[j] = world2screen(model->vert(face[j]));
world_coords[j] = v;
}
// 三角形法线(叉乘)
Vec3f n = cross((world_coords[2] - world_coords[0]), (world_coords[1] - world_coords[0]));
n.normalize();
Vec3f pts[3];
float intensity = n * light_dir;
if (intensity > 0) {
triangle(screen_coords, zbuffer, image, TGAColor(intensity * 255, intensity * 255, intensity * 255, 255));
}
}
得到如下正确效果: