从零开始软渲染3-深度缓冲和纹理贴图

本文介绍了在软渲染中如何使用深度缓冲解决物体遮挡问题,通过改造函数和利用重心坐标系插值计算像素深度,实现了正确的像素排序。接着,文章引入纹理贴图为茶壶增添色彩,详细讲解了读取图片颜色值的过程,并应用半兰伯特光照模型改进了暗部的表现。最后提供了代码链接供读者参考。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

上一回我们画出了一个茶壶的雏形,现在我们把茶壶打扮得更好看一点。

 

之前画茶壶的时候,我们把背面的三角形都裁剪掉了,正面的三角形一股脑都画了出来,而壶盖的边缘处于壶身内部,应该被壶身遮挡,不应该被画出来。如何判断什么应该画,什么不该画呢?有一个算法称为画家算法(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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值