Dual-Depth Shadow Mapping for Hair in GLSL
Dual-depth shadow mapping provides an efficient volumetric approximation for self-shadowing hair fibers by recording two distinct boundary depths along each shadow ray rather than a single occlusion value. This technique bridges the gap between binary hard shadows and computationally expensive deep shadow maps by approximating hair density and optical thickness between a near-depth layer and a far-depth layer. Through the Beer-Lambert law, these depth intervals are converted into continuous light transmittance values, enabling soft, realistic light attenuation through dense hair assemblies in real-time GLSL rendering pipelines.
Mathematical Formulation
Standard shadow maps store only the minimum depth \(d_1\), causing hair to appear completely opaque. Dual-depth shadow mapping captures both the entry depth \(d_1\) (the nearest hair fiber to the light) and an exit or secondary reference depth \(d_2\) (a deeper boundary layer within the hair volume) along the light ray direction.
Light attenuation through participating media such as hair fibers is governed by the Beer-Lambert law:
\[T(d) = e^{-\sigma_t \cdot \tau(d)}\]
Where:
- \(T(d)\) is the transmittance (percentage of light penetrating to depth \(d\)), ranging from \(0.0\) to \(1.0\).
- \(\sigma_t\) is the extinction coefficient of the hair (sum of absorption \(\sigma_a\) and scattering \(\sigma_s\)).
- \(\tau(d)\) is the accumulated optical thickness along the light ray up to the fragment depth \(d\).
To approximate \(\tau(d)\) using only two depth bounds \(d_1\) and \(d_2\), the hair volume between the boundaries is assumed to have an average fiber density \(\rho\). The thickness through the hair volume is evaluated piecewise based on fragment depth \(d\):
\[\tau(d) = \begin{cases} 0, & d \le d_1 \\ \rho \cdot (d - d_1), & d_1 < d \le d_2 \\ \rho \cdot (d_2 - d_1) + \kappa \cdot (d - d_2), & d > d_2 \end{cases}\]
Here, \(\kappa \ge 0\) represents an asymptotic attenuation coefficient for regions beyond the secondary captured boundary \(d_2\). In its most direct form, normalized thickness \(\tilde{\tau}(d)\) across the captured volume interval is defined as:
\[\tilde{\tau}(d) = \text{clamp}\left(\frac{d - d_1}{d_2 - d_1 + \epsilon}, 0.0, 1.0\right)\]
Which yields the final transmittance factor:
\[T(d) = \exp\left(-\sigma_t \cdot (d_2 - d_1) \cdot \tilde{\tau}(d)\right)\]
Shadow Buffer Generation
The dual-depth map is generated using a two-channel floating-point
framebuffer (GL_RG32F). During the shadow pass:
- Target Channel
Rstores the minimum depth: \(d_1 = \min(d_{current}, d_1)\). - Target Channel
Gstores either the maximum depth within a threshold or the depth of the \(n\)-th layer using additive blending or depth peeling: \(d_2 = \max(d_{current}, d_2)\).
GLSL Implementation
The following GLSL fragment shader functions demonstrate the computation of transmittance on hair fibers during the lighting pass.
#version 450 core
uniform sampler2D u_DualDepthMap;
uniform float u_ExtinctionCoeff; // Controls light absorption strength (\sigma_t)
uniform float u_HairDensity; // Volume density factor (\rho)
uniform float u_DepthBias;
// Evaluates hair transmittance based on dual-depth boundaries
float evaluateDualDepthTransmittance(vec4 shadowCoord)
{
// Perspective division
vec3 projCoords = shadowCoord.xyz / shadowCoord.w;
// Transform from [-1, 1] NDC to [0, 1] UV space
projCoords = projCoords * 0.5 + 0.5;
// Fragment is outside the shadow projection frustum
if (projCoords.z > 1.0 || projCoords.x < 0.0 || projCoords.x > 1.0 ||
projCoords.y < 0.0 || projCoords.y > 1.0)
{
return 1.0;
}
// Sample the dual depth bounds (d1 = entry, d2 = secondary boundary)
vec2 depths = texture(u_DualDepthMap, projCoords.xy).rg;
float d1 = depths.r;
float d2 = depths.g;
float currentDepth = projCoords.z - u_DepthBias;
// Case 1: In front of the first hair layer (fully lit)
if (currentDepth <= d1)
{
return 1.0;
}
// Protect against division by zero for planar hair cards
float shellThickness = max(d2 - d1, 1e-4);
// Compute optical thickness through the volume
float opticalDepth;
if (currentDepth <= d2)
{
// Inside the primary hair interpolation boundary
opticalDepth = u_HairDensity * (currentDepth - d1);
}
else
{
// Beyond the secondary boundary, account for total captured volume
float interiorThickness = u_HairDensity * shellThickness;
float tailThickness = (currentDepth - d2) * (u_HairDensity * 0.5);
opticalDepth = interiorThickness + tailThickness;
}
// Apply the Beer-Lambert extinction formula
float transmittance = exp(-u_ExtinctionCoeff * opticalDepth);
return clamp(transmittance, 0.0, 1.0);
}
// Lighting calculation integration
in vec4 v_LightSpacePos;
out vec4 FragColor;
void main()
{
float shadowFactor = evaluateDualDepthTransmittance(v_LightSpacePos);
vec3 directIllumination = vec3(1.0) * shadowFactor;
vec3 ambient = vec3(0.05);
FragColor = vec4(directIllumination + ambient, 1.0);
}Integration Details
- Depth Precision: Storing non-linear depths directly from screen coordinates can lead to severe banding. Linearizing depth values prior to calculating optical depth \(\Delta d\) ensures consistent volumetric extinction regardless of distance from the light source.
- Bias Adjustment: Bias must be scaled by fiber diameter. In self-shadowing hair, an overly aggressive bias eliminates fine strand self-shadowing, while insufficient bias produces self-occlusion artifacts on adjacent hair strands.