With the introduction of more and more powerful hardware comes the allowance of more expensive yet accurate "Physically Based Rendering" (PBR) shaders. The Cook-Torrance Bidirectional Reflectance distribution function (BRDF) is one of the best options for simulating reflections on materials such as metals, mirrors or plastics, though also one of the more expensive PBR choices. The full source code can be found here.
The Pros
Straight off the bat you may look at the demo above and think "Why should I use this when it looks no different to Blinn-Phong?"
Well there are many reasons to pick Cook-Torrance over previously mentioned shaders. It is a physically based shader which means it takes the important bits from the rather large and impractical physical calculation of real life lights and therefore is more accurate and correct under certain conditions where Blinn-Phong is not. It also constrains the artist a little more to ensure the light cannot be physically incorrect such as reflecting more light than is emitted from the source (energy conservation fixes this). It also takes into account how rough a surface is, calculating how light should be spread based on the microscopic bumps and dips in the surface (microfacets).
The Cons
Cook-Torrance is a specular only BRDF and as such is bad for use on rough objects such as wood, brick, clay or other. It is also considerably more expensive than that of the specular calculation we've looked at previously so it may not be appropriate for low-end devices such as handhelds.
The Code
Again we use the vertex passthrough shader for lights as seen in Blinn-Phong so there's nothing new to talk about there. There is a link to the source code for the vertex shader at the top of this page so that you know what you will need.
(Please note code comments are removed from these snippets)
#version 330
in vec3 position;
in vec3 uNormals;
in vec2 texCoord;
layout (location = 0) out vec4 FragColour;
uniform vec3 camPos;
uniform sampler2D textureMap;
uniform float roughness;
#define NUM_LIGHTS 2
uniform struct Light
{
vec3 lightPos;
vec3 lightColour;
float shininess;
float glossiness;
float specularCoefficient;
} light[NUM_LIGHTS];
As with all shaders we start with the setup stuff. First we tell the compiler to compile for GLSL version 330, or OpenGL 3.3 (The chosen version number for the demo project).
Same as Blinn-Phong we pass up our camera position and the texture map from the CPU, set as type sampler2D, meaning 2D texture. A new value is also passed in: Roughness. This value will be used to define how large the microfacets in a material are and therefore how much or little light distributes and self occludes.
There is also a uniform for a structure, containing many values. This structure holds all the properties of a light and allows for multiple lights to be passed into the shader up to the defined amount.
And finally there is the output value. Note that this will usually be seen as "out vec4 FragColour" however the layout location refers to which buffer in the Frame Buffer Object (FBO) this will be saved to if multiple outputs are required. If a custom FBO isn't being used then location 0 will simply refer to the default output so it's good practice to just write it like that.
void main()
{
float ambientCoefficient = 0.01;
vec3 normals = normalize(uNormals);
vec3 camDir = normalize(camPos - position);
vec3 diffuse = vec3(0.0);
vec3 CookTorrance = vec3(0.0);
vec3 ambientColour = vec3(0.0);
float NdotV = clamp(dot(normals, camDir), 0.0, 1.0);
Since Cook-Torrance focuses solely on the specular contribution it can simply replace the specular part of the calculation in Blinn-Phong, combining with the simple ambient light and the Lambertian diffuse that we already have. As such we will only look at the related parts of the code. As usual the full code can be found at the top of this page if you get confused on what's happening where.
The mathematical equation for Cook-Torrance in its highest level looks a little bit like this: (F / PI) * (D * G) / ((N.L) * (N.V)), which can be simplified to (F * G * D) / (PI / * ((N.L) * (N.V))) where F is the Fresnel term, G is the geometric attenuation factor and D is the normal distribution function. I will go into more detail in these below. Each of these three factors have many different ways of being calculated for different results and varying performance differences. I will be going through my personal favourite methods.
// for (int i = 0; i < NUM_LIGHTS; i++)
// {
/*
float mirror = 0.0;
float air = 1.0;
float bubble = 1.1;
float ice = 1.31;
float water = 1.33;
float glass = 1.5;
float standard = 2.0;
float steel = 2.5;
*/
float Kr = pow((1.0 - 2.0) / (1.0 + 2.0), 2.0);
float F = Kr + (1.0 - Kr) * pow((1.0 - NdotL), 5.0);
Fresnel Term
You're walking across the street and you see a puddle of water. As you approach the puddle you'll notice the water reflecting the world around it like a mirror however when you reach the puddle and look straight down at it it's almost completely transparent. This is the Fresnel effect. Simply put, the more perpendicular the viewing angle is to a surface normal, the more reflective it becomes and this happens with all materials. Yes, even something as non-reflective as a carpet is affected by Fresnel just the rougher a material is the steeper the curve will be.
The Fresnel term shown above is Shlick's approximation. It is the very popular option due to its simplicity yet works exactly as intended. the calculation looks like Kr + (1 - Kr) * (1 - (N.L))^5 where Kr is the coefficient of the reflection when the viewing angle is parallel to the surface normal - the base reflectivity value. While a solid hard coded value could be used for Kr it would technically be incorrect to do so. Instead Kr is calculated using the incidents of refraction of the material ((n1 - n2) / (n1 + n2))^2. n1 is the refraction value of the medium the light is travelling from and n2 is the refraction value the light is travelling to (the object itself). Typically n1 will be set to a value of 1.0 as the first medium is air however in an underwater scene it would be set to 1.33, etc.
The result of the fresnel contribution should look along the lines of how it does at the bottom of the page, where normals that are perpendicular to the light get brighter fragments. Don't worry this isn't wrong as the NDF and GAF contributions will provide a multiplication of 0 on angles where the fresnel effect shouldn't be visible.
// for (int i = 0; i < NUM_LIGHTS; i++)
// {
float NH2 = pow(NdotH, 2.0);
float roughness2 = pow(clamp(roughness, 0.01, 0.99), 2.0);
float denom = NH2 * roughness2 + (1.0 - NH2);
float D = roughness2 / (PI * pow(denom, 2.0));
Normal Distribution Function
The NDF handles the actual direct reflection of the light source. For this example I have chosen the Trowbridge-Reitz method, commonly used with GGX. I personally prefer this method due to the long tail on the specular highlight though it all depends on what is most appropriate for the material you're lighting. Again the source code above will contain multiple alternatives for each contribution.
Trowbridge-Reitz looks a bit like α^2 / (π * ((N.H)^2 * (α^2 - 1) + 1)^2) where alpha is the roughness of the microfacets of the material. The higher the roughness, the more the specular highlight is distributed across the surface and, unlike modifying the shininess value of Blinn-Phong, light is distributed correctly as opposed to the specular dot just getting bigger. This is Energy Conservation - the phenomenon that light reflected cannot exceed the amount of light received. This also means that in a smooth surface the specular highlight can have a value greater than 1.0 which makes it easy to create a bright-pass filter for image effects such as bloom.
Similar to the Blinn-Phong specular contribution we use the half angle to locate the specular dot.
// for (int i = 0; i < NUM_LIGHTS; i++)
// {
/ float roughness2 = pow(clamp(roughness, 0.01, 0.99), 2.0);
float g1 = (NdotL * 2.0) / (NdotL + sqrt(roughness2 + (1.0 - roughness2) * pow(NdotL, 2.0)));
float g2 = (NdotV * 2.0) / (NdotV + sqrt(roughness2 + (1.0 - roughness2) * pow(NdotV, 2.0)));
float G = g1 * g2;
Geometric Attenuation Factor
The third and final explicit contribution for Cook-Torrance is the geometric attenuation factor and handles how light reflects off the objects microfacets. If an object was rough some rays would reflect perfectly whilst others will reflect more randomly and some microfacets may not even be lit at all, simulating shadowing. The GAF assumes microfacets are V shaped crevices where roughness defines how large these crevices are.
Here we are using GGX-Smith for our GAF. Smith refers to calculations that multiply the results of using (N.V) and (N.L) for correct shadow masking. The equation for GGX-Smith is (2 * (N.V)) / ((N.V) + sqrt(α^2 + (1 - α^2) * (N.V)^2)) multiplied by the same equation with (N.L) in place of (N.V)
CookTorrance += light[i].specularCoefficient * (light[i].lightColour * (F * G * D) / ((PI * NdotV)));
}
vec3 Kd = max((1.0 - CookTorrance), 0.0);
vec3 final = texture * (ambience + (Kd * diffuse) + CookTorrance);
Finally we put it all together dividing it by the denominator, which essentially results in the inverse of the Fresnel and is important in displaying the Fresnel effect, multiply it by the specular coefficient and the colour of the light and cumulatively adding it to the fragments final light level (for multiple light sources).
We also define a secondary diffuse coefficient here. What this does is result in an RGB value opposite to that of the Cook-Torrance contribution (Or 0). This is another level of Energy Conservation that dims the diffuse light based on how much specular is in that fragment, preventing the diffuse from resulting in a brighter reflection than was received. This also prevents the specular highlight from appearing slightly bigger than it should.