DEV Community

Cover image for Jergen gets Normal Mapped
The Struggling Dev
The Struggling Dev

Posted on • Edited on

Jergen gets Normal Mapped

Introduction

In my last post I checked the art side - still struggling with the word art - of my player character. This time we're moving on to something more technical, although it's still a bit "artsy". Currently Jergen looks a bit bland.

Bland li'l Jergen

I've therefore decided that its time to turn the lights on.

Let There Be Light

Okay, Jergen already looks sufficiently lit 😉. This is because the current shaders use the texture's color as the final color value to draw. Let's change this so we can later see the effects of lights being shone on Jergen.

First, we'll add some ambient light. I'm struggling to explain ambient light in my own words, but to me it's just the base light level. All the light that is scattered around from multiple light sources, so you can't really make out where the light comes from anymore. Therefore, neither distance to, or direction of the light source matter. I feel the fragment shader does a better job at explaining it.

// Fragment shader
#version 330 core

in vec2 TexCoordinate;
out vec4 FragColor;
uniform sampler2D tex;

void main() {
    // ambient light, kind of a base light amount that is just there.
    // All zeros would mean it's completely dark.
    vec4 ambient = vec4(1.0f, 1.0f, 1.0f, 1.0f) * 0.2f;
    FragColor = texture(tex, TexCoordinate) * ambient;
}
Enter fullscreen mode Exit fullscreen mode

The ambient light is white, but dimmed to 1/5 of.

While proof reading this post, I realized that I made a mistake. I'm also making the final FragColor transparent by multiplying ambient by 0.2f. This makes the entire image darker. All the images in this post contain this mistake. The GIF at the end corrects this and uses vec4 ambient = vec4(0.2f, 0.2f, 0.2f, 1.0f); instead.

For completeness' sake, here's the corresponding vertex shader:

// Vertex shader
#version 330 core

layout (location = 0) in vec3 inPosition;
layout (location = 1) in vec2 inTexCoordinate;

out vec2 TexCoordinates;

void main() {
  gl_Position = vec4(inPosition, 1.0);
  TexCoordinate = aTexCoordinate;
}
Enter fullscreen mode Exit fullscreen mode

Can you still see Jergen? It's quite dark where he is.

Find Jergen

Next, we add diffuse lighting. Diffuse lighting is the light that comes from a certain light source. Therefore, in contrast to the ambient light, the direction of the light plays a role in how it affects the object it illuminates. If our object faces the light source, it should be brighter, if it's facing away it should be in shadow. We'll need to give the light source a position in our world. With that, we can then calculate the direction from our object to the light.

Since the fragment shader doesn't know the world position of the fragment it shades, we need to pass it in from the vertex shader.

Let's add a new output to the vertex shader and assign a value to it.

// Vertex shader
// ...
out vec2 TexCoordinate;
out vec3 FragPosition;  // <- added

void main() {
  gl_Position = vec4(inPosition, 1.0);
  TexCoordinate = inTexCoordinate;
  FragPosition = inPosition;  // <- added
}
Enter fullscreen mode Exit fullscreen mode

Then we add the corresponding input to the fragment shader. We also define variables for our light source and a surface normal for the object we want to illuminate. The variables are described in the following code snippet.

// Fragment shader
// ...
in vec2 TexCoordinate;
in vec3 FragPosition; // <- added
// ...
void main() {
    // ambient light, kind of a base light amount that is just there
    vec4 ambient = vec4(1.0f, 1.0f, 1.0f, 1.0f) * 0.2f;

    // The color our light emits, Halloween season is around the corner,
    // so let's make it a creepy red.
    vec4 lightColor = vec4(1.0f, 0.0f, 0.0f, 1.0f);
    vec3 lightPosition = vec3(10.0f, 1.0f, 0.0f);
    // We normalize the direction vector because we're only interested in the direction.
    vec3 lightDirection = normalize(lightPosition - FragPosition);

    // Next we define a normal for the fragment. The normal defines which way a surface is facing.
    // With this information we can figure out if it's facing towards the light source 
    // and should therefore be illuminated. 
    // Our normal for every fragment points to the right side towards the light source.
    vec3 normal = normalize(1.0f, 0.0f, 0.0f);

    // We use the dot product to figure out how much the surface normal and the light direction align.
    float diffuseStrength = max(dot(normal, lightDirection), 0.0f);
    vec4 diffuse = diffuseStrength * lightColor;

    // We now add the diffuse light component to the already existing ambient component before multiplying.
    FragColor = texture(tex, TexCoordinate) * (ambient + diffuse);
}
Enter fullscreen mode Exit fullscreen mode

If we calculate this for the top left corner of the rectangle (-0.25, 0.5) that displays Jergen we get.
lightDirection = (10, 1, 0) - (-0.25, 0.5) = (10 - -0.25, 1 - 0.5, 0 - 0) = (10.25, 0.5, 0)
which is (0.998812, 0.0487226, 0) when normalized.

The strength of the diffuse light is then:
diffuseStrength = (1, 0, 0) * (0.998812, 0.0487226, 0) = 1 * 0.998812 + 0 * 0.0487226 + 0 * 0 = 0.998812
The diffuse light is therefore strong, which makes sense as the surface normal is almost parallel to the light direction. Let's see how this looks.

Jergen bathed in creepy red light.
Jergen bathed in creepy red light

Let's see what happens if we change the normal to point upwards. This way the normal and the light direction are almost perpendicular, which should result in a much darker image.
But let's calculate the diffuse contribution before we actually check.

The lightDirection stays the same. The new diffuse strength is
(0, 1, 0) * (0.99812, 0.0487226, 0) = 0 * 0.998812 + 1 * 0.0487226 + 0 * 0 = 0.0487226, which matches our expectation.

And now, curtains up. Jergen's almost gone again.

Jergen's hiding in the dark

Now that we have some basic lighting in place, let's go a step further and introduce normal mapping.

A Map of Normals

So far we've used the same normal for all fragments and have set it directly in the shader. We could define the normals in our vertex data, along with the positions and the texture coordinates. That way we would be able to specify a normal per vertex. For Jergen that would mean we could specify four vertices as Jergen is just a rectangle. But, we want more. We want to be able give different normals to Jergen's face, his backpack, .... The way to do this, is to use a normal map.

A normal map is just a texture in which the colors represent normal vectors. You can check out an example in the Wikipedia article on normal mapping

If we wanted to reproduce the same lighting we currently have, we'd just create a texture where each pixel contains the value (1, 0, 0), or (0, 1, 0) respectively. But as mentioned before, we want different normals for (almost) every pixel. But how do we create a normal map? An easy way, at least for pixel art with few pixels, is to just pick colors from an existing normal map - like the one from Wikipedia. And that's exactly what I did. First, I copied the existing texture of Jergen and started to pick colors from the normal map.

Why do normal maps look everything but normal?

Now comes the fun part, we're going to use that map to give Jergen some more variation. Since we'll use a separate texture for the normal map with the same dimensions of the main texture, we can reuse the texture coordinates and keep the vertex shader as is.

The fragment shader needs a few changes though.

// Fragment shader
// ...
uniform sampler2D tex;
uniform sampler2D normalMap; // <- We need to add another sampler for the normal map
// ...
void main() {
    // ...

    // We now use the normal map to get the normals for the current fragment
    vec3 normal = normalize(texture(normalMap, TexCoordinate).rgb);

    // ...
}
Enter fullscreen mode Exit fullscreen mode

And that's all she wrote.

Normal mapped Jergen

And here's the same thing with a light source that circles Jergen.

Light show Jergen

And another bonus (no idea why the video quality is so bad after uploading):

The Struggles

There was one small hiccup at the end when I switched to using the normal map in the shader. The texture had no effect. So I first changed the first texture to the normal map file to see whether I could draw the normal map - everything OK. Reverse everything, try to bind the normal map to texture unit one, draw the color texture on unit two, ... In the end I just forgot to activate the second texture unit - d'Oh.

The math was also easier than I had anticipated, mistook the dot product for the cross product, ...

All in all, this one went relatively smoothly. Dividing the task into sometimes ridiculously small steps, like just adding one more variable to the shader and testing it or defining variables within the shader instead of passing them from the host code, helped a lot.

Thanks for reading and keep on struggling.

Top comments (0)