Path Tracer
Renderer I wrote for .dae 3D scenes in C++ with support for global illumination, physically-based materials, HDRi lighting, and depth of field
Renderer I wrote for .dae 3D scenes in C++ with support for global illumination, physically-based materials, HDRi lighting, and depth of field
In this project, I implemented a physically-based path tracer that could render 3D .dae scene files. It sends out light rays from a virtual camera and tracks their intersections with the 3D objects in the scene. These intersections, along with the surface properties of the objects, determine the final rendered image. I began with a bare-bones approach, naively sending random rays out into the scene, and implemented a series of optimizations (and more realistic light + surface material modeling) that allowed a very functional basic renderer to result.
My implementation takes in a 3D .dae file, reads command line arguments that specify the render parameters, divides the scene into tiles, and renders each tile with the path tracer implementation. The final light calculation determines the r,g,b value of each pixel in the image.
This project was a great way challenge myself technically and learn how renderers work on a fundamental level.
Rays as a data type are simple vectors that store an origin and direction. Here I use them to simulate rays of light - if a ray intersects an observer and then an object, then that object is visible to the observer. For part one, I implemented a very basic form of this process.
For my implementation, I first created a function that took in an x,y pixel coordinate and determined the integral of the irradiance over the pixel, which was calculated by averaging the samples per pixel. I then created a function to generate a ray originating at the camera. I transformed the incoming coordinate space to camera space, and converted the point to world space to get the direction of the ray. This resulted in rays beginning at the camera sensor, pointing outward towards the world.
Once the rays were created, I had to determine whether they intersected anything in the scene. (In this case, the scene was just made up of triangle meshes and spheres). For triangle intersection, I used the Möller–Trumbore intersection algorithm, which determines the distance along a ray where it intersects a triangle, and also the position within the triangle, using barycentric coordinates. These coordinates just define a point relative to a triangle's three vertices. If the length value and the barycentric coordinates were within a certain threshold, then the primitive was considered intersected and rendered onto the screen. As for sphere intersection, this used the sphere's center position and radius to geometrically calculate if the ray intersected the surface of the sphere.
Below are some images that I rendered after this initial step. Since the implementation was very naïve, even these simple renders took a pretty significant time to complete. Also note that the shading here is simply each primitive's (x,y,z) surface normal represented as (r,g,b) pixel values.
To speed up the results from part 1, I implemented a Bounding Volume Hierarchy (BVH) structure. This algorithm just creates a binary tree grouping of primitives based on their bounding box positions in 3D space, which allows the renderer to narrow down which primitives could be intersected, and only run the intersection calculation if the bounding box is intersected. Each bounding volume node contains pointers to smaller right and left sub-categories of bounding boxes, eventually arriving at a single primitive.
I implemented functions that first recursively creates the bounding-box structure from the list of primitives in the mesh. This algorithm created a bounding volume hierarchy node for the given bounding box, and created left and right children. These child vectors were filled with primitives in the bounding box, and a split-point was used to decide if they went in the left or right child. The split point heuristic I used was the average centroids (center position) of the other bounding boxes. The second algorithm I implemented calculated if a ray had intersected a bounding box.
My implementation does the following: first, it decides if a ray has intersected a bounding box using the intersection algorithm. If it has not, the ray is discarded. Otherwise, it recursively iterates through the child nodes of the bounding box, terminating if no intersection or rendering if an intersection takes place.
This optimization made the renders complete much more quickly - see the example below:
Pre optimization took 321.71 seconds
... and post optimization took only 0.052 seconds!
I was now able to render much more complicated scenes, since the BVH sped things up a lot:
Now comes the fun part - actually rendering realistic, physically-based scenes! To do this, we need to simulate actual light ray behavior. So far in this project I had a way to visualize 3D objects, but not their real physical properties. For the first time, now we can play with some virtual light rays.
First I implemented uniform hemisphere sampling. For each ray that bounces off the surface, we send more sample rays out from that point, in a uniform hemisphere around that point's normal vector, and record if they hit any light sources. If they do, we calculate the incoming radiance from that light, and use a Lambertian BSDF function to compute outgoing radiance, and average across all the samples to get our per-pixel values.
My hemisphere sampling function worked, but it was slow and pretty noisy. To speed it up, I implemented importance sampling, which instead of sampling in a uniform hemisphere around the hit point on the surface, only uses samples that intersect a light in the scene.
Below you can see the difference between uniform and importance sampling with 256 samples per pixel:
Uniform Hemisphere Sampling
Importance Sampling
Here are a few more images I made with my direct illumination implementation:
After I got direct illumination working, I had to figure out how to let the light rays bounce around the scene more than once. Real light doesn't bounce once and disappear, it bounces around until it's absorbed.
To do this, I let the light rays bounce around the scene, but after each bounce, they sample a random function to decide if they get to keep going or are absorbed.
My implementation first called the direct illumination function, then recursively called a function to estimate each subsequent bounce of light. This function took a sample from the BSDF, then obtained the sample's reflectance and determined using the coin-flip method whether or not the ray would terminate. If the ray didn't terminate, and the ray had bounced less than the maximum allowed number of bounces (specified by program argument), then the new bounce ray was created, and its radiance was added by making a recursive call.
Here is what it looked like before & after adding global illumination:
Direct lighting only
Direct + Indirect lighting (global illumination)
And here's a comparison after each bounce of light:
0 bounces
1 bounce
2 bounces
3 bounces
100 bounces
And here's a comparison of the number of samples we use per pixel:
1 sample per pixel
4 samples per pixel
8 samples per pixel
16 samples per pixel
64 samples per pixel
1024 samples per pixel
Next I implemented the ability of the path tracer to selectively adjust the amount of sampling per pixel depending on how quickly it converged to any given r,g,b value. That means that areas of the image with higher contrast or more sharp details got a higher sampling rate as they were more difficult to render accurately, while areas with less detail could afford a lower sampling rate because they converged very quickly. This allows for a render that is both less noisy and more efficient.
To accomplish this task, I determined if the illuminance calculations converged per each sample, by first calculating the mean and variance of the illuminance, and the standard deviation from the variance. If a pixel's average illuminance was within a given tolerance value of the expected illuminance, then the sampling finishes, because the value has converged. I had perhaps the most difficult time debugging this implementation as I got a completely solid red sampling image for a while - it turned out, I was saving over the real values, so although they were there the image never rendered.
Sample rate image (red = high, blue = low)
Rendered image with max. 1024 samples per pixel
Next I implemented reflective and refractive materials.
First I made a mirror material, which approximates a perfect specular reflection, where light that makes contact with the surface bounces off at the exact opposite angle from which it came. This was a simple function that took an outgoing angle as input (remember in path tracing, we're working backwards from the camera to the light source), and reflected it across the object's surface normal to get the incoming angle.
To get the refraction of a surface, I made a function that takes an outgoing angle as input, and returns an ingoing angle based on the object's index of refraction (a number associated with the density of the material). I also used Snell's law to determine if there was total internal reflection, which would terminate the ray. I then used Schlick's approximation to determine whether a ray would reflect off the surface of the glass or refract into (or out of) the glass material.
I used the sphere and cornell box setup from earlier, and set the foreground sphere to use the glass material, and the background sphere to use the mirror material. Have a look at what happens to this scene after each bounce of light:
Zero bounces - just the light source
One bounce - only direct lighting in scene
Two bounces - glass sphere still dark, just direct reflections
Three bounces - refracted light leaves the glass sphere
Four bounces - caustics (bright spot) start to appear under glass sphere
Five bounces - caustics are reflected, and also appear on wall
100 bounces
For this part I implemented microfacet materials, which simulate the behavior of real-life conductors like metals. The surface of these materials is essentially made up of tiny facets, whose shape determines how rough or smooth the material appears.
For this material I used importance sampling to sample the BRDF (Bidirectional reflectance distribution function), which, given an input angle, calculates a half-angle h using a probability distribution function (that approximates a Beckmann normal distribution function), and reflects the input angle about this angle to get our incoming angle.
Below are some images with different roughness values for this material:
Below are two images of the bunny rendered using cosine hemisphere sampling and my importance sampling, with 64 samples per pixel and max ray depth 5. Notice how much more accurate the second image looks - although they are the same number of samples, the first image is more noisy and much darker than it should be. Although with enough samples it would converge at a correct image, the importance sampling arrives there much more quickly.
In this part I implemented the use of 360-degree environment maps to simulate real-world lighting on a scene. This method maps a 2D image onto the inside of a sphere, and uses the color values at each point on the image to cast light inwards onto the scene. This is a very easy way to achieve highly realistic results, and is commonly used in the world of CG rendering.
I first implemented uniform sampling of the environment light, meaning given a light ray, I sampled a random direction on the light sphere and returned the resulting radiance. This was simple, but noisy. (See the images below.) I then implemented importance sampling of the sphere - because most light cast onto a scene from an environment comes from its areas of high radiance.
To implement importance sampling, I created a probability map using the local radiance values of the map. This probability map determined how likely it was that a light ray would sample that point in the environment map. I then created the marginal distribution, which is the cumulative distribution function of each row in the map. I then used Bayes' rule to determine the conditional distribution, which is the cumulative distribution function for sampling any point in a given row of the map. After sampling these functions to obtain (x,y) coordinates, I converted these coordinates into a direction vector and returned the radiance value at that point on the map.
The lighting HDRi
The probability map for the HDRi
Below are some images rendered at 4 samples per pixel, and 64 samples per light, to demonstrate the difference in sampling methods:
Copper material, uniform sampling
Copper material, importance sampling
Diffuse material, uniform sampling
Diffuse material, importance sampling
Finally, I implemented a representation of a real camera lens. This contrasts with previous parts of the project where we used a virtual pinhole camera, where everything in the scene was in focus at once. But to create some out-of-focus effects, I needed to figure out how to model real life lens behavior in my renderer.
My implementation took a focal point position, which is simply where a perfectly-focused ray would have intersected the scene, and calculated a ray from the sensor plane to this point.
Below are GIFs of some renders at 128 samples per pixel with different focal length and aperture:
Aperture 1.0, varying focal lengths
Focal length 2.5, varying aperture