WebGPU game (#13): Normal maps and specular lighting
For this post, I want to introduce specular light from the Blinn-Phong model. Specular light represents light which is reflected directly from a light source into your eye with minimal scattering. This is supposed to mimic the interaction of light with smooth surfaces.
First, however, we’re going to take a detour into adding normal maps to the game. A normal map is a texture which represents tweaks to the normal within individual triangles. This will make the specular highlights far more interesting. Otherwise, the entire terrain would appear glossy (or not reflective at all).
Tangent space
This section was adapted from the LearnOpenGL article, which more neatly has the UV coordinates at the bottom-left1. If you’re familiar with this process, you can skip this section. Otherwise, if you’re unsure about how to tackle the flipped UV coordinates, read on!
Normal vectors in normal maps are expressed in terms of “tangent space”. This is just a convention, but it essentially means that the normals mostly point in the positive -direction (towards you). This is represented by using the RGB colour channels as the , , and coordinates, respectively. This is why most normal maps have a blue-ish tint. It’s because the normals are in the range , and normals generally point out of the screen2.
Tangent space is local to the triangle that it represents. It means that we’ll need to build up a set of basis vectors so that we can perform a change of basis to get the normal in world space. The basis vectors that make up this transformation are called the tangent (), bitanget () and normal (). You’ll see this shortened to the “TBN” matrix in code. With this TBN matrix, we can take take a vector sampled from the normal map and multiply it by the TBN to get the normal vector in world space.
So, to derive the TBN matrix, we start with the diagram of a triangle which is mapped to some portion of the normal texture.
We can express each edge of the triangle in terms of the tangent and bitangent (as basis vectors). Except, our bitangent is flipped, since we want to be up, but our convention for UV coordinates has pointing down. So, we represent two edges of our triangle as:
where
We can also write this as:
You may recognize this as a system of linear equations, which we can rewrite in matrix form:
We want to solve for and , so we can multiply on the left of both sides of the equation by the inverse of the deltas matrix. This is given as the determinant multiplied by the adjugate for a 2x2 matrix. In code, I’ve just multiplied this out “by hand”.
With this, we can either calculate the normal in the vertex shader (every frame), or on mesh creation. I opted to do this on mesh creation since that only occurs at the start of this particular game. Refer to the code linked at the bottom of this post to see how the TBN matrix is created, in world space, and then used to transform the normal represented by the normal map into the new world normal for a given fragment. This is very powerful, as we’re able to generate per-texel3 normals instead of per triangle normals with no added geometry.
If you recall from the last post, we needed to use the inverse transpose of the model matrix (without the translation) to correctly transform the normal. Note, however, that the tangent and bitangent are aligned with the planes, so we can simply use the model matrix (without the translation) to transform these vectors into world space.
Specular light
Armed with our mighty per-texel normal, we can add specular light as a contributing factor to our light model. Specular highlights are made to mimic the effect of bright spots appearing on shiny objects. These highlights correspond to the direction of the light relative to the surface.
Similar to the diffuse calculation, given the vector and some vector representing the direction to our eye (or the camera) , we calculate the specular contribution as:
We can also calculate the reflection using the built-in reflect function:
Reflections are defined in terms of an incident vector () and the
normal () of the plane for which the vector
is incident. The reflection occurs about the plane. We can derive each of these pieces to build up the reflected vector,
. We can express as the sum of its components aligned
parallel and perpendicular to : Therefore, we can express the reflected value as: We can calculate by projecting (dot product)
onto . Normally, you’d have to divide the magnitude of the projectee
out, but we know that the normal has a magnitude of one. We can crib the result above to define the perpendicular component, : Therefore we have our reflected vector, as:But if you're curious about its implementation...
Finally, we can determine the specular contribution. It’s common to use the power function to make the intensity of the highlight stronger, so you’ll generally see for some number (generally between 2-256):
Gloss map
I’ve also added a gloss map, which you can find in the repository. Some implementations use a roughness map, but I prefer to author the texture with light areas meaning shiny and dark areas meaning rough. It is just a matter of personal preference, in this case. I’ve hard-coded the shininess constant as 64, but this could be supported by another “shininess map” to change the diffusion of the specular light for each pixel.
Conclusion
That’s pretty much all you need. There are a lot of moving parts in the actual
change to ensure that the data layouts suited the various shaders and I made the
light point roughly in the direction of the cursor for this change. I originally
aimed to use the Transform
to fully specify the directional light in this
change, but I ran into some issues trying to build up a view transform for the
directional light. So, I kept the Camera
component on the directional light,
for now.
It may be just me, but I feel that the terrain triangles should be rotated randomly to avoid that obvious pattern in the lighting. I’ll leave that as a challenge!