CPU to GPU normal mapping
Asked Answered
R

1

3

I'm creating a terrain mesh, and following this SO answer I'm trying to migrate my CPU computed normals to a shader based version, in order to improve performances by reducing my mesh resolution and using a normal map computed in the fragment shader.

I'm using MapBox height map for the terrain data. Tiles look like this:

enter image description here

And elevation at each pixel is given by the following formula:

const elevation = -10000.0 + ((red * 256.0 * 256.0 + green * 256.0 + blue) * 0.1);

My original code first creates a dense mesh (256*256 squares of 2 triangles) and then computes triangle and vertices normals. To get a visually satisfying result I was diving the elevation by 5000 to match the tile's width & height in my scene (in the future I'll do a proper computation to display the real elevation).

I was drawing with these simple shaders:

Vertex shader:

uniform mat4 u_Model;
uniform mat4 u_View;
uniform mat4 u_Projection;

attribute vec3 a_Position;
attribute vec3 a_Normal;
attribute vec2 a_TextureCoordinates;

varying vec3 v_Position;
varying vec3 v_Normal;
varying mediump vec2 v_TextureCoordinates;

void main() {

  v_TextureCoordinates = a_TextureCoordinates;
  v_Position = vec3(u_View * u_Model * vec4(a_Position, 1.0));
  v_Normal = vec3(u_View * u_Model * vec4(a_Normal, 0.0));
  gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0);
}

Fragment shader:

precision mediump float;

varying vec3 v_Position;
varying vec3 v_Normal;
varying mediump vec2 v_TextureCoordinates;

uniform sampler2D texture;

void main() {

    vec3 lightVector = normalize(-v_Position);
    float diffuse = max(dot(v_Normal, lightVector), 0.1);

    highp vec4 textureColor = texture2D(texture, v_TextureCoordinates);
    gl_FragColor = vec4(textureColor.rgb * diffuse, textureColor.a);
}

It was slow but gave visually satisfying results:

enter image description here

Now, I removed all the CPU based normals computation code, and replaced my shaders by those:

Vertex shader:

#version 300 es

precision highp float;
precision highp int;

uniform mat4 u_Model;
uniform mat4 u_View;
uniform mat4 u_Projection;

in vec3 a_Position;
in vec2 a_TextureCoordinates;

out vec3 v_Position;
out vec2 v_TextureCoordinates;
out mat4 v_Model;
out mat4 v_View;

void main() {

  v_TextureCoordinates = a_TextureCoordinates;
  v_Model = u_Model;
  v_View = u_View;

  v_Position = vec3(u_View * u_Model * vec4(a_Position, 1.0));
  gl_Position = u_Projection * u_View * u_Model * vec4(a_Position, 1.0);
}

Fragment shader:

#version 300 es

precision highp float;
precision highp int;

in vec3 v_Position;
in vec2 v_TextureCoordinates;

in mat4 v_Model;
in mat4 v_View;

uniform sampler2D u_dem;
uniform sampler2D u_texture;

out vec4 color;

const vec2 size = vec2(2.0,0.0);
const ivec3 offset = ivec3(-1,0,1);

float getAltitude(vec4 pixel) {

  float red = pixel.x;
  float green = pixel.y;
  float blue = pixel.z;

  return (-10000.0 + ((red * 256.0 * 256.0 + green * 256.0 + blue) * 0.1)) * 6.0; // Why * 6 and not / 5000 ??
}

void main() {

    float s01 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.xy));
    float s21 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.zy));
    float s10 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.yx));
    float s12 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.yz));

    vec3 va = (vec3(size.xy, s21 - s01));
    vec3 vb = (vec3(size.yx, s12 - s10));

    vec3 normal = normalize(cross(va, vb));
    vec3 transformedNormal = normalize(vec3(v_View * v_Model * vec4(normal, 0.0)));

    vec3 lightVector = normalize(-v_Position);
    float diffuse = max(dot(transformedNormal, lightVector), 0.1);

    highp vec4 textureColor = texture(u_texture, v_TextureCoordinates);
    color = vec4(textureColor.rgb * diffuse, textureColor.a);
}

It now loads nearly instantly, but something is wrong:

  • in the fragment shader I had to multiply the elevation by 6 rather than dividing by 5000 to get something close to my original code
  • the result is not as good. Especially when I tilt the scene, the shadows are very dark (the more I tilt the darker they get):

enter image description here

Can you spot what causes that difference?

EDIT: I created two JSFiddles:

The problem appears when you play with the tilt slider.

Reina answered 22/6, 2020 at 17:1 Comment(9)
The only use of the 256x256 mesh is passing the normals? Don't you use it to do height displacement? I.e. how many triangles do you draw in your second approach?Cutinize
The 256*256 picture contains elevation at each pixel, not normals. I'm passing it to compute the normals. I don't know what you mean by height displacement (sorry, I have a very limited experience with OpenGL/WebGL), so I guess the answer is no :)Reina
By height displacement I mean the "up" coordinate of your a_Position variable. Do you set the height of the vertices according to the height in your heightmap?Cutinize
Oh ok, then yes I'm doing this. But in the new version my mesh has a much lower resolution (32*32). EDIT: and I'm dividing by 5000 for the vertices positions, not multiplying by 6.Reina
Oh, ok. I can't spot what is wrong with the normal map calculation, but you haven't shared the CPU code, so I can't compare. But I doubt the normal calculation per se is the performance problem. The way I would do it is generate one 256*256 normal map texture (either at startup or load a precomputed file), and simply get the normal value with a single texture2D() call.Cutinize
I'll try to create a JSFiddle. Yes creating a normal map server is another option, but I'll need to serve the entire earth so having to do it only for the height map would be nice, if the normal map computation performance is ok on the GPU. Do you think I'll get performances problems with this solution?Reina
Also, the two version are currently live here: 176.150.209.228:8080/normals-cpu (CPU based) and 176.150.209.228:8080/normals (GPU based)Reina
Now that I see that this is not a mobile game, and it is rendered only when needed, no, I don't think there will be a performance problem (but you never know on what hardware the client is running). And now I also understand the performance problem, as the normal map computation is done in JavaScript, not on some compiled language.Cutinize
Ok thanks. I added 2 JSFiddles, please check my edits.Reina
C
1

There were three problems I could find.

One you saw and fixed by trial and error, which is that the scale of your height calculation was wrong. In CPU, your color coordinates varies from 0 to 255, but on GLSL, texture values are normalized from 0 to 1, so the correct height calculation is:

return (-10000.0 + ((red * 256.0 * 256.0 + green * 256.0 + blue) * 0.1 * 256.0)) / Z_SCALE;

But for this shader purpose, the -10000.00 doesn't matter, so you can do:

return (red * 256.0 * 256.0 + green * 256.0 + blue) * 0.1 * 256.0 / Z_SCALE;

The second problem is that the scale of your x and y coordinates was also wrong. In the CPU code the distance between two neighbor points is (SIZE * 2.0 / (RESOLUTION + 1)), but in GPU, you had set it to 1. The correct way to define your size variable is:

const float SIZE = 2.0;
const float RESOLUTION = 255.0;

const vec2 size = vec2(2.0 * SIZE / (RESOLUTION + 1.0), 0.0);

Notice that I increased the resolution to 255 because I assume this is what you want (one minus the texture resolution). Also, this is needed to match the value of offset, which you defined as:

const ivec3 offset = ivec3(-1,0,1);

To use a different RESOLUTION value, you will have to adjust offset accordingly, e.g. for RESOLUTION == 127, offset = ivec3(-2,0,2), i.e. the offset must be <real texture resolution>/(RESOLUTION + 1), which limits the possibilities for RESOLUTION, since offset must be integer.

The third problem is that you used a different normal calculation algorithm in the GPU, which strikes to me as having lower resolution than the one used on CPU, because you use the four outer pixels of a cross, but ignores the central one. It seems that this is not the full story, but I can't explain why they are so different. I tried to implement the exact CPU algorithm as I thought it should be, but it yield different results. Instead, I had to use the following algorithm, which is similar but not exactly the same, to get an almost identical result (if you increase the CPU resolution to 255):

    float s11 = getAltitude(texture(u_dem, v_TextureCoordinates));
    float s21 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.zy));
    float s10 = getAltitude(textureOffset(u_dem, v_TextureCoordinates, offset.yx));

    vec3 va = (vec3(size.xy, s21 - s11));
    vec3 vb = (vec3(size.yx, s10 - s11));

    vec3 normal = normalize(cross(va, vb));

This is the original CPU solution, but with RESOLUTION=255: http://jsfiddle.net/k0fpxjd8/

This is the final GPU solution: http://jsfiddle.net/7vhpuqd8/

Cutinize answered 23/6, 2020 at 17:4 Comment(3)
Thank you so much!! It is indeed now nearly identical :) . To get them really identical we can simply increase the mesh resolution to 255 as well in the GPU sample. Increasing it to 63 is enough to get a decent result though. I'll also try to compute the normals map on the CPU with the GPU algorithm, it will probably be much faster than my per vertex version.Reina
If you want, you can try WebAssembly, that is theoreticallys much faster for numerical computations on browsers, but I never used it and don't know about initialization time.Cutinize
Thanks, I'll take a look!Reina

© 2022 - 2024 — McMap. All rights reserved.