Computer Graphics and Imaging

PathTracer

Kevin Arias

In this project, I was able to implement a physically-based renderer that used a pathtracing algorithm in order to generate very realistic looking images. We use the concept of rays in order to help us realistically depict light, shadows, and shading within an image. Throughout the project we cast these rays and observe when they happen to intersect with primitives such as triangles and spheres. Simply looking for all of the rays intersections are not enough because this process can be very slow and computationally expensive. By using Bounding Volume Hierarchies, we make the process of testing intersections less costly by recursively splitting bounding boxes in the space through tree traversal so that we can perform ray intersection tests on a much smaller section of primitives rather than constantly checking all primitives for each ray. We then accumulate direct and indirect illumination to create realistic looking global illumination. We simulate bounces between different surfaces and materials in order to create global illumination. And in order to speed this process up, we apply adaptive sampling so that instead of using more samples to reduce noise in an image, we search for pixels that are already converged, allowing us to concentrate our samples in more difficult parts of the image that may require more sampling in comparison. This project has been one of the most difficult to implement and conceptually difficult to understand so far in the course, but it has been one of the most rewarding because our resulting images and renders are extremely detailed and life-like.

Part 1: Ray Generation and Intersection

Our goal for this part of the project is to be able to generate rays and trace them through a scene. Rays in this class are generated using the formula r(t) = o + t * d where o and d are the origin and direction vectors respectively and t is a scalar value representing time which will be helpful in calculating when a ray intersects with primitives. As we’ll see later on, rays in this project can also hold a depth value and a max_t value which I will describe in more detail in a bit. So far, in the function raytrace_pixel(), our goal is to generate and cast random rays throughs a given pixel based on a certain number of samples per pixel (ns_aa). We are provided coordinates corresponding to pixel space which is a problem because ultimately we want to generate our rays in world space. To help with this, we will use our camera::generate_ray() method. In this method, we define bottom left and top right corners using two field of view angles and linearly interpolate these two points with our input point so that we can scale the sample point to the size of the sensor plane. We now convert the resulting direction in camera space to world space applying our provided transform c2w. We now have all of the necessary elements to generate a ray: our ray’s position is the camera’s position, the ray’s direction is our normalized direction we have just calculated in world space, and its max_t value is the provided fClip parameter. We want to repeat this process for ns_aa amount of samples where each sample point is a random sampling point between the range [0,1] which results in us generating ns_aa amount of rays (or in the case where we sample only once, we only cast a single ray through the center of the pixel).


World Space Ray Generation Visual

Simply generating the rays is not enough, we must observe their intersection with primitives (more specifically triangles and spheres for this part) in order to fully understand the relationship between rays and primitives and how together they help depict a scene’s elements. I used the Moller Trumbore algorithm in order to detect ray to triangle intersection:


Moller Trumbore Algorithm

Our intersection point p can be defined as p = b0 * p0 + b1 * p1 + b2 * p2 where p1, p2, and p3 are the triangle’s vertices and b0, b1, and b2 are the barycentric coordinates. Since p is what we are defining as our intersection point, we should equate this with our ray equation defined earlier r(t) = o + t * d in order to calculate t such that our ray intersects our point p at value t. What the Moller Trumbore algorithm allows us to do is calculate our needed t-value and barycentric coordinates by using information we already have such as strategically adding, multiplying, and dividing points from our triangle vertices, our ray’s origin, and our ray’s direction vector. After the algorithm evaluation, we will now have solved for our t-value and our barycentric coordinates. Using this information, we can find out if we have a valid ray-triangle intersection if conditions such as our calculated t-value lying between our ray’s min_t (minimum t-value our ray can have) and max_t (maximum t-value our ray can have) values are met:




Conditions Needed For Valid Ray-Triangle Intersection

If any of these conditions are not met, then the ray does not intersect this triangle. If all conditions are met however, then the ray intersects the triangle at our calculated t-value. We then update our ray’s max_t value to our calculated t-value so that our ray’s max_t value now holds the t-value that intersects the closest primitive. If during the intersection algorithm we are also provided an intersection structure, then we update the intersection structure with our calculated t-value, surface normal at the hit point (calculated using interpolation of our barycentric coordinates and vertex mesh normals), primitive that our ray hits, and the surface bsdf. A similar procedure is also done for detecting ray-sphere intersection. In the case for ray-sphere intersection, we use the quadratic formula in order to solve for t-values that intersect the sphere (there may be more than one t-value that accomplishes this). Our a, b, and c values used for the quadratic function can be calculated by using a combination of our ray’s origin and direction vector, the sphere’s center origin, and sphere’s radius:


Sphere Ray Intersection

Similarly to ray-triangle intersection, we must check if our calculated t-values lie between the range of min_t and max_t. If not then our ray does not intersect the sphere. If the conditions are met, then we set our ray’s max_t value to the lowest t-value calculated since this t-value is when the ray hits the sphere first. If we are also provided an intersection structure then we must update our intersection structure with our calculated closest t-value, surface normal at the hit point (which is a vector pointing from the sphere’s center to the hit point), primitive that our ray hits, and the surface bsdf. After finishing this first part of the project, I was now able to render the normal shading for a few small dae files as shown below:


dae/sky/CBspheres_lambertian.dae
dae/sky/CBcoil.dae


dae/sky/CBgmes.dae


Part 2: Bounding Volume Hierarchy

A bounding volume hierarchy (BVH) is a tree of bounding box volumes that contains a theoretical box of geometric primitive children. Through this algorithm, we can accelerate ray intersection tests because instead of testing for intersections with every single primitive, we test each bounding volume which contains multiple primitives and if a ray does not intersect a bounding volume we reject those primitives inside the bounding box thus eliminating a lot of unnecessary ray intersection tests.


BVH Visual

In my implementation of the BVH, I used a recursive approach to construct my BVH. First we must calculate the bounding box of the primitives provided in the prims vector argument and then we must create a new BVHNode for our BVH and assign our recently calculated bounding box to that node. This process so far has already been generously provided to us by the staff, the rest of the BVH implementation had to be implemented by me. After the construction of the node for our BVH, we must check whether this node is a leaf node or not. In order to determine this, we must check whether the current amount of primitives contained in the node exceeds the maximum amount of primitives a leaf node is allowed to contain (which can be found in max_leaf_size). If it does not exceed max_leaf_size, then this node is a leaf node and should be returned, it will not have any children. If our node is not a leaf node then we continue with the algorithm. We must now pick the longest axel to split by comparing the bounding box’s extent values of each dimension of the bounding box. The extent values are just the differences in distance between the maximum and minimum corners of the bounding box, or in other words it tells us the length of each dimension of the box. So we compare the extent values in the x, y, and z dimensions and pick the largest of the three in order to help us split our primitives into two groups for our BVH. The heuristic that I used in order to decide which child node each primitive will be assigned to is to compare each primitive’s centroid coordinate and see if it is less than or greater than the midpoint of the bounding box on the largest axis (which is our splitting point for the BVH). If a primitive’s centroid coordinate is less than the splitting point (midpoint of the bounding box on the largest axis) then we add this primitive to the vector of primitives assigned to the left child of the current node, and if the primitive’s centroid coordinate is greater than the splitting point, then we add this primitive to the vector of primitives assigned to the right child of the current node. We then recursively call our BVH construction algorithm on the left and right children of the current node until we reach a leaf node.

However there is one important case that we must take into consideration during BVH construction. If we were ever to be in a situation where one of the left or right children contain no primitives and the other contains all of the primitives, we run into infinite recursion because the child that is assigned all of the primitives will never reach the base case. In order to prevent infinite recursion, I select the vector of primitives that contains all of the primitives, and evenly split the vector into two vectors such that each vector now has an equal amount of primitives and we assign one of the vector of primitives to the left child of the current node and the other to the right child of the current node. Now both children have an even amount of primitives rather than having one child have all of the primitives and the other be empty. We then continue the recursion process on these children instead.

Up to this point, we have only constructed the BVH. Now it is time to use the BVH in order to find ray-intersections in primitives. In order to do this, we must recursively traverse our BVH and check a node’s bounding box to see whether an intersection occurs. We can see if a ray intersects with a bounding box by using the ray intersection with axis-aligned box method as described in class. This process involves us checking the rays intersection of each plane forming the bounding box (yz, xz, and xy). We create two t-values for each plane (one for the entry and other for the exit point of a box using our ray) thus giving us a total of six t-values. Given each pair of t-values for each plane, we want to store the max of the smaller t-values of each pair, and we also want to store the min of the larger t-values of each pair and store each of those t-values in t0 and t1 respectively. Using t0 and t1, we can now determine if the ray intersects the bounding volume. If the following conditions are met (t0 <= t1 and t1 > 0), then an intersection exists.


t-value Axis-Alligned-Plane Equation



Ray Intersection with Axis-Aligned Box

Now that we know how to check whether a ray intersects a bounding box, let us traverse our BVH and perform some intersection tests efficiently. We recursively check each node to see if the ray intersects with the node’s bounding box, if not we return false and no longer need to traverse that node’s children since that node is not hit by the ray. If we hit a leaf, we iterate through each of the node’s primitives and check whether or not our ray intersects through the primitive. If it does, then we return true after a single hit or in the other case where we have an intersection argument, we set our intersection to the closest intersection hit along the ray (since we want our intersection structure to hold a t-value of the nearest intersection point we must make sure to iterate through all primitives in the node) and return true after. If our node does not happen to be a leaf, then we recurse on the left and right children of the node and continue traversing the rest of the BVH efficiently testing for ray-intersections. We can now show the normal shading for a few larger dae files that were previously very slow or unable to render without the efficient BVH:


dae/meshedit/cow.dae
dae/meshedit/maxplanck.dae


dae/sky/CBdragon
dae/sky/CBlucy.dae




Rendering Speed Comparisons


As we can see by comparing the rendering speeds with and without the BVH implementation, the BVH acceleration has drastically decreased render times by an unbelievable amount. Using the original rendering implementation from Part 1 alongside the starter code, this caused rendering to take a large amount of time to fully complete. The reason as to why this process took so long is because we were originally creating a one node BVH and storing all of the primitives under this one leaf node. This made it so that each ray had to iterate all of the primitives and once there began to be a lot of primitives such as when rendering CBlucy with 133796 primitives, this process began to be very computationally expensive. Now with the BVH implementation, rather than iterating through all of the primitives for each ray, we now traverse the BVH binary tree and only iterate the primitives found in the leaf node. This allows us to drastically reduce the amount of unnecessary intersection tests on primitives we were originally performing and the results show this. The cow mesh originally took around 19 seconds to render without the BVH, and after the BVH implementation the render only took 0.0754 seconds. Similarly the MaxPlanck original render took more than 40 minutes to completely render, whereas with the BVH it only took 0.1833 seconds. Lucy originally took more than 50 minutes to completely render, and after the BVH implementation it only took 0.1575 seconds. The original dragon render took the longest which took nearly four hours to completely render, but with BVH incorporated into the code, the render only took 0.1687 seconds. What we can note here, is that rendering without BVH took large amounts of time to completely render, but by using a BVH, the rendering for the same scenes were nearly instantaneous no matter how many primitives and triangles we were considering. The BVH made the process a lot more computationally inexpensive and allowed for quicker rendering.



Part 3: Direct Illumination

In this part of the project, I implemented two methods of direct lighting: uniform hemisphere sampling and importance sampling. In my implementation of uniform hemisphere sampling, we observe a hit point that represents the point where a ray intersection occurs and take a num_samples amount of samples in a uniform hemisphere around the hit point. The samples returned are in object space so we must convert these ray directions to world space by using the transform o2w. For each sample, we cast a ray from the hit point with some slight additional offset (EPS_D multiplied by the ray direction in world space we just calculated) to the world space sample direction. If the ray does not intersect a light source, then we just ignore the ray. Otherwise, if this ray intersects a light source, then we calculate the incoming radiance from the light source and convert it to irradiance. In order to accomplish this, we must use the provided get_emission() method in order to get the incoming radiance, then we scale the calculated incoming radiance with the cosine of the angled ray-direction of the sample and the BSDF. We also divide by the probability distribution function (which is 1/(2*pi) which makes sense since we are sampling from a hemisphere). After these calculations, we now successfully have the calculated radiance and add it to the current lighting sum in L_out. After we do this on all of our samples, we must average the total light by our number of samples (num_samples) and we return this resulting spectrum value representing the estimated lighting of the original intersection point.

UNIFORM HEMISPHERE SAMPLING: -t 8 -s 16 -l 8 -m 6 -H -r 480 360

Importance sampling a similar implementation to uniform hemisphere sampling but rather than sampling in a hemisphere, we sample each light source in the scene. In my implementation, I first begin by looping over all of the lights sources in the scene (denoted as a SceneLight). We must first determine how many samples we are going to use for each light source which can be calculated by checking if each light is a delta light. If it is, then all the samples will be similar so we only need one sample, otherwise we use the provided ns_area_light amount of samples. We now iterate through each sample and calculate the incoming radiance using the provided sample_L() method based off a hit point (ray intersection point). The sample_L method also sets values such as our sample’s ray’s direction (given in world space which we can convert to object space using the w2o transform in order to pass into the BSDF), distance from hit point to the current light source, and probability density function which we will all need for later calculations. Similar to uniform hemisphere sampling, we cast a ray from the hit point with some slight additional offset (EPS_D multiplied by the ray direction in world space) to the world space sample direction. If the ray does not intersect with the scene using our bvh, then we scale our current sample with the BSDF and cosine value of the sample ray’s direction in world space in order to convert the radiance to irradiance and must also divide by the probability distribution function. We add this to the current sum of light calculated so far from the samples in this current light source. After iterating through the samples in this light source, we must also average by the number of samples by dividing by the number of samples and adding this to the total irradiance calculated so far from the light sources. After looping over all lights in the scene, we now have an estimated total irradiance of the original hit point.

IMPORTANCE SAMPLING: -t 8 -s 64 -l 32 -m 6 -r 480 360

Let us compare some more results between using uniform hemisphere sampling and importance sampling.

All the following images used the following setup -t 8 -s 64 -l 32 -m 6 -r 480 360 :


Uniform Hemisphere Sampling
Importance Sampling


Uniform Hemisphere Sampling
Importance Sampling

When comparing uniform hemisphere sampling and importance sampling, there are some pretty distinctive differences visually between the renders they produce. One of the key visual differences between the two is that Uniform Hemisphere Sampling ends up rendering an image that contains a lot more noise in comparison to that of importance sampling. Another visual difference between the two methods is that importance sampling produces images that look a little darker in comparison to that of Uniform Hemisphere Sampling. In terms of visual quality of the image and efficiency however, importance sampling seems to be the better option. If we observe the two Uniform Hemisphere Sampling images of the bunny I have provided (one image is right above this paragraph and the other is near the beginning of Part 3), one image uses 16 samples per pixel and the other uses 64 samples per pixel. By increasing the number of samples per pixel in the bunny images, we can see that there is much less noise when we increase the number of samples per pixel in Uniform Hemisphere Sampling, but in comparison to the exact same image using the same amount of samples per pixel using importance sampling, importance sample’s produced image is much cleaner and crisper. If we want Uniform Hemisphere Sampling’s produced image to look close to the image produced by importance sampling, we would need to increase the amount of samples per pixel in Uniform Hemisphere Sampling by a lot which is very costly. So not only does importance sampling produce an image that is less noisy but it does so with less amount of samples in comparison to Uniform Hemisphere Sampling. Since we are also performing direct illumination, we are not performing any ray bounces yet (that will be implemented in the next part). This causes the resulting images using direct illumination to have very intense colors. Since we are only concerned with direct paths from rays to a light source, only the upper halves of the objects (such as the spheres and bunny) in the images will be bright since they have a direct path to the light source whereas the bottom halves of the objects are blocked from a direct path to the light source causing the bottom half of the objects to be dark.


1 Light Ray
4 Light Ray


16 Light Ray
64 Light Ray

As we increase the amount of light rays, the images produced through importance sampling tend to have less and less noise. This effect is seen throughout the entire image, the more light rays are used, the cleaner and less noisy the entire image becomes. Another neat result of increasing the amount of light rays is that the soft shadows of the object look more natural. If we observe the produced image by using only 1 light ray and concentrate on the shadow, it seems like the shadow is only one black color because the image is so noisy. If we now observe the same image but now with 64 light rays, we can see that the shadow transitions from being black near the object to beginning to blend with the color of the grey floor the farther we look at the object’s cast shadow. This provides a more realistic effect because rather than having the shadow be one color throughout, the shadow has a more realistic and natural transition in color the farther we travel along the cast shadow as we increase the number of light rays. The shadow blends with the floor, visually feeling more lifelike and authentic.



Part 4: Global Illumination

In this part of the project, I implemented full global illumination. Rather than just having direct illumination, we must also incorporate indirect illumination as well. What we want to achieve in this part of the project is to recursively be able to bounce rays from different surfaces found within the scene in order to get more realistic and natural looking images. There were three different bouncing cases needed to be considered. In the first case where we observe no bounces of light, the function zero_bounce_radiance will return zero light unless our ray intersects a light in which that case we return the radiance of the light. In the second case where we observe one bounce, the function one_bounce_radiance will return the direct illumination of the intersected element using either importance sampling or uniform hemisphere sampling as implemented from the previous part of the project. Now we need to look at the case where we try to implement indirect lighting by using the function at_least_one_bounce_radiance. In this method, we first look at the intersection point inputted into the function and we create one sample from the BSDF based off of the intersection point on the intersected surface (which in the process updates our incoming radiance direction and our probability distribution function). We then use a Russian Roulette in order to determine whether or not we terminate the ray early in order to save on computation time. However, since we are performing indirect illuminance we must guarantee that at least one indirect bounce is observed which I made sure happened by checking whether or not the ray’s depth was equal to the provided max_ray_depth. Since every ray is initialized with a depth of max_ray_depth in the function raytrace_pixel, this check guarantees that upon the first iteration of indirect illumination we will trace one indirect bounce regardless of the Russian Roulette. In any other case that we continue and do not terminate the ray, we create another ray with origin EPS_D away from the hit point and direction pointing towards the incoming radiance (in world space). This is meant to simulate a bounce which means that this ray should be initialized with one less depth. And similarly to the previous part, we want to use this ray to convert incoming radiance to outgoing radiance so similarly we scale by the BSDF and cosine factor and divide by the BSDF probability distribution function. We must also divide by the probability we used during Russian Roulette. To fully simulate the bounce radiances, we must recursively call at_least_one_bounce_radiance in this calculation on the ray in order to estimate the higher bounces and fully determine the indirect illumination.

Global Illumination Render using 64 samples per pixel

Below are some images rendered with global (direct and indirect) illumination using 1024 samples per pixel:


CBbunny
CBspheres_lambertian

Below are two renders of CBspheres_lambertian, one is rendered using only direct illumination and the other is rendered using only indirect illumination:

Only Direct Illumination (1024 samples per pixel)
Only Indirect Illumination (1024 samples per pixel)

If we compare the two renders above we can see that in direct illumination, the spheres are very dark and the shadows are very intense. The walls seem to look pretty normal but the ceiling is completely dark. The reason as to why the image appears like this is that direct illumination only considers direct ray intersections to the light source so naturally, the top of the spheres which are directly under the light source will be bright whereas the bottom of the sphere (which is blocked by the upper half of the spheres) is completely black since it has no direct path to the light source. Now if we look at the image rendered by using indirect illumination only, we can observe that the spheres are a lot more detailed and look a little more natural. But the image does seem to be less bright, the colors in the image seem to contrast less, and the shadows cast by the spheres are a lot weaker. These results occur because in indirect lighting, we are only interested in rays that have already bounced at least once. Because of this, it makes sense that the colors are less vibrant and are more mild in comparison to the strong colors seen in direct illumination.

Below are some renders of CBbunny.dae where each render is produced with a different max_ray_depth value. Each image is rendered using 1024 samples per pixel:


max_ray_depth = 0
max_ray_depth = 1


max_ray_depth = 2
max_ray_depth = 3


max_ray_depth = 100

If we observe the pictures above, we can see that increases in lower max_ray_depth values have some very large differences. When max_ray_depth equals 0, we only see the light source because there are no bounces being performed. When max_ray_depth equals 1, we see an image similar to previous parts of the project because we are essentially performing direct illumination. When max_ray_depth equals 2, we begin to see a more natural looking bunny because we are beginning to perform indirect illumination by using more bounces. These three renders are where we can see the most improvement as we increase our max_ray_depth. With higher max_ray_depth values such as 3 and 100, the differences between the images visually begins to be minimal. As we increase the max_ray_depth to higher values, the chances that the rays have already been terminated or the pixels have reached close to their true illuminance are much higher thus making differences between images with higher max_ray_depths smaller in comparison to lower max_ray_depth values.

Below are some renders of CBbunny.dae each using a different value for sample-per-pixel rates. Each render uses four light rays:


sample-per-pixel rate = 1
sample-per-pixel rate = 2


sample-per-pixel rate = 4
sample-per-pixel rate = 8


sample-per-pixel rate = 16
sample-per-pixel rate = 64


sample-per-pixel rate = 1024

The main conclusion that can be seen by observing the images above are that as we increase the number of samples per pixel, the less noisy the produced image becomes. When we look at the image produced with only one sample per pixel, we can see that there is a lot of noise everywhere in the image it is not just limited to a certain area. Now if we observe the image produced with 1024 samples per pixel, the image no longer contains any visible noise. Another big difference between each of the rendered images is their runtime. All images with 16 or lower samples per pixel were very fast to render whereas the image with 1024 samples per pixel took a substantially longer amount of time to completely render.

Part 5: Adaptive Sampling

In this part of the project, I began to implement Adaptive Sampling. As can be noticed by observing images from the previous part, Monte Carlo path tracing tends to produce images with large amount of noise. This can be solved by sampling more but that would be too costly. What adaptive sampling recognizes is that some pixels might just need less samples in order to converge and produce a noiseless result whereas as other pixels might need many more samples to eliminate noise. Adaptive sampling tests whether or not a pixel has converged and stops tracing more rays for the pixel if it has converged thus allowing our implementation of adaptive sampling to allocate more computational power to other pixels that require higher sampling rates. In order to implement adaptive sampling, I extended my raytrace_pixel() method. I began my algorithm by creating two variables s1 and s2 which represent the sum of all of the samples’ illuminances and the sum of the squares of the samples’ illuminances respectively. We use these variables to calculate the mean and variance of all n samples using the following formula:


Mean and Variance Calculations
Variable Used to Measure Pixel's Convergence

We use this information to measure the pixel’s convergence. If the variance is very small or we take a large amount of samples, then we can be 95% confident that the pixel has converged and conclude that I <= maxTolerance * mean. If this is the case then we stop tracing the rays for this pixel since we the pixel is almost its authentic illuminance. If the pixel has not converged then we continue tracing more rays. It can be very computationally expensive if we check a pixel’s convergence for each new sample so instead, we only check whether a pixel has converged every samplesPerBatch pixels. Below are my two rendered images, one showing my noise-free rendered result and the other showing the sample rate image. Both of these images were produced with 1 sample per light and 5 for max ray depth with 2048 samples per pixel:



Noise Free Rendered Image
Sample Rate Image


Effects and Complex Materials

Overview

In this assignment, I added some new features to the ray tracer made in the previous assignment. Whereas previously we were concerned with our ability to simply render objects in various efficient ways, we now want to be able to render a wider range of materials onto these scenes and objects and provide more realistic lighting and effects. I first started by adding the ability to render mirror and glass objects in our scenes. Through the use of reflective and refractive properties, I was able to create realistic and correctly-behaving mirror and glass objects. Next I implemented the ability to now render microfacet materials which can allow for a wider range of material rendering including glossy and diffuse material objects. Next, I implemented environment lighting which allowed me to give our objects and materials more visually realistic lighting effects when placed in more lifelike and authentic environments through environment maps. And finally, I added the feature to be able to change lens radii and focal distances in order to simulate using a thin-lens camera model and create rendered images with camera-like focusing and depth of field effects. Overall this project was quite interesting just like the last one because I now understand and appreciate many of the graphical concepts that go into rendering a wider range of materials and producing life-like lighting. Its quite difficult to grasp at first but the produced images created after completing this project are truly visually stunning.


Part 1: Ray Generation and Intersection

For the first part of the project, I had to implement mirror and glass models with both reflection and refraction. I first had to implement mirror materials that took advantage of reflective properties. In the BSDF::reflect() function, I had to reflect wo about the normal (0,0,1) and store that in wi. Next, I had to implement the MirrorBSDF::sample_f() where I had to use my previously implemented reflect() function in order to properly set my wi direction, set my pdf to 1, and return reflectance / abs_cos_theta(*wi). After this, I am now able to render images that use reflective materials. Being able to implement glass material requires a little more work in comparison. In the BSDF::refract() function, I use the concepts from Snell’s Law to refract wo and store that result in wi. In order to do this, I check whether or not in this case I am entering or exiting the non-air material and adjust my eta values accordingly which will then be used to properly assign the values for wi:


wi Equations


We must also make sure to check for the case where we have total internal reflection in which we just return false and the wi remains unchanged. After, I had to implement the GlassBSDF::sample_f() function. The easiest case to consider in this function is when total internal reflection occurs, in which we perform the same steps as the previously implemented MirrorBSDF::sample_f() function. However, in any other case, both reflection and refraction will occur but since we are limited to only returning one ray direction, we will calculate Schlick’s reflection coefficient and use the resulting probability to determine whether we will reflect or refract:

Schlick's reflection coefficient R

Once we know whether we are reflecting or refracting, we set wi using the proper reflect or refract function, set our corresponding pdf using our Schlick’s reflection coefficient, and return our calculated Spectrum value (R * reflectance / abs_cos_theta(*wi) for reflection and (1-R) * transmittance / abs_cos_theta(*wi) / eta^2 for refraction).

Below are seven images, each of a pair of spheres (one made of a glass material and the other of a mirror material) where each image is rendered with a different max_ray_depth value. Each image is rendered using 64 samples per pixel and 4 samples per light:



m = 0
m = 1


m = 2
m = 3


m = 4
m = 5


m = 100


Now let us compare the differences between the images based on their max ray depth values. When we set our max_ray_depth value to 0, our entire image is black and only the light source at the very top is visible. This makes sense because none of the rays can be reflected or refracted yet since we are not allowing any bounces. When we raise our max_ray_depth value to 1, the room becomes lit because of direct lighting but our spheres remain black with no reflecting or refracting properties whatsoever. Once we set our max_ray_depth value to 2, we begin to see the spheres experiencing some sort of proper behaviour. The sphere on the left (mirror sphere) starts to show some reflective properties. What I noticed is that the reflection we see on the mirror sphere seems to be a reflection that looks similar to the scene from the previously rendered image when our max_ray_depth equalled 1. Notice that in the reflection the ceiling seems to be completely black outside of the light source whereas in the actual scene the ceiling is not black, and the sphere reflected on the mirror sphere seems to look dark as well. If we observe the glass ball, it seems to be very dark and is actually performing a very small amount of reflective properties. The max_ray_depth is not yet high enough in order for us to be able to see the full refractive properties. Now when we increase the max_ray_depth to 3, we start to see the beginning of proper reflective and refractive behaviours in both our mirror and glass spheres. If we look closely at the mirror sphere, we can still see that the glass ball in the reflection is still very dark when in reality the actual glass sphere is beginning to exhibit refractive properties. Once the max_ray_depth is increased to 4, we can see that the glass sphere is now being properly reflected on the mirror sphere, it is no longer black. If we observe the area beneath the glass sphere, we can see that that area is beginning to light up because the amount of bounces is now high enough to allow the light to exit the glass sphere and thus light up the ground beneath it. When looking at the rendered image with a max_ray_depth of 5, there does not seem to be any major differences in the behaviours of the spheres or any major visual differences between this image and the previously rendered image when m equalled 4 outside of the fact that the m=5 image seems to be a little noisier and there is an illuminated patch of light on the blue wall as a result of the refraction of the glass sphere from the light source. When we raise the max_ray_depth value to 100, we get a rendered image that basically ends up looking almost exactly the same as the previously rendered image when our max_ray_depth value equalled 5.



Part 2: Microfacet Materials

In this part of the project, we are going to begin to implement the Microfacet model so that we can begin to render Microfacet materials. The first three tasks of this part require us to implement the BRDF evaluation function MicrofacetBSDF::f(). In order to implement this function, we must use and return the following equation where n is the macro surface normal (0,0,1) and h is the half vector:


BRDF Evaluation Function

The shadowing-masking term G was already provided to us but we had to implement the functions to calculate the Fresnel term F and the normal distribution function D. I first began by implementing the normal distribution function D. The NDF helps us define how the microfacets’ normals are distributed. In order to calculate the NDF, we use the Beckmann distribution as shown below where alpha is the roughness of the macro surface, our theta value is the angle between h and the macro surface normal n:


Normal Distribution Function

As we will see later through picture examples, changing the alpha values of a rendered image changes the visual properties of the microfacet material: lower values of alpha tend to make the material look more glossy whereas larger alpha values make the material look more diffuse. Then I began to implement the Fresnel term F. Because calculating the Fresnel term for every possible wavelength could be very computationally expensive and complicated, we record our eta and k scalar values at fixed wavelengths 614 nm (red), 549 nm (green) and 466 nm (blue) which will be helpful for when we want to assign proper eta and k values for our object to appear to be made out of a specific metal later on in the project. In order to calculate the Fresnel term, I used the following equations provided to us in the spec where eta and k represent indices of refraction for conductos:


Fresnel Term Equation

We can now technically begin to render microfacet materials because we already have cosine hemisphere sampling already implemented for us but we want a less noisy image and a sampling method more appropriate for Beckmann distribution so I implemented the BRDF sampling function using importance sampling. First we must sample theta and phi values using the inversion method (where r1 and r2 are random numbers within [0,1) ) and use these values to calculate the pdfs for the Beckmann NDF:


Inversion Method to get Theta and Phi
PDF Calculations

Using our sampled theta and phi values we can calculate our microfacet normal h and reflect wo according to h to give us our sampled light incident direction wi. Then by using our previously calculated pdfs and h value, we can calculate the final pdf of sampling wi with respect to the solid angle and use this in our previously implemented BRDF evaluation function:


PDF of sampling h with respect to solid angle
PDF of sampling wi with respect to solid angle

Below are four rendered images of CBdragon_microfacet_au.dae each using a different alpha value. Each image is rendered using 128 samples per pixel, 1 sample per light, and a max ray depth value of 5:


alpha = 0.005
alpha = 0.05


alpha = 0.25
alpha = 0.5

The changing of alpha values in the image above gave some pretty significant visual changes within the objects in the rendered images above. When our alpha value is 0.005, we get a glossy looking dragon. The image itself looks a little noisy on the dragon and even on the walls. Out of all of the images, this dragon looks the darkest. When we increase the alpha value to 0.05, we get a pretty shiny golden dragon. The dragon looks very glossy and is not as dark as that of the image with the alpha value of 0.005. However, this dragon has a more significant amount of noise throughout the image in comparison to the previous dragon with alpha value of 0.005. These first two images have the lowest alpha values and as a result tend to exhibit some slight mirror properties. If we look at these two dragons we can see the red and blue walls being reflected on the bodies of the dragons and we can also observe the light being reflected on the top halves of the dragons (this light reflection effect is more noticeable on the dragon with alpha value 0.05 however). Now when we observe the dragon with an alpha value of 0.25, we can see that the dragon is beginning to lose the glossy visual properties seen previously. We are beginning to have a dragon that looks like it is made of a slightly more diffuse material. We can also notice that this image barely has any noise. If we look at our final image with an alpha value of 0.5, we get a dragon that looks to be made of a very matte/diffuse material. We can see almost every small detail of the dragon including its scales on the skin of its body and the ridges on its spine. The outside layer of the dragon looks rough as well. This image was the one that appeared to have the least amount of noise.

Below are two rendered images of CBbunny_microfacet_cu.dae each using a different sampling method. Each image is rendered using 64 samples per pixel, 1 sample per light, and a max ray depth value of 5:


Cosine Hemisphere Sampling
Importance Sampling

The visual differences between the bunny renders using cosine hemisphere sampling and importance sampling are pretty significant. Let us first look at our rendered image using cosine hemisphere sampling. The image itself looks quite noisy but when we specifically look at the bunny, the material making up the bunny is pretty patchy and sporadic. The bunny is also relatively dark. The way the bunny is currently rendered, it is pretty difficult for someone to know that the bunny is copper. Now let us look at our rendered bunny using importance sampling. Similar to the render using cosine hemisphere sampling, there is still some noise around the scene of the render but the difference here is that the bunny itself in importance sampling has a significant amount of less noise in comparison. The bunny now looks a lot more smoother and glossy and is no longer patchy. When we look at the bunny, we can now actually tell that the material that makes up the bunny is copper.

Below are two different images each using some other conductor material. I changed the material of the bunny to be aluminum and I changed the material of the dragon to be silver. Each image is rendered using 1024 samples per pixel, 4 sample per light, and a max ray depth value of 7:


Aluminum Bunny (alpha = 0.05)
Silver Dragon (alpha = 0.5)

The eta and k values used to render the aluminum bunny were: eta = <1.1927, 0.96169, 0.67049> and k = <7.0756, 6.3890, 5.4863>

The eta and k values used to render the silver dragon were: eta = <0.15395, 0.14499, 0.13627> and k = <3.6675, 3.1824, 2.5194>


Part 3: Environment Light

In this part of the project we began to incorporate and implement environment lighting. In the real world, incoming light comes from many different directions, there is rarely ever only one perfect light source. What environment lighting helps us achieve in graphics is that it helps give our objects and materials more visually realistic lighting effects when placed in more lifelike and authentic environments. In order to get environment lighting in the project, we use two different sampling methods: uniform sampling and importance sampling. I began by first implementing uniform sampling. In uniform sampling, we sample a random direction on the sphere with uniform probability 1/(4*PI). We then use this random direction to be able to look up the environment map’s radiance value in that sampled direction using bilinear interpolation. What we accomplish with uniform sampling is that we generate a good amount of equally distributed samples that examine all directions equally for incoming radiance values. As observed previously, uniform sampling works to achieve correct lighting for the most part but a sampling method that is more effective is importance sampling. In importance sampling, we bias our selection of sampled directions towards where the incoming radiance is the strongest rather than uniformly choosing random directions. In the real world environment light sources are most concentrated towards brighter light sources and we want to achieve this same effect in environment lighting. In this implementation of importance sampling, we want to be able to give each pixel in the environment map a probability based on the amount of flux passing through the solid angle it represents. We do this by first computing the pdf for every pixel in the environment map and store all of these pixel weights in the variable pdf_envmap. We then calculate the marginal and conditional distributions needed and use these when sampling in order to sample pixels more effectively towards the greatest incoming radiances. This way through importance sampling, we have our pdfs properly calculated to make sure that we are sampling stronger radiances rather than randomly selecting directions to sample. As a result, we get rendered images that have environment lighting.

The .exr file that I used for this part of the project was field.exr. The converted .jpg of field.exr is shown below:


field jpg

Below is my probability_debug.png file created when using field.exr:


probability_debug.png

Below are two rendered images of bunny_unlit.dae each using a different sampling method. Each image is rendered using 4 samples per pixel, 64 samples per light, and a max ray depth value of 5:


Uniform Sampling
Importance Sampling

The differences between the uniform sampling bunny render and the importance sampling render are not as apparent here as in previous comparisons but the differences are still noticeable however. The bunny render that uses uniform sampling happens to have a lot more noise around its face, ears, and a large area around its tail. In these particular areas of the render, the surface of the material seems to be quite patchy and not so smooth. Now when we compare this with the bunny render using importance sampling, we can see that those same areas that were noisy in the uniform sampling render, are now much less noisy. These areas are now more smoother and provide more clean and natural looking transitions when going from lighter areas of the bunny to slightly darker areas of the bunny. So noise seems to be much less apparent here when comparing it to the bunny render using uniform sampling.

Below are two rendered images of bunny_microfacet_cu_unlit.dae each using a different sampling method. Each image is rendered using 4 samples per pixel, 64 samples per light, and a max ray depth value of 5:


Uniform Sampling
Importance Sampling

Now let us compare both microfacet bunny renders when we use uniform sampling and importance sampling. A lot of the points used to describe the differences of the previous two renders persist to these microfacet bunny renders. The microfacet bunny that uses uniform sampling has more noise in comparison to the bunny that uses importance sampling, specifically around its face, ears, and around the tail. Because of the microfacet material it is a little more easy to see the noise and patchy parts of the bunny. There also seems to be some noise around the edges of the bunny as well. Even though there does seem to be some noise in this render, it is not severe enough to be able to prevent someone from being able to identify the rendered object as being a bunny and consisting of copper properties. Now when we look at the microfacet bunny rendered using importance sampling, we can see that those same areas that were noisy and patchy in the uniformly sampled bunny are now significantly smoother, glossier, and transition more naturally in color in comparison. However, in the importance sampled bunny, there does actually seem to be some slight noise around its edges similarly to that of the uniformly sampled bunny but to a much less degree. So areas such as the face, ears, and tail look a lot more realistic and natural for a copper material because of the lessened amount of noise there.


Part 4: Depth of Field


Throughout the project so far, we have been using the pinhole camera model to help us render our images. With ideal pinhole camera models, everything in the rendered images remains in focus. So previously in the project when we have rendered images, if you look closely, everywhere in the image seems to be clearly rendered and in focus. Although this looks very visually pleasing and crisp, this is not how real cameras work. Real cameras, and even human eyes, are modeled as lenses and in this part of the project we simulate a thin-lens camera model in order to achieve this depth of field effect and render more realistically behaving images we are accustomed to seeing and observing every day. For the thin-lens camera model, we basically ignore the thickness of the lens but we reproduce the refractive properties of a real lens. Contrary to pinhole camera models, thin-lens camera models no longer only receive radiance from the center of the lens, they can receive radiance from any area on the thin lens. In order to implement the thin-lens camera model, there were a few steps needed to accomplish our desired effect:


Thin Lens Visualization

First we start by generating a ray as if we were using a pinhole camera model such that it passes the center of the thin lens and intersects the plane of focus (this will be the red line segment in the illustration above). After doing this, we begin to incorporate our thin-lens camera model and uniformly sample a random point on the lens. We then generate a ray that goes from this randomly sampled point on the thin lens in the direction towards the previously calculated point of intersection of the plane of focus (this ray will be the blue line segment in the illustration), this will give us the refraction visual element we desire to have for our thin-lens model. Then we just provide our ray with the proper camera-to-world conversions and we can now properly simulate a thin lens where we can now control our lens radius and focal distance to achieve realistic camera-type focal effects.


Lens Radius = 0.04 Focal Distance = 1.45
Lens Radius = 0.04 Focal Distance = 1.8


Lens Radius = 0.04 Focal Distance = 2.35
Lens Radius = 0.04 Focal Distance = 3.0

Each of the four images rendered above have the same lens radius but each have different focal distances. The visual effects we get from solely changing the focal distance is quite interesting. When we use a focal distance of 1.45 we can see that the dragon's mouth is the most in-focus and clear part of the entire image. We can see the dragon's teeth and tongue very clearly. If we look at the dragon's body and even the wall behind him, we can observe that those parts of the image seem to be out of focus and blurry, you can't really make out all of the fine details on the tail for example. Now when we increase the focal distance to 1.8, we can see now that the dragon's claws and his front chest are the most in-focus and clear. As a result we now lose some detail in the mouth and the tail and wall are still out of focus and slightly blurry. As we increase the focal distance now to 2.35, the dragon's tail is focused and very detailed but now the front of the dragon such as its face and chest are less detailed and more blurry. With a focal distance of 3.0, we can now see that the entire dragon is out of focus and rather the corner of the wall is the part of the image that is most in focus and clear. The pattern we see here with the four images with different depths is that the smaller the focal distance is, the more focused objects closer to the camera will become and the bigger the focal distance becomes, the more focused objects farther away from the camera will become.


Lens Radius = 0.01 Focal Distance = 1.7
Lens Radius = 0.06 Focal Distance = 1.7


Lens Radius = 0.15 Focal Distance = 1.7
Lens Radius = 0.24 Focal Distance = 1.7

The four images rendered above each have the same focal distance but now have different aperture sizes. The four rendered images above are all focused on the same areas around the dragon's front chest. If we look at the dragon with a lens radius of 0.01 we can see that the entire image seems to be focused. Since the lens radius is so small, it is almost like we are rendering the scene using a pinhole camera model. Now when we increase the lens radius to 0.06, we begin to see that the dragon's tail, the lower half of the dragon's body, and the walls become slightly out of focus and blurry. However, the dragon's chest still remains in focus. Now when the lens radius is increased to 0.15, we begin to see that everything outsied of the dragon's front chest begins to be much more out of focus. Parts of the dragon such as its mouth, lower body, and tail become out of focus and the wall becomes even more blurry. These same areas now have much less detail in comparison to the very detailed front chest of the dragon. In the final render using a lens radius of 0.24, we see all of the effects from the previous render are still present just to a much higher degree. The dragon's front chest remains in focus but the rest of the image becomes very out of focus and blurry. If someone was not familiar with the shape of the lower half of the dragon and saw this image alone, the viewer would not be able to tell the shape of that part of the dragon because of how out of focus it is. The pattern we see here through the four pictures with different lens radii is that the smaller the lens radius is, the more in-focus a wider spectrum of objects throughout the scene will be. The larger the lens radius is, the more blurry and out of focus the rest of the scene outside of the main area of focus will become.