TT Lab
Get started
Learn Learning paths Courses

3D Math and a Software Rasterizer

Render a Rotating Solid as Eight Frames

Continue in TT Lab

Goal

Light a cuboid and paint each face with a different brightness, then produce eight frames by changing the angle and play them in the browser. When this lab is done, all the pieces of Course 1 come together as one — vectors, matrices, camera, projection, rasterization, depth and lighting.

Why it matters

A cube painted in a single color is just a hexagonal silhouette and does not look three-dimensional. This is because the cue people use to read form is differences in brightness. Brightness is proportional to the cosine of the angle between the surface normal and the light direction, and if you normalize the two vectors and take their dot product, you get that cosine directly. The computation is this simple, yet the result is visible right away.

There are two places where people often go wrong here. One is leaving out max(0, ...), so that the brightness of a face turned away from the light becomes negative, and the other is multiplying with w=1 when rotating the normal with the model matrix, so that the translation gets added. The second one does not show up when the object is near the origin, so it stays for a long time.

Steps

  1. Put the toolbox gfxlib.py and mat3d.py in /root/shading.
  2. Make a cuboid and its face normals with /root/shading/mesh.py and write out/02-normals.txt.
  3. Make Lambertian diffuse reflection in /root/shading/light.py.
  4. Draw the lit cuboid to /root/shading/out/frame00.png.
  5. Produce eight frames at 45-degree steps (frame00.png to frame07.png).
  6. Play them in the browser with /root/shading/out/index.html.
  7. Join the eight frames to make /root/shading/out/spin.png and out/07-sheet.txt.

Notes

Put the drawing toolbox in place

Save /root/shading/gfxlib.py exactly as in the example, and use /root/shading/check.py to draw a test pattern and make /root/shading/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). This lab also uses the matrix tool /root/shading/mat3d.py.

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.

mat3d.py contains rotate_y, mul, apply, normalize, cross and dot. In this lab you use them as material, and what you learn is lighting.

A cuboid mesh and face normals

In /root/shading/mesh.py, make a cuboid with radii (1.0, 0.6, 0.4). VERTS is the eight vertices, TRIS is three indexes each for twelve triangles, FACE_COLORS is the base colors of the six faces, and face_normal(t) is cross(B-A, C-A) normalized. Then write the twelve normals to /root/shading/out/02-normals.txt, from f1=nx,ny,nz through f12=.

If you make the vertices with [(sx*1.0, sy*0.6, sz*0.4) for sx in (-1,1) for sy in (-1,1) for sz in (-1,1)], you get eight.

A face is a quadrilateral, so split it into two triangles. If you call the four vertices a, b, c and d, they are (a,b,c) and (a,c,d).

The order in which you go around the vertices is decisive. Seen from outside, they must go counterclockwise for the normal to point outward. To check, see whether dot(법선, 삼각형 무게중심) is positive (the placeholders are the normal and the triangle's centroid) — the origin is inside, so for an outward normal it is always positive.

The normals of the six faces are the six directions ±x, ±y and ±z, and each must appear twice.

Lambertian diffuse reflection

In /root/shading/light.py, make lambert(normal, light_dir, base_rgb, ambient). Let k = max(0, dot(normalize(normal), normalize(light_dir))), and return the three channels of 색 = base * (ambient + (1-ambient) * k) as real numbers (the placeholder is the resulting color).

If you leave out max(0, ...), the dot product becomes negative on a face turned away from the light and the brightness goes down to negative. When you clip to an integer, it overflows and the color jumps to the opposite.

ambient is a constant that lumps together the light bounced from other surfaces. Without it, the face turned away becomes completely black and the form disappears. It is not a physically exact value, but a correction added in exchange for not computing global illumination.

Be sure to normalize both vectors. If the length is not 1, the dot product is no longer the cosine, and the brightness doubles or dies.

One frame of the lit cuboid

Use /root/shading/render.py to make /root/shading/out/frame00.png (256x256, black background). The camera is look_at((2.2,1.8,3.0),(0,0,0),(0,1,0)), the projection is perspective(45,1,0.1,100), the light direction is normalize((0.4,0.8,1.0)), ambient is 0.2, and the model matrix is rotate_y(0). Separate front from back with the depth buffer, and apply Lambert to the color from FACE_COLORS for each face.

The normal must be rotated along with the model matrix. But because it is a direction, not a position, multiply it with w=0, as in mat3d.apply(model, n, 0.0). If you multiply with w=1, the translation is added too and the lighting goes wrong.

For depth, use the clip-coordinate z divided by w, and interpolate it inside the triangle with barycentric coordinates. It is the same z-buffer you built in the previous lab.

Clip the color to 255 before converting it to an integer. The Lambert coefficient does not exceed 1, but it can overflow in rounding.

If the three visible faces show different brightnesses, it is working correctly. If you see only one brightness, the normals are all the same value or the normalization is missing.

Eight frames at 45-degree steps

Increase the angle from 0 degrees in 45-degree steps to make eight frames, /root/shading/out/frame00.png through frame07.png. The number in the file name has two digits (00, 01, ... 07).

Just call the render(angle, path) from the previous step as it is. One loop does it.

Do not accumulate the angle and multiply rotation matrices along the way. Floating-point error builds up and the object slowly gets distorted. It is safer to keep the angle as state and build the matrix anew every frame.

The cuboid's radii are different from each other, (1.0, 0.6, 0.4), so its appearance clearly changes every 90 degrees. With a cube, the same picture would come out every 90 degrees and you could not see the rotation.

Play it in the browser

Create /root/shading/out/index.html to show the eight frames in turn, start python3 -m http.server 8080 -d /root/shading/out, and open http://localhost:8080/ in the web preview. The HTML must contain all eight names, from frame00.png through frame07.png.

The simplest way is to have one <img> and change its src with JavaScript's setInterval. Make an array of file names and cycle the index.

Start the server in the background with nohup python3 -m http.server 8080 -d /root/shading/out &. Otherwise the terminal is tied up.

Grading looks at index.html and the eight PNGs, not at the server — because if a verdict depended on something that is up and then disappears, the same answer would pass some days and fail on others.

Eight frames into one image

Shrink the eight frames to half size (128x128) and join them in 4 columns by 2 rows to make /root/shading/out/spin.png (512x256), and write three lines to /root/shading/out/07-sheet.txt: cells=8, cell=128x128 and sheet=512x256. The shrink is nearest-neighbor, taking only the top-left one of each two-by-two block of pixels, and frame k is placed at 행 = k // 4, 열 = k % 4 (the placeholders are the row and the column).

With gfxlib.read_png(path), you can read back a PNG you made. Take pixels out with the get(x, y) of the Canvas you read back.

Nearest-neighbor shrinking is simply cell.get(i, j) = frame.get(2*i, 2*j). Do not take an average — the grader compares the values exactly.

The position to place at is x = 열*128 + i and y = 행*128 + j (the placeholders are the column and the row).

This kind of contact sheet is used in practice too. You see the flow of an animation much better than by opening and comparing frames one at a time.