Reconstruct Z from BC5 Normal Map in GLSL

BC5 compression optimizes texture memory by storing only two channels, typically representing the X and Y coordinates of a tangent-space normal vector. Because normalized 3D surface vectors have a constant unit length of one, the missing Z-component can be mathematically reconstructed in real time inside a fragment shader. This guide explains the core formula and provides the standard GLSL implementation used in modern graphics pipelines.

The Mathematical Formula

A normalized 3D vector satisfies the unit sphere equation:

\[x^2 + y^2 + z^2 = 1\]

Tangent-space normal maps assume that vectors point outward from the surface toward the positive Z direction, meaning \(z \ge 0\). By isolating \(z\), the equation becomes:

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

Expressed using vector operations, the sum of squares \(x^2 + y^2\) is equivalent to the dot product of the 2D vector with itself (\(\mathbf{v}_{xy} \cdot \mathbf{v}_{xy}\)):

\[z = \sqrt{1 - (\mathbf{v}_{xy} \cdot \mathbf{v}_{xy})}\]

Texture Unpacking

BC5 textures store data in an unsigned normalized range of \([0.0, 1.0]\). Before deriving \(z\), the sampled red and green values must be remapped to signed tangent-space coordinates spanning \([-1.0, 1.0]\):

\[\mathbf{v}_{xy} = \text{sampled}_{rg} \times 2.0 - 1.0\]

GLSL Implementation

Texture filtering, mipmapping, and compression artifacts can occasionally yield values where \(x^2 + y^2 > 1.0\). Computing the square root of a negative value produces NaN, which creates visual black artifacts. To prevent this, clamp the term inside the square root to zero:

// Sample the two-channel BC5 texture (stored in Red and Green)
vec2 normalXY = texture(u_NormalMap, v_TexCoord).rg;

// Remap from [0.0, 1.0] to [-1.0, 1.0]
normalXY = normalXY * 2.0 - 1.0;

// Reconstruct the Z-component
float normalZ = sqrt(max(0.0, 1.0 - dot(normalXY, normalXY)));

// Combine into the final tangent-space normal vector
vec3 normal = vec3(normalXY, normalZ);

Using dot(normalXY, normalXY) computes \(x^2 + y^2\) in a single GPU instruction. The max(0.0, ...) guard ensures mathematical stability under all sampling conditions while keeping the reconstructed vector normalized.