Implementing Cook-Torrance BRDF in GLSL

This article provides a direct guide to formulating and implementing the Cook-Torrance microfacet specular reflection model inside a modern GLSL fragment shader. It outlines the core mathematical equation, defines the three fundamental microfacet functions—the Normal Distribution Function (NDF), the Geometry Shadowing Function, and the Fresnel factor—and concludes with a complete, production-ready GLSL fragment shader implementation.


The Cook-Torrance Specular Equation

The Cook-Torrance microfacet model calculates specular reflection by treating rough surfaces as microscopic mirrors (microfacets). The specular Bidirectional Reflectance Distribution Function (BRDF) is defined mathematically as:

\[f_{\text{specular}} = \frac{D \cdot F \cdot G}{4 (\mathbf{N} \cdot \mathbf{V})(\mathbf{N} \cdot \mathbf{L})}\]

Where:

  • \(\mathbf{N}\) is the surface normal vector.
  • \(\mathbf{V}\) is the view (camera) direction vector.
  • \(\mathbf{L}\) is the light source direction vector.
  • \(\mathbf{H}\) is the halfway vector, calculated as \(\text{normalize}(\mathbf{L} + \mathbf{V})\).
  • \(D\) is the Normal Distribution Function.
  • \(F\) is the Fresnel reflection coefficient.
  • \(G\) is the Geometric attenuation/shadowing-masking factor.

Component Formulations

Modern physically based rendering (PBR) pipelines standardly use the GGX/Trowbridge-Reitz variants for these terms.

1. Normal Distribution Function (\(D\)): GGX / Trowbridge-Reitz

Describes the alignment of microfacets relative to the halfway vector \(\mathbf{H}\), parameterized by roughness \(\alpha\):

\[D_{\text{GGX}}(\mathbf{N}, \mathbf{H}, \alpha) = \frac{\alpha^2}{\pi \left((\mathbf{N} \cdot \mathbf{H})^2 (\alpha^2 - 1) + 1\right)^2}\]

2. Fresnel Function (\(F\)): Schlick's Approximation

Determines the ratio of light reflected versus refracted based on the viewing angle, using the base surface reflectivity \(F_0\):

\[F_{\text{Schlick}}(\mathbf{V}, \mathbf{H}, F_0) = F_0 + (1.0 - F_0) (1.0 - (\mathbf{V} \cdot \mathbf{H}))^5\]

3. Geometric Shadowing Function (\(G\)): Smith's Model with Schlick-GGX

Accounts for microfacets occluding or casting shadows on one another. It combines geometry terms for both the view and light directions:

\[G(\mathbf{N}, \mathbf{V}, \mathbf{L}, k) = G_1(\mathbf{N}, \mathbf{V}, k) \cdot G_1(\mathbf{N}, \mathbf{L}, k)\]

Where \(G_1\) is defined as:

\[G_1(\mathbf{N}, \mathbf{X}, k) = \frac{\mathbf{N} \cdot \mathbf{X}}{(\mathbf{N} \cdot \mathbf{X})(1 - k) + k}\]

For direct analytic lights, \(k = \frac{(\alpha + 1)^2}{8}\), where \(\alpha = \text{roughness}^2\).


GLSL Fragment Shader Formulation

Below is the GLSL formulation implementing these functions and combining them into the final specular contribution.

#version 330 core

in vec3 vWorldPos;
in vec3 vNormal;

out vec4 FragColor;

uniform vec3 uCameraPos;
uniform vec3 uLightPos;
uniform vec3 uLightColor;
uniform vec3 uAlbedo;
uniform float uRoughness;
uniform float uMetallic;

const float PI = 3.14159265359;

// 1. Normal Distribution Function (Trowbridge-Reitz GGX)
float DistributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;

    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    denom = PI * denom * denom;

    return a2 / max(denom, 0.0000001);
}

// 2. Geometry Function helper (Schlick-GGX)
float GeometrySchlickGGX(float NdotV, float roughness) {
    float r = (roughness + 1.0);
    float k = (r * r) / 8.0;

    float denom = NdotV * (1.0 - k) + k;
    return NdotV / max(denom, 0.0000001);
}

// Geometry Function (Smith's method)
float GeometrySmith(vec3 N, vec3 V, vec3 L, float roughness) {
    float NdotV = max(dot(N, V), 0.0);
    float NdotL = max(dot(N, L), 0.0);
    float ggx1 = GeometrySchlickGGX(NdotV, roughness);
    float ggx2 = GeometrySchlickGGX(NdotL, roughness);

    return ggx1 * ggx2;
}

// 3. Fresnel Equation (Fresnel-Schlick approximation)
vec3 FresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

void main() {
    vec3 N = normalize(vNormal);
    vec3 V = normalize(uCameraPos - vWorldPos);
    vec3 L = normalize(uLightPos - vWorldPos);
    vec3 H = normalize(V + L);

    // Calculate base reflectivity F0 (0.04 for dielectrics, albedo for metals)
    vec3 F0 = vec3(0.04);
    F0 = mix(F0, uAlbedo, uMetallic);

    // Compute Cook-Torrance BRDF components
    float D = DistributionGGX(N, H, uRoughness);
    float G = GeometrySmith(N, V, L, uRoughness);
    vec3 F  = FresnelSchlick(max(dot(H, V), 0.0), F0);

    // Specular Cook-Torrance BRDF
    vec3 numerator = D * G * F;
    float denominator = 4.0 * max(dot(N, V), 0.0) * max(dot(N, L), 0.0) + 0.0001;
    vec3 specular = numerator / denominator;

    // Energy conservation: diffuse vs specular fraction
    vec3 kS = F;
    vec3 kD = vec3(1.0) - kS;
    kD *= 1.0 - uMetallic;

    // Outgoing radiance calculation
    float NdotL = max(dot(N, L), 0.0);
    vec3 diffuse = (kD * uAlbedo / PI);
    
    vec3 radiance = (diffuse + specular) * uLightColor * NdotL;

    FragColor = vec4(radiance, 1.0);
}

Key Considerations for Implementation

  • Epsilon Clamping: Small float additions (e.g., 0.0001) in denominators prevent hardware division-by-zero errors when surface angles approach grazing values.
  • Roughness Parameterization: Squaring perceptual roughness (\(\alpha = \text{roughness}^2\)) before passing it to GGX produces more visually linear specular highlights.
  • Energy Conservation: \(k_D\) and \(k_S\) ensure that the total reflected light (diffuse plus specular) does not exceed the incoming energy. Metallic surfaces absorb diffuse components entirely, which is represented by kD *= 1.0 - uMetallic.