Posted on

Table of Contents

Goal

This time, we will further enhance our small GUI program to draw two solid triangles and manage occlusion by using Z-buffering. Furthermore, we will apply a simple super-sampling technique to achieve softer edges.

Triangle

Last time, our Triangle class didn't do much except hold our triangle information. But this time, we will add some utilities related to triangles.

Transform

First, our transform will be applied triangle-wise. So, we need to move the mvp_transform and viewport_transform to the Triangle class.

This function will accept mvp and viewport matrices from the rasterizer and generate a new Triangle instance.

    pub fn apply_transform(&mut self, mvp: &Mat4, viewport: &Mat4) -> Self {
        let vertices:Vec<Vec4> = self
            .vertices
            .iter()
            .map(|vertex:&Vec4| {
                let vertex: Vec4 = Self::mvp_transform(mvp, &vertex);
                let vertex:Vec4=vertex/vertex.w; // normalization
                Self::viewport_transform(viewport, &vertex)
            })
            .collect();
        let color = self.color;
        Self {vertices,color}
    }

Axis-aligned minimum bounding box (AABB)

When we try to iterate over pixels to check if each pixel is inside the triangle, we can use an axis-aligned minimum bounding box to bypass some unused pixels. This function is simply finding the minimum and maximum x and y values.

    pub fn aabb(&mut self) -> (usize, usize, usize, usize) {
        // use first vertex to init
        let mut min_x = self.vertices[0].x as usize;
        let mut max_x = min_x;
        let mut min_y = self.vertices[0].y as usize;
        let mut max_y = min_y;
		
        // check rest vertices
        for vertex in &self.vertices[1..] {
            let x = vertex.x as usize;
            let y = vertex.y as usize;

            if x < min_x {
                min_x = x;
            }
            if x > max_x {
                max_x = x;
            }
            if y < min_y {
                min_y = y;
            }
            if y > max_y {
                max_y = y;
            }
        }

        (min_x, max_x, min_y, max_y)
    }

Inside Checker

We also need a function that can check if a point (x,y)(x,y) inside the triangle.

The calculation is simple; we just need to obtain three vectors from the point to each vertex and check whether the cross products of those vectors have the same sign.

    // check if point in triangle
    pub fn is_point_inside(&mut self, p: &Vec4) -> bool {
        // vectors 
        let pa = vec3(
            self.vertices[0].x - p.x,
            self.vertices[0].y - p.y,
            self.vertices[0].z - p.z,
        );

        let pb = vec3(
            self.vertices[1].x - p.x,
            self.vertices[1].y - p.y,
            self.vertices[1].z - p.z,
        );
        let pc = vec3(
            self.vertices[2].x - p.x,
            self.vertices[2].y - p.y,
            self.vertices[2].z - p.z,
        );

        // cross product
        let cross0: Vec3 = cross(&pa, &pb);
        let cross1: Vec3 = cross(&pb, &pc);
        let cross2: Vec3 = cross(&pc, &pa);

        // if same sign
        cross0.z.signum() == cross1.z.signum() && cross1.z.signum() == cross2.z.signum()
    }

Barycentric Interpolation

Here we need to implement a barycentric function. This function calculate the Barycentric Coordinates. The barycentric coordinates (u,v,w)(u, v, w) represent the weights of the three vertices. When calculating the z-value of a point in the triangle, we can use these weights to interpolate.

    pub fn barycentric_coordinates(&mut self, p: &Vec4) -> (f32, f32, f32) {
        let v0: Vec4 = self.vertices[1] - self.vertices[0];
        let v1: Vec4 = self.vertices[2] - self.vertices[0];
        let v2: Vec4 = p - self.vertices[0];

        let d00 = dot(&v0, &v0);
        let d01 = dot(&v0, &v1);
        let d11 = dot(&v1, &v1);
        let d20 = dot(&v2, &v0);
        let d21 = dot(&v2, &v1);

        let denom = d00 * d11 - d01 * d01;

        let v = (d11 * d20 - d01 * d21) / denom;
        let w = (d00 * d21 - d01 * d20) / denom;
        let u = 1.0 - v - w;
        (u, v, w)
    }
    pub fn interp_depth(&mut self, u: f32, v: f32, w: f32) -> f32 {
        u * self.vertices[0].z + v * self.vertices[1].z + w * self.vertices[2].z
    }

Rasterizer

Now, back to the rasterizer, first, we need to add a few more parameters, some frame information, and a depth_buffer.

pub struct Rasterizer {
    ...
    depth_buffer: Vec<f32>,
    frame_width: usize,
    frame_height:usize,
    color_num: usize,
}

And since we are looking in the minus-z direction, the higher the depth information, the closer the object is to our camera. Therefore, we can initialize the depth buffer with -f32::INFINITY.

Then, we should add a rasterize_triangle function, as we are handling a group of triangles.

fn rasterize_triangle(&mut self, frame: &mut [u8], triangle: &mut Triangle, is_depth:bool) {
    	// calculate pixels to iter
        let (mut  min_x, mut  max_x,mut  min_y,mut  max_y) = triangle.aabb();
        max_x = min(self.frame_width,max_x);
        max_y = min(self.frame_height, max_y);

        // iter through pixel and find if current pixel in inside triangle
        for x in min_x..=max_x {
            for y in min_y..=max_y {
                let p: Vec4 = Vec4::new(x as f32, y as f32, 0.0, 0.0);
                let pixel_index = x + y * self.frame_width;
                let bit_index = pixel_index * self.color_num;
                if triangle.is_point_inside(&p) {
                    // inside triangle, then check depth
                    let (u, v, w) = triangle.barycentric_coordinates(&p);
                    let z = triangle.interp_depth(u, v, w);
                    
                    if z > self.depth_buffer[pixel_index] {
                        // this pixel is more close to screen, so render it
                        if is_depth{
                            // draw depth image
                            frame[bit_index..bit_index + self.color_num]
                            .copy_from_slice(&[(z*255f32) as u8,(z*255f32) as u8,(z*255f32) as u8,255]);
                        }
                        else {
                            // render color image
                            frame[bit_index..bit_index + self.color_num]
                            .copy_from_slice(&triangle.color);
                        }
						// update depth buffer
                        self.depth_buffer[pixel_index] = z;
                    }
                };
            }
        }
    }

In this function, we first use AABB to determine the valid pixel range, then iterate through all the pixels.

For each pixel, we use is_point_inside to check if this pixel should be rendered as part of the object.

Then, we use barycentric_coordinates and interp_depth to obtain the z-value. We test the z-value against the depth_buffer to see if it is occluded.

depth

color

Antialiasing

When rasterizing the triangle, we use whole pixels to fill in the color and perform z-buffering. This can cause severe aliasing.

So, we can use simple super-sampling for anti-aliasing.

Since we are super-sampling, we cannot process each triangle separately and iterate over pixels. So, let's make a slight change to the rasterize_triangle function:

fn rasterize_triangle(
        &mut self,
        frame: &mut [u8],
        triangle_group: &mut [Triangle],
        is_depth: bool,
    ) {
        // init AABB
        let mut global_min_x = self.frame_width;
        let mut global_max_x = 0;
        let mut global_min_y = self.frame_height;
        let mut global_max_y = 0;

        for triangle in &mut *triangle_group {
            let (min_x, max_x, min_y, max_y) = triangle.aabb();
            // update AABB
            global_min_x = min(global_min_x, min_x);
            global_max_x = max(global_max_x, max_x);
            global_min_y = min(global_min_y, min_y);
            global_max_y = max(global_max_y, max_y);
        }

        // safe bound
        global_max_x = min(self.frame_width, global_max_x + 1);
        global_max_y = min(self.frame_height, global_max_y + 1);

        let step = 1.0 / self.super_sample as f32;
        let super_power = self.super_sample*self.super_sample;

        // iter through pixel and find if current pixel in inside triangle
        for x in global_min_x..global_max_x {
            for y in global_min_y..global_max_y {
                // super sampling
                let mut samples_z = vec![-f32::INFINITY;super_power]; // sub depth
                let mut sample_colors = vec![vec![0u8; 4]; super_power]; // sub color init with background

                for sub_x in 0..self.super_sample {
                    for sub_y in 0..self.super_sample {
                        let sub_pixel_x = x as f32 + sub_x as f32 * step + step / 2.0;
                        let sub_pixel_y = y as f32 + sub_y as f32 * step + step / 2.0;

                        let sample_index:usize = sub_x + sub_y * self.super_sample;
                        let p: Vec4 = Vec4::new(sub_pixel_x as f32, sub_pixel_y as f32, 0.0, 1.0);

                        for triangle in &mut *triangle_group {
                            // test for all triangles
                            if triangle.is_point_inside(&p) {
                                // inside triangle, then check depth
                                let (u, v, w) = triangle.barycentric_coordinates(&p);

                                let z = triangle.interp_depth(u, v, w);

                                if z > samples_z[sample_index] {
                                    // z-test for subpixel
                                    sample_colors[sample_index] = if is_depth {
                                        vec![
                                            (z * 255f32) as u8,
                                            (z * 255f32) as u8,
                                            (z * 255f32) as u8,
                                            255,
                                        ]
                                    } else {
                                        triangle.color.to_vec()
                                    };
                                    // update depth
                                    samples_z[sample_index] = z;
                                }
                            };
                        }
                    }
                }

                // average color
                let avg_color = sample_colors.iter().fold([0, 0, 0, 0], |acc, color| {
                    [
                        (acc[0] as f32 + color[0] as f32 / super_power as f32) as u8,
                        (acc[1] as f32 + color[1] as f32 / super_power as f32) as u8,
                        (acc[2] as f32 + color[2] as f32 / super_power as f32) as u8,
                        255,
                    ]
                });

                let pixel_index = x + y * self.frame_width;
                let bit_index = pixel_index * self.color_num;

                frame[bit_index..bit_index + 4].copy_from_slice(&avg_color);
            }
        }
    }

This time, we pass the entire transformed triangle_group into the rasterizer function. Then, we find the global AABB by iterating over each triangle.

Next, we iterate over pixels. For each pixel, we will loop through sub_pixels and perform the same z-interpolation and z-buffer test on each sub-pixel buffer.

Finally, we can average the pixel color and fill the drawing buffer.

When we use 4x4 super-sampling, our animation will look pretty smooth.

anti-alias