Skip to content

Recitation 04 Bump and Normal Mapping

jijup edited this page Oct 13, 2026 · 5 revisions

Recitation 4: Bump Mapping and Normal Mapping

Objectives

Real surfaces are full of small detail: the pores of an orange peel, the fur of a rabbit, scratches on metal. Modelling every bump with triangles would need millions of them. Most of this detail is too small to change the outline of an object. What we notice is how it catches the light, and the lighting depends only on the normal. So instead of changing the geometry, we keep a smooth or coarse surface and change the normal we use for shading. The detail is stored in a texture.

In this recitation you will:

  1. Implement bump mapping on an orange. The orange is a plain sphere. A grey height texture describes its pores, and you will compute the perturbed normal with the formula we derived in class (Blinn, 1978).
  2. Implement normal mapping on a Stanford bunny made of only 498 triangles. Its normal map was baked from the original 69,451-triangle scan, so the coarse mesh is shaded as if it had all the detail.
  3. Implement bilinear interpolation for texture lookups, so that textures stay smooth when you look at them up close.

Figure 1 is what you will have at the end.

alt text


1. Getting the new exercise

You already have the repository. Do not clone it again. Open Git Bash inside your GraphicsLab2026 folder and run these four commands in order:

git add sampling
git commit -m "my sampling work"
git checkout -- CMakeLists.txt
git pull
  • git add sampling and git commit save last week's work into your own local history.
  • git checkout -- CMakeLists.txt restores my version of the top-level CMakeLists.txt, which has the line for this week's exercise.
  • git pull downloads this week's exercise, the bumpmapping folder.

Be careful with the third command. Typing git checkout -- . with a dot discards your changes to every file.


2. Building

  1. Open the CMake GUI, point it at your GraphicsLab2026 folder and its build folder as before, and click Configure, then Generate. This makes the new bumpmapping project appear.
  2. Click Open Project to open the solution in Visual Studio 2019.
  3. In the Solution Explorer, right-click bumpmapping and choose Set as Startup Project.
  4. Switch the configuration from Debug to Release. The program tests every ray against the bunny's triangles, and the Debug build is many times slower. In Release it takes a few seconds.
  5. Build and run with Ctrl+F5.

The program opens a window showing the image (800 x 400) and waits. Press any key in the image window to close it. The image is also saved as result.png in the program's working folder (normally build/bumpmapping). The textures and the mesh are read from bumpmapping/assets. CMakeLists.txt passes the full path of that folder to the program as ASSETS_DIR, as in Assignment 1, so you do not need to copy anything.


3. The starter code

Build and run the starter code before you change anything. You should get Figure 2: a smooth orange, coloured by its texture but without any pores in the lighting, and a bunny that is obviously made of a few hundred triangles.

alt text

3.1 The scene

All scene values are at the top of main.cpp. Colours are BGR floats in [0, 1], as before.

  • Camera: at the origin, looking down the -z axis. The image plane is at z = -1. zoom and viewCenter choose which part of the image plane you look through. zoom = 1 shows the whole scene, and you will only change it in Task 3.
  • Orange: a sphere of radius orangeRadius centred at orangeCenter. Its colour comes from orange_color.png. orangeMode chooses its shading normal: 0 the smooth sphere normal, 1 bump mapping (Task 1), 2 normal mapping (Task 2). bumpScale is the height of the bumps.
  • Bunny: the triangle mesh bunny_low.obj (498 triangles), placed at bunnyPos and turned by bunnyAngle degrees about the y axis. bunnyMode chooses its shading normal: 0 flat (the triangle normal), 1 smooth (interpolated vertex normals), 2 normal mapping (Task 2).
  • Textures: textureFilter chooses how every texture is read: 0 the nearest texel, 1 bilinear interpolation (Task 3).
  • Light: a point light at lightPos. Both objects use Blinn-Phong shading with k_d, k_s, p, I and I_a, as in Recitation 2. No shadows this time.

3.2 The textures and the mesh

The files in assets are shown in Figure 3.

  • orange_color.png: the colour of the peel.
  • orange_bump.png: the height map of the peel, white for high and black for low. The dark spots are the pores.
  • orange_normal.png: a normal map of the same pores, made from orange_bump.png.
  • bunny_low.obj: the bunny with 498 triangles and texture coordinates (vt lines).
  • bunny_normal.png: the normal map of the bunny.

bunny_normal.png was baked from the full 69,451-triangle scan: for every texel of the coarse mesh, a ray was cast along the coarse surface normal to the detailed mesh, and the normal of the detailed mesh at the hit point was stored. The orange textures use the sphere's longitude and latitude as (u, v), so they are twice as wide as they are tall.

alt text

3.3 What is given and what you write

Function File Status
sphereTangents() main.cpp Task 1a, you complete it
bumpNormal() main.cpp Tasks 1b and 1c, you complete it
decodeNormal() main.cpp Task 2, you write it
Texture::lookupBilinear() Texture.cpp Task 3, you complete it
intersectSphere(), intersectMesh(), sphereUV(), blinnPhong(), trace(), loadBunny() main.cpp given
Texture::lookupNearest(), Texture::lookup() Texture.cpp given
Triangle::getTextCoord(), Triangle::getVertexNormal() Triangle.cpp given: interpolation with barycentric coordinates
loadOBJ() ObjLoader.h given: the Assignment 1 loader, plus a version that also reads texture coordinates

Every texture is read with lookup(uv), which returns the colour at uv as BGR floats in [0, 1], the same order as the scene colours. Keep this in mind in Task 2. lookup() calls lookupNearest() or lookupBilinear(), depending on textureFilter.


4. Task 1: Bump mapping the orange (30%)

Set orangeMode = 1 at the top of main.cpp.

4.1 The idea

Recall the derivation from class (Lecture Slides 5.2). The orange is a parametric surface p(u, v) with unit normal n. The bump map gives a height d(u, v), and we pretend the surface is moved along its normal by that height:

p'(u, v) = p(u, v) + d(u, v) n

The normal of the moved surface is the cross product of its tangent vectors. With the product rule, and dropping the term d n_u (the bumps are tiny, so height times curvature is negligible):

p'_u = p_u + d_u n
p'_v = p_v + d_v n

n' = p'_u x p'_v = p_u x p_v  +  d_u (n x p_v)  +  d_v (p_u x n)  +  d_u d_v (n x n)

The last term is zero, since n x n = 0. So you need three things: the tangents p_u and p_v of the sphere (Task 1a), the derivatives d_u and d_v of the height (Task 1b), and the formula itself (Task 1c). We never move the surface. We only use the new normal for shading.

In the code, the height is bumpHeight(uv), which is bumpScale times the value in orange_bump.png.

4.2 Task 1a: The tangents of the sphere

Spherical coordinates. Any point on a sphere of radius r around a centre c is fixed by two angles (Figure 4, left):

  • theta, the angle down from the +y axis (the "north pole"): 0 at the top, pi/2 at the equator, pi at the bottom.
  • phi, the angle around the y axis, measured from the +z axis (the direction of the camera) toward the +x axis. It runs from -pi to pi, so phi = 0 is the side of the orange facing us and phi = +-pi is the back.

Start at the centre and walk a distance r in the direction given by the two angles. The y component is r cos(theta): r at the top, 0 on the equator, -r at the bottom. The rest of the walk, r sin(theta), lies flat in the xz plane, and phi splits it between z (cos(phi)) and x (sin(phi)). So

p = c + r (sin(theta) sin(phi),  cos(theta),  sin(theta) cos(phi))

From angles to texture coordinates. The texture is a rectangle with u and v in [0, 1] (Figure 4, right). As in the lecture, we map the angles to it linearly:

u = (phi + pi) / 2pi          v = (pi - theta) / pi

Going once around the orange (phi from -pi to pi) moves u across the whole texture, and going from the bottom pole to the top pole (theta from pi to 0) moves v from 0 to 1. sphereUV() computes exactly this. For the derivatives we need the inverse, the angles as functions of (u, v):

phi = 2 pi u - pi             theta = pi (1 - v)

Put these into p and you get the surface p(u, v) as a function of the texture coordinates. Every texel of orange_bump.png now has a place on the orange. Each row of the texture wraps around the orange along a circle of latitude, and each column runs along a meridian from pole to pole.

alt text

The tangents. p_u = dp/du is the velocity of the point when u increases and v stays fixed. The point then slides along its circle of latitude, in the direction of increasing phi (red in Figure 4). p_v = dp/dv is the velocity when v increases, which moves the point up its meridian, toward the top pole (blue). Both lie flat on the surface, so their cross product is the normal.

To get p_u, differentiate p with respect to phi and use the chain rule with dphi/du = 2 pi. This is done for you in sphereTangents():

dp/dphi = r (sin(theta) cos(phi), 0, -sin(theta) sin(phi))
p_u     = dp/dphi * dphi/du = 2 pi r (sin(theta) cos(phi), 0, -sin(theta) sin(phi))

Its length is 2 pi r sin(theta): long at the equator, where a step in u covers a long arc, and zero at the poles, where the circles of latitude shrink to a point.

Your task. Write p_v the same way:

  1. Differentiate p with respect to theta. Each component is a sine or a cosine of theta, multiplied by terms in phi that stay fixed.
  2. Multiply by dtheta/dv. Since theta = pi (1 - v), dtheta/dv = -pi. The minus sign is there because v grows upward while theta grows downward.

Check your answer: p_v should point toward the top pole (its y component is positive), its length should be pi r everywhere, and p_u x p_v must point out of the sphere, along (point - orangeCenter). If it points inward, the sign of p_v is wrong.

4.3 Task 1b: The height derivatives

The formula in Section 4.1 needs d_u and d_v: how fast the height changes when you move a little in u, and a little in v. We have no formula for d(u, v), only a grid of texels, so we estimate the derivatives with finite differences.

Recall the definition of a derivative: d_u is the limit of (d(u + h, v) - d(u, v)) / h as h goes to 0. On a texture we cannot make h smaller than one texel, because there is nothing finer to see. So we take h = one texel and do not take the limit. The central difference looks one texel ahead and one texel behind, and divides by the distance between the two samples:

du = 1 / width                     dv = 1 / height       (of orangeBumpTex)

d_u = (d(u + du, v) - d(u - du, v)) / (2 du)
d_v = (d(u, v + dv) - d(u, v - dv)) / (2 dv)

So each bump mapped point needs four extra lookups, two along its row and two along its column (Figure 5, left). Geometrically, d_u is the slope of the line through the two samples on either side of u (Figure 5, right). A one-sided difference (d(u + du) - d(u)) / du would give the slope halfway to the next texel instead of at u. The central difference is centred on the point and is more accurate for the same number of lookups.

alt text

Note the units. du is a fraction of the whole texture (1/2048 here), so d_u is the change in height per unit of u, not per texel. This matches p_u, which is also per unit of u, so the two fit together in the formula of Section 4.1.

In code, d(u + du, v) is bumpHeight(uv + Vec2(du, 0)), and d(u, v - dv) is bumpHeight(uv - Vec2(0, dv)). Replace the two 0 placeholders.

4.4 Task 1c: The new normal

Replace the nPrime placeholder with the formula from Section 4.1. n (the unit normal), p_u and p_v are already there. Eigen's cross product is a.cross(b).

You should now get Figure 6. The peel is covered in pores, and the highlight is broken up by them. Yet the outline of the orange is still a perfect circle.

alt text


5. Task 2: Normal mapping (20%)

Set bunnyMode = 2.

Bump mapping computes the normal from a height. A normal map stores the normal itself, one per texel. A normal has components in [-1, 1], and a colour channel holds values in [0, 1], so each component is stored as

colour = (n + 1) / 2          and back:          n = 2 colour - 1

with x in the red channel, y in green and z in blue. Our two normal maps store the normal in the object's own coordinate system, which is why they show every colour: the normals point in every direction. Such a map is called an object space normal map. Since loadBunny() only moves the bunny (with bunnyAngle = 0), these are already the world space normals.

Write decodeNormal(colour):

  1. colour comes from lookup(), so it is in BGR order: red is colour(2) and blue is colour(0).
  2. Map each channel from [0, 1] back to [-1, 1].
  3. Normalize. Interpolation and 8-bit colours leave the vector a little off unit length.

You should now get Figure 7. Compare it with the smooth bunny in Figure 2: the mesh is the same 498 triangles, but the fur, the eyes and the feet are back. Only the outline still shows the coarse mesh.

alt text

Now set orangeMode = 2. The orange reads its normals from orange_normal.png, which was made from orange_bump.png with the formula from Task 1. It should look the same as after Task 1 (Figure 6). Set orangeMode back to 1 when you move on.


6. Task 3: Bilinear interpolation (20%)

Set textureFilter = 1.

A texture is a grid of texels, but texture coordinates are real numbers, so a lookup almost never lands on the centre of a texel. lookupNearest() simply returns the texel that contains (u, v). When a texel covers several pixels on screen, every pixel inside it gets the same colour, and the texture looks blocky. Bilinear interpolation blends the four texels around (u, v) instead, weighted by how close each one is (Figure 8).

alt text

Complete Texture::lookupBilinear() in Texture.cpp:

  1. Texel coordinates (Task 3a). Texel (i, j) has its centre at u = (i + 0.5) / width and v = (j + 0.5) / height. Turn (u, v) into real-valued texel coordinates (x, y) by solving these for i and j. Then x = 2.3 means 30% of the way from the centre of column 2 to the centre of column 3.
  2. Weights (Task 3b). i0 = floor(x) and j0 = floor(y) are already computed. The weight au is how far x is past i0, and av how far y is past j0. Both are in [0, 1).
  3. Blend (Task 3c). The four texels p00, p10, p01, p11 are already read for you (p10 is one column to the right of p00, p01 one row up). For each channel, blend p00 and p10 with au, blend p01 and p11 with au, then blend those two results with av. Divide by 255.

Remove the // replace placeholders. At the normal view the image hardly changes, because each pixel covers several texels. To see the difference, look closely at the orange: set zoom = 10 and viewCenter to Vec2(-0.32f, 0.06f). You should get Figure 9, with smooth pores. Then try textureFilter = 0 with the same view (Question 4).

alt text

Set zoom back to 1 and viewCenter back to Vec2(0.0f, 0.0f) for your final image. You should get Figure 1.


7. Check yourself

  • The bumpmapping project appears in your solution and builds with no errors, in Release.
  • Your starter output matches Figure 2.
  • After Task 1, p_u x p_v points out of the orange, and your image matches Figure 6. There is no visible seam on the orange.
  • After Task 2, your image matches Figure 7, and orangeMode = 2 looks like Figure 6.
  • After Task 3, your zoomed image matches Figure 9, and with textureFilter = 0 the same view looks blocky.
  • Every TODO is done, with no placeholders left: no Vec3(0, 0, 0); // replace, no float d_u = 0; // replace, no p_u.cross(p_v); // replace, no return Vec3(0, 0, 1); // replace, no float x = 0; // replace, no float au = 0; // replace, no p00[c] / 255.0f; // replace.
  • Your final image (orangeMode = 1, bunnyMode = 2, textureFilter = 1, zoom = 1) matches Figure 1.
  • You have worked through the five questions in Section 8 and saved an image for each change.

8. Questions (25%, 5% each)

Try each change below, look at the image, and answer in one or two sentences in answers.txt. Save the image for each change with the file name given, since you will submit them. The program always writes result.png, so copy and rename it after each run. Change the value back to the original before you try the next one. The original settings are orangeMode = 1, bumpScale = 0.0025, bunnyMode = 2, bunnyAngle = 0, textureFilter = 1 and zoom = 1.

  1. Set bumpScale = -0.0025. What happens to the pores, and why does a negative scale do that? (save as negative.png)
  2. Set bumpScale = 0.02. The pores now look very deep, but look at the outline of the orange. Why is it still a perfect circle? What would you have to change to make the outline bumpy too? (save as deep.png)
  3. Set orangeMode = 2. The image is almost identical to the bump mapped one. How many texture lookups does each mode (orangeMode = 1 and orangeMode = 2) make per pixel to find the normal (count the calls to lookup() in the bumpHeight() and lookup() and consider the number of lookup()s when you set the textureFilter=1)? Name one thing you can still change at run time with the bump map that you cannot change with the normal map. (save as normalmode.png)
  4. Set textureFilter = 0, zoom = 10 and viewCenter to Vec2(-0.32f, 0.06f). Compare it with Figure 9. What do you see, and why? Why is there almost no difference between the two filters at zoom = 1? (save as nearest.png)
  5. Set bunnyAngle = 90. The bunny turns, but its lighting is wrong. Why? How could you fix it without baking a new normal map? (save as turned.png)

9. Submission

Submit through the Recitation 4 assignment in MS Teams by Sunday 18 October, 11:59pm.

Marks are based on attendance and a completed submission. The TA reviews a sample of submissions in detail. The weightings below show how the work is distributed and what the TA looks at.

Part Weight
Task 1: bump mapping the orange (Section 4) 30%
Task 2: normal mapping (Section 5) 20%
Task 3: bilinear interpolation (Section 6) 20%
Questions, with their images (Section 8) 25% (5% each)
final.png 5%
Total 100%

Submit one zip file named lastname_rec4.zip, laid out like this:

lastname_rec4/
├── code/      your main.cpp and Texture.cpp
└── output/    six images and your answers:
               final.png       the final image (Figure 1)
               negative.png    Question 1
               deep.png        Question 2
               normalmode.png  Question 3
               nearest.png     Question 4
               turned.png      Question 5
               answers.txt     your answers to the five questions in Section 8

Do not include the build folder or the assets folder.


10. If something goes wrong

  • The bumpmapping project is not in the solution. You have not rerun Configure and Generate since pulling. Go back to Section 2.
  • "Could not load the assets" in the console. The assets folder is missing or was moved. It must be inside the bumpmapping folder, next to main.cpp. Rerun Configure and Generate if you moved the project.
  • The program takes a long time. You are in the Debug configuration. Switch to Release.
  • The orange is black, or has black specks. p_v is still zero, so the normal has zero length and normalizing it gives NaN. Finish Task 1a.
  • The orange looks lit from below, or the pores look like bumps. The sign of p_v is wrong, so p_u x p_v points into the sphere. Check that you multiplied by dtheta/dv = -pi.
  • The bumps are very faint, or the shading is noisy. Check the central differences: the step must be one texel (1.0f / width), and you must divide by 2 du, not du.
  • The bunny turns strange colours of light and dark, as if lit from the side. You decoded the channels in RGB order. lookup() returns BGR, so x is colour(2).
  • Everything turns one flat colour with textureFilter = 1. Task 3 is not finished: x and y are still 0, so every lookup reads the same texel.
  • The textures look shifted by half a texel, or blocky stripes remain. Check the - 0.5 in Task 3a, and that au is the weight of the right texel (p10), not the left one.
  • Your new image looks exactly like the old one. You forgot to rebuild, you did not change orangeMode, bunnyMode or textureFilter, or you are looking at an old copy of result.png.

Acknowledgements

The starter code was adapted from the texture mapping recitation and bilinear interpolation exercise used in a previous offering of this course. The bunny model is from the Stanford 3D Scanning Repository. AI-assisted tools were used in code development, texture generation, model simplification, normal map baking, and drafting the recitation materials. All content was reviewed, refined, and tested by the course instructor.