1000 Forms of Bunnies victor's tech art blog.

Simplified PBR Shading Model for Unreal Part 1

To get ready for the comming project, I am looking into an customized shading model for the base material - the one with a minimal implementation of PBR and without any unnecessary built-in features for a better rendering performance. After digging into the PBR theories as well as this amazing article spacific about PBR in Unreal Engine, I finally pieced up the puzzle of the basic shading model.

Theory Breakdown

Shading an object in rendering pipeline is basically generating colors on its surface area. In the language of physics, this is done by calculating the outgoing light energy (perceived by human as colors) of all the points on the surface of the object.

Light energy is described as Radiance with the letter $L$. (The definition of radiance is rather complex and will be noted later; it actually represents light energy differential on area and solid angle). The outgoing light contribution $L_o$ can be described as a function of a point position $p$ on the object surface and the direction $\omega_o$ of the outgoing light ray: $L_o(p,\omega_o)$.

To calculate this $L_o$ we have to integrated the incoming light energy $L_i$ (which is also a function of position and direction like $L_o$) on the hemesphere guided by surface normal at that point. $L_i$ is also weighted by the cosine of the incident angle (Lambert’s Law) which can be put as $n \cdot \omega_i$.

So the total lighting phenomenon can be described as:

\[L_o(p,\omega_o) = \int\limits_{\Omega} L_i(p,\omega_i) (n \cdot \omega_i) d\omega_i\]

However, because different surface has different properties on influencing the light reaction (energy absorbing, reflection, refraction, subsurface scattering etc.) The integral is also weighted by another factor, which is the classic concept of BRDF (More explanation later).

In general, a typical BRDF consists a diffuse term and a specular term.

The diffuse part we are using Lambertian Diffuse BRDF

\[f_{Lambert} =\frac{c}{\pi}\]

The specular part we are using Cook-Torrance Microfacet Specular BRDF

\[f_{cook-torrance} = \frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}\]

Then we also scale the diffuse term and specular term by $k_d$ and $k_s$, and put all together we get the final description of the lighting phenomenon:

\[L_o(p,\omega_o) = \int\limits_{\Omega} (k_d\frac{c}{\pi} + k_s\frac{DFG}{4(\omega_o \cdot n)(\omega_i \cdot n)}) L_i(p,\omega_i) (n \cdot \omega_i) d\omega_i\]

This is called Cook-Torrance reflectance equation. Details explained as follows.



Diffuse Term

Using Lambertian Diffuse BRDF

 * Standard Lambertian diffuse lighting.
vec3 CalculateDiffuse(
    in vec3 albedo)
    return (albedo * ONE_OVER_PI);

Specular Term

Using Cook-Torrance Microfacet Specular BRDF

D - Normal distribution function (NDF)

We are using GGX/Trowbridge-Reitz NDF function. Roughness lies on the range [0.0, 1.0], with lower values producing a smoother, “glossier”, surface. Higher values produce a rougher surface with the specular lighting distributed over a larger surface area.

\[NDF_{GGX TR}(n, h, \alpha) = \frac{\alpha^2}{\pi((n \cdot h)^2 (\alpha^2 - 1) + 1)^2}\]

where Here h is the halfway vector - half-way between the light (l) and view (v); $\alpha$ is adopted Disney’s reparameterization which equal to $Roughness^2$.

If we plot GGX/Trowbridge-Reitz NDF like this:

 * GGX/Trowbridge-Reitz NDF
float CalculateNDF(
    in vec3  surfNorm,
    in vec3  halfVector,
    in float roughness)
    float a2 = (roughness * roughness);
    float halfAngle = dot(surfNorm, halfVector);

    return (a2 / (PI * pow((pow(halfAngle, 2.0) * (a2 - 1.0) + 1.0), 2.0)));

G - Microfacet geometric attenuation

We are using GGX/Schlick-Beckmann function and Smith’s method for analytical lighting scenario(TBD). The attenuation is modified by the roughness (input as $k$) and approximates the influence/amount of microfacets in the surface.

\[G_{SchlickGGX}(n, v, k) = \frac{n \cdot v} {(n \cdot v)(1 - k) + k }\]

Note that for analytical lighting scenario,

\[k = \frac{({Roughness} + 1)^2}{8}\]
 * GGX/Schlick-Beckmann microfacet geometric attenuation.
float CalculateAttenuation(
    in vec3  surfNorm,
    in vec3  vector,
    in float k)
    float d = max(dot(surfNorm, vector), 0.0);
 	return (d / ((d * (1.0 - k)) + k));
\[G_{Smith}(n, v, l, k) = G_{l}(n, l, k) G_{v}(n, v, k)\]
 * GGX/Schlick-Beckmann attenuation for analytical light sources.
float CalculateAttenuationAnalytical(
    in vec3  surfNorm,
    in vec3  toLight,
    in vec3  toView,
    in float roughness)
    float k = pow((roughness + 1.0), 2.0) * 0.125;

    // G(l) and G(v)
    float lightAtten = CalculateAttenuation(surfNorm, toLight, k);
    float viewAtten  = CalculateAttenuation(surfNorm, toView, k);

    // Smith
    return (lightAtten * viewAtten);


F - Fresnel reflectivity

We are using Fresnel-Schlick approximation and Spherical Gaussian approximation. The Metallic parameter controls the fresnel incident value (fresnel0), more explanation later.

 * Calculates the Fresnel reflectivity.
vec3 CalculateFresnel(
    in vec3 surfNorm,
    in vec3 toView,
    in vec3 fresnel0)
	float d = max(dot(surfNorm, toView), 0.0);
    float p = ((-5.55473 * d) - 6.98316) * d;

    // Fresnel-Schlick approximation
    return fresnel0 + ((1.0 - fresnel0) * pow(1.0 - d, 5.0));
    // modified by Spherical Gaussian approximation to replace the power, more efficient
    return fresnel0 + ((1.0 - fresnel0) * pow(2.0, p));

Put together

 * Cook-Torrance BRDF for analytical light sources.
vec3 CalculateSpecularAnalytical(
    in    vec3  surfNorm,            // Surface normal
    in    vec3  toLight,             // Normalized vector pointing to light source
    in    vec3  toView,              // Normalized vector point to the view/camera
    in    vec3  fresnel0,            // Fresnel incidence value
    inout vec3  sfresnel,            // Final fresnel value used a kS
    in    float roughness)           // Roughness parameter (microfacet contribution)
    vec3 halfVector = CalculateHalfVector(toLight, toView);

    float ndf      = CalculateNDF(surfNorm, halfVector, roughness);
    float geoAtten = CalculateAttenuationAnalytical(surfNorm, toLight, toView, roughness);

    sfresnel = CalculateFresnel(surfNorm, toView, fresnel0);

    vec3  numerator   = (sfresnel * ndf * geoAtten); // FDG
    float denominator = 4.0 * dot(surfNorm, toLight) * dot(surfNorm, toView);

    return (numerator / denominator);


Solve Reflectance Equation

Now we have to combine Diffuse Term + Specular Term.

 * Calculates the total light contribution for the analytical light source.
vec3 CalculateLightingAnalytical(
    in vec3  surfNorm,
    in vec3  toLight,
    in vec3  toView,
    in vec3  albedo,
    in float roughness)
    vec3 fresnel0 = mix(vec3(0.04), albedo, Metallic);
    vec3 ks       = vec3(0.0);
    vec3 kd       = (1.0 - ks);
    vec3 diffuse  = CalculateDiffuse(albedo);
    vec3 specular = CalculateSpecularAnalytical(surfNorm, toLight, toView, fresnel0, ks, roughness);

    float angle = clamp(dot(surfNorm, toLight), 0.0, 1.0);

    return ((kd * diffuse) + specular) * angle;


TBC - IBL/Unreal Material Implementation


  1. Learnopengl.com - PBR - Theory
  2. Real Shading in Unreal Engine 4
  3. Shadertoy - PBR Lighting Demo
  4. Specular BRDF Reference
  5. PBRT
comments powered by Disqus
Your Browser Don't Support Canvas, Please Download Chrome ^_^``