PBR in a nutshell
PBR is at the heart of many rendering engines. It was originally developed for offline rendering, but thanks to the advances in hardware capabilities and research efforts by the graphics community, it can now be used for real-time rendering as well.
As the name implies, this technique aims at modeling the physical interactions of light and matter and, in some implementations, ensuring that the amount of energy in the system is preserved.
There are plenty of in-depth resources available that describe PBR in great detail. Nonetheless, we want to give a brief overview of our implementation for reference. We have followed the implementation presented in the glTF spec.
To compute the final color of our surface, we have to determine the diffuse and specular components. The amount of specular reflection in the real world is determined by the roughness of the surface. The smoother the surface, the greater the amount of light that is reflected. A mirror reflects (almost) all the light it receives.
The roughness of the surface is modeled through a texture. In the glTF format, this value is packed with the metalness and the occlusion values in a single texture to optimize resource use. We distinguish materials between conductors (or metallic) and dielectric (non-metallic) surfaces.
A metallic material has only a specular term, while a non-metallic material has both diffuse and specular terms. To model materials that have both metallic and non-metallic components, we use the metalness term to interpolate between the two.
An object made of wood will likely have a metalness of 0, plastic will have a mix of both metalness and roughness, and the body of a car will be dominated by the metallic component.
As we are modeling the real-world response of a material, we need a function that takes the view and light direction and returns the amount of light that is reflected. This function is called the bi-directional distribution function (BRDF).
We use the Trowbridge-Reitz/GGX distribution for the specular BRDF, and it is implemented as follows:
float NdotH = dot(N, H); float alpha_squared = alpha * alpha; float d_denom = ( NdotH * NdotH ) * ( alpha_squared - 1.0 ) + 1.0; float distribution = ( alpha_squared * heaviside( NdotH ) ) / ( PI * d_denom * d_denom ); float NdotL = dot(N, L); float NdotV = dot(N, V); float HdotL = dot(H, L); float HdotV = dot(H, V); float visibility = ( heaviside( HdotL ) / ( abs( NdotL ) + sqrt( alpha_squared + ( 1.0 - alpha_squared ) * ( NdotL * NdotL ) ) ) ) * ( heaviside( HdotV ) / ( abs( NdotV ) + sqrt( alpha_squared + ( 1.0 - alpha_squared ) * ( NdotV * NdotV ) ) ) ); float specular_brdf = visibility * distribution;
First, we compute the distribution and visibility terms according to the formula presented in the glTF specification. Then, we multiply them to obtain the specular BRDF term.
There are other approximations that can be used, and we encourage you to experiment and replace ours with a different one!
We then compute the diffuse BDRF, as follows:
vec3 diffuse_brdf = (1 / PI) * base_colour.rgb;
We now introduce the Fresnel term. This determines the color of the reflection based on the viewing angle and the index of refraction of the material. Here is the implementation of the Schlick approximation, both for the metallic and dielectric components:
// f0 in the formula notation refers to the base colour here vec3 conductor_fresnel = specular_brdf * ( base_colour.rgb + ( 1.0 - base_colour.rgb ) * pow( 1.0 - abs( HdotV ), 5 ) ); // f0 in the formula notation refers to the value derived from ior = 1.5 float f0 = 0.04; // pow( ( 1 - ior ) / ( 1 + ior ), 2 ) float fr = f0 + ( 1 - f0 ) * pow(1 - abs( HdotV ), 5 ); vec3 fresnel_mix = mix( diffuse_brdf, vec3( specular_brdf ), fr );
Here we compute the Fresnel term for both the conductor and the dielectric components according to the formula in the glTF specification.
Now that we have computed all the components of the model, we interpolate between them, based on the metalness of the material, as follows:
vec3 material_colour = mix( resnel_mix, conductor_fresnel, metalness );
The occlusion term is not used as it only affects indirect light, which we haven’t implemented yet.
We realize this is a very quick introduction, and we skipped over a lot of the theory that makes these approximations work. However, it should provide a good starting point for further study.
We have added links to some excellent resources in the Further reading section if you’d like to experiment and modify our base implementation.
In the next section, we are going to introduce a debugging tool that we rely on whenever we have a non-trivial rendering issue. It has helped us many times while writing this book!