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:
- Horizontal Angle (\(\theta\)): Computed using the two-argument arctangent function, \(\text{atan2}(z, x)\), yielding an angle in the range \([-\pi, \pi]\).
- 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 toasin(). Rounding errors in vector normalization can occasionally produce values like \(1.000001\), which causesasin()to returnNaNand 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.