Skip to content

Recitation 02 Recursive Ray Tracing

jijup edited this page Sep 29, 2026 · 5 revisions

Recitation 2: Recursive Ray Tracing

Objectives

In this recitation, you will write your first ray tracer and use it to render an image of the scene below: a camera, a point light, a golden sphere, and a shiny floor.

alt text

To do this, you will:

  • Generate a primary ray for every pixel of a pinhole camera
  • Intersect rays with a sphere
  • Shade the scene with the Blinn-Phong model
  • Make the floor reflective using a recursive reflection ray

By the end, your program should produce this image:

alt text


1. Getting the new exercise

You already have the repository. Do not clone it again. Cloning a second time gives you a second folder, and your Recitation 1 work stays behind in the first one. You update the copy you already have instead.

Open Git Bash inside your GraphicsLab2026 folder and run these four commands in order:

git add vectors
git commit -m "my vector work"
git checkout -- CMakeLists.txt
git pull

What each one does:

  • git add vectors and git commit save last week's work into your own local history. This stays on your machine and nothing is sent anywhere. If something goes wrong later, your work is recoverable.
  • git checkout -- CMakeLists.txt discards your changes to the top-level CMakeLists.txt and restores my version. That file is mine. Your copy gets replaced every week, and that is fine, because my version already has the lines for the earlier exercises plus the new one for this week. Your own work lives in the exercise folders and is never touched.
  • git pull downloads this week's exercise, the raytrace folder.

Be careful with the third command. Typing git checkout -- . with a dot instead of a filename discards your changes to every file, not just that one.

If your copy is in a state you cannot fix, clone a fresh one into a new folder:

git clone https://github.com/jijup/GraphicsLab2026.git

2. Building

  1. Open the CMake GUI, point it at your GraphicsLab2026 folder and its build folder as before, and click Configure, then Generate. This is what makes the new raytrace project appear.
  2. Click Open Project to open the solution in Visual Studio 2019.
  3. In the Solution Explorer, right-click raytrace and choose Set as Startup Project.
  4. Build and run with Ctrl+F5. The program opens a window showing the image and waits. Press any key in the image window to close it. The image is also saved as result.png in the program's working folder (normally build/raytrace/debug (or release)).

On Linux or macOS

This recitation and the ones that follow use two libraries:

  • OpenCV stores the image you generate, saves it as a PNG, and shows it in a window. Later recitations use it for other image operations too.
  • Eigen provides vectors and vector operations: dot products, normalization, and so on. It does the same job as the Vector class you wrote in the last recitation, but it is much faster and far more complete. Eigen needs no setup on any system. The copy in external/eigen works on Windows, Linux and macOS.

OpenCV is different. On Windows, the build uses the copy in external/OpenCV. On Linux and macOS, you install OpenCV yourself, once, as shown below.

Do this before the lab. The macOS install can take a while.

  • Ubuntu / Debian: sudo apt install build-essential cmake libopencv-dev
  • macOS: install the Xcode command line tools (xcode-select --install) and Homebrew, then brew install cmake opencv Then build and run from a terminal in your GraphicsLab2026 folder:
cmake -S . -B build
cmake --build build
cd build/raytrace
./raytrace

result.png is written to the folder you run the program from. If CMake stops with "Could not find a package configuration file provided by OpenCV", OpenCV is not installed yet. On an Apple Silicon Mac, if it is installed but still not found, add -DOpenCV_DIR=/opt/homebrew/lib/cmake/opencv4 to the first command.


3. The starter code

The starter code is in raytrace/main.cpp. It uses Eigen for vectors and OpenCV for images. Eigen vectors use () to access elements, not [], and are zero-indexed. For example, the z-component of a vector v is v(2). More on Eigen can be found here.

The code is split into small functions. Some are given and some you will complete:

Function What it does Status
intersectSphere() Ray-sphere intersection TODO (Step 2)
intersectFloor() Ray-plane intersection for the floor Given
blinnPhong() Local illumination at a point TODO (Step 3)
trace() Follows one ray and returns its colour Floor reflection is TODO (Step 4)
main() Loops over pixels and builds primary rays Primary ray is TODO (Step 1)

All scene settings are at the top of the file: the sphere, the floor, the background, and the light.

3.1 The scene

These are the objects shown in Figure 1.

  • The sphere has centre (0, -0.5, -3.5) and radius 1.
  • The floor is the infinite plane y = -1.5. Its normal is (0, 1, 0) everywhere. The sphere's bottom is at y = -1.5, so it just touches the floor.
  • The background is plain white. It is not an object: it is simply the colour of any ray that hits nothing.
  • The light is a point light at lightSource.

Build and run the starter code before you change anything (see Section 2). You should get a plain white image. Every ray currently points straight ahead, level with the floor, so every ray misses everything and gets the background. That is expected. alt text

3.2 Colours are BGR, not RGB

OpenCV stores pixels as BGR: blue first, red last. All colours in this code follow that order. For example, the gold sphere is Vec3(0.12f, 0.67f, 0.92f): a lot of red, some green, little blue. Colours inside the tracer are floats in [0, 1]. They are multiplied by 255 and clamped only at the end, in main().


4. Step 1: The camera and primary rays (15%)

We use a simple pinhole camera. The camera sits at the origin (0, 0, 0), looks down the -z axis, and its up direction is +y.

The image plane lies in front of the camera at z = -1. It is a rectangle defined by its lower left corner llc and upper right corner urc. The plane is shifted down a little so the floor fills more of the picture. pixelUV holds the size of one pixel on this plane.

For each pixel at row i and column j, find its position on the image plane:

  • x comes from the column: start at llc(0) and move right by pixelUV(0) * (j + 0.5).
  • y comes from the row, but row 0 is the top of the image. So start at urc(1) and move down by pixelUV(1) * (i + 0.5).
  • z is -1.

The + 0.5 puts the ray through the centre of the pixel, not its corner.

The ray starts at the camera (Origin) and its direction is pixelPos - Origin. Normalize the direction. The rest of the code assumes it has length 1.

When this step works, you should see the white background at the top and a dark floor at the bottom. Rays pointing down hit the floor. Rays pointing level or up never do, so they get the background. The floor is dark because blinnPhong() only returns ambient light for now.

alt text


5. Step 2: Ray-sphere intersection (20%)

Complete intersectSphere(). The sphere has centre SpherePos and radius sphereRadius.

A point on the ray is p(t) = Origin + t * direction. Substituting this into the sphere equation gives a quadratic A t^2 + B t + C = 0 (see the lecture slides or Section 4.4.1 of the textbook):

A = direction . direction
B = 2 * direction . (Origin - SpherePos)
C = (Origin - SpherePos) . (Origin - SpherePos) - sphereRadius^2

Compute the discriminant B^2 - 4AC. If it is negative, the ray misses the sphere. Return -1.

Otherwise compute the smaller root:

t2 = (-B - sqrt(discriminant)) / (2A)

This is the point where the ray enters the front of the sphere. If t2 > eps, return it. Otherwise the sphere is behind the ray, so return -1.

Why eps and not 0? In Step 4, a new ray starts on the floor's surface. Rounding errors can make a ray hit the surface it starts on at a tiny t. Ignoring hits closer than eps avoids this.

When this step works, a sphere appears. It will look flat and dark until you do Step 3.

For comparison, look at the given intersectFloor(). The floor is the plane y = floorY, so the ray hits it where Origin(1) + t * direction(1) = floorY, which gives t = (floorY - Origin(1)) / direction(1). A plane is much simpler than a sphere: one division, no quadratic.


6. Step 3: Blinn-Phong shading (20%)

Complete blinnPhong(). It uses the Blinn-Phong model from the textbook (Section 4.5.3):

L = k_a I_a + k_d I max(0, n . l) + k_s I max(0, n . h)^p

The function gets the hit point, the unit Normal there (n), the ray direction, and the surface's three coefficients k_a, k_d and k_s. The light values are set at the top of the file: I (light intensity), I_a (ambient light intensity) and p (the Phong exponent). The same function shades both the sphere and the floor, each with its own coefficients.

  1. Light vector l: lightSource - point. Normalize it.
  2. Diffuse term max(0, n . l): LightVector . Normal. If it is negative, set it to 0. The surface faces away from the light.
  3. Half vector h: the sum of the light vector and the view vector. The view vector points from the surface toward the camera, which is the opposite of the ray direction. Normalize the half vector.
  4. Specular term max(0, n . h): HalfVec . Normal, with negative values set to 0.
  5. Combine using the formula above. Each coefficient and intensity has three values, one each for B, G and R, and the products k_a I_a, k_d I and k_s I multiply them channel by channel. In Eigen that is cwiseProduct, for example k_a.cwiseProduct(I_a). Plain * between two Vec3s does not compile, because Eigen reads it as a matrix product.

Note that k_s is grey, not gold, so the highlight takes the colour of the light rather than the surface. The normals are computed for you in trace(). For the sphere it is Intersection - SpherePos, normalized. For the floor it is (0, 1, 0).

You should now get the image below: a lit golden sphere on a teal floor. You wrote one shading function and it lights the whole scene. Try a few values of p (16, 64, 256) and see how the size of the highlight changes.

alt text


7. Step 4: Recursive ray tracing, a shiny floor (15%)

A shiny floor shows two things: its own colour, and a reflection of whatever is above it. To get the reflection, we build a new ray at the floor and trace it. This is where the ray tracer becomes recursive: trace() calls itself.

Complete the TODO in the floor part of trace():

  1. Compute the reflected direction:
r = d - 2 (d . n) n

where d is the incoming ray direction and n is the floor normal (0, 1, 0). Normalize r. Check it by hand: for this normal, r is just d with its y-component flipped. A ray going down bounces up.

  1. Start the new ray at Intersection + eps * Normal. The small push above the floor keeps the new ray from hitting the floor it starts on.

  2. Call trace() with the new origin, direction r, and depth + 1. This returns the colour the reflected ray sees: the sphere, or the white background.

  3. Blend the floor's own shading (local) with the reflection:

c = (1 - floorReflect) * local + floorReflect * reflected

floorReflect = 0.4 means 40% reflection. At 0 the floor is matte. At 1 it is a perfect mirror.

maxDepth stops the recursion. A ray that bounces more than maxDepth times returns black. In this scene, a reflected ray from the floor goes up and can never come back down to the floor, so the recursion is only one level deep. With more objects, rays could bounce many times.

You should now get Figure 2. The reflection of the sphere appears upside down in the floor.

Look at the reflection closely. It is darker than the sphere. The floor sees the sphere from below, so it mostly reflects the underside. The light is above, so the underside is in shade.


8. Check yourself

  • The raytrace project appears in your solution and builds with no errors.
  • After Step 1, your image matches Figure 3: white at the top, floor at the bottom, horizon straight across.
  • Every TODO is done, with no placeholders left: no return -1; // remove this line, no Vec3 direction(0, 0, -1);, no return ambient * color; // replace, no Vec3 c = local;.
  • Your final image matches Figure 2.
  • Changing shininess changes the size of the highlight on the sphere.
  • You can explain why the reflection is upside down and darker than the sphere.
  • You have worked through the five questions in Section 9 and saved an image for each change.

9. Questions (25%, 5% each)

Try each change below, look at the image, and answer in one or two sentences in answers.txt. Save the image for each change with the file name given, since you will submit them (see Section 10). The program always writes result.png, so copy and rename it after each run before it gets overwritten. Change the value back to the original before you try the next one.

  1. Set floorReflect = 1.0. The horizon disappears. Why? (save as mirror.png)
  2. With the original settings, why is the reflection of the sphere darker than the sphere itself? (use your final.png)
  3. Raise the sphere off the floor: SpherePos = Vec3(0.0f, 0.5f, -3.5f). The reflection moves away from the sphere, leaving a gap. Where is the "reflected sphere" in world coordinates, and why does the gap appear? (save as raised.png)
  4. Set maxDepth = 0. The reflection disappears and the floor gets darker. Why? Why is the sphere itself unchanged? (save as depth0.png)
  5. Move the light behind the sphere: lightSource(0.0f, 2.0f, -8.0f). The side of the sphere facing you goes dark, but the floor just in front of the sphere stays brightly lit. Explain both. In a real scene, what would you see on that part (the floor between camera and the sphere) of the floor, and what would the ray tracer need to produce it? (save as behind.png)

10. Submission

Submit through the Recitation 2 assignment in MS Teams by Monday 5 October, 11:59pm.

Upload a single zip named lastname_rec2.zip containing two folders:

lastname_rec2/
├── code/      your main.cpp
└── output/    six images and your answers:
               final.png       the final image (Figure 2)
               shininess.png   the same scene with a different shininess (try at least three (e.g., 32, 128, 256), combine three or more rendering into a single image)
               mirror.png      Question 1
               raised.png      Question 3
               depth0.png      Question 4
               behind.png      Question 5
               answers.txt     your answers to the five questions in Section 9

Do not include the build or external folders. They are large, they are not your work, and a zip containing them may fail to upload at all.

Marks for this recitation are based on attendance and a completed submission. The TA reviews a sample of submissions in detail each week and returns feedback. The weightings below show how the work is distributed and what the TA looks at.

Part Weight
Step 1: primary rays (Section 4) 15%
Step 2: ray-sphere intersection (Section 5) 20%
Step 3: Blinn-Phong shading (Section 6) 20%
Step 4: reflective floor (Section 7) 15%
Questions, with their images (Section 9) 25% (5% each)
final.png and shininess.png 5%
Total 100%

Discuss the concepts with each other freely. Write your own code.


11. If something goes wrong

The image is plain white Step 1 is not done. Every ray still points straight ahead and misses everything.

The image looks sideways or upside down Check Step 1. Column j gives x, row i gives y, and y is counted down from the top.

No sphere appears intersectSphere() still returns -1 for every ray. Remove the placeholder line at the end of the function.

The sphere is there but flat and dark Step 3 is not done. blinnPhong() still returns only the ambient term.

Colours look wrong, e.g. the gold sphere comes out blue Colours are BGR, not RGB. See Section 3.2.

Strange shading, or colours that jump to white or black A vector was not normalized. Check the ray direction, light vector, half vector, and reflected direction.

No reflection in the floor You did not replace the placeholder Vec3 c = local; with your blend.

Black speckles or noisy dots on the floor A reflected ray is hitting the floor it started on. Check that the new ray starts at Intersection + eps * Normal.

The pull refused, saying local changes would be overwritten If it is the top-level CMakeLists.txt, run git checkout -- CMakeLists.txt and pull again. If it is something else, come and find me rather than guessing.

The raytrace project is not in the Solution Explorer You have not rerun Configure and Generate since pulling. Go back to Section 2.

The program starts and immediately fails with a missing .dll message The build copies the OpenCV .dll files next to your .exe automatically. If they are missing, rerun Configure and Generate (Section 2), then rebuild.

The console shows [ INFO:0] ... => FAILED lines These are harmless. OpenCV looks for optional plugins (extra window systems and threading libraries), does not find them, and uses its built-in versions instead. Your program is fine. The messages only appear in Debug builds. Switch the configuration at the top of Visual Studio to Release if you want a quiet console.

The console window flashes and disappears Use Ctrl+F5, not F5.


12. Going further (optional)

These are not part of the submission and are not marked. Try them if you have time. Assignment 1 builds on this code, so each one you get working here is a head start on the assignment.

  • Shadows. In blinnPhong(), cast a ray from the point toward the light. If it hits the sphere first, use only the ambient term. The sphere should then cast a shadow on the floor.
  • A mirror sphere. Give the sphere the same reflection code as the floor, with floorReflect = 1. What does it show?
  • Checkerboard floor. Choose the floor colour from floor(x) + floor(z) being even or odd.