Simple GPU Path Tracing, Part. 3.2 : Physically Based Material

 

We now have implemented the simplest form of brdf.

It's time to implement a new one that can represent materials with more properties.

We'll be using a physically based brdf that is commonly used in computer graphics, which allows some parameterization to change the material properties and visual (Metallic and roughness parameters).

 As for the matte brdf, we need to write 3 functions : 

  • One for sampling a direction from this brdf
  • One for evaluating the value of the brdf, given 2 directions
  • One for evaluating the probability of generating a given direction from that brdf.

I'll go over the theory of physically based BRDF, and show how it's implemented in the code.

I won't go in too much details, but see at the end of this page for more resources.



 

 

 

 

 

The code for this post will be on this branch of the github repo.

Theory

Here I'll briefly review the basic theory of the physically based BRDF we will implement. 

It's actually going to be composed of 2 BRDFs that we will combine together :

  • A diffuse brdf that accounts for the light that scatters inside the surface and escapes in any direction across the hemisphere
  • A specular brdf that accounts for the light that gets reflected straight away from the surface leaning more towards the reflection direction of the light on the surface.


The factor that dictates which brdf is going to be used for a given sample is given by the fresnel equations that tell us how much light is reflected from a surface, given the angle at which light arrives and the viewing angle.

We already have a diffuse brdf : the one we were using just before will work perfectly. 

 

Now let's discuss the specular component of our brdf.

Specular BRDFs are based on microfacet theory. At a microscopic level, all surfaces are made of micro surfaces of different slopes and heights. Each individual microfacet is usually modeled as a perfect mirror surface.

We can use a "roughness" parameter to modulate the profile of those microfacets. A low roughness parameter will present microfacets that are almost parallel to the underlying surface. Conversely, a high roughness parameter will present microfacets that have a lot of crevices and slopes.

 

Example of a rough material with its microfacets shown

 

Example of a smooth material with its microfacets shown

 

So how do we model a brdf using this microfacet theory ? Here I'll present a global view of how that's done, but I won't dive into the low level maths of how it works. I'll link some useful resources that explain more in depth the theory.

First, let's see the general equation for our microfacets based BRDF, and we'll then explain each term :

A microfacet BRDF is defined by 3 important functions : 

- D : Microfacet distribution function, which defines how the microfacets vary in slopes and height. It will define how smooth/rough the microfacets are distributed (See previous images)

- G : Geometric attenuation function, which accounts for mutual shadowing and masking of microfacets : Some microfacets might shadow or mask other microfacets.

 

Microfacets shadowing : A ray of light blocked by a microfacet will shadow the one behind.

 

Microfacet masking : A reflected ray that's blocked by another microfacet.

 

- F : Fresnel function, which as said earlier tells us how much of incoming light is reflected straight away from the surface.


Now let's see the other parameters :

N : Normal of the surface

L : Direction towards the Light (or Outgoing direction)

V : Direction towards the Eye (or Incoming direction)

H : "Half Vector", which is the vector halfway between L and V

 

The reason D depends on H is because it actually returns the fraction of microfacets that are oriented like H. 

All the microfacets that are oriented towards H will reflect all the light coming from L to V, because as we said  individual microfacets are treated are mirror-like surface. 

A mirror surface will reflect the incoming light to a single direction, called the reflection direction : 


 So if microfacets are all treated like mirror surfaces, it gives something like that :

 

So as you can see, the outgoing direction can get quite random on the hemisphere if the roughness is high, which is exactly what we want.

Implementation

 Let's go through the individual D, G, F functions now : 

Distribution

As said earlier, the distribution function returns the fraction of microfacets that are aligned with the input vector H.
 
There are multiple distribution functions available (Beckmann, Trowbridge & Reitz, GGX...). We will be using GGX in our implementation.
 
Here, we can remove the square tangent term using a trigonometric identity : 
 
Taking the formula of sec(t), we get :
 
And putting it all on the same denominator, 

and if we substitute that in the denominator, we get a much simpler formula : 

And its implementation in our code : 

FN_DECL float MicrofacetDistribution(float Roughness, vec3 Normal, vec3 Halfway)
{
    float Cosine = dot(Normal, Halfway);
    if(Cosine <= 0) return 0;

    float Roughness2 = Roughness * Roughness;
    float Cosine2 = Cosine * Cosine;
    return Roughness2 / (PI_F * (Cosine2 * Roughness2 + 1 - Cosine2) * (Cosine2 * Roughness2 + 1 - Cosine2));
}

 

Geometric attenuation

Geometric attenuation accounts for the shadowing and masking of microfacets (see images above).
We will use Smith's formula which works well with GGX, and here is the original one :
 
where the delta function is : 
 
and the a function is defined as : 
 
 So this becomes the following super hairy equation : 


but don't worry, luckily in practice it simplifies well to the following final equation for our geometric term :

 

and the implementation :

FN_DECL float MicrofacetShadowing1(float Roughness, vec3 Normal, vec3 Halfway, vec3 Direction)
{
    float Cosine = dot(Normal, Direction);
    float Cosine2 = Cosine * Cosine;
    float CosineH = dot(Halfway, Direction);
   
    if(Cosine * CosineH <= 0) return 0;

    float Roughness2 = Roughness * Roughness;
    return 2.0f / (sqrt(((Roughness2 * (1.0f - Cosine2)) + Cosine2) / Cosine2) + 1.0f);
}
 
Now, because we have to account for both shadowing and masking, we can evaluate it twice, one for shadowing, using the Light direction, and another one for masking, using the View direction.
We could multiply the 2 evaluations, but that would ignore the correlations between masking and shadowing (some microfacets can be both shadowed and masked). 
A more correct way of doing it is to use the following formula, called the "height-correlated" form of the geometric term : 
 
With the implementation : 
FN_DECL float MicrofacetShadowing(float Roughness, vec3 Normal, vec3 Halfway, 
                                    vec3 Outgoing, vec3 Incoming)
{
    return 1.0f / 
        (1.0f + MicrofacetShadowing1(Roughness, Normal, Halfway, Outgoing) +  
                MicrofacetShadowing1(Roughness, Normal, Halfway, Incoming));
}
 

Fresnel

Lastly, we need to have a function for the fresnel term. This will determine how much light is reflected off the surface, which is quite important because it will tell us how much light will contribute to the specular or to the diffuse BRDF.

The fresnel takes the view direction into account. When looking at the surface from a low angle (Looking at the front), a very small amount of light is reflected. When looking at grazing angle, all the light gets reflected. That's quite obvious when looking at planes of water : 

Fresnel Reflection and Fresnel Reflection Modes Explained – shanesimmsart

On the foregreound of the image, we see through the water : No light is reflected.

In the background, we see more and more the reflections and less and less through the water.

 

The fresnel equations are quite hairy and involve complex numbers and account for polarization of light. Luckily, there's a simplified approximation of those equations, introduced by Christophe Schlick in this paper.

Here's the equation : 

 

Where 

  • u is the cosine of the angle between N and V 
  • F90 is equal to the reflectance value at 90 degrees, (the biggest angle we can view a surface from)
  • F0 which is the reflectance value when looking at the surface from 0 degree angle. 

However, F90 is always 1 : All the light is reflected when looking from a 90 angle. F0, however, may vary, depending on the index of refraction of the material. The formula to find F0 is the following : 

 

where eta_1 is the index of refraction of the material that the light is entering, and eta_2 is the index of refraction of the material the light is escaping. In our case, the light is escaping the air, which has an index of refraction of 1, so we can simplify this formula and implement it like that :

FN_DECL vec3 EtaToReflectivity(vec3 Eta) {
  return ((Eta - 1.0f) * (Eta - 1.0f)) / ((Eta + 1.0f) * (Eta + 1.0f));
}

 And here's the implementation of the main formula :

FN_DECL vec3 FresnelSchlick(INOUT(vec3) Specular, INOUT(vec3) Normal, INOUT(vec3) Outgoing) {
  if (Specular == vec3(0, 0, 0)) return vec3(0, 0, 0);
  float cosine = dot(Normal, Outgoing);
  return Specular +
         (1.0f - Specular) * pow(clamp(1.0f - abs(cosine), 0.0f, 1.0f), 5.0f);
}

 

So that's quite simple, but we'll add a bit of complexity to it, because because we want to have a parameter that will define how "metallic" the material is.

High metallic value will simulate a conductor-like material, meaning most of the light that enters the surface gets absorbed and never escapes the surface again. That's because in metals, electrons are not tightly bound to individual atoms but are free to move throughout the material. When light hits a metal surface, it interacts with these free electrons, causing a phenomenon called "Plasmon", that makes the electrons oscillate, and absorbing the energy of the incoming light.

 

Low metallic value, on the other hand, will tend to what's called "dielectric", or "insulator" material, with a low absorption, meaning most of the light that enters will eventually escape the surface elsewhere: 



This metallic parameter will effectively change the colour of the reflections that the material exhibits. Indeed, in metallic materials, as all the light is absorbed within the surface, the tint of the reflections will lean more towards the colour of the material itself, whereas for dielectrics, the colour of the light will not get absorbed, and we will still see some of that colour coming out of the surface

To implement this behaviour, we can calculate F0 by blending between the default reflectance at 0 angle, which is a grey tint, and the base colour for metal.

 

This way, at 0 angle, for a dielectric material, the reflection will be the result of our EtaToReflectivity function, which is a grey colour between 0 and 1, and for a metallic material, it will be the base colour of the material.

So to calculate the fresnel term, we do that :

vec3 Reflectivity = mix(EtaToReflectivity(vec3(IOR, IOR, IOR)), Colour, Metallic);
vec3 F = FresnelSchlick(Reflectivity, UpNormal, Outgoing);

Putting it all together

Now that we have all the D, G and F functions, we can write the BRDF function finally !
FN_DECL vec3 EvalPbr(INOUT(vec3) Colour, float IOR, float Roughness, float Metallic, INOUT(vec3) Normal, INOUT(vec3) Outgoing, INOUT(vec3) Incoming) {
    // Evaluate a specular BRDF lobe.
    if (dot(Normal, Incoming) * dot(Normal, Outgoing) <= 0) return vec3(0, 0, 0);

    auto Reflectivity = mix(EtaToReflectivity(vec3(IOR, IOR, IOR)), Colour, Metallic);
    auto UpNormal = dot(Normal, Outgoing) <= 0 ? -Normal : Normal;
    auto F1        = FresnelSchlick(Reflectivity, UpNormal, Outgoing);
    auto Halfway   = normalize(Incoming + Outgoing);
    auto F         = FresnelSchlick(Reflectivity, Halfway, Incoming);
    auto D        = MicrofacetDistribution(Roughness, UpNormal, Halfway);
    auto G         = MicrofacetShadowing(Roughness, UpNormal, Halfway, Outgoing, Incoming);

    float Cosine = abs(dot(UpNormal, Incoming));
    vec3 Diffuse = Colour * (1.0f - Metallic) * (1.0f - F1) / vec3(PI_F) *
                abs(dot(UpNormal, Incoming));
    vec3 Specular = F * D * G / (4 * dot(UpNormal, Outgoing) * dot(UpNormal, Incoming));

    return  Diffuse * Cosine + Specular * Cosine;
}

 This is a simple transposition of the equation, with also the cosine term from the rendering equation, and the diffuse part of the BRDF that's multiplied by 1-F.

That's it !

We also need to define functions for sampling this brdf, and for evaluating the PDF given a direction.

Sampling a direction :


FN_DECL vec3 SamplePbr(INOUT(vec3) Colour, float IOR, float Roughness,
    float Metallic, INOUT(vec3) Normal, INOUT(vec3) Outgoing, float Rand0,
    INOUT(vec2) Rand1) {
  auto UpNormal    = dot(Normal, Outgoing) <= 0 ? -Normal : Normal;
  auto Reflectivity = mix(
      EtaToReflectivity(vec3{IOR, IOR, IOR}), Colour, Metallic);
  if (Rand0 < Mean(FresnelSchlick(Reflectivity, UpNormal, Outgoing))) {
    auto Halfway  = SampleMicrofacet(Roughness, UpNormal, Rand1);
    auto Incoming = reflect(-Outgoing, Halfway);
    if (!SameHemisphere(UpNormal, Outgoing, Incoming)) return {0, 0, 0};
    return Incoming;
  } else {
    return SampleHemisphereCosine(UpNormal, Rand1);
  }
}

 Here, we first calculate the fresnel term that will dictate if the light is being reflected or scattered inside the shape. Based on that value, we either sample a cosine distribution, or a microfacet distribution.


Evaluating the pdf : 


FN_DECL float SamplePbrPDF(INOUT(vec3) Colour, float IOR, float Roughness, float Metallic, INOUT(vec3) Normal, INOUT(vec3) Outgoing, INOUT(vec3) Incoming) {
  if (dot(Normal, Incoming) * dot(Normal, Outgoing) <= 0) return 0;
  auto UpNormal    = dot(Normal, Outgoing) <= 0 ? -Normal : Normal;
  auto Halfway      = normalize(Outgoing + Incoming);
  auto Reflectivity = mix(EtaToReflectivity(vec3{IOR, IOR, IOR}), Colour, Metallic);
  auto F = Mean(FresnelSchlick(Reflectivity, UpNormal, Outgoing));
  return F * SampleMicrofacetPDF(Roughness, UpNormal, Halfway) /
             (4 * abs(dot(Outgoing, Halfway))) +
         (1 - F) * SampleHemisphereCosinePDF(UpNormal, Incoming);
}

Here, we evaluate the fresnel term again, and then weight the PDF of the microfacet distribution and the PDF of the cosine weighted distribution with it.

The microfacet distribution PDF function is defined as follows : 

FN_DECL float SampleMicrofacetPDF(float Roughness, vec3 Normal, vec3 Halfway)
{
    float Cosine = dot(Normal, Halfway);
    if(Cosine < 0) return 0;

    return MicrofacetDistribution(Roughness, Normal, Halfway) * Cosine;
}

Remember, the microfacet distribution returns the fraction of microfacets that are oriented towards the halfway vector. We simply factor that with the cosine between the normal and the halfway, and we're done.


Wrapping up

We now have a new brdf, which means that we need to create a new material to use this brdf.
In Scene.h, we'll add some definitions : 

#define MATERIAL_TYPE_MATTE 0
#define MATERIAL_TYPE_PBR   1

We will also change the material struct to include a roughness and a metallic parameters : 

struct material
{
    glm::vec3 Emission = {};
    float Roughness = 0;
   
    glm::vec3 Colour = {};
    float Metallic = 0;
   
    int MaterialType = 0;
    glm::ivec3 Padding2;
};

 

and we can then use those when creating a material : 

    auto& BackWallMaterial    = Scene->Materials.back();
    BackWallMaterial.Colour    = {0.725f, 0.71f, 0.68f};    
    BackWallMaterial.Roughness = 0.1f;
    BackWallMaterial.Metallic = 0.8f;
    BackWallMaterial.MaterialType = MATERIAL_TYPE_PBR;  

and we can use that in the BSDF functions : 


FN_DECL vec3 EvalBSDFCos(INOUT(materialPoint) Material, vec3 Normal, vec3 OutgoingDir, vec3 Incoming)
{
    if(Material.MaterialType == MATERIAL_TYPE_MATTE)
    {
        return EvalMatte(Material.Colour, Normal, OutgoingDir, Incoming);
    }
    else if(Material.MaterialType == MATERIAL_TYPE_PBR)
    {
        return EvalPbr(Material.Colour, 1.5, Material.Roughness, Material.Metallic, Normal, OutgoingDir, Incoming);
    }
}

FN_DECL float SampleBSDFCosPDF(INOUT(materialPoint) Material, INOUT(vec3) Normal, INOUT(vec3) OutgoingDir, INOUT(vec3) Incoming)
{
    if(Material.MaterialType == MATERIAL_TYPE_MATTE)
    {
        return SampleMattePDF(Material.Colour, Normal, OutgoingDir, Incoming);
    }
    else if(Material.MaterialType == MATERIAL_TYPE_PBR)
    {
        return SamplePbrPDF(Material.Colour, 1.5, Material.Roughness, Material.Metallic, Normal, OutgoingDir, Incoming);
    }
}

FN_DECL vec3 SampleBSDFCos(INOUT(materialPoint) Material, INOUT(vec3) Normal, INOUT(vec3) OutgoingDir, float RNL, vec2 RN)
{
    if(Material.MaterialType == MATERIAL_TYPE_MATTE)
    {
        return SampleMatte(Material.Colour, Normal, OutgoingDir, RN);
    }
    else if(Material.MaterialType == MATERIAL_TYPE_PBR)
    {
        return SamplePbr(Material.Colour, 1.5, Material.Roughness, Material.Metallic, Normal, OutgoingDir, RNL, RN);
    }
}

 

...Annnd that's it ! Here's the result : 

As you can see, we have some nice reflections in the walls.


Some sources

This chapter was quite math-heavy, and I didn't go fully to the bottom of each equation.
Here are some very good resources that will help you understand better all those formulas if you want : 
  • Crash Course in BRDF Implementation by Jakub Boksansky. For me it's simply the best resource for an overview of all BRDF that are used in computer graphics. Very well explained and detailed, and also contains all the references to the papers and articles that defined all those BRDFs. It also contains the sources of all the BRDFs that are discussed in the article, and this is all actually being used in practice in a "Reference Path Tracer" as well.
  • Background: Physics and Math of Shading by Natty Hoffman. That's also a great course that goes through all the maths and physics of BRDF, with some quite in depth explanations.
  • Microfacet Models for Refraction through Rough Surfaces by Walter et al. That's the actual paper that defined most of the formulas we used for implementing our BRDF, so very well worth a read. Granted that it's more involved, but really helps understanding the topic more in depth!
  • The Microfacet Models chapter of pbrt, that goes through all the theory and implementation of physically based BRDF. As for the rest of this book, it's an excellent resources that really goes in depth on the maths and physics foundations of BRDFs.

Next Post : Simple GPU Path Tracing, Part. 3.4 : Small Improvements, Camera and wrap up

Commentaires

Articles les plus consultés