Kajiya-Kay Specular Highlights in GLSL

This article provides a technical breakdown of how hair and fur shaders evaluate the Kajiya-Kay specular reflection model within the OpenGL Shading Language (GLSL). Unlike traditional polygonal surface models like Blinn-Phong that rely primarily on surface normals, the Kajiya-Kay model treats individual hair strands as thin, idealized cylinders. By relying on tangent vectors that run along the strand rather than standard surface normals, shaders can simulate the characteristic anisotropic light rings that appear across hair and fur.

The Anisotropic Geometry of Hair Strands

Standard bidirectional reflectance distribution functions (BRDFs) fail on hair because individual fibers lack a distinct planar surface normal. Instead, a hair strand can be imagined as an infinitely small cylinder oriented along a tangent vector \(\mathbf{T}\). Because the normal varies continuously across 360 degrees around the cylinder circumference, reflection off a strand creates a cone of reflected light rather than a single reflected ray.

When multiple aligned strands are illuminated, these reflection cones align to form anisotropic specular bands perpendicular to the hair direction. Evaluating the highlight therefore requires calculating the angle between the hair tangent \(\mathbf{T}\), the light vector \(\mathbf{L}\), and the view vector \(\mathbf{V}\).

Mathematical Formulation

The classical Kajiya-Kay formulation calculates the specular component by finding the angle difference between the incoming light and the outgoing view vector projected onto the cylinder's tangent frame:

\[\cos(\theta_r - \theta_d) = \cos(T, L) \cos(T, V) + \sin(T, L) \sin(T, V)\]

Using vector dot products, \(\cos(T, L) = \mathbf{T} \cdot \mathbf{L}\) and \(\cos(T, V) = \mathbf{T} \cdot \mathbf{V}\). Using the trigonometric identity \(\sin(x) = \sqrt{1 - \cos^2(x)}\), the sine terms become:

\[\sin(T, L) = \sqrt{1.0 - (\mathbf{T} \cdot \mathbf{L})^2}\] \[\sin(T, V) = \sqrt{1.0 - (\mathbf{T} \cdot \mathbf{V})^2}\]

The specular intensity \(I_s\) with exponent \(p\) (roughness) is:

\[I_s = \max(0.0, \cos(T, L) \cos(T, V) + \sin(T, L) \sin(T, V))^p\]

The Half-Vector Approximation

In modern real-time rendering, shaders often use a half-vector approximation introduced for real-time fur and hair. Using the normalized half-vector \(\mathbf{H} = \frac{\mathbf{L} + \mathbf{V}}{\|\mathbf{L} + \mathbf{V}\|}\), the specular term can be computed using only the dot product between the tangent and the half-vector:

\[\sin(T, H) = \sqrt{1.0 - (\mathbf{T} \cdot \mathbf{H})^2}\] \[I_s = (\sin(T, H))^p\]

When \(\mathbf{T} \cdot \mathbf{H} = 0\), the half-vector is perpendicular to the strand tangent, meaning the strand orientation matches the perfect reflection condition, yielding maximum specular intensity.

Tangent Shifting for Cuticle Scales

Natural hair fibers have overlapping cuticles arranged like roof shingles, tilted slightly toward the root. This structure directs reflections away from the ideal geometric reflection angle.

To recreate this in GLSL, shaders shift the tangent vector \(\mathbf{T}\) along the underlying mesh normal \(\mathbf{N}\) prior to the specular calculation:

vec3 shiftTangent(vec3 T, vec3 N, float shift) {
    return normalize(T + N * shift);
}

By supplying different shift values, shaders generate two distinct highlights:

  1. Primary Highlight: Shifted toward the hair tip, representing direct, uncolored surface reflection (reflected by the outer cuticle).
  2. Secondary Highlight: Shifted toward the root, representing light that penetrated the hair cortex, absorbed pigment (melanin), and reflected out with a softer, wider exponent.

GLSL Implementation

Below is a complete GLSL fragment shader implementation evaluating dual-highlight Kajiya-Kay specular terms using the half-vector approach.

#version 330 core

in vec3 vWorldPos;
in vec3 vNormal;
in vec3 vTangent;

out vec4 FragColor;

uniform vec3 uCameraPos;
uniform vec3 uLightPos;
uniform vec3 uBaseColor;
uniform vec3 uLightColor;

vec3 shiftTangent(vec3 T, vec3 N, float shift) {
    return normalize(T + N * shift);
}

float strandSpecular(vec3 T, vec3 H, float exponent) {
    float dotTH = dot(T, H);
    float sinTH = sqrt(max(0.0, 1.0 - dotTH * dotTH));
    
    // Smooth attenuation to prevent harsh specular cutoff at strand edges
    float dirAtten = smoothstep(-1.0, 0.0, dotTH);
    return dirAtten * pow(sinTH, exponent);
}

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

    // Kajiya-Kay Diffuse term: sin(T, L)
    float dotTL = dot(T, L);
    float sinTL = sqrt(max(0.0, 1.0 - dotTL * dotTL));
    vec3 diffuse = uBaseColor * sinTL;

    // Shift tangents for dual specular lobes
    vec3 t1 = shiftTangent(T, N, 0.1);  // Primary highlight shifted toward tip
    vec3 t2 = shiftTangent(T, N, -0.1); // Secondary highlight shifted toward root

    // Primary highlight: Sharp, uncolored
    float spec1 = strandSpecular(t1, H, 80.0);
    vec3 primaryHighlight = uLightColor * spec1;

    // Secondary highlight: Broader, tinted by hair pigment
    float spec2 = strandSpecular(t2, H, 20.0);
    vec3 secondaryHighlight = uBaseColor * uLightColor * spec2;

    vec3 finalColor = diffuse + primaryHighlight + secondaryHighlight;
    FragColor = vec4(finalColor, 1.0);
}

Performance and Numerical Stability Considerations

Evaluating \(\sqrt{1.0 - x^2}\) requires clamping inputs using max(0.0, 1.0 - dotTH * dotTH). Floating-point imprecision can occasionally push the absolute value of dotTH slightly above 1.0, resulting in negative values inside sqrt(), which creates black NaN artifacts in the framebuffer. Additionally, using smoothstep or a cosine falloff term helps suppress specular bleeding when light sources are positioned behind the direction of the strand.