Simple GPU Path Tracing, Part. 3.0 : Path Tracing Basics
- Simple GPU Path Tracing : Introduction
- Simple GPU Path Tracing, Part. 1 : Project Setup
- Simple GPU Path Tracing, Part. 1.1 : Adding a cuda backend to the project
- Simple GPU Path Tracing, Part. 2.0 : Scene Representation
- Simple GPU Path Tracing, Part. 2.1 : Acceleration structure
- Simple GPU Path Tracing, Part. 3.0 : Path Tracing Basics
- Simple GPU Path Tracing, Part. 3.1 : Matte Material
- Simple GPU Path Tracing, Part. 3.2 : Physically Based Material
- Simple GPU Path Tracing, Part. 3.4 : Small Improvements, Camera and wrap up
- Simple GPU Path Tracing, Part. 4.0 : Mesh Loading
- Simple GPU Path Tracing, Part. 4.1 : Textures
- Simple GPU Path Tracing, Part. 4.2 : Normal Mapping & GLTF Textures
- Simple GPU Path Tracing, Part. 5.0 : Sampling lights
- Simple GPU Path Tracing, Part 6 : GUI
- Simple GPU Path Tracing, Part 7.0 : Transparency
- Simple GPU Path Tracing, Part 7.1 : Volumetric materials
- Simple GPU Path Tracing, Part 7.2 : Refractive material
- Simple GPU Path Tracing, Part 8 : Denoising
- Simple GPU Path Tracing, Part 9 : Environment Lighting
- Simple GPU Path Tracing, Part 10 : Little Optimizations
- Simple GPU Path Tracing, Part 11 : Multiple Importance Sampling
Now that we can ray trace our scene, we're ready to start implementing a path tracing algorithm!
In this post we will be implementing a very naive approach and the result will still be far from our goal of photorealistic imagery, but we will still make some progress.
The code for this post will be on this branch of the github repo.
Some theory
Let's first quickly review some overly simplified theory around how we will be calculating lighting in the scene with path tracing.
In a scene with a light, we can think of light as millions of rays that are emitted from the light source and that scatter in the scene :
Then, some of those rays will escape from the surface they hit, and they will get reflected into other directions :
Note that each ray has a colour here. The ray that's coming from the light is white, which is the colour of the light. Then, when it hits the red box, all the rays that will get reflected from that surface will become red too, because the ray's colour gets multiplied by the colour of the shape it hits, so (1,1,1) * (1, 0, 0) becomes (1,0,0).
That's a big (huge) simplification, but it helps understanding that the rays of light change colour as they bounce across the scene.
Some of those reflected red rays will eventually hit a camera, and form a red colour on the camera film.
Now, if we were to generate lots of ray from the light source, and bounce them around the scene until one reaches the camera, we would have to wait for a very long time for the camera film to be filled with colours, because only a small portions of all the light rays eventually hit the camera.
Instead, in path tracing, we do the same process backwards : we generate lots of rays from the camera and we check if they intersect the scene. When an intersection is found, we want to know if it's being illuminated by a light, and what colour that shape is at this intersection. We can then do the same calculation as described before, where we multiply the colour coming from the light ray with the colour of the shape.
So you can think of the ray coming from the camera as a black ray, meaning it's not transporting any light on it. If it hits a red shape, (that would be intersection #1), it would still be black, as the red shape doesn't emit any light as far as we know. But if we shoot another ray from that red shape, and that ray hits the light (intersection #2), then we know that intersection #1 on the red shape can be illuminated by that light, and we can apply the colour multiplication, and the camera ray will become red.
So we can return that red colour to the camera, and set the pixel to red on the film.
Again, that's very simplified. In theory, when we reach intersection #1, we want to shoot rays in the entire hemisphere around the surface, to know how much light comes from all directions to that point.
The only problem is there doesn't exist a way of calculating that mathematically.
I'm not going to go into too much details, there are lots of resources that explain the low level maths much better than I could possibly do, but basically that process of calculating how much light comes in to a specific point boils down to the calculation of an integral over the hemisphere surrounding the point.
This integral is called the "Rendering Equation", and doesn't have a closed form, meaning we cannot calculate it. Here's the formula of this equation :
Let's break down each term of that equation :
- L(x, wo) : How much of the light coming in the position X in the scene reflects to the direction Wo (Direction from this point to the camera), i.e what's the value of the pixel whose ray hits the scene at position x.
You can see that as I said, it's an integral over a hemisphere. Let's analyse the 4 terms of this integral :
- f(x, wo, wi) : This is called the BRDF (Bidirectionnal reflectance distribution function). It's a function that takes 2 input directions, wo and wi. What it calculates is, at the given point x, how much of the light coming from direction wi is reflected to direction wo. We will see more on that later when we implement BRDF functions, but essentially it defines how a material reacts to lighting. A mirror BRDF would only reflect light to a very specific direction (the "perfect" reflection direction). A diffuse or lambertian brdf would reflect incoming light to all directions in the hemisphere. You can learn more about brdf's on this course by Jakub Boksansky
- L(x, wi) : This is the rendering equation itself ! And it's the reason why there's no closed form to this integral, it's because it's a kind of recursive integral. It accounts for how much light is coming to this point x from the direction wi, which is the direction that we're integrating with.
- cos(theta): That's to account for a physics rule that states that the the more light arrives perpendicular to a surface, the higher the light intensity will be. If the light hits a surface at a grazing angle, it will less intense. Here's an illustration of that :
If the light hits perpendicularly to the surface, you see that all the power of the light is concentrated in a small area part of the surface :
contrarily, if the light hits at an angle, the power of the light becomes spread on a larger surface, meaning each point on that surface receives smaller amounts of light :
- d(w) : This represents the solid angle over which we're integrating.
But again, we can't solve this integral analytically : What we can do though is using something called Monte-Carlo integration to approximate the result of this integral, by taking random numbers, also called "samples", evaluating the functions at those samples, and summing all the samples to obtain an approximation of the result.
This website has an in-depth explanation of how and why this works.
In practice, what happens is that when we shade the point X, and we want to calculate how much light comes to this point from the surrounding scene (L(X, wi), we will shoot many rays in all directions around this point, and evaluate how much light is coming. This is how Monte-Carlo integration works : To evaluate an integral, we take many many samples of that function and kind of compute an average of all those values.
In this post, we will use a much simpler version of the rendering equation. We will not use the cos(theta) term, and the BRDF will always be equal to the colour of the shape.
Let's build up an example to illustrate all of that :
As before, we will be generating one ray for each pixel in the image.
we will test if the ray intersect the scene, keeping track of 2 colour values :
- Radiance, which starts at (0,0,0)
- Attenuation, which starts at (1,1,1)
If the ray intersects an object, we will modulate the attenuation with the colour of that object :
So in this case, the attenuation becomes (0.8, 0.8, 0.8) (the colour of the floor) and the radiance still is (0,0,0)
We then trace a ray from this intersection point to another random location, and do the same :
If it hits an object, we modulate the attenuation with the colour of the this object :
The attenuation becomes ((0.8, 0.8, 0.8) * (0.9, 0.1, 0.1) = (0.72, 0.08, 0.08) and the radiance still is (0,0,0)
We then shoot another random ray from the new position, which is going to hit the sky :
If it hits the sky, we set the global radiance variable to emission of the sky times the current attenuation, and we step out of the ray recursion. We set the value of the pixel to this radiance value.
Here, let's say that the sky's emission is (1,1,1), which means it's emitting a white colour from all directions.
therefore, Radiance = (0.72, 0.08, 0.08) * (1,1,1)
the pixel value becomes (0.72, 0.08, 0.08).
But here, the floor is supposed to be white, why is the pixel red then ?
Well, that's just one random sample. Another random sample could be :
Ray hits the floor, Attenuation = (0.8, 0.8, 0.8)
Then, the secondary ray hits the sky directly, which means Radiance = (0.8, 0.8, 0.8) * (1,1,1) = ( (0.8, 0.8, 0.8)
When we accumulate a lot of samples, some will be red because they hit the red wall next to the floor, and some will be white because they hit the sky. Because we blend the result of each sample together, this will give the floor at this position a red tinted white colour, which makes sense because the wall kinda reflects its own colour on the floor.
Here's an outline of the algorithm that we will implement :
For each pixel
Ray = GenerateRay(pixel)
Radiance = 0
Attenuation = 1
for(bounce in maxBounces):
intersection = IntersectScene(ray)
if(!intersection.hit):
Radiance += EnvLight()
break out of the loop;
else:
NextDir = RandVectorInHemisphere()
Attenuation *=HitShapeColour
Ray = {HitPosition, NextDir}
Image[Pixel] = Radiance
We will repeat this process many times, and the image will slowly converge to a good looking render.
Note that this is not a physically correct computation, but it's a good start and will give us some hints on how to improve the system later on.
Implementation
So, let's go through the code and explain each step :
So at the start, we just calculate UV coordinates based on the thread ID. We will use that for primary ray generation.
We then enter a loop that's gonna run 100 times. For each sample, we generate a ray with the same function as before. We then initialize the Radiance and the Weight (Attenuation).
Then, we'll enter the tracing part :
We need define a maximum amount of bounces, here we set that to 3.
We then test the intersection of the ray with the scene. If it doesn't hit something, it means that it hit the sky (Or the environment).
Here we set the sky to have an emission value of 1, which is a white sky.
So when the ray hits the sky, we add the sky emission to the radiance of this pixel, multiplied by the current attenuation (or weight).
When we hit the sky, we don't wanna bounce again, so we can break out of the loop.
If we did hit a scene object, we need to calculate some values to keep going.
We first find the transform of the instance that we hit. We then get the normal of the point that we hit and transform it with the instance transform.
We then find the colour of the shape that we hit.
Finally, we calculate the position of the shape that we hit.
Note that all those calculations are based on the barycentric coordinates that allow us to interpolate any value accross the surface of a triangle.
Next, we can modulate the weight by the colour of the shape :
and finally, we can start building the next ray. The ray origin will be the point we hit on the shape.
Now we need to calculate the next ray direction.
We need to consider the light incoming from all directions, but that's quite a lot of directions!
So for now we'll just generate a random direction in the upper hemisphere, with higher chance for directions closer to the normal. That is called a cosine distribution, because the probability of sampling a direction is proportional to the cosine between that direction and the normal.
We will heavily use that function later, so may as well define it now.
Once we've gone through all the bounces, we step out of the bounces loop, and we can calculate the new colour for the current pixel :
We're blending between the previous value of the pixel, and the newly calculated value. mix() actually does a linear interpolation between 2 values.
The weight of the interpolation is calculated with SampleWeight : For example in the first sampe, SampleWeight will be 1, so we're taking the entire value of new colour.
In the second sample, SampleWeight is 1 / 2 = 0.5. We take half of the previous colour, and add half of the new colour. and so on...
We also set PreviousColour to the new colour.
after we've finished the sample loop, we store the NewColour value in the image, and finally exit the kernel.
And let's look at the result :
So... That's a noisy and not so great image, but it's a start !
We can guess the shadows behind the cubes. we can see the green and red walls reflecting on the sides of the boxes.
Commentaires
Enregistrer un commentaire