Calculating normals from heightmap in vertex shader

Hi! I’ve recently been trying to implement a simple heightmap by extending the phong shader in Three.js

Displacing the vertices was no big problem, but I’m having a lot of trouble with calculating the normals. I’m using pretty much a direct port of the approach presented in this paper, which simply looks at neighbouring pixels based on the current UV coordinate.

vec3 getNormal(vec2 uv) {
  
  float u = texture2D(heightMap, uv + texelSize * vec2(0.0, -1.0)).r * texelMaxHeight;
  float r = texture2D(heightMap, uv + texelSize * vec2(1.0, 0.0)).r * texelMaxHeight;
  float l = texture2D(heightMap, uv + texelSize * vec2(-1.0, 0.0)).r * texelMaxHeight;
  float d = texture2D(heightMap, uv + texelSize * vec2(0.0, 1.0)).r * texelMaxHeight;
  
  vec3 n;
  n.z = u - d;
  n.x = l - r;
  n.y = 2.0;
  return normalize(n);
}

Where texelSize should be the size of 1 pixel in UV coordinates, and texelMaxHeight is the maximum possible displacement of a vertex.

But it seems like there’s something wrong with my implementation, because the mesh comes out totally flat, as if all the normals are the same:

heightmap3

By flatShading: true, you can get a feel for how it should look:

heightmap2

I really can’t see what I’m doing wrong here, but I’ve noticed that I can stick a multiplier in there such as uv + 8.0 * texelSize * vec2(0.0, -1.0) which causes the slope to become at least a little bit visible, but it doesn’t feel right and I don’t want to be falling back on magic numbers.

Here’s a codepen in case anyone is able to help: https://codepen.io/geckojsc/pen/zYGNpgN

Thanks ^^

I’ve got the same unshaded result, using .displacementMap and flatShading: false

I got your approach working with a few modifications:

  • If you calculate new normals before the morphtarget, skinning & displacementmap vertex shader code, your calculations will get overwritten. You should calculate normals AFTER those shader chunks, or remove them altogether if you’re not using them.
  • I got rid of uv + 8.0, I’m not sure why you would want to shift your texel calculation 8/256ths in each direction
  • I set n.y to equal 1/256 because your n.z & n.x values are in color steps of the same magnitude.
  • I also replaced all your geometry creation code with a THREE.PlaneBufferGeometry for simplicity.

I’m not sure this method gives you truly accurate normals, but it’s a pretty close estimate.

5 Likes

TexelSize is definitely something like 1/x where x is your heightMap size.

In my heightmap I use something like this:

vec2 texelSize = vec2(1.0 / WIDTH, 1.0 / WIDTH);

return normalize(vec3(
    (texture2D(heightmap, uv + vec2(-texelSize.x, 0)).x - texture2D(heightmap, uv + vec2(texelSize.x, 0)).x),
    (texture2D(heightmap, uv + vec2(0, -texelSize.y)).x - texture2D(heightmap, uv + vec2(0, texelSize.y)).x),
    1.0));

where WIDTH is texture size (in my case #define WIDTH 512).

But in fact this method should be equivalent to yours. You just need to set proper texelSize.

1 Like

Ah, wow thank you so much!

I think fortunately those unused shader chunks weren’t hurting anything because I never supplied the properties to make them work, but thanks for the heads up.

The + 8.0 was leftover by mistake, I noticed it did something while I was experimenting, but didn’t mean to share that, sorry. x)

And yep, n.y = 2.0 is the key thing I was doing wrong, though I’m wondering if there’s a better value than 1/256, since the max height needs to be taken into account somewhere for sure.

I rolled my own plane because I was worried about the standard plane being oriented vertically, and whether that might mess things up if the normals are calculated in model space. Turns out I didn’t know that geom.rotateX() was a thing ^^

1 Like