TT Lab
Get started
Learn Learning paths Courses

3D Math and a Software Rasterizer

Write PPM and PNG by Hand

Continue in TT Lab

Goal

Write an image file yourself without a library, so that you can see the pixels you computed in a browser. When this lab is done, you can check results by eye in everything else in this path.

Why it matters

The most common reason graphics study stalls midway is having no way to see the result. But writing an image file is not hard. PPM is just pixels listed after a three-line header, and PNG needs only an 8-byte signature and three chunks. The compression and CRC are done by zlib in the standard library.

Writing it yourself teaches a few things along the way: that pixels are laid out row-major, that the position of coordinate (x, y) is (y * 폭 + x) * 3 (the placeholder is the image width), and that y in an image grows downward while y in 3D math grows upward. These three things keep coming up in every later lab. And when a shader looks wrong, painting the intermediate values as colors and dumping them is the fastest diagnosis, which requires having this function at hand.

Steps

  1. Write a 4x4 P6 PPM to /root/gfx/out/tiny.ppm.
  2. Write a 256x256 gradient to /root/gfx/out/gradient.ppm.
  3. Write a PNG with only zlib and struct to make /root/gfx/out/gradient.png.
  4. Create /root/gfx/out/index.html, start an HTTP server, and view it in the web preview.
  5. Fill a circle to make /root/gfx/out/shapes.png.
  6. Draw 12 lines to make /root/gfx/out/lines.png.
  7. Combine the two to make /root/gfx/out/final.png and /root/gfx/out/report.txt.

Notes

A 4x4 PPM

Create /root/gfx/tiny.py and write a 4x4 P6 PPM to /root/gfx/out/tiny.ppm. The top-left (0,0) is red (255,0,0), the top-right (3,0) is green (0,255,0), the bottom-left (0,3) is blue (0,0,255), the bottom-right (3,3) is white (255,255,255), and the other twelve pixels are black.

The P6 header is three lines. Join P6, 4 4 and 255 with newlines, then write the 48 pixel bytes right after it as they are.

Pixels go from the top left toward the right, and when a row ends you go on to the next row. The start position of coordinate (x, y) is (y * 4 + x) * 3.

Always open the file in binary mode (open(path, "wb")). If you open it in text mode, byte value 10 (newline) may be altered depending on the platform.

A 256x256 gradient

Use /root/gfx/gradient.py to write a 256x256 P6 PPM to /root/gfx/out/gradient.ppm. The color at coordinate (x, y) is exactly (x, y, 64).

The structure is the same as in the previous step; only the size gets bigger. With a double loop with y outside and x inside, the order in which bytes go out is the file order.

Appending to a bytearray with += is faster than computing an index every time. The file size is a 15-byte header + 256×256×3 = 196,608 bytes.

You should get a picture that turns redder from left to right and greener from top to bottom.

Write a PNG with only zlib and struct

Make a write_png(path, w, h, px) function in /root/gfx/png.py, and write the same picture as in the previous step to /root/gfx/out/gradient.png. The color type is 2 (truecolor), the bit depth is 8, and every row filter is 0 (None).

A PNG starts with the 8-byte signature \x89PNG\r\n\x1a\n, followed by chunks. One chunk is 길이(4바이트 빅엔디안) + 종류(4글자) + 내용 + CRC(4바이트) (length as 4 bytes big-endian, type as 4 letters, content, and CRC as 4 bytes), and the CRC is computed over the type and the content only (zlib.crc32).

The content of IHDR is struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0). The content of IDAT is the rows, each with one filter byte 0 attached in front, joined together and then compressed with zlib.compress. The content of IEND is an empty byte string.

If you leave out the filter byte attached to each row, the picture looks shifted diagonally. That is the most common mistake in this format.

See my picture in a browser

Create /root/gfx/out/index.html and link gradient.png with <img>, start python3 -m http.server 8080 -d /root/gfx/out, and then open http://localhost:8080/ in the web preview.

The HTML takes only a few lines. The image is in the same folder, so write its path as a relative path like gradient.png.

The server holds on to the terminal, so start it in the background by adding & at the end, as in python3 -m http.server 8080 -d /root/gfx/out &. If you click the Web preview button at the top of the screen, you can choose port 8080 and view it.

Grading looks at the index.html file and the PNG, 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.

Fill a circle

Use /root/gfx/shapes.py to write a 256x256 PNG to /root/gfx/out/shapes.png. The background is white (255,255,255), the inside of the circle with center (128,128) and radius 60 is filled with blue (40,80,220), and the outer border of 1 pixel around the image is black (0,0,0).

Decide whether a point is inside the circle without using a square root. If (x - 128) ** 2 + (y - 128) ** 2 <= 60 ** 2, it is inside. A square root is slow, and you do not need one here.

The border is the pixels where x or y is 0 or 255. If you draw the circle first and the border afterwards, the border wins where they overlap.

For writing the PNG, use the write_png in the png.py you made in the previous step by importing it with from png import write_png.

Draw a line

Use /root/gfx/line.py to write a 256x256 PNG to /root/gfx/out/lines.png. The background is black, and twelve white lines of length 100 radiate from the center (128,128) at 30-degree intervals (0 degrees, 30 degrees, ... 330 degrees). The endpoint for angle t is (128 + 100*cos(t), 128 - 100*sin(t)).

With Bresenham's line algorithm, you can draw using integer arithmetic only. Keep an error value err = dx + dy (with dx = abs(x1-x0) and dy = -abs(y1-y0)) and, at each step, compare 2*err with dy and dx to decide whether to step in x or in y.

Note that there is a subtraction in the endpoint's y. This is because y in math grows upward while y in an image grows downward.

Round the endpoint to integers before drawing.

Combine them and write down what you counted

Use /root/gfx/report.py to combine the circle and the lines on one sheet and make /root/gfx/out/final.png (256x256, white background, blue circle, 12 black lines, black border), and write four lines to /root/gfx/out/report.txt: width=, height=, background=r,g,b and nonbackground=. nonbackground is the number of pixels that are not the background color.

Just join the previous two steps. Fill the circle first, draw the lines over it in black, and then draw the border.

Do not make up nonbackground; count the pixels after drawing everything and write that value. The grader opens the PNG directly, counts, and compares with the value you wrote. If the two values disagree, one of them is wrong.

background is three integers joined by commas. Example: background=255,255,255