Shader Code for Physically Based Lighting
SIGGRAPH 2011 took place a couple of weeks ago. Whilst I wasn't fortunate enough (or rather, could not afford) to attend, I've been reading up on the games related presentations which have been helpfully archived on the Real Time Rendering website. One presentation that caught my eye in particular was Physically-based Lighting in Call Of Duty: Black Ops. This is a subject I've been interested in for a while after reading Naty Hoffman's SIGGRAPH 2010 Course: Physically-Based Shading Models in Film and Game Production.
The reason I'm writing this post is that none of the above presentations show any shader code. I hope I'm not alone in saying that I can understand an algorithm quicker with a small piece of shader code than a mathematical formula - it's just the notation I'm used to given my day to day job.
I won't go into detail with the many reasons why physically based shading is better - you can read all of that in the links above - suffice to say it looks better, simplifies the lighting interface for artists, and makes materials that hold up better in different lighting conditions without manual tweaking. It's important to note that "physically based" does not necessarily mean "absolutely physically accurate" - after all we still need to run within a tight performance budget so all of the calculations below are still approximations, but it's a big step forward compared to the old ad-hoc hodge podge of lighting that we're used to.
Another benefit of a physically based model is that you can scale the calculations for a performance/quality trade-off as I will show further below.
A prerequisite for all of this is that lighting takes place in linear space and it's highly recommended that it is done in HDR.
It is infeasible for me to post an entire shader here as the interface for such code depends entirely on the engine in question - instead I'll just post snippets showing the actual lighting calculations, and assume that the input have already been calculated using a method of your own choosing - perhaps interpolated from the vertex shader, fetched and decoded from a g-buffer, or some other method.
I'm not going to take into account any texture colour (albedo) - you might be doing that in the same shader as your lighting, or later on in a composition pass - but at whatever stage you do this, it's still the classical "colour = diffuse * albedo + specular;".
Also, I'm not taking into account any light attenuation. It's up to you and your game how you do this.
The calculations below refer to a single light. For multiple lights you will need to repeat the calculations for each light and accumulate the results.
Lastly, please let me know if you spot any errors below! Any errors here are my own, not of the presentations linked above!
I'll start with the easy bit, as the diffuse part of physically based lighting is the same Lambert model we're all used to.
float3 diffuse = saturate( dot( normal, light_direction ) ) * light_colour;
From now on, I'll refer to the saturated dot product of two vectors in the form "n_dot_l" to simplify the code, so the diffuse simply becomes
float3 diffuse = n_dot_l * light_colour;
Now things start to change from what we're used to. We're going to use a microfacet BRDF. If you want an explanation of the equation, the presentations linked above do a far better job than I can. In shader code form, we can translate the specular BRDF into the following form
float3 specular = (PI / 4.0f) * specular_term * cosine_term * fresnel_term * visibility_term * light_colour;
I'll go through each of the terms in turn and describe them.
A good choice for a physically based specular term in a game is Normalised Blinn-Phong. You can read the presentations linked above for the reasons and some images showing that it is a good choice. The "Normalised" part is particularly important, as it means that the specular highlight is energy conserving - as the specular power increases, the highlight should get brighter, as well as smaller. Code that implements this model looks something like this
1 2 3
float normalisation_term = ( specular_power + 2.0f ) / 2.0f * PI; float blinn_phong = pow( n_dot_h, specular_power ); // n_dot_h is the saturated dot product of the normal and half vectors float specular_term = normalisation_term * blinn_phong;
Earlier, I showed a (PI / 4.0f) constant multiplied into the BRDF. We can remove this by pre-multiplying it into the normalisation term as follows
float normalisation_term = ( specular_power + 2.0f ) / 8.0f;
This means that the specular BRDF is simplified to
float3 specular = specular_term * cosine_term * fresnel_term * visibility_term * light_colour;
The cosine term exists to prevent specular highlights leaking to the unlit side of the objects, and helpfully also saves a conditional in the shader.
float cosine_term = n_dot_l;
This is the same n_dot_l from the diffuse calculation earlier, so it is essentially free.
The Fresnel term captures the phenomenon from the real world in which a specular highlight is relatively dim when the view and light vectors are both close to the surface normal, but gets brighter as the angle becomes more glancing. To show what I mean, here are two photos of a black piece of cardboard - excuse the poor quality of my phone's camera!
To do this in shader code, we shall use Schlick's approximation of the Fresnel term, modified for microfacet rather than mirror reflection.
1 2 3
float base = 1.0f - h_dot_l; // Dot product of half vector and light vector. No need to saturate as it can't go above 90 degrees float exponential = pow( base, 5.0f ); float fresnel_term = specular_colour + ( 1.0f - specular_colour ) * exponential;
The specular colour is what you see in the situation in the first image, and this will interpolate towards white as the angle becomes more glancing. The surprising thing is that the colour bears very little relation to the diffuse albedo. For most non-metallic materials in the real world, the specular colour will be quite low, around 0.04, for metals this increases to 0.5 or more. You can use a monochrome or RGB specular colour (and by extension a monochrome or RGB Fresnel term), but for most materials, monochrome is fine as the specular power has a much greater impact on the visuals than the specular colour.
This term describes whether the microfacets are being shadowed by the microscopic structure of the surface. It changes the appearance of the specular in a subtle but important way. Mathematically, it is the G (geometry) term from the specular BRDF equation divided by the foreshortening term (n_dot_l * n_dot_v). In the following shader code, I am using the Smith shadowing function, based on the formula in the Call of Duty presentation.
1 2 3
float alpha = 1.0f / ( sqrt( PI_OVER_FOUR * specular_power + PI_OVER_TWO ) ); float visibility_term = ( n_dot_l * ( 1.0f - alpha ) + alpha ) * ( n_dot_v * ( 1.0f - alpha ) + alpha ); // Both dot products should be saturated visibility_term = 1.0f / visibility_term;
Now we have all the terms we need, we multiply them all together to get our final specular lighting value.
Put together this shader code is clearly more expensive than the old simple Phong or Blinn-Phong specular, but we don't have to do it all on slower hardware. The first thing we can do to make this quicker is to make the visibility term equal to 1. As the visibility term is G (from the BRDF equation) divided by ( n_dot_l * n_dot_v ), we simply make G the same as the denominator, making the entire visibility term equal to 1. This will only be a small drop in quality - comparison images are available in the Call of Duty presentation.
If this is still too slow for the target hardware, the next casualty is the Fresnel term, which also can be changed to a constant 1. This will mean that the specular highlights will be the same brightness from any angle, but we still get the major benefits of normalised Blinn-Phong specular. Therefore, we can have three variants of our physically based specular calculation, depending on the performance of the hardware.
1 2 3
float3 slow_hardware_specular = specular_term * cosine_term * light_colour; float3 mid_hardware_specular = specular_term * cosine_term * fresnel_term * light_colour; float3 fast_hardware_specular = specular_term * cosine_term * fresnel_term * visibility_term * light_colour;
I hope that anyone considering physically based lighting may find this article useful. Any comments and/or corrections are welcome below!