Paraboloid Shadow Maps for Wide Spotlights in GLSL
This article explains how to project wide-angle spotlights into single-pass paraboloid shadow maps using GLSL. Traditional perspective projection matrices break down when a spotlight's field of view approaches or exceeds 180 degrees due to planar perspective distortion and division by zero. By using parabolic projection, an entire forward hemisphere can be continuously mapped into a single standard 2D texture, allowing wide-angle spotlights to generate shadows in a single render pass without requiring multi-pass cubemaps.
The Mathematics of Paraboloid Projection
A paraboloid surface reflects parallel rays to a single focal point. For a forward-facing spotlight, we project world positions relative to the light's local frame where the light points down the positive Z-axis.
Given a light-space vertex position \(\mathbf{P} = (x, y, z)\), the distance to the light is \(d = \|\mathbf{P}\|\). The normalized direction vector is \(\mathbf{u} = \frac{\mathbf{P}}{d}\).
The stereographic reflection across a forward-facing paraboloid maps this direction vector to normalized device coordinates \((x_{ndc}, y_{ndc})\):
\[x_{ndc} = \frac{\mathbf{u}.x}{1.0 + \mathbf{u}.z} = \frac{x}{d + z}\] \[y_{ndc} = \frac{\mathbf{u}.y}{1.0 + \mathbf{u}.z} = \frac{y}{d + z}\]
Because \(\mathbf{u}.z > -1.0\) covers the entire forward hemisphere (and slightly beyond), this projection avoids the singularities inherent to standard perspective projections at wide angles up to 180 degrees.
Depth Map Generation: Vertex Shader
To render the shadow map in a single pass, the vertex shader must transform vertices directly into paraboloid clip coordinates.
#version 330 core
layout(location = 0) in vec3 aPos;
uniform mat4 uLightView; // Transforms world space to light space (+Z forward)
uniform float uNear;
uniform float uFar;
out float vDepth;
void main()
{
// Transform position into light space
vec4 viewPos = uLightView * vec4(aPos, 1.0);
// Distance from light source
float dist = length(viewPos.xyz);
// Projected coordinates
vec2 proj;
proj.x = viewPos.x / (dist + viewPos.z);
proj.y = viewPos.y / (dist + viewPos.z);
// Normalize depth between near and far planes
float normalizedDepth = (dist - uNear) / (uFar - uNear);
vDepth = normalizedDepth;
// Convert depth to OpenGL NDC range [-1, 1]
gl_Position = vec4(proj.x, proj.y, normalizedDepth * 2.0 - 1.0, 1.0);
}Depth Map Generation: Fragment Shader
Because paraboloid projection is non-linear, linear interpolation of depth across triangles can cause artifacts if geometry is coarse. Outputting the computed distance ensures consistent numerical precision:
#version 330 core
in float vDepth;
layout(location = 0) out float outDepth;
void main()
{
// Write normalized linear depth directly
outDepth = vDepth;
}Sampling the Shadow Map in the Lighting Pass
When computing lighting for the scene, the fragment's position must be mapped into the paraboloid shadow texture. The direction from the spotlight to the surface is normalized, mapped to \([0, 1]\) UV coordinates, and compared against the recorded depth value.
#version 330 core
in vec3 vWorldPos;
uniform sampler2D uShadowMap;
uniform mat4 uLightView;
uniform float uNear;
uniform float uFar;
uniform float uBias;
float EvaluateParaboloidShadow(vec3 worldPos)
{
// Transform surface position into light space
vec4 lightSpacePos = uLightView * vec4(worldPos, 1.0);
// Discard fragments behind the light
if (lightSpacePos.z <= 0.0)
{
return 0.0;
}
float dist = length(lightSpacePos.xyz);
vec3 dir = lightSpacePos.xyz / dist;
// Parabolic UV mapping
vec2 uv;
uv.x = (dir.x / (1.0 + dir.z)) * 0.5 + 0.5;
uv.y = (dir.y / (1.0 + dir.z)) * 0.5 + 0.5;
// Check bounds
if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0)
{
return 0.0;
}
float currentDepth = (dist - uNear) / (uFar - uNear);
float shadowMapDepth = texture(uShadowMap, uv).r;
return (currentDepth - uBias > shadowMapDepth) ? 0.0 : 1.0;
}Non-Linear Projection Artifacts
Standard rasterization hardware assumes linear vertex transformation in clip space. Because paraboloid coordinates curve straight lines into hyperbola-like curves, large polygons will visibly distort near the edges of the hemisphere.
To resolve edge warping without abandoning the single-pass architecture:
- Geometry Tessellation: Subdivide large scene geometry so that non-linear vertex warping is approximated closely by smaller linear segments.
- Conservative Biasing: Apply a normal-dependent slope bias to mitigate shadow acne resulting from interpolation curvature.