LabHub
学习 学习路径 课程

3D 数学与软件光栅化器

图像文件为什么长成这样

在 LabHub 中继续学习

一句话总结

图像归根结底是数字数组,文件格式只是约定如何排列、如何压缩这个数组。PPM 选择不压缩,PNG 则选择压缩。

概念图: 没有办法看到结果 · PPM(Portable PixMap) · 行优先(row-major) · PNG

为什么需要它

学习 3D 图形学的人最先遇到的障碍通常不是数学,而是没有办法看到结果。显示窗口需要窗口管理器,保存图像需要图像库;实验环境两者都没有时,学习很容易停滞。

但直接写出一个图像文件比想象中简单,而且过程本身很有价值。如果只用 image.save("a.png") 一行带过,就不会理解像素在内存中的排列顺序、宽度为奇数时为什么会产生对齐问题,以及增加 alpha 通道为什么会让数据从三份增加为四份。在图形系统中,这些问题日后都会以性能问题再次出现。

如何工作

**PPM(Portable PixMap)**属于 Netpbm 格式族,在文件头之后原样排列像素。二进制 P6 的全部结构如下。

P6            <- 매직 넘버 (P3 이면 픽셀을 십진수 글자로 적는다)
256 256       <- 폭과 높이
255           <- 채널 최댓값
<픽셀 바이트>  <- R,G,B,R,G,B, ... 왼쪽 위부터 오른쪽으로, 그다음 아래 줄로

像素按**行优先(row-major)**排列。左上角是第一个像素,填满一行后进入下一行,因此坐标 (x, y) 的字节位置是 (y * 폭 + x) * 3。y 轴向下增长是格式本身的约定,与 3D 数学中 y 轴向上相反。应该在哪一层翻转这个差异,会在后续不断出现。

PPM 的问题是浏览器无法直接显示,所以需要 PNG。PNG 以 8 字节签名开头,随后是一系列由长度、类型、内容和 CRC 组成的数据块。最少需要三种块。

89 50 4E 47 0D 0A 1A 0A   <- 서명
IHDR   폭, 높이, 비트깊이 8, 색 타입 2(트루컬러)
IDAT   각 행 앞에 필터 바이트 1개를 붙인 뒤 zlib 으로 압축한 것
IEND   끝

关键是每行前面都有一个过滤器字节。过滤器通过只保存与相邻像素的差异提高压缩率,编号 0 表示“无过滤”。只使用 0 时,编码器只需在每行前加一个 0。压缩与 CRC 都可由 Python 标准库 zlib 完成,即 zlib.compresszlib.crc32,因此 PNG 编码器不到二十行就能写完。

加入 alpha 通道后,颜色类型变为 6,每像素字节数从 3 增到 4,数据量也随之增大三分之一。计算纹理内存时,这一个通道经常成为问题;连不使用 alpha 的纹理也存为四通道,会多占 33% 的 GPU 内存。

行间距也值得理解。PNG 与 PPM 中每行以 폭 × 채널 수 字节紧密相连,但图形 API 的帧缓冲通常会把每行对齐到 4 字节或其倍数。因此真实行间距(stride)可能大于按宽度计算的值。忽略差异读取时,每行会逐渐错位,图像看起来像沿对角线倾斜。

在实际项目中

编写渲染器时最先遇到的两种缺陷都来自这一层。第一种是图像上下颠倒:OpenGL 帧缓冲以左下角为原点,而图像格式以左上角为原点,读取保存时不翻转就会倒置。第二种是图像沿对角线错位,几乎总是行长度(stride)计算错误,在宽度 × 通道数与真实行间距不一致时发生。

它也适用于调试。着色器结果异常时,把中间值(法线、深度、UV)直接映射为颜色并导出图像,是最快的诊断方式。因此手边应有一个能把“当前数字数组写成文件”的函数。

还要注意颜色值的含义。文件中的 0~255 通常是经过伽马校正的值,并不与光强成正比。直接平均两个整数色值,得到的并不是光强意义上的中间值。本实验可以暂时忽略,但以后照明结果显得灰暗时,应优先怀疑这里。

下一实验要做什么

先手写 PPM 生成 4×4 图像,再扩展为 256×256 渐变,并仅使用 zlibstruct 把同一组像素写成 PNG。之后在 pod 内启动小型 HTTP 服务器,通过网页预览自己的图像。最后画出圆和直线,并核对图像中读取到的值是否与写入值一致。