Implementing Minnaert Limb Darkening in GLSL

This article provides an implementation guide for the Minnaert limb-darkening reflectance model used to simulate astronomical surfaces in real-time computer graphics. It covers the mathematical formulation of the Minnaert equation, explains the physical significance of its parameters for planetary bodies, and presents an optimized OpenGL Shading Language (GLSL) fragment shader implementation designed to avoid common rendering artifacts.

Understanding the Minnaert Model

Standard Lambertian reflectance assumes a completely diffuse surface that scatters incoming light equally in all directions, yielding uniform brightness across a sphere regardless of viewing angle. Astronomical surfaces rarely behave as ideal Lambertian reflectors. Rocky airless bodies (such as the Moon) exhibit limb brightening or retro-reflection, while gas giants (such as Jupiter or Uranus) and stars exhibit limb darkening due to atmospheric absorption and scattering depth.

Marcel Minnaert introduced a phenomenological reflectance model that adapts the Lambertian cosine law to account for these photometric effects. The standard Minnaert equation is defined as:

\[R = \rho \cdot (N \cdot L)^k \cdot (N \cdot V)^{k - 1}\]

Where:

  • \(N\) is the normalized surface normal vector.
  • \(L\) is the normalized direction vector pointing toward the light source.
  • \(V\) is the normalized direction vector pointing toward the observer/camera.
  • \(\rho\) is the surface albedo.
  • \(k\) is the Minnaert limb parameter:
    • When \(k = 1.0\), the model reduces to pure Lambertian reflectance (\(N \cdot L\)).
    • When \(k < 1.0\), the surface exhibits limb brightening, common on rough, porous regolith like that of the Moon or asteroids (typically \(k \approx 0.5\) to \(0.7\)).
    • When \(k > 1.0\), the surface exhibits limb darkening, typical for dense atmospheres and gas giants (typically \(k \approx 1.2\) to \(2.0\)).

Numerical Stability Considerations in Shaders

Directly translating \((N \cdot V)^{k - 1}\) into GLSL can cause floating-point errors. When \(k < 1.0\), the exponent \((k - 1.0)\) becomes negative. As the viewing angle approaches the silhouette edge of the planet, \(N \cdot V\) approaches zero, leading to division by zero or pow(0.0, negative_value), which produces NaN or Inf artifacts on the geometry edges.

To ensure numerical stability, clamp dot products using a small positive epsilon (e.g., 0.001) and ensure that back-facing lighting (\(N \cdot L \le 0\)) drops to zero smoothly.

GLSL Implementation

Below is a complete GLSL function implementing the Minnaert reflectance model:

/**
 * Calculates Minnaert reflectance for astronomical bodies.
 * 
 * @param normal     Normalized surface normal (N)
 * @param lightDir   Normalized vector toward light source (L)
 * @param viewDir    Normalized vector toward viewer (V)
 * @param k          Minnaert limb parameter (k < 1: brightening, k > 1: darkening)
 * @return           Reflectance intensity scalar
 */
float calculateMinnaert(vec3 normal, vec3 lightDir, vec3 viewDir, float k) {
    float nDotL = dot(normal, lightDir);
    
    // Discard unlit surfaces to prevent light leaking
    if (nDotL <= 0.0) {
        return 0.0;
    }
    
    // Prevent division by zero or NaN at glancing angles
    const float EPSILON = 0.001;
    float nDotV = max(dot(normal, viewDir), EPSILON);
    
    // Minnaert formulation: (N.L)^k * (N.V)^(k - 1)
    float minnaertFactor = pow(nDotL, k) * pow(nDotV, k - 1.0);
    
    return minnaertFactor;
}

Integration in a Fragment Shader

To use this function in an astronomical rendering pipeline, combine the Minnaert intensity with the planetary surface texture and light source color:

#version 330 core

in vec3 vNormal;
in vec3 vPosition;
in vec2 vTexCoord;

out vec4 fragColor;

uniform sampler2D uAlbedoMap;
uniform vec3 uLightPosition;
uniform vec3 uCameraPosition;
uniform vec3 uLightColor;
uniform float uMinnaertK; // e.g., 0.65 for the Moon, 1.4 for Jupiter

void main() {
    vec3 N = normalize(vNormal);
    vec3 L = normalize(uLightPosition - vPosition);
    vec3 V = normalize(uCameraPosition - vPosition);
    
    vec4 albedo = texture(uAlbedoMap, vTexCoord);
    
    float intensity = calculateMinnaert(N, L, V, uMinnaertK);
    
    vec3 finalColor = albedo.rgb * uLightColor * intensity;
    
    fragColor = vec4(finalColor, albedo.a);
}

This shader allows dynamic tuning of the parameter uMinnaertK, enabling a single shader to accurately depict both rough, rocky terrestrial moons and smooth, scattering planetary atmospheres.