Dual-Paraboloid Shadow Coordinate GLSL Formulation
Dual-paraboloid shadow mapping encodes a full \(360^\circ\) omnidirectional light field into two \(2\text{D}\) textures (front and back paraboloids) instead of using a standard six-sided cubemap. This article details the mathematical transformation required to project view-space light coordinates onto a parabolic surface, the non-linear distortion steps, and the corresponding GLSL implementations for both the shadow map generation and sampling stages.
Mathematical Derivation
Let a vertex in light-view space be represented as a homogeneous point \(\mathbf{P} = (x, y, z, 1)^T\). The goal is to project this position onto a paraboloid surface whose focal point resides at the origin and whose principal axis aligns with the light's direction of view (\(+z\) for the forward hemisphere and \(-z\) for the backward hemisphere).
1. Normalizing the Direction Vector
First, determine the normalized direction vector \(\mathbf{v}\) from the light position to the vertex:
\[\mathbf{v} = \frac{\mathbf{P}_{xyz}}{\|\mathbf{P}_{xyz}\|} = (v_x, v_y, v_z)\]
Where \(\|\mathbf{P}_{xyz}\| = \sqrt{x^2 + y^2 + z^2}\).
2. Parabolic Projection
A standard paraboloid parameterized along the \(z\)-axis reflects incident parallel rays to a single focal point. When projecting a directional vector onto this surface:
Forward Paraboloid (\(z \ge 0\)): The reflection vector gives intersection coordinates: \[x' = \frac{v_x}{v_z + 1} = \frac{x}{\|\mathbf{P}_{xyz}\| + z}\] \[y' = \frac{v_y}{v_z + 1} = \frac{y}{\|\mathbf{P}_{xyz}\| + z}\]
Backward Paraboloid (\(z < 0\)): Inverting the direction for the opposite hemisphere: \[x' = \frac{v_x}{1 - v_z} = \frac{x}{\|\mathbf{P}_{xyz}\| - z}\] \[y' = \frac{v_y}{1 - v_z} = \frac{y}{\|\mathbf{P}_{xyz}\| - z}\]
3. Depth Normalization
The depth value must be mapped linearly between the near plane (\(z_{near}\)) and the far plane (\(z_{far}\)):
\[z' = \frac{\|\mathbf{P}_{xyz}\| - z_{near}}{z_{far} - z_{near}}\]
4. Mapping to Texture Space (UVs)
The projected values \(x'\) and \(y'\) lie in the normalized device coordinate (NDC) range \([-1, 1]\). To sample a 2D texture, map these to \([0, 1]\):
\[u = 0.5 \cdot x' + 0.5\] \[v = 0.5 \cdot y' + 0.5\]
GLSL Implementation: Shadow Map Generation
During shadow map generation, the projection is executed directly in the vertex shader to write native hardware depth, or passed to the fragment shader to store custom linear depth. Because parabolic projection produces non-linear edges, sufficient geometry tessellation is required to avoid distortion artifacts.
// Vertex Shader (Forward Hemisphere)
#version 330 core
layout(location = 0) in vec3 aPos;
uniform mat4 uModelView;
uniform float uNear;
uniform float uFar;
void main()
{
// Transform vertex into light view space
vec4 viewPos = uModelView * vec4(aPos, 1.0);
// Distance from the light source
float L = length(viewPos.xyz);
// Normalize view direction
vec3 v = viewPos.xyz / L;
// Compute parabolic NDC coordinates (Forward: v.z >= 0)
// Add 1.0 to the z-component to project onto the forward paraboloid
vec2 proj;
proj.x = v.x / (v.z + 1.0);
proj.y = v.y / (v.z + 1.0);
// Linear normalized depth mapped to [-1, 1] for OpenGL clip space
float depth = (L - uNear) / (uFar - uNear);
float zClip = depth * 2.0 - 1.0;
gl_Position = vec4(proj.x, proj.y, zClip, 1.0);
}GLSL Implementation: Shadow Map Sampling
When evaluating shadows in a forward or deferred lighting pass, determine which hemisphere contains the surface fragment, project into the appropriate UV coordinates, and perform depth comparison.
// Fragment Shader Sampling Routine
#version 330 core
out vec4 FragColor;
uniform sampler2D uShadowMapFront;
uniform sampler2D uShadowMapBack;
uniform mat4 uWorldToLightView;
uniform float uNear;
uniform float uFar;
uniform float uBias;
in vec3 vWorldPos;
float SampleDualParaboloidShadow(vec3 worldPos)
{
// Transform position into light view space
vec3 lightViewPos = (uWorldToLightView * vec4(worldPos, 1.0)).xyz;
float L = length(lightViewPos);
vec3 v = lightViewPos / L;
vec2 uv;
float currentDepth = (L - uNear) / (uFar - uNear);
float shadowMapDepth = 0.0;
// Determine hemisphere and evaluate coordinates
if (v.z >= 0.0)
{
// Forward hemisphere
uv.x = (v.x / ( 1.0 + v.z)) * 0.5 + 0.5;
uv.y = (v.y / ( 1.0 + v.z)) * 0.5 + 0.5;
shadowMapDepth = texture(uShadowMapFront, uv).r;
}
else
{
// Backward hemisphere
uv.x = (v.x / ( 1.0 - v.z)) * 0.5 + 0.5;
uv.y = (v.y / ( 1.0 - v.z)) * 0.5 + 0.5;
shadowMapDepth = texture(uShadowMapBack, uv).r;
}
// Depth test comparison
return (currentDepth - uBias > shadowMapDepth) ? 0.0 : 1.0;
}