Skip to content

Recitation 03 Supersampling

jijup edited this page Oct 6, 2026 · 3 revisions

Recitation 3: Supersampling and Soft Shadows

Objectives

Look closely at the edge of a shape in a ray traced image and you will see a staircase, often called jaggies. The edge of a shape is a sharp jump in colour. A sharp jump has detail at every scale, which in signal processing terms means arbitrarily high frequencies. A raster image can only hold a finite amount of detail, one colour per pixel. With one ray per pixel we only ask what is at the centre of each pixel, so every pixel ends up fully inside or fully outside the shape. Under-sampling a signal like this is called aliasing, and jaggies are one of its symptoms.

In this recitation you will:

  1. Ray trace a triangle using barycentric coordinates.
  2. Give the floor a checkerboard pattern, a classic test for aliasing.
  3. Reduce aliasing with supersampling: several rays per pixel, averaged. You will implement regular, random and jittered sampling (from our discussion) and compare them.
  4. Replace the point light with an area light and render soft shadows, using the same sampling code on the light.

The scene is a red triangle standing on a checkerboard floor, lit by a square light behind it. Every part of the scene shows a different sampling problem. The edges of the triangle show jaggies. The checkerboard shrinks toward the horizon until its squares are smaller than a pixel, which produces strange patterns. The shadow shows whether the light is sampled well. Figure 1 is what you will have at the end.

alt text


1. Getting the new exercise (By now, you should be familar with this step)

You already have the repository. Do not clone it again. Cloning a second time gives you a second folder, and your Recitation 2 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 raytrace
git commit -m "my raytrace work"
git checkout -- CMakeLists.txt
git pull

What each one does:

  • git add raytrace 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 supersampling 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 supersampling project appear.
  2. Click Open Project to open the solution in Visual Studio 2019.
  3. In the Solution Explorer, right-click sampling and choose Set as Startup Project.
  4. Switch the configuration from Debug to Release. It is the dropdown in the toolbar that says Debug. This exercise shoots hundreds of rays per pixel, and the Debug build is about 50 times slower. In Release the program finishes in under a second. In Debug it can take a minute.
  5. 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/supersampling).


3. The starter code

Build and run the starter code before you change anything (see Section 2). You should get Figure 2: a plain white floor, with no triangle, no checkerboard and no shadow. Those are your job.

alt text

3.1 The scene

All scene values are at the top of main.cpp. Colours are BGR floats in [0, 1], as in Recitation 2.

  • Camera: at the origin, looking down the -z axis. The image plane is at z = -1. The window llc to urc is shifted down a little so the camera looks at the floor.
  • Triangle: triangle, red, with its base on the floor. It is shaded plainly with its colour (no lighting), so its edges are as sharp as possible.
  • Floor: the plane y = floorY. You will turn it into a checkerboard of white and green squares (floorColor1, floorColor2) of size checkSize. It has diffuse shading from the light.
  • Light: a square of side lightSize, centred at lightPos, parallel to the floor. With lightSize = 0 it becomes a point light.
  • Sampling: samplingMode (0 regular, 1 random, 2 jittered), h_samples and v_samples (samples per pixel), and light_samples (samples along each side of the light).

3.2 What is given and what you write

Function File Status
Triangle::BaryCentric() Triangle.cpp Step 1, you complete it
intersectTriangle() main.cpp Step 1, you complete it
the floor colour in trace() main.cpp Step 2, you complete it
sampleOffset() main.cpp Step 3, you complete it
the pixel loop in main() main.cpp Step 3, you complete it
lightVisibility() main.cpp Step 4, you complete it
intersectFloor(), trace(), random01() main.cpp given

trace() works out what a single ray sees. It returns the triangle's colour if the triangle is the closest hit. For the floor it picks the floor colour (Step 2), adds diffuse lighting, and scales the light by lightVisibility(), the fraction of the light that can be seen from that point. If the ray hits nothing it returns the background.


4. Step 1: Ray tracing a triangle (15%)

A ray hits a triangle in two stages. First find where it hits the plane of the triangle. Then check whether that point is inside the triangle.

4.1 Barycentric coordinates

Complete Triangle::BaryCentric() in Triangle.cpp. It returns the barycentric coordinates (alpha, beta, gamma) of a point p with respect to the triangle's vertices a, b, c (vertex1_, vertex2_, vertex3_). Use the equations in Section 2.7.2 of the textbook (Equation 2.36).

Each coordinate comes from the normal of a small triangle formed by p and two of the vertices. ComputeNormal(p1, p2, p3) returns (p2 - p1) x (p3 - p1). The first one is done for you. Follow the same pattern for the other two, going around the triangle:

Vec3 na = ComputeNormal(vertex2_, vertex3_, point);
Vec3 nb = ------;
Vec3 nc = ------;

Scalar norm = normal_.squaredNorm();
Scalar alpha = normal_.dot(na) / norm;
Scalar beta = -------;
Scalar gamma = ------;

4.2 Ray-triangle intersection

Complete intersectTriangle() in main.cpp. It returns the distance t along the ray to the hit point, or -1 for a miss.

  1. Ray-plane intersection. The plane passes through vertex1_ with unit normal tNormal (already computed for you). From Section 4.4.3 of the textbook:

    t = ((a - e) . n) / (d . n)
    

    where a is triangle.vertex1_, e is Origin, d is Direction and n is tNormal. Return -1 if the denominator is close to 0 (the ray is parallel to the plane; use epsilon), or if t <= eps (the plane is behind the ray).

  2. Intersection point. Plug t into the ray equation: Origin + t * Direction.

  3. Inside test. Compute the barycentric coordinates of the intersection point with triangle.BaryCentric(). The point is inside the triangle when the coordinates sum to 1 and each one lies between 0 and 1. Use epsilon for the sum, to allow for rounding errors:

    if (fabs(baryCoords.sum() - 1) < epsilon && (baryCoords[0] <= 1 && baryCoords[0] >= 0) &&
        ----- && -------) {
        return t;
    }

Remove the return -1; // remove this line placeholder. You should now see the red triangle on the plain floor, as in Figure 3. Look at it closely, or zoom in. The slanted edges of the triangle are staircases. This is aliasing.

alt text


5. Step 2: A checkerboard floor (5%)

In trace(), the floor colour k_d is always floorColor1. Replace that line so the floor alternates between floorColor1 and floorColor2 in squares of size checkSize.

  1. Work out which square the hit point is in. Along x it is square number floor(point(0) / checkSize), and along z it is floor(point(2) / checkSize). Convert both to int.
  2. Add the two numbers. Moving one square in x or in z changes the sum by 1, so neighbouring squares always differ between even and odd.
  3. Use floorColor1 if the sum is even and floorColor2 if it is odd.

Use floor(), not a plain (int) cast. A cast rounds toward zero, so -0.3 and 0.3 both become square 0, and the squares next to x = 0 and z = 0 come out twice as wide.

You should now get Figure 4. Look near the horizon. The squares get smaller with distance until they are smaller than a pixel. Each pixel centre then lands on a light or dark square more or less by chance, and the far floor turns into noise and strange patterns. This is aliasing too, and it is much harder to fix than the jagged edges.

alt text


6. Step 3: Supersampling (25%)

Supersampling shoots several rays through each pixel and sets the pixel to the average of their colours. A pixel half covered by the triangle now gets a colour halfway between red and the floor, instead of all or nothing. See Section 13.4.1 of the textbook.

The variants differ only in where inside the pixel the samples go. Split the pixel into an n x n grid of sub-cells.

  • Regular: one sample at the centre of each sub-cell. This gives the same answer as rendering the image at n times the resolution in each direction and averaging blocks of n x n pixels.
  • Random: each sample anywhere in the pixel, using random numbers from [0, 1). This breaks up regular patterns, but samples can clump together and leave parts of the pixel empty.
  • Jittered (stratified): one sample at a random position inside each sub-cell. This combines the two: samples are spread evenly, like regular sampling, but are not lined up, like random sampling.

alt text

6.1 Placing the samples

Complete sampleOffset(k, n). It returns the position of sample k out of n along one side of the pixel, as a number in [0, 1). The pixel loop calls it once for the horizontal position and once for the vertical one. random01() gives a random number in [0, 1).

  • Regular: the centre of sub-cell k, that is (k + 0.5f) / n.
  • Random: anywhere in the pixel. Ignore k and n.
  • Jittered: anywhere in sub-cell k. Sub-cell k starts at k / n and is 1 / n wide.

6.2 The pixel loop

In main(), the nested loops over dx and dy are already there. Make two changes:

  1. In the lines that compute u and v, replace 0.5f with sampleOffset(dx, h_samples) and sampleOffset(dy, v_samples).
  2. After the two sample loops, average pixelColour. It holds the sum of all the sample colours, so divide by the number of samples.

Then set h_samples and v_samples to 4 at the top of the file. Try samplingMode = 0, then 1, then 2, and compare the three images (Figure 6). Leave it at 2 (jittered) when you move on.

alt text


7. Step 4: Soft shadows (25%)

A point light is either visible from a point on the floor or not, so its shadows have perfectly sharp edges. Real lights have a size. A point on the floor can see all of the light (fully lit), none of it (the umbra, full shadow, see the Fig. 7 ), or part of it (the penumbra, partial shadow). The penumbra is what makes a shadow soft. See Section 13.4.2 of the textbook. alt text

To render it, we estimate how much of the light each point can see. We shoot shadow rays from the point to many sample points spread over the light, and count how many reach the light without hitting the triangle. This is the same sampling problem as Step 3, on the light instead of on the pixel, so it reuses sampleOffset().

Complete lightVisibility(point):

  1. Loop over i and j from 0 to light_samples - 1.

  2. A point on the light. The light is a square of side lightSize centred at lightPos, lying flat (constant y). Turn the two offsets into a point on it:

    Vec3 lightPoint = lightPos + Vec3(lightSize * (sampleOffset(i, light_samples) - 0.5f), 0.0f,
                                      lightSize * (sampleOffset(j, light_samples) - 0.5f));
  3. Shadow ray. Its direction is lightPoint - point. Do not normalize it. Unnormalized, the ray reaches the light exactly at t = 1.

  4. Blocked or not. Call intersectTriangle(point, ShadowDir). The shadow ray is blocked only if the triangle is hit between the point and the light, that is with t between 0 and 1. A miss (-1) or a hit beyond the light (t > 1) means the light is visible.

  5. Return the number of visible rays divided by the total number of shadow rays. This gives 1 for fully lit, 0 for the umbra, and values in between in the penumbra.

Remove the return 1.0f; // remove this line placeholder. You should now get Figure 1. The shadow is sharp where it touches the base of the triangle and becomes softer further away. Figure 8 compares a point light with two light sizes.

alt text


8. Check yourself

  • The supersampling project appears in your solution and builds with no errors, in Release.
  • After Step 1, your image matches Figure 4: the red triangle with jagged edges and no shadow.
  • After Step 2, your image matches Figure 5, with no extra-wide squares along x = 0 or z = 0.
  • Every TODO is done, with no placeholders left: no Vec3(0, 0, 0); // replace, no return -1; // remove this line, no Vec3 k_d = floorColor1; // replace, no return 0.5f; in sampleOffset(), no return 1.0f; // remove this line.
  • With 4 x 4 samples, the three sampling modes look different, as in Figure 7.
  • Your final image matches Figure 2.
  • Setting lightSize = 0 gives a hard shadow, as in Figure 8.
  • 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. The original settings are samplingMode = 2, h_samples = v_samples = 4, light_samples = 4 and lightSize = 1.0.

  1. Set samplingMode = 0 (regular), with h_samples = v_samples = 4, light_samples = 4 and lightSize = 1.0. The triangle edges are smooth, but the soft edge of the shadow shows stripes. Why? Hint: with regular sampling, every point on the floor sends its shadow rays to the same 16 points on the light. What would one of those points on its own produce? (save as regular.png)
  2. Set samplingMode = 1 (random). Compare it with your jittered final.png, which uses the same number of samples. Which has smoother triangle edges and a smoother shadow, and why? (save as random.png)
  3. Set h_samples = v_samples = 8 (jittered). What improves compared to 4 x 4, and how many times more rays does the program shoot? (save as jitter8.png)
  4. Set lightSize = 0 and light_samples = 1 for a point light. Then set lightSize = 2.5 with light_samples = 4. How does the shadow edge differ between the two images? In biglight.png, why is the shadow sharp near the base of the triangle and blurry further away? (save as hard.png and biglight.png)
  5. Set light_samples = 1, keeping all other values at the original settings (samplingMode = 2, h_samples = v_samples = 4, lightSize = 1.0). The shadow becomes noisy. Why? For one pixel whose 16 samples all hit the floor, how many rays does the program shoot in total, counting primary and shadow rays, with light_samples = 1 and with light_samples = 4? (save as onelight.png)

10. Submission

Submit through the Recitation 3 assignment in MS Teams by Sunday 11 October, 11:59pm.

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

Part Weight
Step 1: ray tracing the triangle (Section 4) 15%
Step 2: checkerboard floor (Section 5) 5%
Step 3: supersampling (Section 6) 25%
Step 4: soft shadows (Section 7) 25%
Questions, with their images (Section 9) 25% (5% each)
final.png 5%
Total 100%

Submit one zip file named lastname_rec3.zip, laid out like this:

lastname_rec3/
├── code/      your main.cpp and Triangle.cpp
└── output/    seven images and your answers:
               final.png       the final image (Figure 2)
               regular.png     Question 1
               random.png      Question 2
               jitter8.png     Question 3
               hard.png        Question 4
               biglight.png    Question 4
               onelight.png    Question 5
               answers.txt     your answers to the five questions in Section 9

Do not include the build folder. It is large and the TA cannot use it.


11. If something goes wrong

  • The supersampling project is not in the solution. You have not rerun Configure and Generate since pulling. Go back to Section 2.
  • The program takes a long time, or the window does not appear for a while. You are in the Debug configuration. Switch to Release (Section 2, item 4).
  • The image is almost all white after Step 3. You are adding up the samples but not averaging them, so every colour is clamped to 255.
  • No triangle after Step 1. Check that all the placeholders in BaryCentric() are replaced, and that nb and nc follow the same pattern as na: each pair of vertices in order around the triangle, then point. Check also that the inside test looks at all three coordinates.
  • The triangle has holes or speckles along its edges. The sum test is too strict. It must use fabs(... - 1) < epsilon, not == 1.
  • The squares along x = 0 or z = 0 are twice as wide as the others. You used an (int) cast instead of floor() in Step 2.
  • The whole floor is in shadow, or there is no shadow at all. Check that you did not normalize ShadowDir, and that a ray counts as blocked only for t between 0 and 1.
  • Every run looks slightly different. That is expected. Random and jittered sampling use new random numbers each run.
  • Your new image looks exactly like the old one. You forgot to rebuild, or you are looking at an old copy of result.png.

Acknowledgements

The starter code is adapted from the previous offering of this course. An AI assistant (Claude) was used to help update and extend it, and to help draft the text of this recitation. All content was reviewed, edited and tested by the course instructor.