Repository navigation
Recitation 04 Bump and Normal Mapping
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:
- 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).
- 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.
- 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.

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 samplingandgit commitsave last week's work into your own local history. -
git checkout -- CMakeLists.txtrestores my version of the top-levelCMakeLists.txt, which has the line for this week's exercise. -
git pulldownloads this week's exercise, thebumpmappingfolder.
Be careful with the third command. Typing git checkout -- . with a dot discards your changes to every file.
- Open the CMake GUI, point it at your
GraphicsLab2026folder and itsbuildfolder as before, and click Configure, then Generate. This makes the newbumpmappingproject appear. - Click Open Project to open the solution in Visual Studio 2019.
- In the Solution Explorer, right-click
bumpmappingand choose Set as Startup Project. - 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.
- 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.
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.

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.
zoomandviewCenterchoose which part of the image plane you look through.zoom = 1shows the whole scene, and you will only change it in Task 3. -
Orange: a sphere of radius
orangeRadiuscentred atorangeCenter. Its colour comes fromorange_color.png.orangeModechooses its shading normal: 0 the smooth sphere normal, 1 bump mapping (Task 1), 2 normal mapping (Task 2).bumpScaleis the height of the bumps. -
Bunny: the triangle mesh
bunny_low.obj(498 triangles), placed atbunnyPosand turned bybunnyAngledegrees about the y axis.bunnyModechooses its shading normal: 0 flat (the triangle normal), 1 smooth (interpolated vertex normals), 2 normal mapping (Task 2). -
Textures:
textureFilterchooses 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 withk_d,k_s,p,IandI_a, as in Recitation 2. No shadows this time.
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 fromorange_bump.png. -
bunny_low.obj: the bunny with 498 triangles and texture coordinates (vtlines). -
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.

| 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.
Set orangeMode = 1 at the top of main.cpp.
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.
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.

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:
- Differentiate p with respect to theta. Each component is a sine or a cosine of theta, multiplied by terms in phi that stay fixed.
- 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.
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.

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.
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.

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):
-
colourcomes fromlookup(), so it is in BGR order: red iscolour(2)and blue iscolour(0). - Map each channel from [0, 1] back to [-1, 1].
- 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.

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.
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).

Complete Texture::lookupBilinear() in Texture.cpp:
- 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.
-
Weights (Task 3b).
i0 = floor(x)andj0 = floor(y)are already computed. The weightauis how far x is pasti0, andavhow far y is pastj0. Both are in [0, 1). -
Blend (Task 3c). The four texels
p00,p10,p01,p11are already read for you (p10is one column to the right ofp00,p01one row up). For each channel, blendp00andp10withau, blendp01andp11withau, then blend those two results withav. 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).

Set zoom back to 1 and viewCenter back to Vec2(0.0f, 0.0f) for your final image. You should get Figure 1.
- The
bumpmappingproject 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 = 2looks like Figure 6. - After Task 3, your zoomed image matches Figure 9, and with
textureFilter = 0the same view looks blocky. - Every TODO is done, with no placeholders left: no
Vec3(0, 0, 0); // replace, nofloat d_u = 0; // replace, nop_u.cross(p_v); // replace, noreturn Vec3(0, 0, 1); // replace, nofloat x = 0; // replace, nofloat au = 0; // replace, nop00[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.
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.
- Set
bumpScale = -0.0025. What happens to the pores, and why does a negative scale do that? (save asnegative.png) - 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 asdeep.png) - Set
orangeMode = 2. The image is almost identical to the bump mapped one. How many texture lookups does each mode (orangeMode = 1andorangeMode = 2) make per pixel to find the normal (count the calls tolookup()in thebumpHeight()andlookup()and consider the number oflookup()s when you set thetextureFilter=1)? Name one thing you can still change at run time with the bump map that you cannot change with the normal map. (save asnormalmode.png) - Set
textureFilter = 0,zoom = 10andviewCentertoVec2(-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 atzoom = 1? (save asnearest.png) - Set
bunnyAngle = 90. The bunny turns, but its lighting is wrong. Why? How could you fix it without baking a new normal map? (save asturned.png)
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.
-
The
bumpmappingproject 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
assetsfolder is missing or was moved. It must be inside thebumpmappingfolder, next tomain.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_vis 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 by2 du, notdu. -
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 iscolour(2). -
Everything turns one flat colour with
textureFilter = 1. Task 3 is not finished:xandyare still 0, so every lookup reads the same texel. -
The textures look shifted by half a texel, or blocky stripes remain. Check the
- 0.5in Task 3a, and thatauis 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,bunnyModeortextureFilter, or you are looking at an old copy ofresult.png.
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.