Mapping 3D Vectors to Equirectangular UVs in GLSL

This article explains how to transform a normalized 3D Cartesian direction vector into 2D texture coordinates \((u, v)\) for sampling an equirectangular 360-degree panorama in GLSL. By converting spatial coordinates to spherical angles—yaw and pitch—and scaling them into the \([0.0, 1.0]\) range using standard trigonometric functions, shaders can accurately project and sample spherical textures.

The Mathematical Mapping

An equirectangular projection maps the horizontal angle (longitude/azimuth) across the X-axis from \(-\pi\) to \(+\pi\), and the vertical angle (latitude/elevation) across the Y-axis from \(-\pi/2\) to \(+\pi/2\).

Assuming a standard right-handed coordinate system where \(+Y\) points up, \(+X\) points right, and \(+Z\) points forward:

  1. Horizontal Angle (\(\theta\)): Computed using the two-argument arctangent function, \(\text{atan2}(z, x)\), yielding an angle in the range \([-\pi, \pi]\).
  2. Vertical Angle (\(\phi\)): Computed using arcsine, \(\arcsin(y)\), yielding an angle in the range \([-\frac{\pi}{2}, \frac{\pi}{2}]\).

To transform these continuous angles into standard texture coordinates ranging from \(0.0\) to \(1.0\):

\[u = \frac{\text{atan2}(z, x)}{2\pi} + 0.5\]

\[v = \frac{\arcsin(y)}{\pi} + 0.5\]

GLSL Implementation

Below is the standard, optimized GLSL function to perform this mapping:

const float PI = 3.14159265358979323846;

vec2 dirToEquirectangular(vec3 dir) {
    // Ensure the vector is normalized
    vec3 n = normalize(dir);

    // Calculate normalized spherical coordinates
    // atan(y, x) in GLSL corresponds to atan2
    float u = atan(n.z, n.x) / (2.0 * PI) + 0.5;
    float v = asin(clamp(n.y, -1.0, 1.0)) / PI + 0.5;

    return vec2(u, v);
}

Key Considerations

  • Floating-Point Clamping: Always clamp the \(y\)-component using clamp(n.y, -1.0, 1.0) before passing it to asin(). Rounding errors in vector normalization can occasionally produce values like \(1.000001\), which causes asin() to return NaN and produces rendering artifacts at the poles.
  • Texture Inversion: Depending on whether your graphics API considers the UV origin \((0, 0)\) to be the bottom-left (OpenGL default) or top-left (Vulkan, DirectX, WebGPU), you may need to invert the vertical axis: v = 1.0 - v;.
  • Seam Filtering: The seam where \(u\) wraps from \(1.0\) to \(0.0\) can cause texture filtering artifacts when mipmapping is enabled due to sharp derivative jumps. To resolve this, use textureGrad() or specify manual derivatives when sampling the texture.