A quick look at Physically-Based Rendering (Part 1 of a series)
Hello there, internet! For my first blog post, I'm going to talk about how I've tackled Physically-Based Rendering (PBR) in my own graphics engine. This post is not meant to be a tutorial, and assumes the reader already understands the concepts and math used. If you want a quick rundown of the process, here is a good tutorial (and the basis for my current work).
So, first, to the drawing board. What exactly do we need for PBR?
1. Diffuse term (N * L)
2. Specular term
a. Cook/Torrence Microfacet BRDF
i. Specular Distribution Term (D(h), Trowbridge-Reitz GGX)
ii. Fresnel Term (F(v,h), Schlick Approximation)
iii. Geometric Shadowing Term (G(l,v,h), Schlick-GGX)
3. Image-Based lighting
a. Irradiance Map
i. Spherical Harmonics
b. Pre-filtered Mip-Mapped Radiance Environment Map (PMREM)
i. Split-Sum Approximation
That's the process in a nutshell, from my notes. That looks like a lot! However, this post only covers 1 and 2, more specifically #2. There will later be a follow-up post on IBL, when I've delved deeper into that part of it.
So, for the BRDF, we have our terms and the chosen functions to find those terms, as outlined above. But how do these functions translate into code? Luckily, there exists a blog called Graphics Rant, run by a Senior Graphics Programmer at Epic Games. The math shown was used in Unreal Engine 4. Before we get too far, it's important to know all the code right now is being done in the shaders.
So, to start, we actually won't be getting the Specular Distribution term, D, although it is listed first. We actually need to get a way to solve for GGX. We will be using Schlick-Beckmann equation, and this GGX method is simply:
(n*v)/((n*v)(1-k)+k).
However, the value of K is instead what would be used for Schlick-GGX, which is a/2 (a being "roughness"). Normally, the value of K for Schlick-Beckmann would be a(sqrt(2/pi)). Why are we remapping K in this case? We are matching the Schlick approximation to GGX-Smith. In keeping consistent with this pattern, when looking at which option to adopt as the Fresnel term, we use Schlick-Fresnel.
The formula is as shown:
This is very simple to make into a function:
float3 Schlick_Fresnel(float3 f0, float3 h, float3 l)
{
return f0 + (1.0f - f0) * pow((1.0f - dot(l, h)), 5.0f);
}
Lastly, Geometric Shadowing. But here, we use the Smith approximation. Smith is very simple, as it essentially breaks G into two components: light and view, and uses the same equation for both: G(l,v,h) = G(l)G(v). So, what are we really doing but just simply one GGX times another? Good thing we already made that GGX function, seen above. So, to get the G term, we would do:
float G_Smith(float a, float nDotV, float nDotL)
{
return GGX(nDotL, a * a) * GGX(nDotV, a * a);
}
After saving those helper functions in an external file, it's time to move on to the Pixel Shader, where the real magic happens! The rest of this blog post is just the commented code:
float3 DirectSpecularBRDF(float3 specularAlbedo, float3 positionWS, float3 normal, float3 lightDir)
{
//NOTE: the normal, like the position, is also in world space
//variables that are not input have been defined earlier in the shader
float3 viewDir = normalize(CameraPos - positionWS);
float3 halfVec = normalize(viewDir + lightDir);
float nDotH = saturate(dot(normal, halfVec));
float nDotL = saturate(dot(normal, lightDir));
float nDotV = max(dot(normal, viewDir), 0.0001f);
float alpha2 = roughness * roughness; //we define alpha as roughness squared
// Computes the distribution of the microfacets for the shaded surface.
// Trowbridge-Reitz/GGX normal distribution function.
float D = alpha2 / (Pi * pow(nDotH * nDotH * (alpha2 - 1) + 1, 2.0f));
// Computes the amount of light that reflects from a mirror surface given its index of refraction. // Schlick's approximation.
float3 F = Schlick_Fresnel(specularAlbedo, halfVec, lightDir);
// Computes the shadowing from the microfacets.
// Smith's approximation.
float G = G_Smith(roughness, nDotV, nDotL); return D * F * G; //later to be divide by 4(nDotL)(nDotV)
}
I suggest looking at the links listed, as they prove very helpful and easily readable. So, now that I have the essential equations, next step is Image-Based Lighting. This is a much more difficult task that requires more components, and more reading. I will have to edit the Mesh.cpp class, generate an Irradiance Map and PMREM, and a bit more shader math. Stay tuned for part 2!