上一回我们画出了一个茶壶的雏形,现在我们把茶壶打扮得更好看一点。
之前画茶壶的时候,我们把背面的三角形都裁剪掉了,正面的三角形一股脑都画了出来,而壶盖的边缘处于壶身内部,应该被壶身遮挡,不应该被画出来。如何判断什么应该画,什么不该画呢?有一个算法称为画家算法(Painter's algorithm),含义就是一个画家在画画的时候,会先画最远的物体,再画近一点的物体,这样就可以遮住最远的物体,最后画距离最近的物体,这样就可以遮住其他所有物体。然而画家算法无法处理物体相互穿插的情况:
比如说这三个长方形无论如何排序,用画家算法画出来的重叠部分都是有问题的。
这是因为画家算法是按照物体排序的。为了能够正确地画出每个点,我们需要对每个像素进行排序,这就需要有一个地方记录每个像素点的深度信息,这个地方就成为深度缓冲。
深度缓冲
既然要记录每个像素的深度信息,我们先开辟一个存储空间:
int* zbuffer = new int[SCREEN_WIDTH * SCREEN_HEIGHT];
然后把深度缓冲里的值都设为无限远(因为每帧都要更新图像,所以深度缓冲每帧都要初始化一次):
for (int i = SCREEN_WIDTH * SCREEN_HEIGHT; i--; zbuffer[i] = -std::numeric_limits<float>::max());
方便起见,这句代码直接放在ShowObjShaded函数里。同时改造一下ShowObjShaded函数,把顶点的z坐标一起传递给绘制三角形的triangle函数:
void ShowObjShaded()
{
//Depth Buffer
for (int i = SCREEN_WIDTH * SCREEN_HEIGHT; i--; zbuffer[i] = -std::numeric_limits<float>::max());
tinyobj::attrib_t attrib;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string err;
bool ret = tinyobj::LoadObj(&attrib, &shapes, &materials, &err, "../Assets/utah-teapot.obj", NULL, true);
if (!ret) {
printf("Failed to load/parse .obj.\n");
return;
}
Vec3f light_dir(0, 0, -1);
for (size_t i = 0; i < shapes.size(); i++) {
size_t index_offset = 0;
assert(shapes[i].mesh.num_face_vertices.size() == shapes[i].mesh.material_ids.size());
// For each face
for (size_t f = 0; f < shapes[i].mesh.num_face_vertices.size(); f++) {
size_t fnum = shapes[i].mesh.num_face_vertices[f];
Vec3f screen_coords[3];
Vec3f world_coords[3];
// For each vertex in the face
for (size_t v = 0; v < fnum; v++) {
tinyobj::index_t idx = shapes[i].mesh.indices[index_offset + v];
int x0 = (attrib.vertices[3 * idx.vertex_index + 0]) * 8 + SCREEN_WIDTH / 2.;
int y0 = (attrib.vertices[3 * idx.vertex_index + 1]) * 8 + SCREEN_HEIGHT / 2;
float z0 = attrib.vertices[3 * idx.vertex_index + 2];
screen_coords[v] = Vec3f(x0, y0, z0);
world_coords[v] = Vec3f(attrib.vertices[3 * idx.vertex_index + 0], attrib.vertices[3 * idx.vertex_index + 1], attrib.vertices[3 * idx.vertex_index + 2]);
}
Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
n.normalize();
float intensity = n * light_dir;
if (intensity > 0) {
float c = intensity * 255;
SDL_SetRenderDrawColor(gRenderer, c, c, c, 0xFF);
triangle(screen_coords);
}
index_offset += fnum;
}
}
}
在triangle函数里面,我们把每个顶点的z坐标作为顶点的深度,那么在填充三角形的时候,怎么得到每个像素的深度呢?这就要靠之前提到的重心坐标系了。在[从零开始软渲染1-直线和三角形]里提到过,重心坐标系可以帮助我们对顶点属性进行插值,深度也是同理,我们用重心坐标乘上每个顶点的深度,就可以得到对应像素的深度了。画每个像素点之前,先把像素的深度和深度缓冲里的值比较一下,只有像素的深度更大的时候才会更新深度缓冲,同时把这个像素画出来。
void triangle(Vec3f* pts) {
Vec2f bboxmin(SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1);
Vec2f bboxmax(0, 0);
Vec2f clamp(SCREEN_WIDTH - 1, SCREEN_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, P);
if (bc_screen.x < 0 || bc_screen.y < 0 || bc_screen.z < 0) continue;
P.z = pts[0][2] * bc_screen[0] + pts[1][2] * bc_screen[1] + pts[2][2] * bc_screen[2];
if (zbuffer[int(P.x + P.y * SCREEN_WIDTH)] < P.z)
{
zbuffer[int(P.x + P.y * SCREEN_WIDTH)] = P.z;
SDLDrawPixel(P.x, P.y);
}
}
}
}
因为triangle函数的参数变了,所以传递给barycentric函数的参数也变了,所以barycentric函数也稍微改一下:
Vec3f barycentric(Vec3f* pts, Vec3f P) {
Vec3f u = cross(Vec3f(pts[2][0] - pts[0][0], pts[1][0] - pts[0][0], pts[0][0] - P[0]), Vec3f(pts[2][1] - pts[0][1], pts[1][1] - pts[0][1], pts[0][1] - P[1]));
if (std::abs(u[2]) < 1e-2) return Vec3f(-1, 1, 1);
return Vec3f(1.f - (u.x + u.y) / u.z, u.y / u.z, u.x / u.z);
}
我们来看一下现在的茶壶:
有了深度缓冲之后,可以看到,所有像素的排序都正确了。
纹理贴图
我们的茶壶光是黑白灰,太单调了,现在我们来给点颜色瞧瞧。
为了能够给茶壶贴上纹理,首先我们得能够读取图片里的颜色值。图片的格式繁多,简便起见,我们这里直接用一个现成的轮子:https://github.com/ssloy/tinyrenderer/blob/master/tgaimage.cpp
先初始化一下TGAImage:
TGAImage tgaImage = TGAImage();
tgaImage.read_tga_file("../Assets/brick3.tga");
然后在ShowObjShaded函数里拿到顶点的纹理坐标:
void ShowObjShaded()
{
//Depth Buffer
for (int i = SCREEN_WIDTH * SCREEN_HEIGHT; i--; zbuffer[i] = -std::numeric_limits<float>::max());
tinyobj::attrib_t attrib;
std::vector<tinyobj::shape_t> shapes;
std::vector<tinyobj::material_t> materials;
std::string err;
bool ret = tinyobj::LoadObj(&attrib, &shapes, &materials, &err, "../Assets/utah-teapot.obj", NULL, true);
if (!ret) {
printf("Failed to load/parse .obj.\n");
return;
}
Vec3f light_dir(0, 0, -1);
for (size_t i = 0; i < shapes.size(); i++) {
size_t index_offset = 0;
assert(shapes[i].mesh.num_face_vertices.size() == shapes[i].mesh.material_ids.size());
// For each face
for (size_t f = 0; f < shapes[i].mesh.num_face_vertices.size(); f++) {
size_t fnum = shapes[i].mesh.num_face_vertices[f];
Vec3f screen_coords[3];
Vec3f world_coords[3];
Vec2f uv_coords[3];
// For each vertex in the face
for (size_t v = 0; v < fnum; v++) {
tinyobj::index_t idx = shapes[i].mesh.indices[index_offset + v];
int x0 = (attrib.vertices[3 * idx.vertex_index + 0]) * 8 + SCREEN_WIDTH / 2.;
int y0 = (attrib.vertices[3 * idx.vertex_index + 1]) * 8 + SCREEN_HEIGHT / 2;
float z0 = attrib.vertices[3 * idx.vertex_index + 2];
screen_coords[v] = Vec3f(x0, y0, z0);
world_coords[v] = Vec3f(attrib.vertices[3 * idx.vertex_index + 0], attrib.vertices[3 * idx.vertex_index + 1], attrib.vertices[3 * idx.vertex_index + 2]);
float u0 = attrib.texcoords[2 * idx.texcoord_index + 0];
float v0 = attrib.texcoords[2 * idx.texcoord_index + 1];
uv_coords[v] = Vec2f(u0 - (int)u0, v0 - (int)v0);
}
Vec3f n = (world_coords[2] - world_coords[0]) ^ (world_coords[1] - world_coords[0]);
n.normalize();
float intensity = n * light_dir;
if (intensity > 0) {
triangle(screen_coords, uv_coords, intensity);
}
index_offset += fnum;
}
}
}
相应的,triangle也需要改动一下,同理,用重心坐标插值纹理坐标,然后从纹理中把对应的颜色读取出来,乘上我们之前计算的光照值:
void triangle(Vec3f* pts, Vec2f* uvs, float c) {
Vec2f bboxmin(SCREEN_WIDTH - 1, SCREEN_HEIGHT - 1);
Vec2f bboxmax(0, 0);
Vec2f clamp(SCREEN_WIDTH - 1, SCREEN_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, P);
if (bc_screen.x < 0 || bc_screen.y < 0 || bc_screen.z < 0) continue;
P.z = pts[0][2] * bc_screen[0] + pts[1][2] * bc_screen[1] + pts[2][2] * bc_screen[2];
if (zbuffer[int(P.x + P.y * SCREEN_WIDTH)] < P.z)
{
zbuffer[int(P.x + P.y * SCREEN_WIDTH)] = P.z;
Vec2f uv = uvs[0] * bc_screen[0] + uvs[1] * bc_screen[1] + uvs[2] * bc_screen[2];
TGAColor tgaColor = tgaImage.get(uv[0] * tgaImage.get_width(), uv[1] * tgaImage.get_height()) * c;
SDL_SetRenderDrawColor(gRenderer, tgaColor[2], tgaColor[1], tgaColor[0], 0xFF);
SDLDrawPixel(P.x, P.y);
}
}
}
}
来看看我们的茶壶吧:
目前的茶壶还有一点美中不足的地方,那就是壶盖太暗了。为什么会这么暗呢?因为我们使用的兰伯特(Lambert)光照模型,壶盖部分的法线方向和光照方向夹角太大了。如何改善这种表现呢?可以使用半兰伯特(Half Lambert)光照模型。半兰伯特是在原有的兰伯特上进行了一个简单的修改,把原来通过兰伯特模型计算出来的颜色加上缩放和偏移:
float intensity = (n * light_dir) * 0.5f + 0.5f;
我们这里把缩放和偏移都定位0.5,相当于把原来结果范围从[-1,1]映射到了[0,1],这样原先的暗部就得到了增强。
最后附上代码:
https://github.com/LittleLittleWind/TaurusSoftRenderer/tree/SimpleRenderer