Dual-Paraboloid Shadow Mapping Coordinates in GLSL

Dual-paraboloid shadow mapping is an omnidirectional shadowing technique that encodes a 360-degree view of a scene into two hemispherical 2D paraboloid maps instead of a standard six-sided cubemap. This article explores the purpose of calculating dual-paraboloid shadow mapping coordinates in GLSL, detailing the mathematical transformation from light-space vectors to 2D texture coordinates, its role in both depth generation and lighting evaluation, and its performance advantages in real-time rendering.

The Purpose of Dual-Paraboloid Mapping

In real-time graphics, omnidirectional point lights typically require a cube shadow map, which demands six separate rendering passes to capture every direction (\(+X, -X, +Y, -Y, +Z, -Z\)). Dual-paraboloid shadow mapping reduces this overhead to just two passes: one for the forward hemisphere (\(+Z\)) and one for the backward hemisphere (\(-Z\)).

Calculating dual-paraboloid coordinates in GLSL allows shaders to map arbitrary 3D direction vectors from a light source to a pair of 2D textures. This enables efficient omnidirectional depth storage and lookups using standard 2D textures or texture arrays, avoiding the memory footprint, draw-call overhead, and hardware state-switching associated with cubemaps.

The Coordinate Transformation in GLSL

The transformation relies on the geometric property of a paraboloid, which focuses parallel rays onto a single focal point. To map a 3D position into paraboloid texture coordinates, GLSL shaders execute a specific projection.

When a point in light-view space is represented by the direction vector \(\vec{v} = (x, y, z)\), the vector is first normalized. Depending on whether the front or back hemisphere is targeted, the coordinate calculation proceeds as follows:

  1. Hemisphere Evaluation: The sign of the \(z\)-coordinate determines which paraboloid map to sample:
    • Front hemisphere: \(z > 0\)
    • Back hemisphere: \(z \le 0\)
  2. Parabolic Projection: The reflection vector across the paraboloid surface flattens the 3D vector onto a 2D plane:
    • For the front map: \[u = \frac{x}{2(1 + z)} + 0.5\] \[v = \frac{y}{2(1 + z)} + 0.5\]
    • For the back map: \[u = \frac{x}{2(1 - z)} + 0.5\] \[v = \frac{y}{2(1 - z)} + 0.5\]

The denominator \((1 \pm z)\) performs the non-linear parabolic division, while the factor of \(0.5\) and the addition of \(0.5\) transform the normalized device coordinates from \([-1, 1]\) into standard OpenGL \([0, 1]\) texture space.

Implementation Roles in the Rendering Pipeline

The GLSL coordinate calculation serves two distinct stages of the shadow pipeline:

1. The Depth Pass (Generation)

During the shadow generation pass, a vertex or geometry shader transforms world-space geometry into light space and applies the paraboloid projection to output non-linear clip-space coordinates:

// Vertex Shader (Depth Pass - Front Paraboloid)
vec3 P = (u_LightViewMatrix * vec4(a_Position, 1.0)).xyz;
float L = length(P);
P = P / L; // Normalize

gl_Position.x = P.x / (1.0 + P.z);
gl_Position.y = P.y / (1.0 + P.z);
gl_Position.z = (L - u_NearPlane) / (u_FarPlane - u_NearPlane); // Linear depth
gl_Position.w = 1.0;

Because the transformation is non-linear across triangles, highly tessellated meshes or fragment-stage evaluations are required to prevent geometric distortion along triangle edges.

2. The Lighting Pass (Shadow Evaluation)

During the final scene pass, the fragment shader determines whether a surface is in shadow by calculating the light-to-surface vector, determining the appropriate hemisphere, computing the UV coordinates, and comparing the stored depth with the fragment's distance to the light:

// Fragment Shader (Lighting Pass)
vec3 lightToFrag = fragWorldPos - lightWorldPos;
float dist = length(lightToFrag);
vec3 dir = lightToFrag / dist;

vec2 uv;
float shadowDepth;

if (dir.z >= 0.0) {
    uv = vec2(dir.x, dir.y) / (2.0 * (1.0 + dir.z)) + 0.5;
    shadowDepth = texture(u_FrontShadowMap, uv).r;
} else {
    uv = vec2(dir.x, dir.y) / (2.0 * (1.0 - dir.z)) + 0.5;
    shadowDepth = texture(u_BackShadowMap, uv).r;
}

float bias = 0.005;
float shadow = (dist - bias > shadowDepth * (u_FarPlane - u_NearPlane) + u_NearPlane) ? 0.0 : 1.0;

Benefits and Trade-offs

  • Reduced Render Passes: Generating depth requires only two draw passes per point light instead of six, significantly cutting CPU driver overhead and batch counts.
  • Atlas Integration: Two 2D paraboloid maps pack seamlessly into existing 2D shadow atlases alongside directional and spot shadows, simplifying uniform buffer configurations.
  • Artifacts and Distortion: The non-linear projection can cause projection stretching near the perimeter (\(z \approx -1\) or \(z \approx 1\)). GLSL implementations often introduce overlapping field-of-view margins or slight geometric over-tessellation to eliminate visible seam lines between the two hemispheres.