TinyRenderer笔记1:Z-buffer和纹理插值

上一篇结尾渲染出了光照下的模型:

但是这个模型看起来有些奇怪,尤其是嘴巴的部分。因为渲染时候仅仅是按照从模型中读取的顶点信息,将三角形一个个的画了出来,但是并没有考虑三角形的遮挡关系。如果我们先画出了面部的三角形,然后又画了脑后勺的三角形,那最终展示出来的图形就会像上面一样很奇怪,原因是我们没有处理深度信息。

渲染模型是将3维映射到2维,先来考虑将2维映射到1维该怎么做。目标是把几条2d空间的线段,映射到x轴上。

如果我们以一维的视角从上往下看,看到的应该是一条彩色的线段,写一个函数来绘制这个线段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
pub fn resterize<I: GenericImage>(
    mut a: glm::IVec2,
    mut b: glm::IVec2,
    image: &mut I,
    color: I::Pixel,
) {
    if (a.x - b.x).abs() < (a.y - b.y).abs() {
        swap(&mut a.x, &mut a.y);
        swap(&mut b.x, &mut b.y);
    }
    for x in a.x..=b.x {
        for i in 0..image.height() {
            image.put_pixel(x as u32, i, color);
        }
    }
}

{
    draw::resterize(glm::ivec2(330, 463), glm::ivec2(594, 200), &mut image, BLUE);
    draw::resterize(glm::ivec2(120, 434), glm::ivec2(444, 400), &mut image,GREEN);
    draw::resterize(glm::ivec2(20, 34), glm::ivec2(744, 400), &mut image, RED);
}

绘制出来的线段长这样:

明显不太对,只看到了红色,因为我们是最后画的红色线段,红色线段又最长,所以直接覆盖了另外两条。

为了正确画出线段,我们需要知道每个像素的深度信息。在这个例子中,y坐标更大的像素不能被更小的像素覆盖。

引入y-buffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
pub fn resterize<I: GenericImage>(
    mut a: glm::IVec2,
    mut b: glm::IVec2,
    image: &mut I,
    ybuffer: &mut [i32],
    color: I::Pixel,
) {
    if (a.x - b.x).abs() < (a.y - b.y).abs() {
        swap(&mut a.x, &mut a.y);
        swap(&mut b.x, &mut b.y);
    }
    for x in a.x..=b.x {
        let t = (x - a.x) as f32 / (b.x - a.x) as f32;
        let y = a.y as f32 * (1. - t) + b.y as f32 * t;

        if ybuffer[x as usize] < y as i32 {
            ybuffer[x as usize] = y as i32;
            for i in 0..image.height() {
                image.put_pixel(x as u32, i, color);
            }
        }
    }
}

{
    let mut ybuffer = vec![0; 800];
    draw::resterize(glm::ivec2(330, 463), glm::ivec2(594, 200), &mut image, ybuffer, BLUE);
    draw::resterize(glm::ivec2(120, 434), glm::ivec2(444, 400), &mut image, ybuffer, GREEN);
    draw::resterize(glm::ivec2(20, 34), glm::ivec2(744, 400), &mut image, ybuffer, RED);
}

现在得到了正确的线段颜色:

详细代码见这里f23d87e4e34700767da9c6754d2b0aa6b37fe58f

回到3d,为了能映射到2d屏幕上,zbuffer需要两个维度:

1
let zbuffer = vec![0; width*height];

虽然需要的数组是二维的,但可以用一维数组来表示,只需要简单的变换:

1
2
3
4
let idx = x + y*width;

let x = idx % width;
let y = idx / wdith;

与ybuffer唯一的不同是如何计算z值,前面y值是这样计算的:

1
let y = p0.y*(1-t) + p1.y*t

上面的式子可以看作两个向量的点积: $(y_1, y_2) * (1-t, t)$,$(1-t, t)$ 其实是点x关于线段p0p1的重心坐标。

所以对于z值,可以用三角形三个顶点z坐标和重心坐标计算: $(z_1,z_2,z_3)*(1-u-v, u, v)$

在模型渲染的代码中引入zbuffer:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
pub fn triangle<I: GenericImage>(
    //...
    zbuffer: &mut [f32],
) {
    //...
    for px in bboxmin.x as i32..=bboxmax.x as i32 {
        for py in bboxmin.y as i32..=bboxmax.y as i32 {
            let bc_screen = barycentric(t0, t1, t2, glm::vec3(px as f32, py as f32, 0.));

            if bc_screen.x < 0. || bc_screen.y < 0. || bc_screen.z < 0. {
                continue;
            }
            // 计算z值
            let pz = glm::dot(glm::vec3(t0.z, t1.z, t2.z), bc_screen);
            let idx = px + py * image.width() as i32;
            if zbuffer[idx as usize] <= pz {
                zbuffer[idx as usize] = pz;
                image.put_pixel(px as u32, py as u32, color);
            }
        }
    }
}

{
    let mut zbuffer = vec![f32::MIN; (image.width() * image.height()) as usize]; // 注意一定初始化为最小值
    //...
    for arr in model.indices.chunks(3) {
        //...
        draw::triangle(sa,sb,sc,&mut image,
            Rgba([(255. * intensity) as u8, (255. * intensity) as u8 ,(255. * intensity) as u8, 255]),
            &mut zbuffer,
        );
    }
}

这样就能正确渲染了,效果如下:

详细代码见这里7bd6b1b50f602336a3fbc8d345399c5f9872a19d

前面渲染的模型没有皮肤,都是用白色的强度来表示光照,接下来要进行纹理插值,通过纹理坐标算出每个像素应该是什么颜色。

在模型文件里,有些行是这样的格式: vt u v 0.0 它给出了一个纹理(顶点)坐标。

这些行f x/x/x x/x/x x/x/x 描述了一个面,每组数据(按/分割)中间的x,就是三角形该顶点的纹理坐标编号。

根据纹理坐标对三角形进行插值,乘以纹理图像的宽度-高度,就会得到要渲染的颜色。

作者是通过扫描线的方式计算纹理坐标的,我尝试了一下用重心坐标来计算,zbuffer那里给了我启发,没想到真的可以,体会下重心坐标的神奇。

首先从这里可以下载作者提供的纹理图片。先读取图片:

1
2
let mut diffus = image::open("obj/african_head/african_head_diffuse.tga").unwrap().to_rgba8();
let diffuse = flip_vertical_in_place(&mut diffus);

因为我们之前翻转了y轴,所以也需要将纹理图片翻转一下。

接下来写一个新的函数来画三角形。上面zbuffer部分,我们用重心坐标算出了z值,现在我们有了三角形三个顶点的纹理坐标,同样可以用重心坐标 算出当前位置的纹理坐标:

$$ \begin{aligned} x &= (x_1, x_2, x_3) * barycentric \\ y &= (y_1, y_2, y_3) * barycentric \end{aligned} $$

根据上面式子算出坐标后,放大到纹理图片比例即可:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
pub fn triangle_with_texture<I: GenericImage<Pixel = Rgba<u8>>>(
    a: glm::Vec3, // 模型顶点
    b: glm::Vec3,
    c: glm::Vec3,
    ta: glm::Vec3, // 纹理坐标顶点
    tb: glm::Vec3,
    tc: glm::Vec3,
    image: &mut I,
    intensity: f32,
    zbuffer: &mut [f32],
    diffuse: &I, // 纹理图片
) {
    //...
    for px in bboxmin.x as i32..=bboxmax.x as i32 {
        for py in bboxmin.y as i32..=bboxmax.y as i32 {
            let bc_screen = barycentric(a, b, c, glm::vec3(px as f32, py as f32, 0.));

            if bc_screen.x < 0. || bc_screen.y < 0. || bc_screen.z < 0. {
                continue;
            }
            // 计算z值
            let pz = glm::dot(glm::vec3(a.z, b.z, c.z), bc_screen);

            // 计算纹理坐标
            let tx = glm::dot(glm::vec3(ta.x, tb.x, tc.x), bc_screen) * diffuse.width() as f32;
            let ty = glm::dot(glm::vec3(ta.y, tb.y, tc.y), bc_screen) * diffuse.height() as f32;
            let idx = px + py * image.width() as i32;
            let pi: Rgba<u8> = diffuse.get_pixel(tx as u32, ty as u32); // 获取像素
            if zbuffer[idx as usize] <= pz {
                zbuffer[idx as usize] = pz;
                image.put_pixel(
                    px as u32,
                    py as u32,
                    Rgba([
                        (pi.0[0] as f32 * intensity) as u8,
                        (pi.0[1] as f32 * intensity) as u8,
                        (pi.0[2] as f32 * intensity) as u8,
                        255,
                    ]),
                );
            }
        }
    }
}

效果如下:

详细代码见这里dd09711edb4fa1dcb129ccce64959a0fec749f42