Make a Flat Plane Look Bumpy
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
- Put the toolbox in
/root/normalmap. /root/normalmap/encode.py— conversion between normals and colors./root/normalmap/out/normalmap.png— a hemisphere-grid normal map./root/normalmap/tbn.py— the three axes of tangent space./root/normalmap/out/flat.png— the plane lit with a constant normal./root/normalmap/out/bumped.png— the same plane lit with the normal map./root/normalmap/out/l0.pngtol5.pngandout/07-sweep.txt— moving the light.
Notes
- Start a server with
nohup python3 -m http.server 8080 -d /root/normalmap/out &and openhttp://localhost:8080/in the Web preview. If the six pictures play, you can see the bumps reacting to the light. - The plane in this lab is the XY plane with standard UVs, so tangent space is the same as world space. So the TBN transform becomes the identity and the computation gets simple. For an object in a different orientation, you must go through it.
- Common mistake 1: leaving out normalization in
decode. The length is no longer 1 because of the 8-bit truncation, and the brightness is slightly off. - Common mistake 2: exceeding 255 when clipping the color to an integer. Bright areas flip to strange colors.
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.