Triplanar Normal Blending with Surface Gradients in GLSL

This article explains how to blend triplanar normal maps in GLSL using the surface gradient framework introduced by Morten S. Mikkelsen. Triplanar mapping traditionally suffers from complex tangent space derivations and tangent-basis discontinuities across projection boundaries. By converting tangent-space normal samples into world-space surface gradients before blending, you can linearly accumulate normal perturbations across all three projection axes without distortion or the need for explicit per-vertex tangents, producing a correctly oriented, normalized normal vector.


The Mathematical Formula

The surface gradient framework represents normal perturbations as a vector field tangential to the surface. For a given geometric surface normal \(N\), the final perturbed normal \(N_{final}\) is evaluated by subtracting the accumulated surface gradient \(G\) from \(N\):

\[N_{final} = \text{normalize}(N - G)\]

For triplanar projection, \(G\) is the weighted sum of individual surface gradients evaluated on each coordinate plane (\(X\), \(Y\), and \(Z\)):

\[G = w_x G_x + w_y G_y + w_z G_z\]

Where:

  • \(w_x, w_y, w_z\) are blending weights derived from the geometric normal (such that \(w_x + w_y + w_z = 1\)).
  • \(G_i\) is the surface gradient contributed by projection plane \(i\).

For any planar mapping defined by tangent vector \(T\) and bitangent vector \(B\), a sampled tangent-space normal deviation \((n_x, n_y, n_z)\) yields a surface gradient:

\[G_i = \frac{n_x T_\perp + n_y B_\perp}{n_z}\]

where \(T_\perp\) and \(B_\perp\) are the surface projections of the planar coordinate axes:

\[T_\perp = T - (T \cdot N)N\] \[B_\perp = B - (B \cdot N)N\]

When working directly with standard unit normal maps where perturbations are small, the division by \(n_z\) is often omitted or simplified to \(n_x T_\perp + n_y B_\perp\).


GLSL Implementation

Below is the complete GLSL function evaluating triplanar normal mapping using surface gradients:

// Project a planar direction onto the surface plane defined by geometric normal N
vec3 projectOntoSurface(vec3 axis, vec3 N) {
    return axis - dot(axis, N) * N;
}

// Computes a surface gradient for an individual projection plane
vec3 surfGradPlane(vec3 tsNormal, vec3 T, vec3 B, vec3 N) {
    vec3 T_perp = projectOntoSurface(T, N);
    vec3 B_perp = projectOntoSurface(B, N);
    
    // tsNormal is assumed to be unpacked to the [-1, 1] range
    return (tsNormal.x * T_perp + tsNormal.y * B_perp) / max(tsNormal.z, 1e-4);
}

// Evaluates the blended triplanar normal
vec3 evaluateTriplanarNormal(
    vec3 worldPos, 
    vec3 geomNormal, 
    sampler2D normalMap, 
    float uvScale, 
    float blendSharpness
) {
    vec3 N = normalize(geomNormal);

    // Calculate blending weights
    vec3 weights = pow(abs(N), vec3(blendSharpness));
    weights /= (weights.x + weights.y + weights.z);

    // Projection UV coordinates
    vec2 uvX = worldPos.zy * uvScale;
    vec2 uvY = worldPos.xz * uvScale;
    vec2 uvZ = worldPos.xy * uvScale;

    // Sample normal maps (tangent-space vectors unpacked to [-1, 1])
    vec3 tNormalX = texture(normalMap, uvX).xyz * 2.0 - 1.0;
    vec3 tNormalY = texture(normalMap, uvY).xyz * 2.0 - 1.0;
    vec3 tNormalZ = texture(normalMap, uvZ).xyz * 2.0 - 1.0;

    // Account for projection orientation
    tNormalX.x *= sign(N.x);
    tNormalY.x *= sign(N.y);
    tNormalZ.x *= -sign(N.z);

    // Define planar tangent/bitangent axes
    // X projection (ZY plane): T = (0, 0, 1), B = (0, 1, 0)
    vec3 G_x = surfGradPlane(tNormalX, vec3(0.0, 0.0, 1.0), vec3(0.0, 1.0, 0.0), N);

    // Y projection (XZ plane): T = (1, 0, 0), B = (0, 0, 1)
    vec3 G_y = surfGradPlane(tNormalY, vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0), N);

    // Z projection (XY plane): T = (-1, 0, 0), B = (0, 1, 0)
    vec3 G_z = surfGradPlane(tNormalZ, vec3(-1.0, 0.0, 0.0), vec3(0.0, 1.0, 0.0), N);

    // Accumulate surface gradients linearly
    vec3 G = weights.x * G_x + weights.y * G_y + weights.z * G_z;

    // Resolve final perturbed normal
    return normalize(N - G);
}

Advantages of the Surface Gradient Formulation

  1. Linear Additivity: Because surface gradients reside in the common tangent space of the surface rather than three arbitrary local spaces, linear combinations (\(w_x G_x + w_y G_y + w_z G_z\)) preserve correct slope and direction without renormalization until the final step.
  2. Tangent-Free: Mesh tangent vectors (\(T\)) and bitangents (\(B\)) do not need to be exported or interpolated across vertices.
  3. No Matrix Inversions: Avoids transforming normals between tangent space and world space repeatedly, eliminating artifacts caused by non-orthogonal tangent bases.