index

Article posted on: 2022-01-01 17:43
Last edited on: 2022-01-01 17:43
Written by: Sylvain Gauthier

Convert bump maps to normal maps (with actual code)

What’s a bump map

It’s basically a texture containing a height map. Somehow when rendering a 3D object, you use that texture to modify the normal vector of each pixel you are rendering, to give a lot more details to the object while not adding any vertex information, which would come at a great performance and memory cost.

What’s a normal map

It’s the same except instead of containing a height map, it contains the normal vectors directly (in tangent space, meaning the “up” direction is the normal of the face you’re currently rendering, and the other 2 directions are more or less arbitrary (not really but for our purpose let’s say they are).

The Wikipedia article does a fairly good job at explaining how normals are encoded in RGB components.

So if you are paying attention, you’ll notice that bump maps encode a subset of what you can do with normal maps. They are, however, a lot easier to work with for the artist. Very cool to just be painting “height” on blender directly on your object as if you were spraying some magical paint that digs holes or adds volume.

The Problem

In our custom 3D renderer, we use primarily the relatively new GlTF format. This format is great for many reasons, however it does not support bump maps, only normal maps. It’s a known issue or more precisely, it’s just not something they are planning on supporting at all. Basically, “just export it as a normal map bro”.

Which brings us to the question, “how to convert bump maps to normal maps”.

I love Blender, it’s a fucking miracle to have a piece of software of this quality in the open source ecosystem, but to be fair it does have a few shortcomings. In particular, while it has a pretty good GlTF exporter, it does not bother trying to understand that the texture you’re exporting is a bump map or a normal map, it just exports it as-is.

Which means that you end up having a GlTF file saying “use this PNG file as a normal map lol” when said PNG file is actually a bump map. Bad (or rather ugly) things ensue.

The Stack-Overflow (and more generally Web) solution

If you search on the Web for a solution to this problem, you’ll get pages and pages of answers along the lines of:

TL;DR: nothing stable, reliable, that can be batched in a script or a build system.

My (naive and trivial) solution

Here are a few lines of C that compute the normal from the gradient, taking a dist parameter as an input to describe the amplitude of the height map (public domain):

#include <stdio.h>
#include <stdlib.h>
#include <math.h>

#include <3dmr/img/png.h>

typedef float Vec3[3];

#define set3v(v, x, y, z) { (v)[0] = (x); (v)[1] = (y); (v)[2] = (z); }
#define cross3(dest, u, v) { \
    dest[0] = u[1] * v[2] - u[2] * v[1]; \
    dest[1] = u[2] * v[0] - u[0] * v[2]; \
    dest[2] = u[0] * v[1] - u[1] * v[0]; \
}
#define normalize3(v) { \
    float norm = sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); \
    v[0] /= norm; \
    v[1] /= norm; \
    v[2] /= norm; \
}

static void bump_to_norm(unsigned char* bump,
                         unsigned char* norm,
                         unsigned int w,
                         unsigned int h,
                         float dist) {
    unsigned int row, col;

    for (row = 0; row < h - 1; row++) {
        for (col = 0; col < w - 1; col++) {
            Vec3 dx, dy, n;

            set3v(dx, 1., 0., ((float) bump[(row * w + (col + 1))]
                             - (float) bump[(row * w + col)]) / 255. * dist);
            set3v(dy, 0., 1., ((float) bump[((row + 1) * w + col)]
                             - (float) bump[(row * w + col)]) / 255. * dist);
            cross3(n, dx, dy);
            normalize3(n);

            norm[3 * (row * w + col) + 0] = (n[0] + 1.) / 2. * 255.;
            norm[3 * (row * w + col) + 1] = (n[1] + 1.) / 2. * 255.;
            norm[3 * (row * w + col) + 2] = (n[2] + 1.) / 2. * 255.;
        }
    }
}

int main(int argc, char** argv) {
    unsigned char *inbuf = NULL, *outbuf = NULL;
    unsigned int w, h, chans;
    char ok = 0;
    float dist;

    if (argc < 4) {
        fprintf(stderr, "Usage: %s <bumpmap> <normmap> <dist>\n", argv[0]);
        return 1;
    }

    dist = strtod(argv[3], NULL);
    if (!png_read_file(argv[1], 0, &w, &h, &chans, 1, 0, &inbuf)) {
        fprintf(stderr, "Error: can't open file: %s\n", argv[1]);
    } else if (!(outbuf = malloc(3 * w * h))) {
        fprintf(stderr, "Error: malloc failed\n");
    } else {
        bump_to_norm(inbuf, outbuf, w, h, dist);
        if (!png_write(argv[2], 0, w, h, 3, 0, outbuf)) {
            fprintf(stderr, "Error: can't write png\n");
        } else {
            ok = 1;
        }
    }
    free(inbuf);
    free(outbuf);
    return ok - 1;
}

It uses the 3dmr image interface to load the PNG files, of course you can adapt it to whatever suits your needs.

Basically it just computes, for each pixel (x, y), the vectors:

v1 = (1, 0, h(x + 1, y) - h(x, y))
v2 = (0, 1, h(x, y + 1) - h(x, y))

Where h(x, y) is the height map value for pixel (x, y). Then, the normal is simply the normalised cross product of v1 and v2. Simple as.

This is the most naive implementation but it gives very decent results and it has the advantage of, well, being available online.

It turns this:

ast bump map

Into this:

ast normal map

Which renders in 3dmr into this:

ast in 3dmr

Shortcomings and possible improvements

The main problem with this (but I think it’s inherent to bump maps in general) is the “stair effect”. When you have a very faint gradient of altitude on your bump map, the pixel values will essentially jump by one increment once every few pixels in each direction. The gradient computation being local, you end up with a flat surface with little steps at every increment. And yes, this is very visible and can be quite ugly.

stair effect

I would assume (and hope) that professional paid plugins would compute a global smooth interpolation of the bump map and then compute the normals in a similar way as above, and this could be an improvement.

It also has a directional bias towards the bottom right of the picture, by construction, and leaves the right most pixel column and bottom pixel line empty / uninitialised. This could be mitigated by using a proper centered kernel. It has however few if any practical implications.

This whole thing could be a shader as well and perform the conversion on GPU at runtime. That would make a few cool things possible like realtime modification of the height map, spawning craters under explosions, rendering footprints and whatnot.