Lab 2: Green Mountain Sunset

By the end of this lab, you will:

To access the lab template, please click the link in the Lab 2 Assignment Link post on our Ed discussion board.

In this lab, you will start writing your own ray tracer. The objects in our scene will be defined in 3d, but we will mostly be rendering 2d-like scenes. We'll make our scenes look more 3d-like when we talk about shading techniques next week.

When you open up the initial web page (via Go Live) you should see a sunset-like background which transitions from blue (top) to pink at the bottom:

All of the code you will write will be in the sunset.js file which contains a few class definitions. In particular, there are class definitions for a Ray, Sphere, Triangle and the SunsetRenderer class.

Note that the SunsetRenderer takes in (1) the canvasId (string) of the canvas in which we would like to render our scene, (2) the vertical field-of-view (fov), as well as the objects in the scene. The first object is an instance of the Sphere class which represents the sun. Next, there is an array of Triangle objects which has a length of 3, for the three triangles representing the Green Mountains.

The SunsetRenderer class saves the incoming information (fov, canvas, sun and mountains), which is then used in the render method to render the scene. Your job primarily consists of modifying the render method as well as the intersect method of the Sphere and Triangle classes.

Note that there is already a loop over the pixels in the scene in the SunsetRenderer render method. The relative y-coordinate is used as the interpolation variable between blue and pink to define the initial background sky color.

Part 1: Creating rays.

Please review the notes and exercise from this week on how to create rays from a camera that pass through a particular pixel. Create a ray within the body of the nested for-loop (i.e. for each pixel). You should use the fov as well as the image (or canvas) dimensions (and aspect ratio) to calculate the image plane dimensions ($w$, $h$). You can use $d = 1$ as the distance from the camera (eye) to the image plane. The eye is located at the origin $(0, 0, 0)$.

Part 2: Ray-sphere intersections for the sun.

Please complete the Sphere intersect method to detect intersections between the ray you compute in Part 1 and the sun. The sun is centered at $(-4, 1.5, -10)$ and has a radius of $2$. The color of the sun is already set up for you as:

const cSun = vec3.fromValues(1.0, 0.894, 0.71);

When the ray intersects the sun, please set the pixel color to cSun. Your image should look like the image on the left:

Part 3: Ray-triangle intersections for the Green Mountains.

Part 3A: Now complete the Triangle intersect method to detect intersections between a ray and a single triangle. Remember to check that all three barycentric coordinates ($u$, $v$ and $w$) are in the valid range of $[0, 1]$.

glMatrix reminder!

Recall that glMatrix doesn't allow you to pass two indices (i.e. row and column) when accessing elements in a matrix. There is only one index, which corresponds to the entry in the 1d array that stores the matrix entries in column-major order. This means that a 3x3 matrix like this:

$$ M = \left[ \begin{array}{ccc} a & b & c \\ d & e & f \\ g & h & i \end{array}\right], $$

would be assigned to a glMatrix mat3 like this:

let M = mat3.create();
M[0] = a;
M[1] = d;
M[2] = g;
M[3] = b;
M[4] = e;
M[5] = h;
M[6] = c;
M[7] = f;
M[8] = i;

In other words, think about going one column at a time when indexing the mat3 (as a 1d array) to access the elements.

Part 3B: Now set the pixel color according to the closest intersection point (minimum ray $t$ value) between all triangles and the sun. There are two colors to use for the Green Mountains: cMtn1 and cMtn2, already defined for you right after the definition of cSky. The first and third triangles have a color of cMtn1 and the second triangle (this.mountains[1] of the SunsetRenderer class) has a color of cMtn2.

Once this is complete, your image should look like the rightmost image above.

Part 4: Samples per pixel (spp)

The mountains kind of look like a green staircase, which is because of jaggies:

Recall the 0.5 embedded in the equations for $x$ and $y$. Please change this to Math.random() to generate random numbers between 0 and 1. This will now sample each pixel at a random point within the pixel instead of the center. The objects in your scene should look pretty rough with a single sample. Next, add a loop (nested within the i and j loops for each pixel) to send several random rays within each pixel. The final color you assign to the pixel should be the average of all the colors you compute with your random samples - here is a description of this algorithm:

1. for each pixel:
  a. set color = (0, 0, 0) (this will be the variable we use for the final color)
  b. for some # samples (spp):
      i. cast a ray through this pixel (at a random sample location)
      ii. compute the color from this sample
      iii. add the sample color to the final color
  c. divide the final color by the # of samples (to compute the average)
  d. set pixel color to the final color

You should start to see the edges of the mountains look smoother at higher spp. Notice there is a console.log message at the end of the render function which reports the elapsed time to render the scene (in milliseconds) and saves this in the elapsed variable. Please calculate the framerate (in FPS), and complete the table in the README.md file with the framerate for 1spp, 4spp, 8spp, 16spp and 32spp.



Left: 1spp    Right: 8spp

The README.md file is written in Markdown. Please see this page for an overview of Markdown syntax.

Part 5: Distance to the image plane

This part involves a theoretical question and can be completed at any point while working on the lab.

In the implementation for this lab, we set $d = 1$ (we also used this for the in-class exercise). Try setting $d = 10$. Now try $d = 100$. What happens? Recall the expressions we developed for the pixel sample coordinates, ultimately leading to the ray direction:

$$ x = -\frac{w}{2} + w \frac{(i + 0.5)}{n_x}, \quad y = -\frac{h}{2} + h \frac{(n_y - 0.5 - j)}{n_y}, \quad z = -d. $$

With the eye at the origin $(0, 0, 0)$, the ray direction is $\vec{r} = \frac{\vec{p}}{\lVert \vec{p}\rVert}$ where $\vec{p}$ are the 3d pixel coordinates, $\vec{p} = (x, y, z)$.

Please show that $\vec{r}$ is independent of $d$ - i.e. it only depends on $\alpha$, $n_x$, $n_y$, $i$ and $j$. You can omit the $0.5$ if you like (which means we sample each pixel at the top-left corner of the pixel). Feel free to make any other substitions to make the derivation more concise, for example, substituting a helper variable for the aspect ratio: $a = n_x / n_y$.

Please enter your derivation in the README.md file in your repository.

Math equations can be typeset using LaTeX (enclosed within either two $ for inline expressions or within two $$ for equations on new lines). You can also take a picture of your equations and upload the picture to your repository.

Submission

The initial submission for the lab is due on Thursday 3/6 at 11:59pm EST. Please see the Setup page for instructions on how to commit and push your work to your GitHub repository and then submit your repository to Gradescope (in the Lab 2 assignment). I will then provide feedback within 1 week so you can edit your submission.


© Philip Caplan, 2025