LTC Area Light Specular Reflection in GLSL

Linearly Transformed Cosines (LTC) provide an exact, real-time analytical solution for integrating arbitrary microfacet BRDFs over polygonal area lights. In standard rendering pipelines, computing specular reflection from a rectangular emitter requires solving a spherical polygon integral that lacks an analytical solution under complex BRDFs such as GGX. By using a precomputed \(3 \times 3\) matrix transformation, the specular BRDF is mapped to a clamped cosine distribution, allowing the integral to be solved analytically in GLSL via vector calculus.

The Mathematical Framework

The core idea of LTC is that any microfacet BRDF (such as GGX) can be approximated by linearly transforming a standard clamped cosine distribution \(D_0\):

\[D(\omega) \approx D_0(M^{-1}\omega) \frac{1}{\det(M) \|M^{-1}\omega\|^3}\]

Integrating the BRDF over a spherical polygon \(P\) illuminated by a uniform radiance \(L\) is equivalent to integrating the clamped cosine over a transformed polygon \(P' = M^{-1}P\):

\[E = \int_P \text{BRDF}(\omega) \cos\theta \, d\omega = \int_{P'} D_0(\omega') \, d\omega'\]

Instead of transforming the incident rays, we project the four vertices of the rectangular light source using the inverse matrix \(M^{-1}\). The resulting integral over the transformed polygon can be computed analytically using Stokes' theorem.

Step 1: Fetching the Precomputed LTC Matrix

The matrix \(M^{-1}\) depends on the surface roughness \(\alpha\) and the angle between the view vector \(V\) and surface normal \(N\) (\(\cos\theta = N \cdot V\)). Since \(M^{-1}\) has isotropic symmetry in the tangent space, it can be parameterized by four values packed into a 2D lookup texture:

// Lookup textures:
// ltc_1 contains the inverse matrix components
// ltc_2 contains BRDF magnitude (Fresnel) and optional terms
uniform sampler2D u_LTC1;
uniform sampler2D u_LTC2;

mat3 getLTCInverseMatrix(vec2 uv) {
    vec4 t1 = texture(u_LTC1, uv);
    
    // Construct the M^-1 transformation matrix
    return mat3(
        vec3(t1.x, 0.0,  t1.y),
        vec3(0.0,  1.0,  0.0),
        vec3(t1.z, 0.0,  t1.w)
    );
}

Step 2: Transforming Rectangular Vertices

Given a rectangular area light defined by four world-space vertices \(V_0, V_1, V_2, V_3\), the vertices must be translated relative to the shading point \(P\), transformed into the local shading space (TBN frame), and then transformed by \(M^{-1}\):

vec3 transformVertex(vec3 vertex, vec3 P, mat3 TBN, mat3 Minv) {
    // Translate vertex relative to surface position
    vec3 localV = vertex - P;
    
    // Transform to shading space
    localV = localV * TBN;
    
    // Apply LTC inverse transformation
    return Minv * localV;
}

Step 3: Analytical Polygon Integration

Once the vertices are transformed, the integral of a clamped cosine distribution over the polygon is computed using the boundary integral of the edges. For each directed edge between two normalized vertices \(v_1\) and \(v_2\), the irradiance contribution is proportional to the solid angle subtended by the edge:

vec3 integrateEdge(vec3 v1, vec3 v2) {
    float cosTheta = dot(v1, v2);
    float theta = acos(clamp(cosTheta, -0.9999, 0.9999));
    vec3 crossProduct = cross(v1, v2);
    float lengthCross = length(crossProduct);
    
    if (lengthCross < 0.0001) return vec3(0.0);
    
    // Vector form factor
    return (theta / lengthCross) * crossProduct;
}

Because the clamped cosine distribution is only valid above the horizon (\(z > 0\) in shading space), the polygon must either be clipped to the horizon plane (\(z = 0\)) or approximated. The standard analytical form computes the boundary sum:

float evaluatePolygonIntegral(vec3 p0, vec3 p1, vec3 p2, vec3 p3) {
    // Normalize transformed vertices onto the unit sphere
    vec3 v0 = normalize(p0);
    vec3 v1 = normalize(p1);
    vec3 v2 = normalize(p2);
    vec3 v3 = normalize(p3);

    // Sum edge contributions
    vec3 formFactor = vec3(0.0);
    formFactor += integrateEdge(v0, v1);
    formFactor += integrateEdge(v1, v2);
    formFactor += integrateEdge(v2, v3);
    formFactor += integrateEdge(v3, v0);

    // Integrate with respect to the clamped cosine lobe pointing along the z-axis
    float sum = -formFactor.z;
    return max(sum, 0.0) / (2.0 * 3.14159265);
}

Step 4: Final Specular Shading

The specular color is scaled by the light intensity, the LTC directional irradiance, and the specular Fresnel response:

vec3 computeLTCSpecular(
    vec3 P, vec3 N, vec3 V, vec3 albedo, float roughness,
    vec3 lightPoints[4], vec3 lightColor, float lightIntensity
) {
    float NdotV = clamp(dot(N, V), 0.0, 1.0);
    vec2 uv = vec2(roughness, sqrt(1.0 - NdotV));
    uv = uv * (63.0 / 64.0) + (0.5 / 64.0); // Offset to avoid boundary artifacts

    mat3 Minv = getLTCInverseMatrix(uv);

    // Construct local tangent frame
    vec3 T = normalize(V - N * NdotV);
    vec3 B = cross(N, T);
    mat3 TBN = mat3(T, B, N);

    // Transform all 4 quad vertices
    vec3 p0 = transformVertex(lightPoints[0], P, TBN, Minv);
    vec3 p1 = transformVertex(lightPoints[1], P, TBN, Minv);
    vec3 p2 = transformVertex(lightPoints[2], P, TBN, Minv);
    vec3 p3 = transformVertex(lightPoints[3], P, TBN, Minv);

    // Compute irradiance of the transformed polygon
    float specIrradiance = evaluatePolygonIntegral(p0, p1, p2, p3);

    // Sample Fresnel scale/bias from second LUT
    vec4 ltc2 = texture(u_LTC2, uv);
    vec3 specularColor = mix(vec3(0.04), albedo, 1.0); // Or use standard F0
    vec3 F = specularColor * ltc2.x + (vec3(1.0) - specularColor) * ltc2.y;

    return lightColor * lightIntensity * specIrradiance * F;
}

This procedure accurately reproduces the deformation, broadening, and horizon-clamping of specular highlights produced by polygonal area lights under microfacet reflectance models.