How to Implement Parallax Occlusion Mapping in GLSL

This article provides an implementation guide for parallax occlusion mapping (POM) using ray intersection loops within a GLSL fragment shader. Parallax occlusion mapping enhances surface detail by shifting texture coordinates based on the camera's viewing angle and a height map, simulating 3D depth on flat geometry. Below, you will learn the mathematical principles of tangent-space ray marching, the construction of the GLSL intersection loop, and the interpolation step required to achieve smooth, sub-step surface displacement.

Tangent Space Transformation

Ray marching for parallax occlusion mapping must occur in tangent space, where the surface normal aligns with the Z-axis, and the U and V texture axes align with the X and Y axes.

In the vertex shader, compute the tangent-to-world transformation matrix (or its inverse) and transform the view vector into tangent space:

// Vertex Shader snippet
vViewDirTangent = normalize(transpose(mat3(tangent, bitangent, normal)) * (cameraPosition - worldPosition));

The resulting vViewDirTangent vector indicates how the camera ray travels across the UV coordinates relative to surface depth.

The Ray Intersection Loop Concept

The POM algorithm treats the height map as a continuous 3D volume with a depth range from 0.0 (top surface) to 1.0 (bottom surface). The ray starts at the top surface and steps forward along the view direction:

  1. Calculate the Maximum Shift: The view vector's XY components determine the direction of UV displacement, while the Z component determines the steepness. Dividing XY by Z scales the shift appropriately according to the viewing angle.
  2. Determine Step Count: Grazing angles require more sampling steps to avoid aliasing artifacts, while perpendicular angles need fewer steps.
  3. Ray Marching: Incrementally advance both the UV coordinates and the current ray depth until the ray passes below the height value stored in the height map.

GLSL Fragment Shader Implementation

Below is the complete GLSL function implementing the ray intersection loop:

vec2 ParallaxOcclusionMapping(vec2 texCoords, vec3 viewDir, sampler2D depthMap, float heightScale) {
    // Dynamic layer allocation based on view angle
    const float minLayers = 8.0;
    const float maxLayers = 32.0;
    float numLayers = mix(maxLayers, minLayers, max(dot(vec3(0.0, 0.0, 1.0), viewDir), 0.0));
    
    // Depth step per layer
    float layerDepth = 1.0 / numLayers;
    float currentLayerDepth = 0.0;
    
    // Maximum UV displacement vector
    // Division by viewDir.z elongates steps at steep angles
    vec2 P = viewDir.xy / viewDir.z * heightScale;
    vec2 deltaTexCoords = P / numLayers;
    
    // Initial march states
    vec2 currentTexCoords = texCoords;
    float currentDepthMapValue = texture(depthMap, currentTexCoords).r;
    
    // Ray intersection loop
    while(currentLayerDepth < currentDepthMapValue) {
        currentTexCoords -= deltaTexCoords;
        currentDepthMapValue = texture(depthMap, currentTexCoords).r;
        currentLayerDepth += layerDepth;
    }
    
    // Step before intersection
    vec2 prevTexCoords = currentTexCoords + deltaTexCoords;
    
    // Depths for interpolation
    float afterDepth  = currentDepthMapValue - currentLayerDepth;
    float beforeDepth = texture(depthMap, prevTexCoords).r - currentLayerDepth + layerDepth;
    
    // Weight calculation for linear interpolation
    float weight = afterDepth / (afterDepth - beforeDepth);
    vec2 finalTexCoords = prevTexCoords * weight + currentTexCoords * (1.0 - weight);
    
    return finalTexCoords;
}

Interpolating the Intersection Point

A simple ray march stops immediately after penetrating the surface, leaving discrete banding artifacts known as "pancaking." Parallax occlusion mapping eliminates this by interpolating between two points:

  • The sample point after the ray penetrates the surface.
  • The sample point before the penetration occurred.

The algorithm calculates the distance from the ray to the height curve at both points (afterDepth and beforeDepth) and uses linear interpolation to approximate the true analytical intersection point between the steps.

Integrating Displaced Coordinates

To use the calculated UVs in your fragment shader's main() function, pass the displaced coordinates into standard texture samplers:

void main() {
    vec3 viewDir = normalize(vViewDirTangent);
    vec2 displacedUV = ParallaxOcclusionMapping(vTexCoords, viewDir, uDepthMap, 0.05);

    // Discard fragments that extend beyond the UV boundary if necessary
    if(displacedUV.x > 1.0 || displacedUV.y > 1.0 || displacedUV.x < 0.0 || displacedUV.y < 0.0) {
        discard;
    }

    vec4 albedo = texture(uAlbedoMap, displacedUV);
    vec3 normal = texture(uNormalMap, displacedUV).rgb;
    
    // Continue with standard shading using displacedUV
    FragColor = albedo;
}

This ray intersection method creates sharp self-occlusion and correct motion parallax entirely inside the fragment shader without modifying the underlying geometric topology.