一张图左右颠倒,错误可能只是一行数组索引;颜色发暗,原因也可能发生在写文件之前。只检查程序退出码,不能判断这两类错误。图形学实验的第一个可用结果,是一张能追查每个色块来源的图,以及一组独立于画面观感的检查。

这个实验生成一张 16×16 的非对称小图。左上、右上、左下、右下分别是红、绿、蓝、黄,内部有黑红交替的竖条纹,绿色分量向下分四级增加,坐标 (3,5) 放一个品红标记。它没有相机、光源或三维模型,全部颜色由整数坐标直接决定。

程序生成的16×16诊断图,左上红、右上绿、左下蓝、右下黄;品红点位于3,5

图为程序输出的 PNG 转换版,以最近邻方式放大显示。放大的方块用于检查样本排列,不能据此推断真实显示器的发光结构。原始数据也可下载:diagnostic.ppm

像素数组先确定什么

(x,y) 给一个样本编号,需要先规定原点与方向。这里原点是图像左上角,横坐标向右增加,纵坐标向下增加,合法范围为 0≤x<160≤y<16。这些是图像存储约定,不代表今后的三维世界也必须让 Y 向下。

一个 RGB 样本保存红、绿、蓝三个通道。本篇每个通道是 0 到 255 的整数码值,0 表示相应通道的低端,255 表示高端。红色标记是 (255,0,0),不能把它写成 BGR 再期待查看器替程序纠正。

把二维图像放进一维数组时,按行排列。宽为 WW,位置 (x,y) 对应的像素索引为

i=yW+x.i=yW+x.

这里 ii 的单位是“像素条目”。若把 RGB 三通道铺成同一个标量数组,红通道的位置才是 3(yW+x)3(yW+x),绿色与蓝色再分别加 1 和 2。像素索引、通道索引和字节偏移是三个不同量;只有通道恰好用单字节保存时,后两者的数字才会相同。

(3,5),像素索引为 5×16+3=83,RGB 展平后的通道索引为 249、250、251。完整图像有 256 个 RGB 条目、768 个通道值。用这些计数检查输出,比从图片是否“大致完整”推断数组是否正确更可靠。

配套程序的 Rgb8 使用 std::array<int,3>。名称表示通道的合法数值范围是 8 位,不表示对象在内存中只占三个字节;当前使用 int,便于发现越界数值。std::vector<Rgb8> 拥有像素存储,退出作用域时自动释放,无需手工 newdelete。访问函数返回条目的引用,因此 image.at(3,5)={255,0,255} 修改的就是数组里的那一项。

为什么用不对称的图案

如果四角颜色相同,整幅图上下翻转以后仍可能看起来正确。居中的圆和棋盘格也常有这种问题。诊断图需要让错误改变可识别的东西:四角区分翻转,条纹区分轴与通道,偏心标记区分转置。

实验里的普通样本按下式产生,其中 / 是非负整数除法:

1
image.at(x,y) = {(x % 2) * 255, (y / 4) * 85, 0};

横坐标的奇偶性决定红通道,纵坐标所在的四行分组决定绿通道。因而 (8,8) 应为 (0,170,0)(9,8) 应为 (255,170,0)。四角和品红点随后覆盖基础图案,检查这些点时必须采用覆盖后的值。

颜色表面上能表示方向,数值也能表示方向。把写出顺序从 y=0..15 改成 y=15..0,左下蓝点就会出现在文件的第一行。这个反例不涉及投影矩阵或 GPU,可以直接从文件定位。面对更复杂的渲染错误,也应先寻找能区分候选原因的输入,而不急于加载更复杂的模型。

从整数到 PPM 文件

Netpbm 的 PPM 格式说明区分普通二进制形式 P6 和文本形式 P3。实验采用 P3,开头是:

1
2
3
4
5
P3
16 16
255
255 0 0
255 0 0

前三行说明类型、宽高和最大码值。后面的数字按从上到下、每行从左到右的顺序组成 RGB 三元组。文本换行主要方便阅读,不能把“文本的一行”直接当成“图像的一行”:本程序每个像素写一行,因此会有 256 行像素数据。

写出函数保留了最容易检查的形态:

1
2
3
out << "P3\n" << width_ << ' ' << height_ << "\n255\n";
for (const auto& pixel : pixels_)
out << pixel[0] << ' ' << pixel[1] << ' ' << pixel[2] << '\n';

十进制字符 255 占多个文本字节,所以不能根据“256 个像素,每个三个通道”就断定 P3 文件只有 768 字节。768 是通道值的数量,实际文件还有数字字符、空格、换行与头部。P6 能减少这种文本开销,但首篇优先让数据可以直接阅读。

PPM 规范对颜色还有语义要求:严格 PPM 使用指定的非线性 BT.709 样本;实际工具中也常见 sRGB 或线性值变体。本图只验证坐标与通道,故明确称为诊断码值图,不用截图证明物理亮度正确。第 04 篇会保存独立的线性浮点数据,并区分色调映射与 sRGB 输出编码。

一张黑白各占一半的图,其码值平均可以算出 127.5;这个数字是否代表一半光强,还取决于编码。存储格式把数写对,只解决传输的一层问题。

编译并运行最小程序

完整源码位于仓库的 examples/computer-graphics/。在仓库根目录运行:

1
make -C examples/computer-graphics check

Makefile 使用 clang++ -std=c++17 -O2 -Wall -Wextra -Wpedantic,本次实测是 Apple Clang 21、ARM64、macOS 27.0。其他符合 C++17 的编译器可以通过 make CXX=g++ 选择,但这里没有把未运行的系统列为兼容性已验证。-O2 启用优化;当前图太小,没有进行耗时或加速比比较。

make check 先构建生成程序与独立检查器,运行第 00 个实验,再检查帮助命令和非法参数。单独生成另一份文件时,使用:

1
examples/computer-graphics/build/graphics 00 /tmp/diagnostic.ppm

输出路径是显式参数。图像写进文件,终端只收到状态或错误,不会把“开始渲染”混到 PPM 数字中。std::ofstream 开启了写入异常,关闭时也检查失败;路径不能打开或写入失败时,程序返回非零。

程序的 try/catch 将错误信息写到标准错误流。正常返回 0 只说明这次程序没有报告失败,还需要检查文件。对大型渲染任务,这种区别尤其重要:写出成功与图像正确是不同验收条件。

检查器为什么独立读取输出

如果生成程序写完后只打印“完成 256 像素”,它可能只是打印了循环上限,实际文件却少了一行。检查器重新打开已经写出的 PPM,解析头部、读取通道、检查额外数据,并核对约定样本。预期值来自诊断图定义,不调用生成图案的函数重新生成一遍。

本次运行得到:

1
2
PASS P3 16x16 max=255 channels=768 corners=4 marker=(3,5) stripe=(8,8)
PASS rejected zero dimensions, x=16, x=-1

其中四角检查覆盖了首像素、首行末像素、末行首像素和尾像素。品红点定位偏心标记,内部绿值检查条纹生成。检查器还验证构造宽度为零会失败,访问 x=16x=-1 都会抛出越界异常。

只检查线性索引是否小于数组长度并不够。例如 (16,0) 算出的索引是 16,仍位于数组内,却错误地指向下一行开头。因此 at(x,y) 先检查两个轴的范围,再计算索引。合法存储位置不一定是合法二维坐标,这一判断也适用于纹理访问和网格索引。

检查器只实现本程序使用的无注释 P3 子集。它没有宣称能读完 Netpbm 的所有合法格式;把它当通用图像库会扩大未经验证的边界。

文件正确以后,还要检查显示

macOS 自带的 sips 在本机成功读取了生成的 PPM,并转成 PNG:

1
sips -s format png /tmp/diagnostic.ppm --out /tmp/diagnostic.png

转换保留了 16×16 尺寸。浏览器中显示 PNG,是为了让博客读者无需安装 PPM 插件即可查看。原始 PPM 与转换版一起保留,可以区分“源文件错了”和“转换或显示过程改变了画面”。

图像放大到 320×320 时,一个源样本占据 20×20 的 CSS 区域。这里指定最近邻式显示,便于数格子;若使用平滑缩放,查看器会在相邻样本之间插值,竖条纹边缘可能出现中间颜色。那些额外颜色未必存在于源数组。

PBRT 第四版的采样理论把连续图像函数、离散样本与显示重建分开讨论。数组里的一个 RGB 条目不必对应显示器的一个物理发光单元,图像缩放已经给出了反例。后续抗锯齿实验必须同时保留原始样本和显示条件。

三维图像会增加哪些步骤

同一套写文件代码可以接收不同算法给出的像素。软件光栅器从三角形出发,经过模型与相机变换、裁剪、屏幕覆盖测试、深度比较和着色,把可见样本写进数组。光线追踪器从相机样本发出射线,求最近交点,再计算表面收到和反射的光。两条路径可以使用同一个输出模块,颜色是如何得到的却不同。

几何模块负责表示与修改形状。动画模块让场景状态随时间变化;物理模拟还需要规定力、质量与积分步长。把某一帧冻结后,渲染器仍然要完成可见性和颜色计算。一个物体能移动,并不说明它的材质、遮挡或光照已经算对。

CPU 实验采用 C++17,从当前小程序累计演进。GPU 实验采用 JavaScript、WGSL 与 WebGPU,需要另外验证设备、资源布局与浏览器交互。环境预检已经在 Chrome 153 上取得非回退的 Apple/Metal 适配器,并执行一次整数计算、回读 [0,1,4,9];它证明当前浏览器可以提交这次 GPU 工作,三角形绘制仍由第 25 篇单独验收。

练习与自检

练习一:索引与单位。 一张宽 7、高 3 的 RGB 图,样本 (5,2) 的像素索引是多少?若每通道用一个字节,红通道的字节偏移是多少?将 Rgb8 改成三个 int 后还能直接复用这个字节偏移吗?

答案:像素索引为 2×7+5=19。紧密排列的单字节 RGB 数组里,红通道偏移为 3×19=57。三个 int 的对象布局不是三个单字节,不能继续把 57 当内存字节偏移;序列化代码必须按存储协议写每个通道,不应直接倾倒对象内存。

练习二:损坏文件与翻转。 把输出头部改成 16 17,像素数据仍为原来的 256 组,会发生什么?若头部不变,只把 16 行图像的次序倒过来,计数检查能否发现?应该补什么断言?

答案:17 行需要 272 组 RGB、816 个通道,实际少 48 个通道,完整解析应在结束前发现截断。行序倒置仍有 768 个通道,计数不会失败;首像素应为红色、末行首像素应为蓝色等固定位置断言才会发现。不能以某个宽容查看器仍显示部分图像判定文件有效。

观察到的现象 优先核对的量 当前实验的判据
画面转置或翻转 原点、轴方向、行序 四角与 (3,5) 标记
颜色通道交换 RGB 写出次序 红绿蓝角点的整数值
图片末尾缺失 尺寸与通道数量 16×16×3=768
放大后出现新颜色 查看器重建方式 对照原始码值与最近邻显示

系列导航

当前篇为系列入口,前篇:无。下一篇:01:点、向量与矩阵怎样表达几何

系列按 00–04 数学与图像、05–11 软件光栅、12–16 几何、17–24 光追、25–28 GPU、29–32 动画、33–35 综合实验递进,另包含 E01–E08 全部研究选修。各篇将在实际验收后加入这里;未完成篇目不冒充可访问文章。

参考资料与复跑记录

配套目录:examples/computer-graphics/;原始运行输出:writing-plans/computer-graphics/evidence/00-cpu.txt;环境与浏览器能力:同目录 environment.md。本实验没有随机采样,未产生性能结论。所有文章与实验仅完成本地工作,不表示已发布。