Project 3: Ray Tracing and Illumination

(https://trustinn.github.io/projects/ComputerGraphics/Project3)

Overview

In this project, I have implemented methods simulate light in a scene and have explored ways to speed this up using a bounding volume hierarchy. From completing this assignment, I got a better grasp of how light interacts with objects and how different materials are rendered.

Ray Generation and Scene Intersection

To generate the camera rays for our scene, we normalize our image coordinates which we use to calculate the rays from the camera based on the vertical and horizontal field of views. The bottom left corner of the camera sensor is given by\begin{equation*} bl = \left(-\tan\frac{hFov}{2}, -\tan\frac{vFov}{2} \right) \end{equation*}and for the top right, we get:\begin{equation*} tr = \left(\tan\frac{hFov}{2}, \tan\frac{vFov}{2} \right) \end{equation*}So if we have a point \(x, y\) on the image, it would be a linear interpolation between the bottom left point and top right:\begin{equation*}bl + (x(tr - bl)[0], y(tr - bl)[1]) \end{equation*}

Next would be intersection the ray with various objects in the scene. The first intersection test would be with a triangle, and the method I used included solving a system of equations for the barycentric coordinates of a potential intersection point. This is known as the Moller-Trumbore algorithm, and we can recover the coefficients by solving the system:\begin{equation*}\begin{bmatrix}\mid & \mid & \mid \\-D & (p_{2}-p_{1}) & (p_{3}-p_{1}) \\ \mid & \mid & \mid \end{bmatrix}\begin{bmatrix} t \\ u \\ v \end{bmatrix} = r.o - p_{1} \end{equation*}From this system, we get \(t, u, v\), and for the last barycentric value, we can take \(1 - u - v\). To check that the ray intersects the object, we require that \(t >= 0\) and that there were no objects closer to the ray that the ray has already intersected with. So we also need \(t <= r.max_t\).

Triangle ray intersection renderCoil render

We can also calculate the intersection of rays with sphere since we have the formula for both. The formula for a sphere is:\begin{equation*}\lVert v - s.c \rVert = s.r\end{equation*}And for a ray:\begin{equation*} v = r.o + r.d \cdot t \end{equation*}Then substituting in \(v\) into the sphere equation, we get a quadratic equation and can solve for \(t\) with the quadratic formula:\begin{align*} a &= dot(r.d, r.d) \\ b &= 2 * dot(s.c - r.o, r.d) \\ c &= dot(s.c - r.o, s.c - r.o) - s.r^{2} \end{align*} We check for a nonnegative determinant to get solutions and here is the result of the ray intersection with a sphere:

Sphere ray intersection render

Bounding Volume Hierarchy

In a bvh, we store objects and their bounding boxes. When we construct our tree out of these bounding boxes, we split the bounding boxes into a left and right set and call our constructor on the left and right set. We continue dividing the bounding boxes until there are less than max_children objects in the current set. A bvh allows us to efficiently prune off objects in the tree that we know do not intersect with our ray when we start raytracing.

The split method I used was computing the averages of the centroids of each bounding box. I then iterate over each dimension computing the splits in each. So for dimensions \(x, y, z\), I would get sets \(l, r\). Then the cost given to the split is:\begin{equation*}\text{SA}(l) \cdot \text{l.size}() + \text{SA}(r) \cdot \text{r.size}()\end{equation*}The dimension that gives the lowest cost is chosen. At a high level, this computes the cost of raytracing, as the probability of a ray hitting a box is proportional to the box's surface area, and the cost of traversal is equal to the number of items in the box.

The objects in the bvh were stored in a flat array, and the current objects in our node are represented by a start and end pointer to a continuous segement. So to split, we sort this array similar to inplace quicksorting, then recurse given a left, right partition.

Finally, here are some renders of scenes with a large number of objects:

blob default shading with bounding volume hierarchy
blob, bvh, default shading
blucy default shading with bounding volume hierarchy
blucy, bvh, default shading
dragon default shading with bounding volume hierarchy
dragon, bvh, default shading

Direct Illumination

Direct Illumination refers to the incoming light onto a surface from a light source without indirect bounces. When calculating the illumination an object recieves, we consider the angle of the incoming ray, the bsdf function that describes the surface (ratio/distribution of incoming light to outgoing light), and finally, the actual luminous power and color the ray carries.

Zero-bounce Illumination

In zero-bounce illumination, from a given ray w_in, we would compute the intersection of that ray with our scene. The object that it intersects with gives an emission, which is what I returned from the function.

Direct Lighting with Uniform Hemisphere Sampling

To implement uniform hemisphere sampling for lighting, I sampled for vectors in the hemisphere of the origin point of the ray. Then for each sample, I compute the ray's intersection with the scene, and we can get the illumination by:\begin{equation*}\text{isect.bsdf}\rightarrow\text{get_emission()} \cdot \text{cos_theta(w_in)} \cdot \text{bsdf}\rightarrow\text{f(w_out, w_in)}\end{equation*}Here are some examples:

bunny, uniform sampling
bunny
coil, uniform sampling
coil
dragon, uniform sampling
dragon

Direct Lighting by Importance Sampling Lights

For importance sampling, we iterate over each light and sample from it \(n\) times. After sampling from the light, we set the ray's min_t to EPS_F and max_t to distToLight - EPS_F to check if there are any scene objects between the light and surface that would block the light. If not, we compute the illumination similar to above and take the average out of those \(n\) samples. Since we do not sample uniformly, we need to divide our sample by the probability of making that sample. Finally, summing the illumination over all light sources gives us the lighting from importance sampling.

bunny, importance sampling
bunny
coil, importance sampling
coil
dragon, importance sampling
dragon

Here is an example of increasing the amount of samples we draw from each light source:

Coil with 1 light samples
Coil, light 1
Coil with 4 light samples
Coil, light 4
Coil with 16 light samples
Coil, light 16
Coil with 64 light samples
Coil, light 64

A difference between uniform sampling and importance sampling would be the amount of noise in the image. With importance sampling, we sample directly from the light source, which allows us to converge faster to the solution. From the images above, the ones obtained from uniform sampling are grainier, with much more variation in the color on each wall compared to importance sampling.

Global Illumination

For indirect lighting, we would use a recursive function and trace a ray to a surface. If the ray's depth was 1, we would just return the one bounce illumination from the previous part. Otherwise, we sample a ray on the hemisphere, translated to world coordinates, recurse using the function. Since the function returns incoming light, we would multiply by the bsdf and cos_theta for the angle, giving the illumination of the surface.

This is the illumination where we do not accumulate the illumination from previous bounces:

Luminance with num_ray_bounces = 0
depth = 0
Luminance with num_ray_bounces = 1
depth = 1
Luminance with num_ray_bounces = 2
depth = 2
Luminance with num_ray_bounces = 3
depth = 3
Luminance with num_ray_bounces = 4
depth = 4
Luminance with num_ray_bounces = 5
depth = 5

And this is with isAccumBounces enabled:

Accumulated bounces with max_ray_depth = 0
m_depth = 0
Accumulated bounces with max_ray_depth = 1
m_depth = 1
Accumulated bounces with max_ray_depth = 2
m_depth = 2
Accumulated bounces with max_ray_depth = 3
m_depth = 3
Accumulated bounces with max_ray_depth = 4
m_depth = 4
Accumulated bounces with max_ray_depth = 5
m_depth = 5

When the light is not accumulated, with more bounces, it loses brightness, and this makes sense because the light is scattered when it comes in contact with a surface. When the light is accumulated, the scene becomes brighter, because many areas are more likely to come in contact with light rays when we allow for more bounces.

We can also split the rendering into direct and indirect lighting. Something interesting to note is that with direct lighting, we get the base color of the object while indirect lighting captures more of the reflective properties of the object:

Dragon rendered with original lighting
original
Dragon rendered with direct lighting
direct
Dragon rendered with indirect lighting
indirect

Finally, this shows spheres rendered with a varying number of samples-per-pixel:

Spheres rendered with 1 samples per pixel
1 spp
Spheres rendered with 2 samples per pixel
2 spp
Spheres rendered with 4 samples per pixel
4 spp
Spheres rendered with 8 samples per pixel
8 spp
Spheres rendered with 16 samples per pixel
16 spp
Spheres rendered with 64 samples per pixel
64 spp
Spheres rendered with 1024 samples per pixel
1024 spp

Russian Roulette

Computing the global illumination requires us to sum over all the \(k\)-bounces of light through a scene for \(k \geq 0\). An unbiased solution to this problem involves flipping a coin with weight \(cpdf\) to check whether the ray should continue bouncing. If it does, we calculate the incoming light and divide that additionally by the \(cpdf\). If not, we return the one bounce illumination function as the base case.

Russian roulette bunny scene with max_ray_depth = 0
m_depth = 0
Russian roulette bunny scene with max_ray_depth = 1
m_depth = 1
Russian roulette bunny scene with max_ray_depth = 2
m_depth = 2
Russian roulette bunny scene with max_ray_depth = 3
m_depth = 3
Russian roulette bunny scene with max_ray_depth = 4
m_depth = 4
Russian roulette bunny scene with max_ray_depth = 100
m_depth = 100

Adaptive Sampling

In adaptive sampling, we check that the margin of error is less than a given tolerance. So given \(n\) samples, I calculated the illumination of the sample, the average illumination and variance of illumination. Then \begin{equation*}I = 1.96 \cdot \sqrt{\frac{\sigma^2}{n}}\end{equation*}and I checked for every \(32\) samples if \(I \leq maxTolerance \cdot \mu \) which would be the condition to stop. Here are some results:

Adaptive sampling of bunny
bunny S=2048 M=5
Adaptive sampling rate of bunny
bunny sampling rate
Adaptive sampling of coil
coil S=2048 M=5
Adaptive sampling rate of coil
coil sampling rate
Adaptive sampling of dragon
dragon S=2048 M=5
Adaptive sampling rate of dragon
dragon sampling rate

With adaptive sampling, parts of the scene that are not directly visible to the light are sampled more. For instance, the shadow of the bunny and coil are red, while the walls are blue.