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;
}