WebGL Normal Map
Related Topics: Texture, Bitmap Font
Overview
Normal mapping is a technique to apply a separate 2D texture map to simulate the surface details; bumps and indents. This texture map stores the surface normal information, and is called a normal map. Because the normals are stored per pixel (not per vertex), it greatly improves the details of a low-polygon model. Normally, the normal map is generated from the vertex normals of a high-polygon model. The following images are applying a normal map to a flat surfaces of a cube.



This page explains how to apply the normal map to a model and to compute lighting (diffuse and specular) using the normal map.
Generating Tangent Vectors

The normals in the normal map are stored in a independent coordinate system, called Tangent Space with 3 basis axis; Tangent (T), Bitangent (B) and Normal (N), where they are orthonormal each other. During the diffuse and specular lighting calculation, the view and light vectors need to be transformed to the tangent space using this TBN matrix. Therefore, you need to pass the tangent and normal vectors of TBN matrix to the shader. (Bitangent vector can be computed by cross product of normal and tangent, N x T in the shader, so no need to pass it.)

Suppose 3 vertices; P1, P2 and P3 of a triangle are mapped to the texture space using their texture coordinates (u, v), then they can be written with tangent T and bitangent B basis vectors in the tangent space.
To find the tangent and bitangent basis vectors for each vertex, which are orthonormal (perpendicular each other and unit length), we bring another equations; 2 edge vectors of the triangle, E1 and E2.
To solve this linear system for T, we multiply V2 to the equation (1) and V1 to (2) to cancel B, then subtract the equation (2') from (1'). Finally, we can get the tangent vector T
generateTangents() in webglUtils.js generates the tangent vector array from the given vertex attributes. It is referenced from Morten S. Mikkelsen's Tangent Space Normal Maps algorithm (mikktspace) (You can download the original C code from the site).
Here is a usage of generateTangents(), generating tangents from an OBJ model.
// global var
let gl = {};
...
// load texture and normalmap
gl.tex0 = loadTexture(gl, "grid512.png");
gl.tex1 = loadNormalmap(gl, "grid512_normals.png");
...
// load OBJ
gl.obj = new ObjModel();
gl.obj.read("cube.obj").then(o =>
{
// compute tangent
let tangents = generateTangents(o.vertices, o.normals, o.texCoords, o.indices);
// copy tangent data to VBO
gl.vbo.taOffset = ...;
gl.bufferSubData(gl.ARRAY_BUFFER, gl.vbo.taOffset, tangents);
});
...
// bind texture and normal map before drawing
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, gl.tex0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, gl.tex1);
...
// pass vertex attributes and tangents to shader program
gl.bindBuffer(gl.ARRAY_BUFFER, gl.vbo);
gl.vertexAttribPointer(program.attribute.position, 3, gl.FLOAT, false, 0, gl.vbo.vOffset);
gl.vertexAttribPointer(program.attribute.normal, 3, gl.FLOAT, false, 0, gl.vbo.nOffset);
gl.vertexAttribPointer(program.attribute.texCoord0, 2, gl.FLOAT, false, 0, gl.vbo.tcOffset);
gl.vertexAttribPointer(program.attribute.tangent, 3, gl.FLOAT, false, 0, gl.vbo.taOffset);
...
Drawing Normal Map with Shader
Once you copy the tangent data in addition to the other vertex attributes; positions, normals and tex coordinates to a VBO, you can construct the TBN matrix in the vertex shader, and convert the view and light vectors to the tangent space to calculate diffuse and specular lighting computation.

Also, you need to rescale the value in the normal map, because the range of the normal map is [0, 1] but the vector should range [-1, 1].
Here is a shader example for normal mapping.
// Phong + Normalmap vertex shader
// constants
const float ZERO = 0.0;
const float ONE = 1.0;
// vertex attributes
attribute vec3 vertexPosition; // vertex pos in object space
attribute vec3 vertexNormal; // normal vector in object space
attribute vec2 vertexTexCoord0; // texcoord in object space
attribute vec3 vertexTangent; // tangent vector in object space
// uniforms
uniform mat4 matrixNormal; // normal vector transform matrix
uniform mat4 matrixModelView; // model-view matrix
uniform mat4 matrixModelViewProjection; // model-view-projection matrix
// output varying variables
varying vec3 positionVec; // vertex position in eye space
varying vec3 normalVec; // normal vector in eye space
varying vec2 texCoord0; // texture coords in eye space
varying vec3 tangentVec; // tangent vector in eye space
varying vec3 binormalVec; // binormal (bitangent) vector in eye space
void main(void)
{
// transform vertex position to clip space
gl_Position = matrixModelViewProjection * vec4(vertexPosition, ONE);
// transform the normal vector from object space to eye space
normalVec = (matrixNormal * vec4(vertexNormal, ONE)).xyz;
// get tangent vector in eye space
tangentVec = (matrixNormal * vec4(vertexTangent, ONE)).xyz;
// compute binormal (bitangent) in eye space
binormalVec = normalize(cross(normalVec, tangentVec));
// transform vertex position from object space to eye space
positionVec = vec3(matrixModelView * vec4(vertexPosition, ONE));
// pass texture coord
texCoord0 = vertexTexCoord0;
}
// Phong + Normalmap Fragment Shader
// constants
const float ZERO = 0.0;
const float ONE = 1.0;
// uniforms
uniform vec4 lightColor;
uniform vec4 lightPosition; // should be in the eye space
uniform vec3 lightAttenuations; // attenuation coefficients (k0, k1, k2)
uniform vec4 materialAmbient; // material ambient color
uniform vec4 materialDiffuse; // material diffuse color
uniform vec4 materialSpecular; // material specular color
uniform float materialShininess; // material specular exponent
uniform sampler2D map0; // texture map
uniform sampler2D map1; // normal map
// input varying variables
varying vec3 positionVec; // vertex position in eye space
varying vec3 normalVec; // normal vector in eye space
varying vec2 texCoord0; // texture coords
varying vec3 tangentVec; // tangent vector in eye space
varying vec3 binormalVec; // binormal (bitangent) vector in eye space
void main(void)
{
// re-normalize varying vars
vec3 normal = normalize(normalVec);
vec3 tangent = normalize(tangentVec);
vec3 binormal = normalize(binormalVec); // bitangent
// construct TBN matrix
mat3 matrixTbn = mat3(tangent, binormal, normal);
// compute light vector and attenuation
vec3 light;
float attenuation;
// directional light
if(lightPosition.w == ZERO)
{
light = normalize(lightPosition.xyz);
attenuation = ONE;
}
// positional light
else
{
// compute light vector in eye space
light = lightPosition.xyz - positionVec;
// compute attenuation: 1 / (k0 + k1*d + k2*d*d)
vec3 attFact;
attFact.x = ONE; // 1
attFact.z = dot(light, light); // dist * dist
attFact.y = sqrt(attFact.z); // dist
attenuation = ONE / dot(lightAttenuations, attFact);
light = normalize(light);
}
// compute view vector (from vertex to camera) in eye space
vec3 view = normalize(-positionVec);
// compute view vector in tangent space with TBN
vec3 tsView = matrixTbn * view;
// compute light vector in tangent space with TBN matrix
vec3 tsLight = matrixTbn * light;
// get normal in tangent space from normal map,
// then set the range from [0, 1] to [-1, 1]
vec3 tsNormal = normalize(texture2D(map1, texCoord0).rgb * 2.0 - ONE);
// compute reflected ray vector: 2 * (N dot L) * N - L
vec3 tsReflect = reflect(-tsLight, tsNormal);
// start with ambient
vec3 color = materialAmbient.rgb;
// add diffuse portion using Lambert cosine law
float dotNL = max(dot(tsNormal, tsLight), ZERO);
color += dotNL * materialDiffuse.rgb * lightColor.rgb;
// apply texture before specular
color *= texture2D(map0, texCoord0).rgb;
// add specular portion
float dotVR = max(dot(tsView, tsReflect), ZERO);
color += pow(dotVR, materialShininess) * materialSpecular.rgb * lightColor.rgb;
// finally, set frag color
// keep alpha as original material diffuse has
gl_FragColor = vec4(color * attenuation, materialDiffuse.a);
}
Example
This example loads a texture and normal map and renders a model with the textures. You can choose a different primitive and normal map from the page.
Fullscreen Demo: test_normalmap.html
GitHub Repo: test_normalmap.html