CS 184: Computer Graphics and Imaging, Spring 2026

Project 3: Path Tracer

Nada Hameed & Iris Li

Link to webpage: cal-cs184-student.github.io/hw-webpages-nadahameed/hw3

Path-traced bunny render
Bunny

Overview

In this homework, we implemented a renderer that uses a pathtracer algorithm. First, we implemented ray generation and scene intersection. We generated rays and checked if they intersected our scene. Then, we implemented a bounding volume hierarchy to speed up the path tracer. Then, we implemented several lighting options, direct illumination and global illumination. Direct illumination is when there is one source of light, and global illumination is when there is light constantly being reflected. In global illumination, we implemented recursive methods, which was pretty interesting. But the rendering time was very long, since there are thousands of primitives and we are, again, checking light bounces recursively. Finally, we implemented adaptive sampling, to reduce noise without increasing samples.

This homework was pretty interesting because it's how computer graphics are used in a lot of other things, like game engines or films/animation. So seeing it in practice was pretty interesting. It was also very tedious! But our results ended up being very realistic, so it was worth it! A lot of times, when we view objects we don't really consider things like ambient lighting, but once we add that in, we kind of see how our images somehow become a lot more realistic. Very cool.


Part 1: Ray Generation and Scene Intersection

To generate the rays, we had to take normalized image coordinates and output a ray in the world space. To do this, we followed 3 main steps.

  1. Transform image coordinates to camera space
  2. Generate the ray in camera space
  3. Transform that ray to a normalized ray in world space
To do this, since we know the boundaries of the coordinate systems we were mapping to and from, we found the camera coordinates with these formulas: Then, we had to generate the ray in camera space. To do this, we created a ray with origin (0, 0, 0) and direction (x_n, y_n, -1), and then we normalized the direction. Finally, we had to transform this into a ray in the world space. We tranformed the direction vector via c2w, the camera to world rotation matrix. Then, we normalized the transformed direction. We created a ray with origin pos, direction our new direction vector. Finally, we set this ray's min_t to nClip and max_t to fClip, and returned the ray.

To implement raytrace_pixel, we sampled camera rays and traced them through the scene, returning the average of them. We followed these steps:


We had to implement ray-triangle intersection. To do this, we used the Moller Trumbore formula from lecture. Essentially, we used the edges of the triangle and cross/dot products to check if the ray would hit within the bounds of the triangle. In our code, that means we compute u, v, and t from those products, skip the hit if the determinant is basically zero, and check that u and v stay inside the triangle and t stays between min_t and max_t. If there is a hit, we also get the three vertex normals using u and v. We could've used something similar to homework 1, where we check if a point is inside a triangle via three line equations, but I think that would've been a bit harder to implement with rays, and also Moller Trumbore seemed like the faster, simpler option.

We had to implement ray-sphere intersection. We used the formulae from lecture to implement this, via the quadratic formula/discriminant. We created a helper that found the solutions and ordered them (from small to large), and used the helper to implement checking for intersections and updating the Intersection data. The helper gives us the two t values in order and then we pick the first one that lies on the ray segment and use it to set the intersection.


CBempty.dae
CBspheres.dae
CBgems.dae

Part 2: Bounding Volume Hierarchy

Our BVH construction algorithm, and the heuristic we chose for picking the splitting point:

PART 1: We had to construct a BVH from the given vector of primitives and max leaf size configuration. We followed these steps:

PART 2: We had to check if a ray intersected a bounding box and at what points (of entry and exit). We used the equations from lecture for this, the ray and axis-aligned plane intersection formula and the ray and axis-aligned box intersection method. PART 3: We checked if a ray intersects any primitives in the BVH. We followed this basic algorithm:

Images with normal shading for a few large .dae files that we can only render with BVH acceleration.

maxplanck.dae
CBlucy.dae
cow.dae
blob.dae

Comparing the maxplanck.dae scene with the CBlucy.dae scene, the rendering times for these moderately complex geometries (the first with tens of thousands of triangles and the second with hundreds of thousands of triangles), we can see that the rendering times differ greatly with and without BVH acceleration. For the maxplanck.dae scene, the rendering time with BVH acceleration was 0.0373s. Without BVH accleration, the rendering time was 11.1565s. For the CBlucy.dae scene, the rendering time with BVH acceleration was 0.0225s. Without BVH acceleration, the rendering time was 60.9923s. For the cow.dae scene, the rendering time with BVH acceleration was 0.0302s. Without BVH acceleration, the rendering time was 1.3293s. For the blob.dae scene, the rendering time with BVH acceleration was 0.0468s. Without BVH acceleration, the rendering time was 104.7974s. From these results, we can observe that rendering with BVH acceleration decreases rendering time by at least a multiple of 2. Rendering without BVH acceleration siginificantly lengthens the rendering time, especially with scenes that are composed of moderately complex geometries such as maxplanck.dae and CBlucy.dae.


Part 3: Direct Illumination

We had two different implementations of the direct lighting function.

Images rendered with both implementations of the direct lighting function.




Uniform Hemisphere Sampling Light Sampling
CBbunny.dae Under Universe Sampling
CBbunny.dae Under Light Sampling
CBdragon.dae Under Universe Sampling
CBdragon.dae Under Light Sampling

Focusing on one particular scene with at least one area light and compare the noise levels in soft shadows when rendering with 1, 4, 16, and 64 light rays (the -l flag) and with 1 sample per pixel (the -s flag) using light sampling, not uniform hemisphere sampling:

1 Light Ray (CBlucy.dae)
4 Light Rays (CBlucy.dae)
16 Light Rays (CBlucy.dae)
64 Light Rays (CBlucy.dae)

Here, we display the CBbunny scene with at least one area light. Under light sampling, the noise levels in soft shadows when rendering decreases in area as the number of light rays increases.


The results between uniform hemisphere sampling and lighting sampling is in uniform hemisphere sampling, since rays are distributed fairly in all directions no matter where the light source is located. This diminishes illumination and causes the image to look darker, with more shadows, and more noise. Compared to lighting sampling, lighting sampling specifically direct rays towards where the light source is located which effectively takes in the direct illumination. This causes a brighter image with shadows that are not overpowering and reduces noise. The distribution of rays while taking into account the location of the light source is the main difference between uniform hemisphere sampling and lighting sampling. By using lighting sampling, images will look much brighter and higher quality.


Part 4: Global Illumination

Our implementation of the indirect lighting function:


Some images rendered with global (direct and indirect) illumination, using 1024 samples per pixel:

spheres.dae
CBbunny.dae

Comparing rendered views first with only direct illumination, then only indirect illumination, using 1024 samples per pixel:

Only direct illumination (CBbunny.dae)
Only indirect illumination (example1.dae)

Here, direct illumination is more brighter whereas indirect illumination has softer color bleeding in the ceiling.


N Bounce Rays: For CBbunny.dae, comparing rendered views with max_ray_depth set to 0, 1, 2, 3, and 100, using 1024 samples per pixel. Not accumulating bounces.

max_ray_depth = 0 (CBbunny.dae)
max_ray_depth = 1 (CBbunny.dae)
max_ray_depth = 2 (CBbunny.dae)
max_ray_depth = 3 (CBbunny.dae)
max_ray_depth = 4 (CBbunny.dae)
max_ray_depth = 5 (CBbunny.dae)

N Bounce Rays: For CBbunny.dae, comparing rendered views with max_ray_depth set to 0, 1, 2, 3, and 100. Use 1024 samples per pixel. Accumulating bounces.

max_ray_depth = 0 (CBbunny.dae)
max_ray_depth = 1 (CBbunny.dae)
max_ray_depth = 2 (CBbunny.dae)
max_ray_depth = 3 (CBbunny.dae)
max_ray_depth = 4 (CBbunny.dae)
max_ray_depth = 5 (CBbunny.dae)

Russian Roulette: For CBbunny.dae, comparing rendered views with max_ray_depth set to 0, 1, 2, 3, and 100, using 1024 samples per pixel. Not accumulating bounces.

max_ray_depth = 0 (CBbunny.dae)
max_ray_depth = 1 (CBbunny.dae)
max_ray_depth = 2 (CBbunny.dae)
max_ray_depth = 3 (CBbunny.dae)
max_ray_depth = 4 (CBbunny.dae)
max_ray_depth = 5 (CBbunny.dae)

Here, we see that as max_ray_depth increase, the image gets darker. There is more indirect lighting and softer shadows. The rendering time also increases due to the amount of additional bounces needed.


Comparing rendered views with various sample-per-pixel rates, using 4 light rays.

1 sample per pixel (spheres.dae)
2 samples per pixel (spheres.dae)
4 samples per pixel (spheres.dae)
8 samples per pixel (spheres.dae)
16 samples per pixel (spheres.dae)
64 samples per pixel (spheres.dae)
1024 samples per pixel (spheres.dae)

When samples per pixel is 1, the image has a lot of noise. As the samples per pixel increases, variance is halved at samples per pixel = 2 and moving forward. The image improves in quality, looking more smooth, less noise, and more clean. This shows the impact of convergence rate of Monte Carlo.


Part 5: Adaptive Sampling

Adaptive sampling is when we try to stop continuing rays on pixels that are already considered steady and continue sampling rays that are noisy instead to improve the quality of the image and reduce noise. The implementation was to take several camera rays per pixel and stopping once the pixel color gets steady. For every sample, radiance is calculated and brightness is tracked to estimate mean and variance. Every other sample, we check the noise to make sure it's small enough compared to mean. If it is, stop sampling, if not, continue sampling. Lastly, samples are averaged.


Rendering scenes with at least 2048 samples per pixel, using 1 sample per light and 5 for max ray depth.

Rendered image (bunny.dae)
Sample rate image (bunny.dae)
Rendered image (CBspheres.dae)
Sample rate image (CBspheres.dae)