Screen Space Contact Shadows Raymarching in GLSL
Screen-space contact shadows enhance real-time rendering realism by filling the gap between shadow maps and geometry, capturing fine occlusion details at contact points. This article breaks down the mathematical formula and the raymarching implementation in GLSL to trace rays through the depth buffer in screen space.
Mathematical Formulation
Screen-space contact shadow raymarching involves casting a ray from a visible surface point toward the light source, advancing along discrete steps in screen space, and testing for intersections against the depth buffer.
1. Ray Definition
Let \(\mathbf{P}_v\) be the view-space fragment position and \(\mathbf{L}_v\) be the normalized view-space light direction vector. The position along the ray at step \(i\) is:
\[\mathbf{R}_v(t) = \mathbf{P}_v + \mathbf{L}_v \cdot t\]
where:
- \(t = (i + \text{jitter}) \cdot \Delta t\)
- \(\Delta t = \frac{\text{MaxDistance}}{\text{Steps}}\)
- \(\text{jitter} \in [0, 1)\) is a dither value (e.g., from interleaved gradient noise) to reduce stepping banding artifacts.
2. Screen-Space Projection
To evaluate occlusion, the view-space point \(\mathbf{R}_v(t)\) must be projected into screen-space UV coordinates and projected depth:
\[\mathbf{P}_{clip} = \mathbf{M}_{proj} \cdot \begin{pmatrix} \mathbf{R}_v(t) \\ 1.0 \end{pmatrix}\]
\[\mathbf{P}_{ndc} = \frac{\mathbf{P}_{clip}.xyz}{\mathbf{P}_{clip}.w}\]
\[\mathbf{UV} = \mathbf{P}_{ndc}.xy \times 0.5 + 0.5\]
\[\text{RayDepth} = \mathbf{P}_{ndc}.z \times 0.5 + 0.5 \quad (\text{assuming standard } [0, 1] \text{ OpenGL depth})\]
3. Occlusion Condition
Sample the scene depth buffer at \(\mathbf{UV}\):
\[\text{SceneDepth} = \text{texture}(\text{depthTexture}, \mathbf{UV}).r\]
An intersection occurs if the ray passes behind the depth buffer surface but within a predefined thickness threshold:
\[\text{Occluded} = (\text{RayDepth} > \text{SceneDepth}) \land (|\text{RayDepth} - \text{SceneDepth}| < \text{Thickness})\]
Alternatively, when using linear view-space depth (\(Z_v\)):
\[0 < (Z_{\text{ray}} - Z_{\text{scene}}) < \text{Thickness}\]
GLSL Implementation
Below is the complete GLSL function implementing depth buffer raymarching for contact shadows.
// Uniform inputs required:
// uniform sampler2D uDepthTexture;
// uniform mat4 uProjection;
// uniform float uMaxDistance; // Maximum ray distance (view space)
// uniform float uThickness; // Object thickness threshold (view space)
// uniform int uStepCount; // Number of raymarch steps (e.g., 16 to 32)
float ScreenSpaceContactShadows(
vec3 viewPos,
vec3 viewLightDir,
mat4 projection,
sampler2D depthTexture,
float dither)
{
float stepSize = uMaxDistance / float(uStepCount);
float occlusion = 0.0;
// Linear depth reconstruction helper
// Adjust based on your projection matrix parameters:
// float linearDepth = -viewPos.z;
for (int i = 0; i < uStepCount; ++i)
{
// Compute current sample position along the ray in view space
float rayDist = (float(i) + dither) * stepSize;
vec3 rayViewPos = viewPos + viewLightDir * rayDist;
// Project ray position to clip space
vec4 rayClip = projection * vec4(rayViewPos, 1.0);
vec3 rayNDC = rayClip.xyz / rayClip.w;
// Transform to [0, 1] texture coordinates
vec2 sampleUV = rayNDC.xy * 0.5 + 0.5;
// Discard rays traveling outside screen bounds
if (sampleUV.x < 0.0 || sampleUV.x > 1.0 || sampleUV.y < 0.0 || sampleUV.y > 1.0)
{
break;
}
// Sample the hardware depth buffer
float sceneDeviceDepth = texture(depthTexture, sampleUV).r;
float rayDeviceDepth = rayNDC.z * 0.5 + 0.5;
// Depth comparison (non-reversed depth buffer)
float depthDiff = rayDeviceDepth - sceneDeviceDepth;
// Check for occlusion within the allowable thickness
if (depthDiff > 0.0001 && depthDiff < uThickness)
{
// Edge fade to prevent hard edges near screen borders
vec2 edgeFactor = smoothstep(0.0, 0.05, sampleUV) * (1.0 - smoothstep(0.95, 1.0, sampleUV));
float screenFade = edgeFactor.x * edgeFactor.y;
// Distance fade
float distanceFade = 1.0 - (rayDist / uMaxDistance);
occlusion = screenFade * distanceFade;
break; // Terminate ray upon contact
}
}
// Returns shadow attenuation factor (1.0 = lit, 0.0 = shadowed)
return 1.0 - occlusion;
}Considerations for Accuracy
- Linear Depth vs. Device Depth: Comparing linear view-space depth yields more consistent thickness thresholds across varying distances from the camera compared to non-linear device depth (\(Z_{ndc}\)).
- Temporal Artifacts: Use spatial or temporal denoising filters (such as TAA) to smooth out high-frequency noise introduced by dithering.
- Camera Facing Rays: Rays directed back toward the camera position can produce false occlusions; attenuate ray contributions when the dot product between the view vector and light vector approaches \(-1.0\).