So Cook-Torrance works best with reflective materials, but what about rough microsurfaces such as rubber, wood or clay? Oren-Nayar is a diffuse only BRDF that aims to simulate the way light spreads across a rough, matte surface more accurately than the simple Lambertian method. The full source code can be found here.
The Pros
Oren-Nayar uses a microfacet model to accurately calculate the diffuse contribution for rough materials, distributing the light across the surface in a more true-to-life way than Lambertian.
The Cons
As with all PBR shaders this effect uses a lot of per-fragment maths to achieve the desired result and, as such, is more computationally expensive than other alternatives. It is also not suitable for reflective objects due to its nature as diffuse only and while it can be used in combination with specular BRDF's in place of Lambertian diffuse the result probably won't be accurate as light distribution is different.
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;
} light[NUM_LIGHTS];
const float PI = 3.14159;
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()
{
vec3 normal = normalize(uNormals);
vec3 diffuse = vec3(0.0);
vec3 ambientColour = vec3(0.0);
vec3 camDir = normalize(camPos - position);
float NdotV = clamp(dot(normal, camDir), 0.0, 1.0);
float angleVN = acos(NdotV);
vec3 texture = texture(textureMap, texCoord.xy).rgb;
Oren-Nayar is a diffuse only BRDF that aims to accurately calculate light distribution for rough, non-reflective surfaces such as clay, wood, rubber and others. The problem with Lambertian diffuse is that light falls off too quickly when the normals face away from the light direction vector and around the edges, where normals are near perpendicular, the light level reaches almost 0 where rough surfaces tend to have a more even distribution of light across its surface.
The calculation for the Oren-Nayar BRDF is as follows: (N.L) * (A + (B * gamma * C) where A, B and C are different coefficients to handle microfacets and the effect of a fragment appearing brighter as the viewing angle gets closer to the light angle.
Within the journal article for the technique a simplified version was described after extensive reviewing of the coefficients of the full version to check for computational expense versus contribution to the final result. Through this they found that coefficient C3 had relatively small contribution to the result and was therefore removed. Inter-reflections, which handled light reflecting off the microfacets and back outwards, were also removed. This version is known as Qualitative Oren-Nayar as is what we will be looking at here as it is more suitable for intensive real-time applications such as games.
for (int i = 0; i < NUM_LIGHTS; i++)
{
vec3 lightDir = normalize(light[i].lightPos - position);
float NdotL = clamp(dot(normal, lightDir), 0.0, 1.0);
float angleLN = acos(NdotL);
float alpha = max(angleVN, angleLN);
float beta = min(angleVN, angleLN);
float gamma = cos(angleVN - angleLN);
float roughness2 = roughness * roughness;
So while I've been rambling on about the technique as a whole you may have noticed that for the viewing dot product we performed an acos function, and we've done it here too with the light dot product. acos, or arccos, is the inverse of a cosine function and when the input is a dot product the result is the angle between the two vectors. Until now we haven't needed the angle before as the light techniques are independent of the view vector, however as mentioned before rough surfaces appear brighter when the viewing angle is close to the light angle and therefore we need to check for this.
Next we define alpha and beta as the greater and lesser values of the two angles respectively as seen in the full (quite scary looking) equation. gamma is the cosine of the viewing angle subtracted by the light angle. This tests how close the two are.
float A = 1.0 - 0.5 * (roughness2 / (roughness2 + 0.57));
float B = 0.45 * (roughness2 / (roughness2 + 0.09));
float C = sin(alpha) * tan(beta);
float OrenNayar = NdotL * (A + (B * max(0.0, gamma) * C));
diffuse += light[i].lightColour * Lr;
ambientColour += light[i].lightColour;
}
A
Coefficient A is equivalent to C1 in the full equation at (1 - 0.5) * (roughness^2 / (roughness^2 + 0.57)). Looking at the original equation we see that the value 0.57 was in fact 0.33. Due to the removal of the inter-reflections equation it is generally accepted that 0.57 offers a similar enough result though of course it is only then an approximation.
B
Coefficient B is equivalent to C2 in the full equation at 0.45 * (roughness^2 / (roughness^2 + 0.09)). Again this differs slightly from the full equation. Where C2 could go one of two ways depending on the result of gamma (Whether the result was above or equal to 0 or not) B is now a single equation that is multiplied by the results of gamma (Or 0 as negative values would be bad).
C
While coefficient C is not technically explicitly defined in the equation it cleans up our code when we put it all together. It takes the sine of alpha (the biggest of the two angles) and multiplies it by the tan of beta (the smallest of the two angles).
Putting this all together we get NdotL * (A + ((B * gamma) * C)) and then multiply this all by the colour of the light to receive the desired tint. We cumulatively pass it up out of the light loop along with the ambient colour contribution as per usual.
vec3 ambient = 0.01 * ambientColour;
vec3 final = texture.rgb * (diffuse + ambient);
final = pow(test, vec3(1.0 / 2.2));
FragColour = vec4(final, 1.0);
}
The usual finalisation is done. we set the ambient colour, add it to the result of Oren Nayar, multiply this all by the objects colour and apply gamma correction to the whole thing before setting the resulting colour as the output fragment.