TT Lab
Get started
Learn Learning paths Courses

Shaders and the GPU Pipeline

Make a Flat Plane Look Bumpy

Continue in TT Lab

Goal

Store normals in a texture and use it to make a flat surface look bumpy. When this lab is done, you can explain why a normal map is that light purple and why tangent space is needed.

Why it matters

Most of the cue people use to recognize bumps is the change in brightness. Brightness is decided by the dot product of the normal and the light direction, so instead of changing the geometry, if you swap out only the normal per pixel, you get the same effect. The bumps of a brick appear on a plane made of two triangles.

What matters here is which coordinate system's normal you store. If you store a world-space normal, everything goes wrong the moment the object rotates. So you store it in a coordinate system relative to the surface (tangent space), and in that coordinate system "flat" is always (0, 0, 1). The light purple of a normal map overall is the color of that value.

Steps

  1. Put the toolbox in /root/normalmap.
  2. /root/normalmap/encode.py — conversion between normals and colors.
  3. /root/normalmap/out/normalmap.png — a hemisphere-grid normal map.
  4. /root/normalmap/tbn.py — the three axes of tangent space.
  5. /root/normalmap/out/flat.png — the plane lit with a constant normal.
  6. /root/normalmap/out/bumped.png — the same plane lit with the normal map.
  7. /root/normalmap/out/l0.png to l5.png and out/07-sweep.txt — moving the light.

Notes

Put the drawing toolbox in place

Save /root/normalmap/gfxlib.py exactly as in the example, and use /root/normalmap/check.py to draw a test pattern and make /root/normalmap/out/00-check.png. The pattern is a 64x64 black background with a white (255,255,255) diagonal line from (0,0) to (63,63), and over it a red (255,0,0) horizontal line from (0,32) to (63,32).

From this lab on, you do not rebuild the PNG encoder. We hand you the same code you built by hand in the first lab as a tool — because file formats are not what you learn here.

The lab Pod has no volume, so the files you made in the previous lab are not kept. That is why each lab starts by putting the toolbox in place again.

Create Canvas(w, h, bg), draw the two lines with line(x0, y0, x1, y1, rgb), and then save with write_png(path). Draw the horizontal line later, so that the intersection (32,32) becomes red.

In this lab you use it to export the normal map itself and the result lit with it as pictures.

Normals into colors

In /root/normalmap/encode.py, make encode(n) and decode(rgb). encode returns the three real numbers from applying (q*0.5 + 0.5) * 255 to the three components, and decode returns the unit vector that is normalized after applying c/255*2 - 1.

Each component of a normal is between -1 and 1, while a texture channel is between 0 and 255. Carrying across the range is all there is to it.

The normal of a flat area, (0, 0, 1), becomes (127.5, 127.5, 255). This is why a normal map looks light purple overall — that color simply means "it is flat here".

The reason you normalize in decode is that the length is no longer exactly 1 because of the 8-bit truncation. If you do not normalize, the dot product is no longer the cosine and the brightness is slightly off.

For an input whose length becomes 0 (not near (128,128,128), but a value restored to exactly the origin), make sure you do not divide by zero.

Make a normal map

Use /root/normalmap/genmap.py to write a 128x128 normal map to /root/normalmap/out/normalmap.png. Divide it into cells of size 32, find cx = (x%32)/32*2 - 1, cy = (y%32)/32*2 - 1 and r2 = cx*cx + cy*cy, and if r2 < 1, set the normal to (cx, cy, sqrt(1-r2)), otherwise to (0, 0, 1), then encode it and save it.

(cx, cy, sqrt(1-r2)) is a point on the unit hemisphere and also the normal at that point. This is because on a sphere of radius 1, the position vector and the normal are the same.

One cell is 32 pixels, so 128x128 holds 4x4 = sixteen domes. The edge of a cell (r2 >= 1) is a flat floor.

When clipping the color to an integer, make sure it does not go outside 0–255. The result of encode is originally within that range, but it can overflow in rounding.

The resulting picture should look like round patterns on a light purple background. That light purple is (128,128,255), that is, flat.

The three axes of tangent space

In /root/normalmap/tbn.py, make build_tbn(p0, p1, p2, uv0, uv1, uv2) and return the three unit vectors (T, B, N). Let e1 = p1-p0, e2 = p2-p0, d1 = uv1-uv0, d2 = uv2-uv0 and r = 1/(d1x*d2y - d2x*d1y), and then T = (e1*d2y - e2*d1y)*r, B = (e2*d1x - e1*d2x)*r and N = normalize(cross(e1, e2)), and normalize T and B as well.

This expression is the solution of the two equations "when UV changes by this much, the position changes by this much" solved together. T is the direction in which u increases on the surface, and B is the direction in which v increases.

There are cases where the denominator becomes 0. It is when a triangle's UVs are lumped into a single point or lie on a single line, and tangent space is not defined for such a triangle. If it is 0, just pick any values such as T=(1,0,0), B=(0,1,0) and move on.

If you attach standard UVs to a triangle on the XY plane, you get T=(1,0,0), B=(0,1,0), N=(0,0,1). That is, it is the special case where tangent space is the same as world space.

If you flip the v direction, B flips too. This is the point where the conventions for the green channel of a normal map diverge.

Light it with a constant normal

Use /root/normalmap/light.py to light a 128x128 plane and make /root/normalmap/out/flat.png. The normal is (0,0,1) at every pixel, the light is normalize((0.0, 0.3, 0.8)), ambient is 0.15, the base color is (220,200,180), and the brightness is ambient + (1-ambient)*max(0, dot(n, l)).

The normal is the same at every pixel, so the whole picture comes out as a single color. That is normal.

The purpose of this step is to create a reference to compare with the next step. To see what changes when only the normal is swapped out while the geometry and lighting are the same, you need a reference.

When clipping the color to an integer, make sure it does not exceed 255.

This plane is the XY plane and the UVs are standard too, so tangent space is the same as world space. That is why the TBN transform becomes the identity in the next step and the computation gets simple.

Light it with the normal map

With the same plane and the same light, take only the normal from the normal_at(x, y) of step 3 to make /root/normalmap/out/bumped.png.

Not a bit of geometry has changed. It is still a single plane. You have only swapped out the normal per pixel.

Yet the result looks as if hemispheres were embedded in a grid. This is because the cue people use to recognize bumps is the change in brightness.

The average brightness should not differ much from the flat one. This is because the brightened places and the darkened places cancel out. The spread of brightness (variance), on the other hand, becomes much larger.

This plane is the XY plane with standard UVs, so tangent space is the same as world space. So you get the same result even if you skip the TBN transform — for an object in a different orientation, you must go through it.

Move the light

Change the light's x component from -0.9 to 0.9 in six steps (-0.9 + 1.8*k/5) to make the pictures lit with the normal map, /root/normalmap/out/l0.png through l5.png, and write the average brightness of the left half minus the average brightness of the right half of the 32x32 cell starting at coordinate (32,32) in each picture to /root/normalmap/out/07-sweep.txt as a0= through a5=. Also make /root/normalmap/out/index.html containing the six names.

Leave the light's y and z components at 0.3 and 0.8 and change only x.

If the left half is brighter, a is positive, and if the right is brighter, it is negative. The light moves from left to right, so the six values must keep decreasing. That is the evidence that the bumps react to the light.

The average adds up all the channels and divides — one pixel has three values, so dividing by 16*32*3 does it.

The way one dome gets brighter only on the side receiving light looks alive when you play the six pictures in sequence. That is what a normal map does.