Half-Lambert Diffuse Lighting in GLSL

This article provides an overview of the Half-Lambert diffuse reflection model, its mathematical derivation, and its practical implementation in OpenGL Shading Language (GLSL). Originally developed by Valve Corporation for the original Half-Life, the technique softens shadows on low-poly geometry by mapping dot product values to prevent surfaces from falling completely into blackness, creating a subtle wrap-around lighting effect.


Standard Lambert vs. Half-Lambert

Standard Lambertian reflectance assumes a surface reflects light equally in all directions based on the cosine of the angle between the surface normal (\(\mathbf{N}\)) and the light direction vector (\(\mathbf{L}\)):

\[I_{\text{Lambert}} = \max(\mathbf{N} \cdot \mathbf{L}, 0.0)\]

When the surface points away from the light source (\(\mathbf{N} \cdot \mathbf{L} \le 0\)), the intensity drops immediately to zero. This creates harsh terminator lines and completely dark, unreadable silhouettes on the unlit sides of objects.

The Half-Lambert technique alters this behavior by remapping the dot product from the range \([-1, 1]\) to \([0, 1]\), and then squaring the result to maintain a natural falloff curve.


Mathematical Formulation

The transformation consists of two primary steps:

  1. Scale and Bias: Scale the dot product by \(0.5\) and shift it by \(0.5\):

    \[\text{HalfDot} = 0.5 \times (\mathbf{N} \cdot \mathbf{L}) + 0.5\]

    • When \(\mathbf{N} \cdot \mathbf{L} = 1.0\) (facing directly at light): \(\text{HalfDot} = 1.0\)
    • When \(\mathbf{N} \cdot \mathbf{L} = 0.0\) (perpendicular to light): \(\text{HalfDot} = 0.5\)
    • When \(\mathbf{N} \cdot \mathbf{L} = -1.0\) (facing directly away): \(\text{HalfDot} = 0.0\)
  2. Squaring the Factor: Squaring the result tightens the highlights and restores visual contrast while preventing back-facing surfaces from becoming pitch black:

    \[I_{\text{Half-Lambert}} = (\text{HalfDot})^2 = (0.5 \times (\mathbf{N} \cdot \mathbf{L}) + 0.5)^2\]

  3. Final Color Computation: Multiply the intensity by the light color and the material diffuse color:

    \[C_{\text{final}} = C_{\text{diffuse}} \times C_{\text{light}} \times I_{\text{Half-Lambert}}\]


GLSL Implementation

Below is the GLSL function implementing the Half-Lambert diffuse calculation:

vec3 calculateHalfLambert(vec3 normal, vec3 lightDir, vec3 diffuseColor, vec3 lightColor) {
    // Ensure input vectors are normalized
    vec3 N = normalize(normal);
    vec3 L = normalize(lightDir);

    // Compute the dot product without clamping
    float NdotL = dot(N, L);

    // Map range [-1.0, 1.0] to [0.0, 1.0]
    float halfLambert = NdotL * 0.5 + 0.5;

    // Square the result to restore contrast
    halfLambert = halfLambert * halfLambert;

    // Calculate the final diffuse contribution
    return diffuseColor * lightColor * halfLambert;
}

Complete Fragment Shader Example

#version 330 core

in vec3 vNormal;
in vec3 vFragPos;

out vec4 FragColor;

uniform vec3 uLightPos;
uniform vec3 uLightColor;
uniform vec3 uDiffuseColor;

void main() {
    vec3 lightDir = normalize(uLightPos - vFragPos);
    vec3 diffuse = calculateHalfLambert(vNormal, lightDir, uDiffuseColor, uLightColor);

    FragColor = vec4(diffuse, 1.0);
}

Summary of Benefits

  • Performance: Executes with minimal operations (a multiply-add and a square), incurring no performance penalty compared to standard Lambertian shading.
  • Silhouette Preservation: Maintains visual readability and surface detail on back-facing geometry without the need for additional ambient lights.
  • Stylized Aesthetics: Provides a softer, semi-stylized appearance widely used in both realistic character rendering and cel-shaded art styles.