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:
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\)
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\]
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.