Shader Lab
26.Parallax occlusion mapping

Parallax occlusion mapping

Arc 5 · 3D Geometry · Lesson 26 of 35
coordinate signal color
0.08
30

Purpose

A brick wall can look deeply inset without any of its geometry actually being inset — if every pixel shifts which texel it samples based on the viewing angle, bricks slide realistically as you move, selling depth that isn't there.

Key insight

Normal mapping (L22) fakes lighting as if the surface had bumps. That works until you look at the wall sideways — the bumps don't move against each other like real depth would, and the illusion collapses. POM adds one more trick: for each pixel, instead of sampling the texture at the pixel's own UV, raymarch the view ray into a heightmap attached to the surface.

Step forward along the ray, checking at each step: am I still above the surface at this height? When you first dip below, stop — that's where the pixel "really" is. Use that UV for the texture sample. Bricks slide against each other with parallax, as if they were truly inset.

All of this happens in the fragment stage. The mesh is still a flat quad. The silhouette is still flat. But everything inside the shape feels solid. Critical contrast with L24: vertex-data is unchanged, per-pixel coordinate is hijacked.

Break it

Set depth to max and drag camera tilt toward 80° (grazing angle). The edges of the quad reveal the truth — the brick wall has no thickness. A real displaced mesh (L24 on a subdivided plane) would stick ridges out past the silhouette. POM can't. Teaches the entire tradeoff: POM is cheaper than true displacement (no mesh subdivision needed) but it lies about silhouettes. For flat walls and floors viewed head-on, it's invisible; for objects viewed from any angle, it breaks.

This is the core fragment-stage-vs-vertex-stage tradeoff of Arc 5 — feel it, don't just hear it. Pull depth to 0 and watch the wall flatten to painted-on. Push it up and bricks visibly sink. Find the angle at which they start to shear unnaturally at the quad's edge.

Direct Claude

"painted-on bricks" depth to 0 (normal-map only) "deeper inset bricks" depth up "exaggerated relief / cartoon carving" depth high "carved stone floor" POM with moderate depth "looks fine head-on but weird from the side" POM's limit — switch to real displacement (L24) or tessellation
Meta-phrase you gain here: "fake it per-pixel" vs. "really move the geometry." You can now tell a developer "POM is fine here, the view is always head-on" or "we need actual displacement, this is a hero asset."
Combines with: L24 (the honest vertex-stage alternative), L22 (Normal/Bump — POM reuses the same heightmap), L21 (PBR — POM's depth illusion is sold by how parallax-shifted normals light correctly).
the fragment shader doing all the work (mesh is a flat quad)
// VERTEX STAGE — unchanged. Just a flat quad; pass UV and view direction.
varying vec2 vUv;
varying vec3 vViewTS;     // view direction in tangent space (surface-local)

void main() {
  vUv = uv;
  // Plane is in local space with normal +Z, tangent +X, bitangent +Y.
  // View in tangent space is the vector FROM the surface TO the camera.
  vec3 worldPos = (modelMatrix * vec4(position, 1.0)).xyz;
  vec3 toCam = normalize(cameraPosition - worldPos);
  mat3 invModel = mat3(transpose(inverse(modelMatrix)));
  vViewTS = normalize(invModel * toCam);
  gl_Position = projectionMatrix * viewMatrix * vec4(worldPos, 1.0);
}

// FRAGMENT STAGE — the ENTIRE POM effect lives here.
// Procedural brick heightmap. Raymarch the view ray into it.
uniform float u_depth;
varying vec2 vUv;
varying vec3 vViewTS;

// Brick heightmap: low in mortar channels, high on brick faces.
float heightAt(vec2 uv) {
  vec2 scaled = uv * vec2(6.0, 12.0);    // 6 bricks wide, 12 rows
  float row = floor(scaled.y);
  scaled.x += mod(row, 2.0) * 0.5;       // offset every other row (running bond)
  vec2 f = fract(scaled);
  // Inset mortar gaps: bricks are high (1.0), mortar is low (0.0)
  float mx = smoothstep(0.0, 0.06, f.x) * smoothstep(0.0, 0.06, 1.0 - f.x);
  float my = smoothstep(0.0, 0.10, f.y) * smoothstep(0.0, 0.10, 1.0 - f.y);
  return mx * my;
}

void main() {
  vec3 V = normalize(vViewTS);
  // Parallax offset per unit of depth, in UV units per unit of height.
  // The classic POM direction is -V.xy / V.z (ray entering the surface).
  vec2 dir = -V.xy / max(V.z, 0.1) * u_depth;

  // Sphere-tracing-lite: fixed-step raymarch down through the heightfield.
  const int STEPS = 32;
  float layer = 1.0;
  vec2 uv = vUv;
  float h = heightAt(uv);
  // Walk along the ray, dropping height each step. Stop when layer dips
  // below the heightmap — that's where the view ray first "hits" the surface.
  for (int i = 0; i < STEPS; i++) {
    if (layer < h) break;
    layer -= 1.0 / float(STEPS);
    uv += dir / float(STEPS);
    h = heightAt(uv);
  }

  // Shade with the parallax-shifted UV: this is the ENTIRE POM illusion.
  float height = heightAt(uv);
  vec3 brick = mix(vec3(0.22, 0.12, 0.10), vec3(0.74, 0.38, 0.26), height);
  vec3 mortar = vec3(0.22, 0.20, 0.17);
  vec3 col = mix(mortar, brick, smoothstep(0.05, 0.4, height));

  // Light from above, with a fake normal from the heightmap gradient.
  vec2 e = vec2(0.002, 0.0);
  float hx = heightAt(uv + e.xy) - heightAt(uv - e.xy);
  float hy = heightAt(uv + e.yx) - heightAt(uv - e.yx);
  vec3 n = normalize(vec3(-hx, -hy, 0.05));
  float shade = max(0.0, dot(n, normalize(vec3(0.3, 0.6, 0.8))));
  col *= 0.45 + 0.75 * shade;

  gl_FragColor = vec4(col, 1.0);
}