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:
- 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.
- Determine Step Count: Grazing angles require more sampling steps to avoid aliasing artifacts, while perpendicular angles need fewer steps.
- 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.