Skip to content

Recitation 05 Transformations

jijup edited this page Oct 20, 2026 · 4 revisions

Recitation 5: 3D Transformations

Objectives

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:

  1. 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.
  2. Intersect a ray with an instance by moving the ray into object space, and bring the normal back with the inverse transpose of M.
  3. 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.

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 bumpmapping
git commit -m "my bumpmapping work"
git checkout -- CMakeLists.txt
git pull
  • git add bumpmapping 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 transform 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 transform project appear.
  2. Click Open Project to open the solution in Visual Studio 2019.
  3. In the Solution Explorer, right-click transform and choose Set as Startup Project.
  4. 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.
  5. 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.


3. The starter code

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.

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, 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 = 3 copies of the unit sphere. Instance k has the matrix M[k], its inverse Minv[k] and the colour inst_kd[k]. The matrices are built in buildScene(time).
  • Light: a point light at lightSource, with Blinn-Phong shading and hard shadows (both given).
  • Animation: duration is the length of the video in seconds (0 means no video), fps is the number of frames per second, and stillTime is the moment (in seconds) shown in result.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.

3.2 What is given and what you write

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.


4. Task 1: The matrices (25%)

4.1 Task 1a: Translation and rotation

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.

4.2 Task 1b: Points and vectors

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.

4.3 Task 1c: Building the instances

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.

alt text

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.


5. Task 2: Intersecting an instance (30%)

5.1 Task 2a: The ray in object space

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.

alt text

Complete intersectInstance(k, Origin, direction):

  1. The ray origin is a point: o = transformPoint(Minv[k], Origin).
  2. The ray direction is a vector: d = transformVector(Minv[k], direction).
  3. 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.

5.2 Task 2b: The normal

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.

alt text

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.

alt text


6. Task 3: A bouncing ball (15%)

6.1 What is already done

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

6.2 What you do: one line

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:

alt text

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.

6.3 Check your animation

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.

alt text


7. Check yourself

  • The transform project 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.png matches Figure 8 and bounce.avi is 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, no return v; // replace, no Mat4::Identity(); // replace, no return -1; // remove this line, no transformVector(M[hitK], objNormal); // WRONG, and duration is 8.
  • 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. 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.

  1. Set stillTime = 0.51 (the moment the ball is flattest on its first landing). Then, in Task 3, swap translate(0.0f, 0.65f, 0.0f) and scale(wide, squash, wide), so the ball is squashed before it is lifted. What happens to the ball, and why? Hint: what point does scale() scale about? (save as order.png)
  2. In intersectInstance(), transform the ray direction with transformPoint() instead of transformVector(). What happens to the image? Why must a direction have w = 0? (save as pointdir.png)
  3. In intersectInstance(), add d.normalize(); after computing d. Why do the instances come out wrong? Hint: in which space is t measured now? (save as normalized.png)
  4. Put the WRONG line 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 as wrongnormal.png)
  5. Add a fourth instance: a green egg lying on its side. Set nInstances = 4, add Vec3(0.25f, 0.70f, 0.20f) as a fourth colour in inst_kd, and in buildScene() add M[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 as fourth.png)

9. Submission

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.


10. If something goes wrong

  • The transform project 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() and transformPoint(). A result of 1.00 1.00 3.00 means the two sin entries in rotateZ() have the wrong signs. If the vector line shows 1.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 of intersectUnitSphere().
  • 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 d in intersectInstance() (see Question 3).
  • The disc looks dark on top. The normal is still transformed with M[hitK]. Finish Task 2b.
  • No bounce.avi. duration is 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.

Acknowledgements

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.