CS 184: Computer Graphics and Imaging, Spring 2023

Project 3-1: Path Tracer

Zhihan Cheng, Yuerou Tang

Website URL: https://cal-cs184-student.github.io/project-webpages-sp23-qs/proj3-1/index.html



my bunny is the bounciest bunny

Overview

In this project, we implemented ray tracing methods. We started from constructing BVH, a data structure that allows us to compute ray intersection with objects faster. We then first implemeneted direct lighting, which are lights that directly come from a light source. Then we implemented one bounce illumination, which are the lights that bounce off from other objects. We also implemented Russian Roulette and Adaptive sampling to enable more efficient rendering.


Part 1: Ray Generation and Scene Intersection (20 Points)

Ray Generation

The Camera::generate_ray(...) function takes in (x, y) in image coordinates and output a ray with origin and direction in the world space. To do this, we first compute a i2c matrix that transfer a homogenous 2D coordinates in image space to a homogenous 2D coordinates in the camera space. The i2c matrix is defined so that bottom left corner, $(0, 0)$, transfers to $(-tan(\frac{hFov}{2}), -tan(\frac{vFov}{2})) $, and the upper right corner, $(1, 1)$, transfers to $(tan(\frac{hFov}{2}), tan(\frac{vFov}{2})) $. Since in the camera space, the z coordinates is always -1, we set the z axis of our point to -1, after applying i2c, transforming it into camera space.
We then generate the ray with the camera position in the world space as origin and with direction pointing from $(0, 0, 0)$ to our point in camera space normalized and transformed into the world space.
We also set the max_t and min_t of the ray.

The PathTacer::raytrace_pixel function is used to estimate the radiance at each pixel. To achieve this, we first generate ns_aa number of rays that originate at the camera and passes through the pixel at the given point with some random offset on an image. We use the Camera::generate_ray(...) function to do so. We follow each ray to estimate the radiance. We sum all radiances and store the average value into sampleBuffer.

Ray Intersection with Triangles

To test intersection with triangle, we solve for time t when ray intersects with the plane in which the triangle lies. The intersection is only valid when the following two conditions is met:

  1. Time t for point of intersection must be greater than 0.
  2. Point of intersection must be within the triangle itself. We use barycentric coordinates to test this. If $(\alpha, \beta, \gamma)$ are all within 0 to 1, the intersection is valid.
We used the Möller-Trumbore Algorithm. This algorithm solves the function: $\vec{O} + t\vec{D} = (1 - b1 - b2)\vec{P_0} + b1\vec{P_1} + b2\vec{P_2}$. It is an optimized way to test for whether the intersection is within the triangle without first calculating the plane eqution.


Ray Intersection with Spheres

To test intersection with spheres, we solve for time t when ray intersects the sphere. We solve the quadratic function, and there're three possible outcomes:

  1. There's no solution. This means that the ray does not intersect with the sphere.
  2. There's one solution. This means that the ray is tangent to the sphere.
  3. There're two solutions. If both t are greater than 0, we have two intersections with the sphere.


Images with normal shading for a few small .dae files.

Empty Room
Spheres with normal shading


Part 2: Bounding Volume Hierarchy (20 Points)

BVH construction algorithm & heuristic for picking the splitting point.

We build our BVH resursively. Our algorithm partitions the primitives into two groups and recursively applies the algorithm to each group until the number of primitives left is smaller than maximum_leaf_size. We always choose the split primitives along the axis with the largest range difference. In this way, we will most effeciently decrease the range of the bounding box for the BVHNode in next level. The group that each primitives belong to is determined by comparing each primitive's centroid to the mean centroid of all primitives. If the primitive centroid is smaller than the mean centroid, the primitive belongs to the left group, otherwise, it belongs to the right. In each recursive call, we build a BVHNode. A leaf BVHNode will store all primitives it contains, while a non-leaf node stores left and right pointer to the BVHNode built on its left and right primitive groups.


Normal shading for large .dae files that can only be rendered with BVH acceleration.

maxplanck.dae
CBLucy.dae

Comparison of rendering times on scenes with moderately complex geometries with and without BVH acceleration.

Scene Without BVH With BVH
Cow 25.2083s 0.1387s
MaxPlanck 219.0163s 0.1730s
CBLucy 1330.6861s 0.2100s

As we can see, when rendering moderately complex scenes, using BVH acceleration can significantly reduce rendering times. Without BVH acceleration, the render would need to test each primitive in the scene individually, leading to much longer rendering times. With BVH acceleration, the hierarchy allows the render to more quickly determine which objects the ray could potentially intesect, reducing the number of tests needed and resulting in faster rendering times. The speed up is most significant in scenes with higher complexity.


Part 3: Direct Illumination (20 Points)

Walk through both implementations of the direct lighting function.

Hemisphere sampling: To estimate how much light were reflected at the given intersetion point (hit_p), we first estimate how much light arrived at the point. In this approach, we implemented it by uniformly sampling the hemisphere around hit_p to get the possible rays that might hit the intersection (blue rays below in the illustration).

We then test if the ray intersects the scene. If it does, we have obtained the necessary components to calculate radiance from the intersection with this formula:

where \(f_r\) is \(\frac{\rho}{\pi}\), \(L_i\) is the emission of the light source (since we're dealing with direct lighting), \(cos\theta_j\) is the angle between the sampled vector and the object surface normal, and \(p(w_j)\) is the pdf of the sampled vector. Since we're sampling uniformly, it would be \(\frac{1}{2\pi}\) because the solid angle for a hemisphere would be \(2\pi\).

Importance sampling: We still need to estimate how much light arrived at the point, but instead of uniformly sampling from a hemisphere, we instead will sample the light directly. SceneLight::sample_L(Vector3D& p, Vector3D* wi, double* distToLight, double* pdf) takes 1 sample, and returns the sampled ray (between the object and the light source direction), the sampled emittance, distance from the object to the light source and the pdf of the sampled direction. We can then use the distance from the object to the lightsource, and check if the ray intersects any surface in front of the light source within this range (between the light source and the object). If there is, then the light doesn't really cast any radiance to the object. If there isn't then we calculate radiance from that field with the same technique as above.

Show some images rendered with both implementations of the direct lighting function.




Uniform Hemisphere Sampling Light Sampling
CBbunny.dae, s = 64, l = 32
CBbunny.dae, s = 64, l = 32
CBspheres_lambertian.dae, s = 64, l = 32
CBspheres_lambertian.dae, s = 64, l = 32

Focus 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.

CBbunny.dae, s = 1, l = 1
CBbunny.dae, s = 1, l = 4
CBbunny.dae, s = 1, l = 16
CBbunny.dae, s = 1, l = 64


Compare the results between uniform hemisphere sampling and lighting sampling in a one-paragraph analysis.

Hemisphere sampling produced a more grainy result, and the details were not as good as importance sampling. There is also less noise on importance sampling result. This was very obvious in the background -- hemisphere sampling produced a coarse background, while importance sampling produced a smoother one. This could be due to the fact that hemisphere sampling takes in vectors from all directions with equal weight, but only a few rays are contributing to the final radiance, while importance sampling weighted them with their probability.


Part 4: Global Illumination (20 Points)

Walk through your implementation of the indirect lighting function.

We implemented the PathTracer::at_least_one_bounce_radiance function to achieve global illumination with ray bouncing multiple times in the scene. We first account for cases where max_ray_depth is 0 or 1 in the PathTracer::est_radiance_global_illumination function. Then, inside PathTracer::at_least_one_bounce_radiance function, we first calculate the onc_bounce_radiance at the hit point, and sample a direction and its corresponding next intersection point with DiffuseBSDF::sample_f.

The next intersection is where light would bounce to, or come from, since in ray tracing, we are doing it backwards. And then we check for conditions to continue the recursion:
  1. max_ray_depth must be greater than 1.
  2. Russian Roulette has not terminated the bounce. Russian Roulette is done with coin_flip function with probabily 0.3 to prevent infinite recursion.
If both conditions are met, we call PathTracer::at_least_one_bounce_radiance function recursively on the newly initiated ray and next intersection point and use the reflection function to add the next bounce radiance to current radiance.


Bunny scene rendered with global (direct and indirect) illumination, using 1024 samples per pixel.

Direct illumination of bunny.dae
Global illumination of bunny.dae

CBspheres_lambertian.dae scene, rendered with 1024 samples per pixel.

Only direct illumination
Only indirect illumination

Direct illumination refers to light that comes directly from light sources and shines onto an object, while indirect illumination refers to light that has bounced off other objects and illuminates an object indirectly. The scene above rendered with only direct illumination have very strong and defined shadows, with objects that are not directly illuminated appearing very dark or even black. The overall lighting in the scene is very dependent on the placement of the light sources.

On the other hand, the scene rendered with only indirect illumination have much softer shadows and a more diffused, natural lighting. The scene has a more diffused and natural lighting, as light bounces around the environment and illuminates objects from various angles.


For CBbunny.dae, compare rendered views with max_ray_depth set to 0, 1, 2, 3, and 100 (the -m flag). Use 1024 samples per pixel.

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 = 100 (CBbunny.dae)

The max_ray_depth parameter determines the maximum number of times that a light ray can bounce off surfaces in a scene before it is terminated. When max_ray_depth is set to 0, the rendered view will only show the light source itself. And when max_ray_depth is set to 1, the rendered view shows only direct illumination. Then, as the value of max_ray_depth is increases, the rendered view gets brighter, as light rays bounce off surfaces in the scene more times. This leads to more diffuse lighting and softer shadows, as well as a greater sense of depth and realism in the scene.

However, increasing max_ray_depth beyond a certain point can lead to diminishing returns, as the effect of indirect illumination becomes less noticeable with each additional bounce. In the above scene, the image rendered with max_ray_depth set to 3 and max_ray_depth set to 100 is already very similar. And the rendering process becomes significantly slower without a significant increase in visual quality.


CBspheres_lambertian.dae scene, rendered with 4 light rays and various sample-per-pixel rate.

1 sample per pixel
2 samples per pixel
4 samples per pixel
8 samples per pixel
16 samples per pixel
64 samples per pixel
1024 samples per pixel

The sample-per-pixel rate is the number of samples taken by the render for each pixel in the rendered image. As we can observe from above, images get smoother and less noisier as we increase the sample-per-pixel rate. This makes sense intuitively since as more samples are taken, lighting and material properties of the scene is better captured.


Part 5: Adaptive Sampling (20 Points)

Explain adaptive sampling. Walk through your implementation of the adaptive sampling.

Adaptive sampling is when we don't sample all the pixels all the way to the maximum number of samples that we set. This is because some pixels coverges very fast, so we don't need to compute further samples after it converges. However, some other pixels might need more samples. The threshold that we used to determine if a pixel needs further sampling is by checking whether \(I \leq maxTolerance * \mu\), where \(I = 1.96* \frac{\sigma}{\sqrt{n}}\). \(\mu = \frac{s1}{n}\), \(\sigma^2 = \frac{1}{n-1}*(s_2-\frac{s1^2}{n})\), \(s_2 = \sum{x_k^2}\), \(s1= \sum{x_k}\). \(x_k\) is the illuminance of each sample. For every samplesPerBatch number of samples, we perform this check to see if the pixel has converged, and if so, we stop estimating the radiance for the pixel.


Pick two scenes and render them with at least 2048 samples per pixel. Show a good sampling rate image with clearly visible differences in sampling rate over various regions and pixels. Include both your sample rate image, which shows your how your adaptive sampling changes depending on which part of the image you are rendering, and your noise-free rendered result. Use 1 sample per light and at least 5 for max ray depth.

CBbunny.dae
s_max = 2048, l = 1, m = 5
Sample rate image CBbunny.dae
s_max = 2048, l = 1, m = 5
CBspheres_lambertian.dae
s_max = 2048, l = 1, m = 5
Sample rate image CBspheres_lambertian.dae
s_max = 2048, l = 1, m = 5

As you can see, different areas now have different sampling rates. Red indicates high sampling rate, and blue indicates low sampling rate.