LabHub
学习 学习路径 课程

3D 数学与软件光栅化器

手写 PPM 和 PNG

在 LabHub 中继续学习

目标

不借助任何库,直接写出图像文件,让浏览器能够显示自己计算出的像素。完成本实验后,在这条学习路径的其余所有实验中,你都能用眼睛检查结果。

为什么重要

图形学学习中途停滞最常见的原因,是没有查看结果的方法。但写出一个图像文件并不困难。PPM 只是在三行文件头后依次排列像素;PNG 也只需 8 字节签名和三个数据块。压缩和 CRC 可由标准库的 zlib 完成。

亲自写一次,就会理解:像素按行优先排列;坐标 (x, y) 的位置是 (y * 폭 + x) * 3;图像的 y 轴向下增长,而 3D 数学中的 y 轴向上增长。这三点会在后续所有实验中反复出现。此外,当着色器异常时,最快的诊断方法是把中间值映射为颜色输出;为此必须熟练掌握这里的函数。

步骤

  1. /root/gfx/out/tiny.ppm 写入 4x4 P6 PPM。
  2. /root/gfx/out/gradient.ppm 写入 256x256 渐变图。
  3. 仅使用 zlibstruct 写出 PNG,生成 /root/gfx/out/gradient.png
  4. 创建 /root/gfx/out/index.html,启动 HTTP 服务器并通过网页预览查看。
  5. 填充圆形,生成 /root/gfx/out/shapes.png
  6. 绘制 12 条直线,生成 /root/gfx/out/lines.png
  7. 合并两者,生成 /root/gfx/out/final.png/root/gfx/out/report.txt

参考

创建一个 4x4 PPM

创建 /root/gfx/tiny.py,将 4x4 的 P6 PPM 写入 /root/gfx/out/tiny.ppm。左上角 (0,0) 为红色(255,0,0),右上角 (3,0) 为绿色(0,255,0),左下角 (0,3) 为蓝色(0,0,255),右下角 (3,3) 为白色(255,255,255),其余十二个像素为黑色。

P6 的文件头有三行。用换行符依次连接 P64 4255,然后原样写入 48 个像素字节。

像素从左上向右排列,一行结束后进入下一行。坐标 (x, y) 的起始位置是 (y * 4 + x) * 3

必须以二进制模式open(path, "wb"))打开文件。文本模式下,字节值 10(换行)可能因平台而改变。

256x256 渐变图

使用 /root/gfx/gradient.py 将 256x256 P6 PPM 写入 /root/gfx/out/gradient.ppm。坐标 (x, y) 的颜色必须恰好为 (x, y, 64)。

结构与上一步相同,只是尺寸更大。在双重循环中把 y 放在外层、x 放在内层,字节生成顺序就与文件顺序一致。

使用 bytearray 并通过 += 追加,比每次计算索引更快。文件大小为文件头 15 字节 + 256×256×3 = 196,608 字节。

图像应从左到右越来越红,从上到下绿色越来越强。

仅使用 zlib 和 struct 写出 PNG

/root/gfx/png.py 中创建 write_png(path, w, h, px) 函数,并将与上一步相同的图像写入 /root/gfx/out/gradient.png。颜色类型为 2(真彩色),位深为 8,所有行过滤器均为 0(None)

PNG 以 8 字节签名 \x89PNG\r\n\x1a\n 开始,之后依次是数据块。一个数据块的格式为 길이(4바이트 빅엔디안) + 종류(4글자) + 내용 + CRC(4바이트),CRC 仅针对类型和内容计算(zlib.crc32)。

IHDR 内容为 struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)。IDAT 内容是在每行前添加一个值为 0 的过滤器字节、拼接全部行,再经 zlib.compress 压缩所得。IEND 内容为空字节串。

如果遗漏每行的过滤器字节,图像会呈现对角错位。这是该格式中最常见的错误。

在浏览器中查看自己的图像

创建 /root/gfx/out/index.html,通过 gradient.png 将其放入 <img>;启动 python3 -m http.server 8080 -d /root/gfx/out 后,在网页预览中打开 http://localhost:8080/

HTML 只需几行。图像位于同一文件夹,因此使用 gradient.png 这样的相对路径。

服务器会占用终端,请像 python3 -m http.server 8080 -d /root/gfx/out & 一样在末尾添加 &,让它在后台运行。点击界面顶部的网页预览按钮,选择端口 8080 即可查看。

评分检查的是 index.html 文件和 PNG,而不是服务器——如果用时有时无的进程来判定,同一答案可能今天通过、明天失败。

填充圆形

使用 /root/gfx/shapes.py 将 256x256 PNG 写入 /root/gfx/out/shapes.png。背景为白色(255,255,255),以 (128,128) 为圆心、半径 60 的圆内部填充蓝色(40,80,220),图像外围 1 像素边框为黑色(0,0,0)。

无需开平方即可判断点是否在圆内。满足 (x - 128) ** 2 + (y - 128) ** 2 <= 60 ** 2 即在内部。开平方更慢,而且这里没有必要。

边框是 x 或 y 等于 0 或 255 的像素。先画圆、最后画边框,重叠位置将以边框为准。

请导入上一步在 png.py 中创建的 write_png,写法为 from png import write_png

绘制直线

使用 /root/gfx/line.py 将 256x256 PNG 写入 /root/gfx/out/lines.png。背景为黑色,从中心 (128,128) 向外绘制 12 条长度为 100 的白色直线,角度间隔 30 度(0 度、30 度、...、330 度)。角度 t 的端点为 (128 + 100*cos(t), 128 - 100*sin(t))

使用 Bresenham 直线算法,可仅用整数运算完成绘制。设置误差值 err = dx + dydx = abs(x1-x0)dy = -abs(y1-y0)),每一步将 2*errdydx 比较,以决定沿 x 还是 y 前进。

请注意端点 y 坐标中有减号。数学坐标的 y 轴向上增长,而图像坐标的 y 轴向下增长。

先将端点四舍五入为整数,再进行绘制。

合并绘制并记录可靠数值

使用 /root/gfx/report.py 将圆和直线合并到一张图中,生成 /root/gfx/out/final.png(256x256、白色背景、蓝色圆、12 条黑色直线、黑色边框);并在 /root/gfx/out/report.txt 中写入 width=height=background=r,g,bnonbackground= 四行。nonbackground 是颜色不等于背景色的像素数量。

把前两个步骤连接起来即可。先填充圆,在其上用黑色绘制直线,最后绘制边框。

不要猜测 nonbackground,请在全部绘制完成后统计像素再写入。评分器会直接打开 PNG 进行统计,并与你写下的值核对。两者不一致就说明其中一个有误。

background 是以逗号连接的三个整数。例如:background=255,255,255