Repository navigation
Recitation 05 Transformations
In the lectures we wrote scaling, rotation and translation as matrices. With homogeneous coordinates all three become 4 x 4 matrices, so any sequence of them is just one matrix product. In this recitation you will use these matrices in the ray tracer from Recitation 2, and then animate the scene by letting a matrix change over time.
The program knows only one shape: a unit sphere at the origin. Every object in the scene is a copy of it, with its own matrix M that scales, turns and moves it into place. We call each copy an instance. One sphere and three matrices give a ball, a flat disc and an egg. To intersect a ray with an instance, we do not move the sphere. We move the ray the other way, with M⁻¹, and intersect it with the plain unit sphere.
In this recitation you will:
- Write the translation and rotation matrices, apply a matrix to a point (w = 1) and to a vector (w = 0), and build the matrices of the instances.
- Intersect a ray with an instance by moving the ray into object space, and bring the normal back with the inverse transpose of M.
- Animate the ball: it falls, bounces, squashes when it lands and stretches when it moves fast. You write a single line of code. The program saves the animation as an 8 second video.
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 bumpmapping
git commit -m "my bumpmapping work"
git checkout -- CMakeLists.txt
git pull-
git add bumpmappingandgit 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, thetransformfolder.
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 newtransformproject appear. - Click Open Project to open the solution in Visual Studio 2019.
- In the Solution Explorer, right-click
transformand choose Set as Startup Project. - Switch the configuration from Debug to Release, as in the last two recitations. Task 3 renders 480 images (8 seconds at 60 frames per second). That takes about a minute in Release, and far longer in Debug.
- Build and run with
Ctrl+F5.
The program first prints a short test of Task 1 in the console. Then it opens a window showing the image (500 x 500) 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/transform). After Task 3, the program also saves the video bounce.avi in the same folder.
Build and run the starter code before you change anything. You should get Figure 2: the shiny teal floor from Recitation 2 and nothing else. The three instances are your job.

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, as in Recitation 2. The primary rays are given.
-
Floor: the plane y =
floorY, deep teal and 40% reflective. This is your Recitation 2 floor, given in full. -
Instances:
nInstances = 3copies of the unit sphere. Instancekhas the matrixM[k], its inverseMinv[k]and the colourinst_kd[k]. The matrices are built inbuildScene(time). -
Light: a point light at
lightSource, with Blinn-Phong shading and hard shadows (both given). -
Animation:
durationis the length of the video in seconds (0 means no video),fpsis the number of frames per second, andstillTimeis the moment (in seconds) shown inresult.png.
New in this recitation are two Eigen types: Vec4, a 4D vector, and Mat4, a 4 x 4 matrix. Mat4::Identity() gives the identity matrix, and A(row, col) is one entry of A, counting from 0.
| Function | Status |
|---|---|
translate(), rotateZ()
|
Task 1a, you complete them |
transformPoint(), transformVector()
|
Task 1b, you complete them |
the two static matrices in buildScene()
|
Task 1c, you complete them |
intersectInstance() |
Task 2a, you complete it |
the normal in trace()
|
Task 2b, you complete it |
the moving matrix of the ball in buildScene()
|
Task 3, you complete it |
scale(), intersectUnitSphere(), intersectFloor(), inShadow(), blinnPhong(), the rest of trace(), render(), main()
|
given |
Everything is in main.cpp. Image.h and Image.cpp are the same as in Recitation 2.
scale() is given as an example. It puts the three scale factors on the diagonal of the identity matrix.
Complete translate(tx, ty, tz). Recall from the lecture on homogeneous coordinates that a translation is not linear in 3D, but it becomes a matrix in 4D. The amounts go in the last column:
| 1 0 0 tx |
| 0 1 0 ty |
| 0 0 1 tz |
| 0 0 0 1 |
Complete rotateZ(degrees). A rotation about the z axis is the 2D rotation from the lecture, applied to x and y, with z left alone:
| cos -sin 0 0 |
| sin cos 0 0 |
| 0 0 1 0 |
| 0 0 0 1 |
The angle theta in radians is already computed for you. Set only the entries that differ from the identity.
A point and a vector are both three numbers, but they are not the same thing. A point is a position, so moving the object moves it. A vector is a direction, like a ray direction or a normal, so moving the object must not change it. Homogeneous coordinates tell them apart with the fourth number, w:
- a point gets w = 1, so the last column of the matrix (the translation) is added to it;
- a vector gets w = 0, so the translation is multiplied by 0 and ignored. Scaling and rotation still apply.
Complete transformPoint(A, p): build the Vec4 (p(0), p(1), p(2), 1), multiply it by A, and return the first three components. In Eigen, v.head<3>() gives the first three components of v. Then complete transformVector(A, v) in the same way, with w = 0.
Run the program and look at the console:
Task 1 check
point (1, 0, 0) rotated and moved: 1.00 3.00 3.00 (expected: 1.00 3.00 3.00)
vector (1, 0, 0) rotated and moved: -0.00 1.00 0.00 (expected: 0.00 1.00 0.00)
Both lines use the matrix translate(1, 2, 3) * rotateZ(90). The point is rotated to (0, 1, 0) and then moved by (1, 2, 3). The vector is only rotated. A -0.00 is just a tiny rounding error and counts as 0.
In buildScene(), the matrix of instance 0 is given:
M[0] = translate(0.0f, -0.85f, -3.8f) * scale(0.65f, 0.65f, 0.65f);When a matrix product is applied to a point, the matrix closest to the point acts first. So a product is read from right to left: here the sphere is first scaled to radius 0.65, then moved into place. Figure 3 shows the order for the egg, in 2D.

Build the other two matrices in the same way:
- Instance 1, the red disc: scale by (0.85, 0.25, 0.85), then translate by (-1.45, -1.25, -3.3).
- Instance 2, the blue egg: scale by (0.35, 0.8, 0.35), then rotate by -30 degrees about z, then translate by (1.3, -0.78, -3.3).
Remove the two Mat4::Identity(); // replace placeholders. The inverse matrices Minv[k] are computed for you at the end of buildScene(). The image still shows only the floor, because nothing can hit an instance yet. That is Task 2.
We want to know where a ray hits instance k. The instance is the unit sphere moved by M[k]. Instead of moving the sphere, move the ray into the sphere's own coordinate system, called object space, with Minv[k]. In object space the ray meets the plain unit sphere, and intersectUnitSphere() (given) finds the hit. See Section 13.2 of the textbook.

Complete intersectInstance(k, Origin, direction):
- The ray origin is a point:
o = transformPoint(Minv[k], Origin). - The ray direction is a vector:
d = transformVector(Minv[k], direction). - Return
intersectUnitSphere(o, d).
Do not normalize d. The matrix changes the length of the direction. Because we keep that change, every point o + t d in object space is exactly the point Origin + t direction in world space, for the same t (Figure 4). So the t you return can be used directly in world space, to find the hit point and to compare which instance is the closest. If you normalize d, t is measured in object space units and no longer fits the world space ray.
Remove the return -1; // remove this line placeholder and run the program. All three instances appear, with their shadows and their reflections in the floor. This works because trace(), inShadow() and the reflection all call intersectInstance(). Look closely at the disc: its top is darker than it should be. The normals are still wrong.
For the unit sphere the normal at a point is simply the point itself (the reason: c=(0,0,0), radius, r=1). trace() already finds the hit point in object space (objPoint) and its normal (objNormal). The normal must then be brought back to world space.
A normal is a vector, so the obvious answer is transformVector(M[hitK], objNormal), which is what the starter code does. This works for rotations and translations. It fails when the scale is not the same in every direction. Figure 5 shows why. Squashing the circle tilts its surface, but it tilts the normal the other way, so the result is no longer perpendicular to the surface.

The fix is to transform normals with the inverse transpose of M, that is (M⁻¹)ᵀ. A short reason: a tangent t on the surface transforms with M like any other vector, and the new normal must stay perpendicular to it. (M⁻¹)ᵀ is exactly the matrix that keeps the dot product n . t = 0. See Section 6.2.2 of the textbook for the derivation.
In trace(), replace the line marked WRONG with:
Vec3 Normal = transformVector(Minv[hitK].transpose(), objNormal);Minv[hitK] is already computed, so this costs one transpose. It is still a vector, so it still uses w = 0. You should now get Figure 6: the top of the disc is lit evenly, and the highlight on the egg sits where the light actually reflects.

An animation is a sequence of images, called frames, each rendered at a slightly later time. In a ray tracer that is all there is to it: for every frame, update the matrices and render again. main() does this for you. It calls buildScene(time) and render() once per frame, with time in seconds, and writes the frames to bounce.avi.
The physics of the bounce is also done for you, in bounceState(time, h, squash). You do not need to change it, but here is what it does:
- The ball is dropped from a height of 1 above the floor. It falls under gravity, so it speeds up on the way down and slows down near the top of each bounce.
- Each time it lands, it stays on the floor for a moment (0.12 seconds) and squashes: it flattens to half its height and springs back. Harder landings squash more.
- Each bounce reaches 75% of the height of the previous one. After about 7 seconds the ball comes to rest.
- While it moves fast, it stretches a little along its direction of motion. This is the classic squash and stretch of cartoon animation, and it makes the motion easier to read.
At the top of buildScene(time), three values come out of this:
| Value | Meaning |
|---|---|
h |
the height of the bottom of the ball above the floor (0 while it touches the floor) |
squash |
how much to scale the ball vertically: 1 is round, below 1 is flattened (down to 0.5), above 1 is stretched |
wide |
how much to scale it horizontally, 1 / sqrt(squash), so the ball keeps its volume when it flattens or stretches |
Set duration = 8 at the top of the file (with fps = 60 that is 480 frames). Then, in buildScene(), write one line that replaces M[0], the matrix of the ball, using h, squash and wide.
The difficult part is not the code but the order. The ball must squash about the point where it touches the floor, its contact point, and not about its centre. Otherwise it would flatten toward its centre and float above the floor at every landing. Recall from the lecture that scale() always scales about the origin. So before we squash, we place the ball so that its contact point is at the origin. Figure 7 shows the four steps:

| Step | What it does | Matrix |
|---|---|---|
| 1 | Make the ball: the unit sphere with radius 0.65, centred at the origin | scale(0.65f, 0.65f, 0.65f) |
| 2 | Lift it by its radius, so it sits on the origin: its contact point is now at the origin | translate(0.0f, 0.65f, 0.0f) |
| 3 | Squash or stretch it about the origin, which is now the contact point, so the bottom stays in place | scale(wide, squash, wide) |
| 4 | Move the contact point to the floor, h above it, at the ball's place in the scene |
translate(0.0f, floorY + h, -3.8f) |
A matrix product acts from right to left: the matrix written last, next to the point, is applied first. So step 1 goes at the right end of the line and step 4 at the left end:
M[0] = (step 4) * (step 3) * (step 2) * (step 1);Replace each (step n) with the matrix from the table. Compare with the static ball from Task 1, M[0] = translate(0.0f, -0.85f, -3.8f) * scale(0.65f, 0.65f, 0.65f);. Your line has the same scale at the right and a translation at the left, with two new matrices in between. When the ball is round and in the air (squash = 1, wide = 1), step 3 does nothing, and steps 2 and 4 together are a single translation by (0, floorY + h + 0.65, -3.8), just as in Task 1.
Run the program. You can watch the frames appear in the window as they are rendered. Then open bounce.avi (in build/transform) in any video player. It should look like Figure 1:
- the ball stretches as it falls, flattens on the floor and springs back up;
- the bottom of the ball stays on the floor while it is flattened, with no gap and without sinking in;
- each bounce is lower than the one before, and the ball comes to rest;
- its shadow and reflection follow it.
You did not write any code for the shadow or the reflection. They move because every ray, of every kind, goes through the same matrices. The still image result.png shows the scene at stillTime = 0.96 seconds, the top of the first bounce, and should match Figure 8.

- The
transformproject appears in your solution and builds with no errors. - Your starter output matches Figure 2.
- After Task 1, the console shows the expected values on both lines of the Task 1 check.
- After Task 2, your image matches Figure 6, and the top of the disc is evenly lit.
- After Task 3,
result.pngmatches Figure 8 andbounce.aviis 8 seconds long and looks like Figure 1. When the ball lands, it flattens but never sinks into the floor or floats above it. - Every TODO is done, with no placeholders left: no
return p; // replace, noreturn v; // replace, noMat4::Identity(); // replace, noreturn -1; // remove this line, notransformVector(M[hitK], objNormal); // WRONG, anddurationis 8. - 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. Undo each change before you try the next one, so that you start every question from your final code (Figure 8). To save time, set duration = 0 while you work on the questions, and set it back to 8 at the end.
- Set
stillTime = 0.51(the moment the ball is flattest on its first landing). Then, in Task 3, swaptranslate(0.0f, 0.65f, 0.0f)andscale(wide, squash, wide), so the ball is squashed before it is lifted. What happens to the ball, and why? Hint: what point doesscale()scale about? (save asorder.png) - In
intersectInstance(), transform the ray direction withtransformPoint()instead oftransformVector(). What happens to the image? Why must a direction have w = 0? (save aspointdir.png) - In
intersectInstance(), addd.normalize();after computingd. Why do the instances come out wrong? Hint: in which space is t measured now? (save asnormalized.png) - Put the
WRONGline from Task 2b back. One of the three instances looks exactly the same as in your final image. Which one, and why does M give the correct normal for it? Would it still look right at the moments when the ball squashes? (save aswrongnormal.png) - Add a fourth instance: a green egg lying on its side. Set
nInstances = 4, addVec3(0.25f, 0.70f, 0.20f)as a fourth colour ininst_kd, and inbuildScene()addM[3] = translate(0.1f, -1.2f, -2.3f) * rotateZ(90.0f) * scale(0.3f, 0.7f, 0.3f);just before the inverse matrices are computed. How much new intersection code did you need for it? Why is the egg lying down? (save asfourth.png)
Submit through the Recitation 5 assignment in MS Teams by Monday 26 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: the matrices (Section 4) | 25% |
| Task 2: intersecting an instance (Section 5) | 30% |
| Task 3: a bouncing ball (Section 6) | 15% |
| Questions, with their images (Section 8) | 25% (5% each) |
final.png and bounce.avi
|
5% |
| Total | 100% |
Submit one zip file named lastname_rec5.zip, laid out like this:
lastname_rec5/
├── code/ your main.cpp
└── output/ six images, the video and your answers:
final.png the final still image (Figure 8)
bounce.avi the 8 second animation (Figure 1)
order.png Question 1
pointdir.png Question 2
normalized.png Question 3
wrongnormal.png Question 4
fourth.png Question 5
answers.txt your answers to the five questions in Section 8
Do not include the build folder.
-
The
transformproject is not in the solution. You have not rerun Configure and Generate since pulling. Go back to Section 2. -
The Task 1 check prints the wrong numbers. If the point line is wrong, check
translate(),rotateZ()andtransformPoint(). A result of1.00 1.00 3.00means the twosinentries inrotateZ()have the wrong signs. If the vector line shows1.00 3.00 3.00,transformVector()uses w = 1 instead of w = 0, so the translation was added to the vector. -
The whole image is one colour, or the camera seems to be inside a sphere. A matrix in
buildScene()is still the identity, so that instance is the unit sphere around the camera. Finish Task 1c. -
Still only the floor after Task 2. Check that you removed the
return -1;placeholder and that you return the value ofintersectUnitSphere(). -
The instances are in the wrong places or have the wrong shapes. Check the order of each product in
buildScene(), read from right to left. -
The instances look cut off, too small, or are missing parts. You normalized
dinintersectInstance()(see Question 3). -
The disc looks dark on top. The normal is still transformed with
M[hitK]. Finish Task 2b. -
No
bounce.avi.durationis still 0. Set it to 8. -
The ball floats above the floor or sinks into it when it lands. The squash happens about the wrong point. Check that
translate(0.0f, 0.65f, 0.0f)comes to the right of the squash, so the ball sits on the origin before it is squashed (see Question 1). - The video does not play. Try VLC, which plays every format. The video is Motion JPEG inside an AVI file.
-
The program takes a long time. 480 frames take about a minute in Release. In Debug they take far longer, so switch to Release. While you work on Tasks 1 and 2 or the questions, keep
duration = 0. -
Your new image looks exactly like the old one. You forgot to rebuild, or you are looking at an old copy of
result.png.
The material in this recitation follows the course textbook, Fundamentals of Computer Graphics by Steve Marschner and Peter Shirley:
- Sections 6.1 and 6.2: 2D and 3D linear transformations (scaling, rotation, composition), used in Task 1.
- Section 6.3: translation, affine transformations and homogeneous coordinates (points with w = 1, vectors with w = 0), used in Tasks 1 and 2.
- Section 6.2.2: transforming normal vectors with the inverse transpose, used in Task 2b. The derivation in Section 5.2 follows the textbook.
- Section 13.2: instancing, intersecting a transformed object by transforming the ray, used in Task 2a.
The starter code is adapted from from Recitation 2. AI tools were used to help write the code and the figures, and to help draft the text of this recitation. All content was reviewed, edited and tested by the course instructor.