Implementing a Simple Rasterizer


Introduction

For our first assignment in CS184, we are told to build a simple rasterizer, with features such as drawing triangles, supersampling, and texture mapping with antialiasing.

This post will detail my journey through this project, talking about implementation details and my thought process behind them.

Drawing Single-Coloured Triangles

The first problem we have to tackle is drawing single-coloured triangles. To do this, we rasterize triangles as follows:

  • First, we construct a bounding box. This can be done by taking the minimum and maximum xx and yy components of our given points. Note then that this box is the smallest rectangle which guarantees that our triangle lies inside.
  • Next, we iterate through each pixel inside of this bounding box. For each pixel, we can then determine whether or not it is “inside” our triangle. If it is, we colour the pixel. Otherwise, we don’t.

We also note that it’s important for our vertices to have a consistent winding order; this is to ensure that our point-in-triangle check works for any triangle.

Initial Implementation

Initially, we constructed our bounding box as follows:

float min_x = floor(std::min({ x0, x1, x2 }));
float max_x = ceil(std::max({ x0, x1, x2 }));
float min_y = floor(std::min({ y0, y1, y2 }));
float max_y = ceil(std::max({ y0, y1, y2 }));

Then, in order to construct our triangle edges, we defined three Vector3D() structs and computed the cross-product to ensure that they are in a consistent winding order (in this case, clockwise):

Vector3D p0(x0, y0, 0); 
Vector3D p1(x1, y1, 0); 
Vector3D p2(x2, y2, 0);
if (cross(p1 - p0, p2 - p0).z > 0) { 
    swap(p1, p2); 
}

Vector3D z(0, 0, 1);

Now, we iterate through our bounding box. Initially, we calculated the edges for our triangle in each iteration, and their corresponding normals. Then, we tested to see if a point was inside the edge or not using the following check:

(dot(p - p1, n0) <= 0) && (dot(p - p2, n1) <= 0) && (dot(p - p0, n2) <= 0)

If this was true, we filled in the pixel. Else, we didn’t. This algorithm runs through each pixel inside of the bounding box and determines whether it lies inside the triangle or not; thus, by definition, it is no worse than one that checks each sample inside the bounding box.

Now, running this, we see that we get the following result:

Naïve Implementation on basic/test4.svg

Optimizations

Moving Repeated Operations Out of Loops

The first thing I noticed was that calculating the edges and their normals didn’t have to be done inside of our loop each time. As such, I moved them out of the loop. This provided an approximately 2x speedup.

Direct Edge Computations

After further pondering, I realised that there was another way to speed up my implementation. With the current implementation, I had to calculate the difference between our points three times, and then call the dot product thrice to ensure that the point lies inside our triangle.

Instead however, I could simply compute the edge function directly, and use that in our computations. More concretely, we note that an edge function is simply the following:

E(x,y)=Ax+By+C\begin{equation*} E(x, y) = Ax + By + C \end{equation*}

And we note that to move across the bounding box, we just have to increment AA and BB. So, we can use the edge functions directly as follows:

float A0 = y1 - y0;
float B0 = x0 - x1;
float C0 = x1*y0 - x0*y1;

float A1 = y2 - y1;
float B1 = x1 - x2;
float C1 = x2*y1 - x1*y2;

float A2 = y0 - y2;
float B2 = x2 - x0;
float C2 = x0*y2 - x2*y0;

Then, we calculate the edge for the first pixel in our bounding box, and inside our loop, we simply increment AA and BB appropriately to move across the area. Furthermore, instead of having to call the dot() function, we can directly check whether or not our point is valid using the following:

if (w0 <= 0 && w1 <= 0 && w2 <= 0) {
    fill_pixel(x, y, color);
}

This provided an even larger speedup (of around 3x from the naïve implementation).

Below is a table to show the result while running our implementations on basic/test4.svg:

ImplementationTime Elapsed (s)
Naïve0.0005514
Moved Operations0.0003070
Direct Edge Computations0.0001788

Antialiasing by Supersampling

Need for Supersampling

One of the things you may have noticed from the image above is how along certain edges, there are “jaggies,” making the image looks, well, jagged. A way to mitigate these issues is through supersampling: this process allows us to “smooth out” the edges, thus making them look less jagged.

The Algorithm

The high-level overview of the supersampling algorithm is as follows:

  • We first take the square root of the sampling rate. This will give us the area we will be working within to sample our point.
  • Now, we will be modifying our implementation from the previous task. Initially, we have a double-loop that iterates through our bounding box. However, with supersampling, we will now be sampling sampling_rate times per pixel, so we will have to add in a double nested loop as we iterate through each point.
  • From here, we calculate the subpixels, and then check whether they are inside the triangle or not. If they are, we mark the corresponding index in our sample buffer. Otherwise, we don’t.
  • Finally, once we’re done with everything, we will have to resolve the framebuffer and calculate the average value for each pixel.

Now, due to this, we will also have to change a few things in our current implementation of rasterization. As mentioned before, we will have to edit rasterize_triangle() to account for looping through the subpixels as well by introducing a double nested loop.

Furthermore, we edit fill_pixel() to also go through each of the subpixels and mark it. However, because we don’t have to antialias points and lines, we don’t need to introduce any checks.

Additionally, we will have to modify resolve_to_framebuffer() by having it go through and averaging the pixel values at the end.

Finally, we also have to tweak set_sample_rate() and have sample_buffer be resized to width * height * sample_rate.

Results

By using supersampling, we are able to smooth out the edges. Below, we illustrate how the edge smoothens out as we increase the sampling rate. By the sample the sampling rate is 1616, we see that we can’t really observe any jaggies:

Sampling Rate of 1

Sampling Rate of 4

Sampling Rate of 9

Sampling Rate of 16

Jittering

Finally, we note that the original implementation utilizes grid sampling. However, we also experimented with jitter sampling. Although it causes images to appear “grainy”, we note that it can help dramatically with more complex pictures. For example, let us look at 02_degenerate_sqare2.svg. Here is what the square looks like with grid sampling, with a rate of r=1r=1 and r=16r=16 respectively:

Degenerate Square with Sampling Rate of 1

Degenerate Square with Sampling Rate of 16

Here, we see that the Moire effect is clearly visible, even in the case of r=16r=16. However, with our jitter sampling, these effects are reduced a lot more. However, the trade-off is that there is very clear “graininess” to the images (though this gets better as rr increases):

Jittering with Sampling Rate of 1

Jittering with Sampling Rate of 16

Transformations

For this task, we have to implement the following three transformations:

  • Translation
  • Scaling
  • Rotation

We do this by implementing the appropriate matrices. More concretely, for translations, we would need the following matrix:

[10dx01dy001]\begin{bmatrix} 1 & 0 & dx \\ 0 & 1 & dy \\ 0 & 0 & 1 \end{bmatrix}

For scaling, we would use this:

[sx000sy0001]\begin{bmatrix} sx & 0 & 0 \\ 0 & sy & 0 \\ 0 & 0 & 1 \end{bmatrix}

And for rotations, we first note that since the angle is in degrees, we will have to convert it to radians by calculating θr=(θdπ)/180\theta_r = (\theta_d * \pi)/180. Then, we use the following matrix:

[cos(θr)sin(θr)0sin(θr)cos(θr)0001]\begin{bmatrix} \cos(\theta_r) & -\sin(\theta_r) & 0 \\ \sin(\theta_r) & \cos(\theta_r) & 0 \\ 0 & 0 & 1 \end{bmatrix}

Now, with these functions implemented, we now see that transforms/robot.svg renders correctly:

Default Robot

And using these functions, we can make it look like the robot is praising the sun:

Praising the Sun

Here, we created this transformation by applying a 45-degree rotation to its right arm, and a 315-degree (or simply, -45-degree) rotation to the other arm.

GUI Additions

Additionally, we implemented extra functionalities to the GUI. Namely, the A and D keys rotate the image left and right by 5-degrees respectively. In order to rotate from the center of origin, we first calculated where this point is (by simply taking the midpoint of our width and height).

From there, we simply apply the transformations to each vertex by first shifting it, rotating, and then reshifting it back. The result of this can be seen below, where we rotate our robot by 90 degrees to the right:

GUI Rotation

Barycentric Coordinates

For this section, we want to draw triangles with colors defined at the vertices and interpolated across the triangle’s area using barycentric interpolation.

So, what exactly are Barycentric Coordinates? First, suppose we have a triangle defined by points A,B,CA, B, C. Now, for any point PABCP \in \triangle ABC, we can write it as:

P=αA+βB+γC\begin{equation*} P = \alpha A + \beta B + \gamma C \end{equation*}

Now, these weights α,β,γ\alpha, \beta, \gamma are the barycentric coordinates of PP. Intuitively, we can think of these weights as the “massses” we’d need at the vertices of our triangle so that its center of mass is at the point PP.

To better understand this, let’s consider an extreme case where our point PP is at one of the vertices (say, at A). In this case, we’d want α=1\alpha = 1 so that the center of mass of our triangle is at AA. Similarly, if our point PP is in the center of our triangle ABC\triangle ABC, then α=β=γ=13\alpha = \beta = \gamma = \frac{1}{3}.

We can visualize this better below: Coloured Triangle

Now, with this in mind, we can implement this with some linear algebra. We note that for a triangle defined by points p0,p1,p2p_0, p_1, p_2, we have:

[xy1]=[x0x1x2y0y1y2111][αβγ][αβγ]=M1[xy1]\begin{align*} \begin{bmatrix} x \\ y \\ 1 \end{bmatrix} &= \begin{bmatrix} x_0 & x_1 & x_2 \\ y_0 & y_1 & y_2 \\ 1 & 1 & 1 \end{bmatrix} \begin{bmatrix} \alpha \\ \beta \\ \gamma \end{bmatrix} \\ \begin{bmatrix} \alpha \\ \beta \\ \gamma \end{bmatrix} &= M^{-1}\begin{bmatrix} x \\ y \\ 1 \end{bmatrix} \end{align*}

So, following through with our previous implementation of drawing single-coloured triangles, we now add in this extra computation to calculate the barycentric coordinates of each point pp and use that to determine the colour. Running this on basic/test7.svg yields us the following result:

basic/test7.svg

Pixel Sampling for Texture Mapping

In this part, we will be drawing triangles with colors defined by texture mapping with the given 2D texture coordinate at each vertex and the given Texture image. In addition, we will be using two different methods: nearest-neighbour and bilinear interpolation.

But before we dive into our implementation and discuss the results, we first will talk about what exactly pixel sampling is. Simply put, we can think of pixel sampling as using sampling to determine what color each of our pixel should be based on the texture used.

We utilized pixel sampling to perform texture mapping by sampling each point and using barycentric coordinates to convert them to their corresponding texture mapping coordinates.

In this task, we implemented two different sampling methods:

  • Nearest: In this case, we simply round to the nearest pixel in our texture mapping and use that for our colour.
  • Bilinear: For this, we first get the four closest points to our current pixel pp. Then, we perform bilinear interpolation in order to calculat the final colour needed. Although the result is smoother, it is more computationally expensive.

Below, we provide images of the two different sampling methods. First, we have nearest sampling with r=1r=1:

Nearest with Rate 1

Next, we have bilinear sampling with r=1r=1:

Bilinear with Rate 1

Now, we have nearest with rate r=16r = 16:

Nearest with Rate 16

And finally, bilinear with r=16r = 16:

Bilinear with Rate 16

We notice how with nearest sampling, it fails to capture some of the white lines that bilinear sampling does. We also note that with nearest sampling, jaggies are more apparent (at least with the r=1r = 1 case). The differences between them will be most apparent in situations where the pattern changes often, as bilinear sampling takes into account neighbours whereas nearest sampling doesn’t.

Level Sampling with Mipmaps for Texture Mapping

For this task, we are asked to implement level sampling with mipmaps for texture mapping.

Level sampling basically is the process of constructing different “levels”, where each level has progressively lower resolution. The idea then is that with this, we can use different levels for different levels of details depending on how far away we are. This allows us to reduce aliasing artifacts that may occur.

Now, for this part, we had to implement three different types of level sampling:

  • L_Zero: In this case, we simply set level = 0. This is effectively the same as the previous part.
  • L_NEAREST: Here, we round the level to the nearest integer. Then, we use the mipmap corresponding to that level.
  • L_LINEAR: For this, we take the two levels closest to us (which we can calculate using floor() and ceil()). Then, we average out the color values from these two values using bilinear interpolation.

To actually get the level, we calculate it using the following formula:

D=log2(max((dudx)2+(dudy)2,(dvdx)2+(dvdy)2)) \begin{equation*} D = \log_2\left(\mathrm{max}\left( \sqrt{\left( \frac{du}{dx} \right)^2 + \left( \frac{du}{dy} \right)^2 }, \sqrt{\left( \frac{dv}{dx} \right)^2 + \left( \frac{dv}{dy} \right)^2 }\right)\right) \end{equation*}

Now, let’s actually see the results. Here, we used the album cover from Tame Impala’s Currents (primarily due to the wavy effects it has, which I hopped would make aliasing artifacts more apparent).

First, we show the case of L_ZERO and P_NEAREST. We observe here that there is very apparent jaggies and also the Moiré effect happening:

L_ZERO and P_NEAREST

Next, let’s try L_NEAREST and P_NEAREST. In this case, we see that the edges of our shape are becoming a bit blurred, helping reduce some aliasing artifacts. However, the differences aren’t dramatically noticeable with this image.

L_LINEAR and P_NEAREST

Now, we try L_ZERO and P_LINEAR. With this, we see that jaggies and the Moiré effect are reduced dramatically. However, the edges look jagged.

L_ZERO and P_LINEAR

Finally, we try L_NEAREST and P_NEAREST. With this, we see that the image looks much better than the other three combinations, with much less noticeable aliasing artifacts.

L_LINEAR and P_LINEAR