DEV Community

Valerius Petrini
Valerius Petrini

Posted on • Originally published at valerius-petrini.vercel.app

OpenGL Meets Calculus: The Math Behind Normal Maps

This article was originally published on Valerius Petrini's Programming Blog on 08/18/2025

In OpenGL, you're bound to come across normals, which are vectors that define how light reflects off of an object. Normals are usually defined per-vertex or per-face in 3D models, but normal maps are used to simulate small details on surfaces by giving each pixel a normal vector. While normal maps are commonly used in graphics applications like OpenGL, the details behind how it works is often hidden behind shader logic that transforms the normal vectors from texture space to the coordinate space for lighting calculations. This article aims to explain what normals are, how they are calculated, and how normal maps are generated.

What is a normal vector?

Normal vectors are essentially vectors that are perpendicular to the surface of an object at a specific point. If you had a flat plane resting on the xy plane, the normal vector would be pointing up in the positive z direction.

Although yy is considered "up" in OpenGL, for simplicity’s sake we will use zz as up.

Normals in OpenGL are used to define how light reflects off of an object. The direction of the normal is used to calculate direction that the light travels after bouncing off a surface. Normals are useful in OpenGL because they allow us to create a sort of fake depth to objects without changing their geometry by adding new vertices. Take a brick wall for example; instead of extruding the mortar sections of the brick wall, we can just define the normal values for each pixel.

Calculating a 2D Normal

Normal vectors in 2D space are incredibly easy to find. Let's take this function and its derivative:

f(x)=4x33x2 f(x)=4x^3 - 3x^2
f(x)=12x26x f'(x)=12x^2 - 6x

If you recall from calculus, the derivative allows us to get the line tangent to a function. In order to get the normal slope ( mm ), we can get the negative reciprocal of our derivative.

m=1f(x) m = \frac{-1}{f'(x)}

After we have the slope for our point, we can write the normal vector as

n=1,m \vec{n} = \langle 1, m \rangle

The 11 is just a simple way to represent the xx -component, while mm gives the corresponding yy -component based on the slope. We then normalize the vector to make it a unit normal. The direction of the vector encodes the orientation of the surface at that point, while its length doesn’t matter once it’s normalized.

Alternatively, you can also write the vector as

n=f(x),1 \vec{n} = \langle -f'(x), 1 \rangle

(rotating by 90°).

In Graphics Programming, "Normalizing" a vector means making its magnitude 1. You can do that by finding the magnitude of the vector n||\vec{n}|| ( x2+y2\sqrt{x^2 + y^2} for 2D, x2+y2+z2\sqrt{x^2 + y^2 + z^2} for 3D) and dividing each component of the vector by that magnitude.

For example, the normalized vector of n=x,y,z\vec{n} = \langle x, y, z \rangle would be

n^=x/n,y/n,z/n \hat{n} = \langle x / ||\vec{n}||, y / ||\vec{n}||, z / ||\vec{n}|| \rangle

For example, at x=1x = 1 :

f(1)=12(1)26(1)=6 f'(1) = 12(1)^2 - 6(1) = 6
m=16 m = -\frac{1}{6}
n=1,16 \vec{n} = \langle 1, -\frac{1}{6} \rangle

And then normalizing it:

n=12+(16)2=37361.014 ||\vec{n}|| = \sqrt{1^2 + (-\frac{1}{6})^2} = \sqrt{\frac{37}{36}} \approx 1.014
n^=11.014,1/61.014 \hat{n} = \langle \frac{1}{1.014}, \frac{-1/6}{1.014} \rangle

Calculating a 3D Normal

When we go into three dimensions, calculating the normal gets a bit more challenging, as now we have two separate variables, xx and yy . Because of this, the slope isn't going in one direction per point, so we need to use partial derivatives to solve for them. Like a vector, we need to split the slope into the xx and yy direction.

f(x,y)=3x2+4y4 f(x,y) = 3x^2 + 4y^4
fx=6x \frac{\partial f}{\partial x} = 6x
fy=16y3 \frac{\partial f}{\partial y} = 16y^3

Now we have two tangent lines representing the slopes of both the xx and yy directions. To find the normal, we can convert these two tangent lines into vectors and find the cross product of those two values.

Tx=1,0,fx=1,0,6x \vec{T}_x = \langle 1, 0, \frac{\partial f}{\partial x} \rangle = \langle 1, 0, 6x \rangle
Ty=0,1,fy=0,1,16y3 \vec{T}_y = \langle 0, 1, \frac{\partial f}{\partial y} \rangle = \langle 0, 1, 16y^3 \rangle
n=Tx×Ty=6x,16y3,1 \vec{n} = \vec{T}_x \times \vec{T}_y = \langle -6x,-16y^3,1 \rangle

Additionally, recall that there are two possible vectors that can be perpendicular to Tx\vec{T}_x and Ty\vec{T}_y : 6x,16y3,1\langle -6x,-16y^3,1 \rangle and 6x,16y3,1\langle 6x,16y^3,-1 \rangle . For this example we will use the first one as it points upward (positive zz ), but the second can also be used based on use case. The reason we use 1 in the vectors is just to provide a reference in the xx or yy direction so the tangent vector has a defined direction.

In fact, we can use this fact by using another method. Instead of calculating the cross product, we can instead calculate the gradient. We can first rewrite z=f(x,y)z = f(x,y) as an implicit function.

F(x,y,z)=f(x,y)z=0 F(x,y,z) = f(x,y) - z = 0
F=Fx,Fy,Fz=6x,16y3,1 \nabla F = \langle \frac{\partial F}{\partial x}, \frac{\partial F}{\partial y}, \frac{\partial F}{\partial z} \rangle = \langle 6x, 16y^3, -1 \rangle

The reason we can use gradients for this is that we convert our function into an implicit function that is a level surface. The surface is only made up of (x,y,z)(x,y,z) points that satisfy F(x,y,z)=0F(x,y,z)=0 . Moving along the surface does not change FF , so any vector tangent to the surface produces zero change in FF . By definition, the gradient of FF points in the direction of the greatest change, which is perpendicular to all the tangent vectors, making it the normal vector.

That normal vector points downwards, so to make it go up we can multiply by negative one.

n=1×6x,16y3,1=6x,16y3,1 \vec{n} = -1 \times \langle 6x, 16y^3, -1 \rangle = \langle -6x,-16y^3,1 \rangle

In that case, when we have two partial derivatives for xx and yy , we can write an equivalence:

F=1,0,fx×0,1,fy -\nabla F = \langle 1, 0, \frac{\partial f}{\partial x} \rangle \times \langle 0, 1, \frac{\partial f}{\partial y} \rangle

After that, you should normalize the vector as usual to get the unit vector.

Creating Normal Maps

When creating normal maps for textures in video games, it usually begins with a heightmap, a grayscale image where white (#FFF) corresponds to the highest point of that texture, and black (#000) corresponds to the lowest point. Take this brick wall found from freepbr.com:

Brick Wall Image

The heightmap of this texture would be this:

Brick Wall Heightmap

Here, the mortar sections of the wall appear more inset than the bricks themselves.

We need the heightmap so we can find the normal using the gradient at each point, we can convert those into RGB values and create a normal map.

In spite of this, we run into an issue very quickly. In our examples, we always had continuous functions like 3x2+4y43x^2 + 4y^4 and 4x33x24x^3 - 3x^2 , but our heightmap doesn't have a specific function we can use.

To circumvent this, we can get the "local" derivative by using our definition of a derivative:

f(x)=limh0f(x+h)f(x)h f'(x) = \lim_{h \to 0} \frac{f(x + h) - f(x)}{h}
f(y)=limh0f(y+h)f(y)h f'(y) = \lim_{h \to 0} \frac{f(y + h) - f(y)}{h}

Since we can't have hh be 0 in our code, we need to use a small number for hh and approximate the derivative using the central difference approximation:

h=1 h = 1
f(x)f(x+h)f(xh)2h f'(x) \approx \frac{f(x + h) - f(x - h)}{2h}
f(y)f(y+h)f(yh)2h f'(y) \approx \frac{f(y + h) - f(y - h)}{2h}

We use 1 for hh as we are using the next and previous pixel value for our calculations.

In GLSL, that would look something like this:

// Assume 'heightMap' is a sampler2D containing the grayscale heightmap
// 'texCoords' are the texture coordinates for the current fragment
// 'texelSize' is vec2(1.0/width, 1.0/height) of the texture

vec3 computeNormal(vec2 texCoords) {
    // This won't work for pixels on the edge of the screen.
    // In that case, you'll want to use forward and backward difference approximations.
    // Additionally, as our texture is grayscale, r, g, and b 
    // have the same value, so we can get any one of them
    float hL = texture(heightMap, texCoords - vec2(texelSize.x, 0.0)).r;
    float hR = texture(heightMap, texCoords + vec2(texelSize.x, 0.0)).r;
    float hD = texture(heightMap, texCoords - vec2(0.0, texelSize.y)).r;
    float hU = texture(heightMap, texCoords + vec2(0.0, texelSize.y)).r;

    // Central differences
    // You can multiply these by a height scale factor to exaggerate the bumps
    float dX = (hR - hL) * 0.5;
    float dY = (hU - hD) * 0.5;

    // Construct normal vector
    vec3 normal = normalize(vec3(-dX, -dY, 1.0));

    // Map from [-1, 1] to [0, 1] for RGB
    // GLSL expects a value from 0 to 1 for RGB values,
    // but if you're exporting to an image, you need to
    // multiply by 255 in order to get RGB values from 0 to 255
    return normal * 0.5 + 0.5;
}
Enter fullscreen mode Exit fullscreen mode

If we did write to an image, it would look something like this:

Brick Wall Normal Map

Final Thoughts

When our normal map is done, we can finally use it in our code. If you want to experiment more, try implementing forward and backward difference approximations in the GLSL code.

Happy Coding!

Top comments (0)