Dual-Paraboloid Mapping Coordinates in GLSL

Dual-paraboloid environment mapping encodes a full 360-degree panoramic view into two parabolic textures: one for the forward hemisphere and one for the backward hemisphere. This article provides the mathematical derivation for transforming a 3D direction or reflection vector into 2D texture coordinates \((u, v)\) for both paraboloids, followed by an efficient implementation in OpenGL Shading Language (GLSL).

Geometric Principles of the Paraboloid

A paraboloid with its focus at the origin and opening towards the negative \(z\)-axis can be defined implicitly by:

\[z = \frac{1}{2} - \frac{1}{2}(x^2 + y^2)\]

The key optical property of a paraboloid is that any ray originating from the focus \((0, 0, 0)\) reflects off the inner surface parallel to the principal axis—in this case, along the vector \(\vec{d} = (0, 0, 1)\).

To find the surface normal \(\vec{n}\) at a given point \((x, y, z)\) on this surface, express the paraboloid as the zero level-set of a function \(F(x, y, z)\):

\[F(x, y, z) = \frac{1}{2}(x^2 + y^2) + z - \frac{1}{2} = 0\]

The gradient \(\nabla F\) gives an unnormalized normal vector:

\[\nabla F = (x, y, 1)\]

Normalizing this vector yields the unit surface normal \(\vec{N}\):

\[\vec{N} = \frac{(x, y, 1)}{\sqrt{x^2 + y^2 + 1}}\]

Forward Derivation: Relating the Ray to Surface Coordinates

When a ray leaves the origin in direction \(\vec{R} = (R_x, R_y, R_z)\) (where \(\|\vec{R}\| = 1\)), it strikes the paraboloid at \((x, y, z)\) and reflects along the direction \(\vec{V} = (0, 0, 1)\). By the law of specular reflection:

\[\vec{R} = 2(\vec{N} \cdot \vec{V})\vec{N} - \vec{V}\]

Substitute \(\vec{V} = (0, 0, 1)\) and \(\vec{N} = \frac{(x, y, 1)}{\sqrt{x^2 + y^2 + 1}}\):

\[\vec{N} \cdot \vec{V} = \frac{1}{\sqrt{x^2 + y^2 + 1}}\]

Now substitute this dot product back into the reflection equation:

\[\vec{R} = 2\left(\frac{1}{\sqrt{x^2 + y^2 + 1}}\right) \left(\frac{(x, y, 1)}{\sqrt{x^2 + y^2 + 1}}\right) - (0, 0, 1)\]

\[\vec{R} = \frac{2}{x^2 + y^2 + 1}(x, y, 1) - (0, 0, 1)\]

Breaking this into vector components:

\[R_x = \frac{2x}{x^2 + y^2 + 1}\]

\[R_y = \frac{2y}{x^2 + y^2 + 1}\]

\[R_z = \frac{2}{x^2 + y^2 + 1} - 1 = \frac{1 - (x^2 + y^2)}{x^2 + y^2 + 1}\]

Inverting to Solve for \((x, y)\)

To sample a texture, \((x, y)\) must be solved in terms of the known unit vector \(\vec{R}\). Adding \(1\) to the \(R_z\) expression:

\[R_z + 1 = \frac{1 - (x^2 + y^2)}{x^2 + y^2 + 1} + 1 = \frac{2}{x^2 + y^2 + 1}\]

Dividing \(R_x\) and \(R_y\) by \((R_z + 1)\):

\[\frac{R_x}{R_z + 1} = \frac{\frac{2x}{x^2 + y^2 + 1}}{\frac{2}{x^2 + y^2 + 1}} = x\]

\[\frac{R_y}{R_z + 1} = \frac{\frac{2y}{x^2 + y^2 + 1}}{\frac{2}{x^2 + y^2 + 1}} = y\]

Because the hemisphere covers the domain \(x^2 + y^2 \le 1\), the coordinates \(x\) and \(y\) are constrained to \([-1, 1]\).

Texture Coordinate Mapping

To map \(x, y \in [-1, 1]\) into standard normalized UV coordinates \([0, 1]\):

\[u = \frac{x}{2} + 0.5 = \frac{R_x}{2(1 + R_z)} + 0.5\]

\[v = \frac{y}{2} + 0.5 = \frac{R_y}{2(1 + R_z)} + 0.5\]

Derivation for the Backward Hemisphere

The second paraboloid faces the opposite direction along the negative \(z\)-axis. Repeating the reflection geometry with \(\vec{V} = (0, 0, -1)\) flips the sign of \(R_z\). To preserve clockwise/counter-clockwise orientation and prevent mirror inversion, the resulting coordinates are:

\[u = \frac{R_x}{2(1 - R_z)} + 0.5\]

\[v = \frac{R_y}{2(1 - R_z)} + 0.5\]

GLSL Implementation

In a GLSL shader, determine whether the reflection ray points forward (\(R_z \ge 0\)) or backward (\(R_z < 0\)), compute the UV coordinates using the respective denominator, and sample the appropriate texture:

// R: Normalized 3D reflection or view vector
vec4 sampleDualParaboloid(sampler2D frontMap, sampler2D backMap, vec3 R) {
    vec2 uv;
    
    if (R.z >= 0.0) {
        // Forward hemisphere
        uv.x = (R.x / (2.0 * (1.0 + R.z))) + 0.5;
        uv.y = (R.y / (2.0 * (1.0 + R.z))) + 0.5;
        return texture(frontMap, uv);
    } else {
        // Backward hemisphere
        uv.x = (R.x / (2.0 * (1.0 - R.z))) + 0.5;
        uv.y = (R.y / (2.0 * (1.0 - R.z))) + 0.5;
        return texture(backMap, uv);
    }
}

When storing both maps inside a single sampler2DArray, the layer index can be selected directly using a condition such as float layer = R.z >= 0.0 ? 0.0 : 1.0;, avoiding multiple branching texture fetch routines.