A height map shader with only two texture lookups

Setup: suppose you have a monochrome texture that contains a height map. A value of 1 is highest, and 0 is lowest. You want to use this texture as a ‘bump map’ to shade a 2D polygon via GLSL, computing light and shadow from the gradient of the height map at any given point. Let’s assume there is a single light source, infinitely far away (so the light rays are parallel). This is the setup we use in the game Aranami.

The naive way of doing this is to compute the gradient in the x and y direction by sampling in two orthogonal directions, and assembling the result into a gradient vector. The gradient, as you might know, points towards the direction of largest positive change, i.e., “towards the hilltop”. If we have a 2D light vector pointing in the direction the light rays are moving, the dot product of light and gradient gives you exactly the amount of light the pixel receives.

Let’s introduce some equations to make this more precise. Say f(x, y) is the value of the height map at point x, y. To estimate the gradient, we sample f at four locations at a small distance delta from the target location x, y:

left   = f(x - delta, y)
right  = f(x + delta, y)
bottom = f(x, y - delta)
top    = f(x, y + delta)
gradient = [(right - left) / (2*delta), (top - bottom) / (2*delta)]

The brightness B of the point at x, y is then simply the dot product of the gradient with the light vector:

B = gradient . light

Recall that the dot product is largest when both vectors point in the same direction: e.g. if the light shines towards the right, areas will be brightest if their gradient also points towards the right, which implies a hill going up towards the right so the left of the hill is bathing in sunshine.

A simple GLSL fragment shader that does this:

uniform sampler2D heightMap;
uniform vec3 color;
uniform float delta;
uniform vec2 light;

varying vec2 texCoord;

void main() {
  float gradientX =
    (
      texture2D(heightMap, texCoord + vec2(delta.x, 0)).r -
      texture2D(heightMap, texCoord - vec2(delta.x, 0)).r
    ) / (2 * delta);
  float gradientY =
    (
      texture2D(heightMap, texCoord + vec2(0, delta.y)).r -
      texture2D(heightMap, texCoord - vec2(0, delta.y)).r
    ) / (2 * delta);
  vec2 gradient = vec2(gradientX, gradientY);
  float brightness = dot(gradient, light);
  gl_FragColor = vec4(color * brightness, 1.0);
}

This works, and life is good. Until you try to run this on underpowered hardware, when the four texture lookups become a bit of a bottleneck. Can we do better? It turns out, as long as we have just one light source, we can.

When computing the gradient, we already sort of assumed that f was linear. Let’s make that assumption explicit:

f(x, y) = a + b*x + c*y

Now we can work this through the equations for the gradient. I’ll do just the x component:

gradientX = (right - left) / (2*delta)
          = (f(x + delta, y) - f(x - delta, y)) / (2*delta)
          = ((a + b*x + b*delta + c*y) - (a + b*y - b*delta + c*y))
              / (2*delta)
          = (2*b*delta) / (2*delta)
          = b

Similarly, we find gradientY = c. So the gradient is just [b, c]! This should come as no big surprise, as the gradient is essentially a multi-dimensional derivative of f.

The brightness (dot product with the light vector) now becomes:

B = gradient . light
  = [b, c] . [lightX, lightY]
  = b*lightX + c*lightY

Above, we sampled at locations [x - delta, y] and [x + delta, y], that is, we offset our position by [-delta, 0] and [+delta, 0]. This makes sense if we care about the x and y components of the gradient individually. But what happens if we offset by another vector? Say, [lightX*delta, lightY*delta] and [-lightX*delta, -lightY*delta]? We can’t really call these left and right anymore, but one points towards the light source and one points away from it, so let’s name them accordingly and see what happens…

towards = f(x - lightX*delta, y - lightY*delta)
away    = f(x + lightX*delta, y + lightY*delta)
thing   = (away - towards) / (2*delta)
        = (
            f(x - lightX*delta, y - lightY*delta) -
            f(x + lightX*delta, y + lightY*delta)
          ) / (2*delta)
        = (
            (a + b*(x + lightX*delta) + c*(y + lightY*delta)) -
            (a + b*(x - lightX*delta) - c*(y - lightY*delta))
          ) / (2*delta)
        = (2*b*lightX*delta + 2*c*lightY*delta) / (2*delta)
        = b*lightX + c*lightY

Does that look at all familiar? By sampling the gradient in a non-axis-aligned direction, we have computed exactly what we were after: the brightness, B! Let’s write this up as GLSL code:

uniform sampler2D heightMap;
uniform vec3 color;
uniform float delta;
uniform vec2 light;

varying vec2 texCoord;

void main() {
  float brightness =
    (
      texture2D(heightMap, texCoord + delta * light).r -
      texture2D(heightMap, texCoord - delta * light).r
    ) / (2 * delta);
  gl_FragColor = vec4(color * brightness, 1.0);
}

The advantages are obvious. Not only is the code much shorter, but instead of four texture lookups, typically one of the most expensive things in a shader, we need only two! Who said that calculus was good for nothing?