Introduction to Parallax Occlusion Mapping (POM)
In 3D graphics, Parallax Occlusion Mapping (POM) is an advanced shading technique that simulates depth on a flat surface by distorting texture UV coordinates based on the viewer’s angle. Unlike traditional bump mapping or normal mapping, POM fakes real displacement without modifying geometry.
Why Use POM Instead of Displacement Mapping?
- More detailed than normal mapping – it creates real depth perception.
- No need for extra geometry – everything happens in the shader.
- Great for high-quality textures – ideal for surfaces like rocks, bricks, wood, and tiles.
How Does POM Work?
- Heightmap Ray Marching – The shader marches through the heightmap texture to simulate parallax depth.
- UV Distortion – The view angle shifts the texture coordinates based on height variations.
- Binary Search Refinement – Improves accuracy by refining depth intersections.
- Normal Mapping – Enhances surface shading for better realism.
- Roughness Mapping – Controls surface reflections and material roughness.
Required Textures for POM
Before implementing POM, we need:
- Diffuse Map (Albedo): The base color texture.
- Height Map: A grayscale image defining depth (white = high, black = low).
- Normal Map: Adds fine surface details using tangent-space normals.
- Roughness Map: Controls how rough or shiny the surface appears.
Example Rock Texture Set:
rock_texture.jpg(Diffuse/Albedo)rock_heightmap.jpg(Height Map)rock_normal.jpg(Normal Map)rock_roughness.jpg(Roughness Map)
Implementing POM in Three.js
We will create a custom ShaderMaterial in Three.js that implements POM along with normal and roughness mapping.
3.1 Vertex Shader
In the vertex shader, we:
- Pass UV coordinates to the fragment shader.
- Compute the view direction in tangent space for correct POM calculations.
- Create a Tangent-Bitangent-Normal (TBN) matrix for normal mapping.
const vertexShader = `
varying vec2 vUv;
varying vec3 vViewDir;
varying mat3 vTBN;
uniform mat3 normalMatrix;
void main() {
vUv = uv;
// Compute TBN (Tangent, Bitangent, Normal) matrix
vec3 objectNormal = normalize(normalMatrix * normal);
vec3 tangent = normalize(normalMatrix * vec3(1, 0, 0));
vec3 bitangent = cross(objectNormal, tangent);
vTBN = mat3(tangent, bitangent, objectNormal);
// Compute view direction in tangent space
vec3 viewDir = normalize(cameraPosition - position);
vViewDir = vTBN * viewDir;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
3.2 Fragment Shader
In the fragment shader, we:
- Perform Parallax Occlusion Mapping using ray-marching.
- Blend Normal Mapping to enhance surface details.
- Apply Roughness Mapping to control reflections.
const fragmentShader = `
uniform sampler2D diffuseMap;
uniform sampler2D heightMap;
uniform sampler2D normalMap;
uniform sampler2D roughnessMap;
uniform float heightScale;
varying vec2 vUv;
varying vec3 vViewDir;
varying mat3 vTBN;
#define STEPS 32 // Number of parallax depth steps
#define REFINEMENT_STEPS 5 // Extra refinement steps for accuracy
void main() {
vec2 uv = vUv;
vec3 viewDir = normalize(vViewDir);
// === Parallax Occlusion Mapping ===
float height = texture2D(heightMap, uv).r * heightScale;
vec2 parallaxOffset = viewDir.xy * (height / viewDir.z);
vec2 newUv = uv - parallaxOffset;
// Ray-marching loop for depth correction
float layerDepth = 1.0 / float(STEPS);
float currentLayer = 0.0;
vec2 P = newUv;
float depth = texture2D(heightMap, P).r;
for (int i = 0; i < STEPS; i++) {
if (currentLayer >= depth) break;
P -= parallaxOffset * layerDepth;
depth = texture2D(heightMap, P).r;
currentLayer += layerDepth;
}
// Binary search refinement for more accurate depth
for (int i = 0; i < REFINEMENT_STEPS; i++) {
P += parallaxOffset * layerDepth * 0.5;
depth = texture2D(heightMap, P).r;
if (currentLayer < depth) {
P -= parallaxOffset * layerDepth * 0.5;
}
}
// === Normal Mapping ===
vec3 normalColor = texture2D(normalMap, P).rgb * 2.0 - 1.0;
vec3 normalTangent = normalize(vTBN * normalColor);
// === Roughness Mapping ===
float roughness = texture2D(roughnessMap, P).r;
// === Final Color with Lighting Approximation ===
vec3 lightDir = normalize(vec3(1.0, 1.0, 1.0));
float diff = max(dot(normalTangent, lightDir), 0.0);
vec3 color = texture2D(diffuseMap, P).rgb;
vec3 finalColor = color * diff * (1.0 - roughness);
gl_FragColor = vec4(finalColor, 1.0);
}
`;
3.3 Creating the Three.js Material
Now, we apply the shader to a PlaneGeometry in Three.js.
const material = new THREE.ShaderMaterial({
uniforms: {
diffuseMap: { value: new THREE.TextureLoader().load('rock_texture.jpg') },
heightMap: { value: new THREE.TextureLoader().load('rock_heightmap.jpg') },
normalMap: { value: new THREE.TextureLoader().load('rock_normal.jpg') },
roughnessMap: { value: new THREE.TextureLoader().load('rock_roughness.jpg') },
heightScale: { value: 0.1 }
},
vertexShader,
fragmentShader
});
const geometry = new THREE.PlaneGeometry(5, 5, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
Optimizations & Performance Tips
Reduce STEPS from 32 → 16 for better FPS.
- Pack height, normal, and roughness into a single RGBA texture to reduce texture fetches.
- Use dynamic height scaling (
heightScale) based on camera distance. - Limit viewing angles – extreme angles can break the illusion.
Conclusion
With Parallax Occlusion Mapping (POM), Normal Mapping, and Roughness Mapping, we can create highly realistic 3D textures without increasing geometry complexity.
This method is ideal for surfaces like:
- Rocky grounds
- Brick walls
- Wood planks
- Sci-fi panels