Shader Example: Parallex Mapping

Live Demo

Fragment Shader

Edit the shader and press Run (or Ctrl+Enter) to recompile. The source below the demo is the same text the playground is running.

Seeing the Surface From Behind

The camera orbits all the way around, so for half of each revolution it is behind the plane. That case needs care, and getting it wrong is easy to mistake for a feature: an earlier version of this example produced a smeared, glassy, almost refractive surface from the back. It looked interesting and was entirely wrong.

Two things break when the viewer crosses to the other side.

The parallax offset depends on the sign of the view vector's normal component. A bump that appears to shift left as you lean left from the front must shift right when you lean left from behind. The offset divides by that component, so clamping it with max(z, 0.05) - a common way to stop the divisor reaching zero at grazing angles - discards the sign and substitutes a tiny positive number. Every back-facing pixel then received an offset pointing the wrong way and up to twenty times too large, sampling the height field almost at random across the surface. That scrambled lookup was the glassiness. Clamp the magnitude and keep the sign.

The shading normal points the wrong way. normalFromHeight builds its normal from the height gradient, so it always lies on the +z side. From behind, the visible face is the back of the same relief, whose normal is the negation. Without turning it the lighting says one thing and the silhouette says another.

Decide that from the side the camera is on, not from the perturbed normal. Using faceforward against the perturbed normal also flips individual steep bumps on the near side, which quietly changes the front view - a fix for the back that damages the front is not a fix.

Neither shows up while the camera stays on one side, which is why a demo like this wants an orbit that goes right around rather than a comfortable arc.

Snippets

Shadertoy

// Shadertoy: parallax mapping demo without textures
// Paste into https://www.shadertoy.com/ as the Image shader.

float heightMap(vec2 uv)
{
    // Procedural height field
    float h = 0.0;
    h += 0.50 * sin(uv.x * 10.0) * sin(uv.y * 10.0);
    h += 0.25 * sin(uv.x * 20.0 + 1.0) * sin(uv.y * 20.0 + 0.7);
    return h * 0.5 + 0.5;
}

vec3 normalFromHeight(vec2 uv)
{
    float e = 0.002;
    float h  = heightMap(uv);
    float hx = heightMap(uv + vec2(e, 0.0));
    float hy = heightMap(uv + vec2(0.0, e));

    vec3 dpdx = vec3(e, 0.0, hx - h);
    vec3 dpdy = vec3(0.0, e, hy - h);

    return normalize(cross(dpdx, dpdy));
}

vec2 parallaxOffset(vec2 uv, vec3 viewDirTS)
{
    float heightScale = 0.08;

    // Basic parallax mapping.
    //
    // viewDirTS.z is negative when the surface is seen from behind, and the
    // whole effect depends on its sign: a bump that shifts one way as you lean
    // left from the front must shift the other way from the back. Clamping it
    // with max(z, 0.05) threw that sign away and, worse, replaced it with a
    // near-zero divisor, so every back-facing pixel got an offset up to twenty
    // times too large pointing the wrong way. The height field was then sampled
    // almost at random across the surface, which is what produced the smeared,
    // glassy look - it was not refraction, it was a lookup running wild.
    //
    // Clamp the magnitude to keep the divisor away from zero at grazing angles;
    // keep the sign.
    float vz = viewDirTS.z;
    vz = (abs(vz) < 0.05) ? (vz < 0.0 ? -0.05 : 0.05) : vz;

    float h = heightMap(uv);
    return uv - viewDirTS.xy / vz * ((h - 0.5) * heightScale);
}

vec3 albedo(vec2 uv)
{
    // Checker + gradient for visibility
    vec2 g = floor(uv * 8.0);
    float checker = mod(g.x + g.y, 2.0);
    vec3 a = mix(vec3(0.15, 0.18, 0.22), vec3(0.75, 0.80, 0.90), checker);
    a *= 0.7 + 0.3 * heightMap(uv);
    return a;
}

mat3 camera(vec3 ro, vec3 ta)
{
    vec3 f = normalize(ta - ro);
    vec3 r = normalize(cross(vec3(0.0, 1.0, 0.0), f));
    vec3 u = cross(f, r);
    return mat3(r, u, f);
}

void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
    vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y;

    // Camera
    float t = iTime * 0.8;
    vec3 ro = vec3(1.8 * cos(t), 0.9, 1.8 * sin(t));
    vec3 ta = vec3(0.0, 0.0, 0.0);
    mat3 cam = camera(ro, ta);

    vec3 rd = normalize(cam * vec3(p, 1.8));

    // Intersect plane z=0 in tangent/object space
    // Plane basis: x,y = tangent plane, z = height direction
    float planeZ = 0.0;
    float tt = (planeZ - ro.z) / rd.z;

    vec3 col = vec3(0.02, 0.03, 0.05);

    if (tt > 0.0)
    {
        vec3 hit = ro + rd * tt;
        vec2 uv = hit.xy;

        // Tile UVs
        uv *= 1.2;

        // View dir in tangent space for a flat XY plane
        vec3 viewDirTS = normalize(-rd);

        // Apply parallax
        vec2 puv = parallaxOffset(uv, viewDirTS);

        // Normal + lighting from displaced UV.
        //
        // normalFromHeight always returns a normal on the +z side, because it is
        // built from the height gradient. Seen from behind, the face actually
        // visible is the back of the same relief, so the normal has to be turned
        // to meet the viewer - otherwise the surface is lit as though the camera
        // were still in front of it and the shading contradicts the silhouette.
        // Which side of the plane the camera is on decides which face is visible.
        // Deciding it from the perturbed normal instead - with faceforward - also
        // flips individual steep bumps on the near side, which changes the front
        // view for no reason. The plane's geometry is what is two-sided here, not
        // each bump.
        float side = (ro.z >= 0.0) ? 1.0 : -1.0;
        vec3 n = normalFromHeight(puv) * side;
        vec3 l = normalize(vec3(0.6, 0.7, 0.5));
        vec3 v = normalize(-rd);
        vec3 h = normalize(l + v);

        float diff = max(dot(n, l), 0.0);
        float spec = pow(max(dot(n, h), 0.0), 48.0);

        vec3 base = albedo(puv);
        col = base * (0.15 + 0.85 * diff) + 0.35 * spec;

        // Slight vignette / fade
        col *= exp(-0.08 * tt);
    }

    fragColor = vec4(pow(col, vec3(0.4545)), 1.0);
}