把图案贴上去,再取出采样
目标
创建纹理,实现两种采样方式,并亲眼看到透视校正插值带来的差异。最后用数字确认缩小时产生的闪烁,以及 mipmap 如何消除它。
为什么重要
如果给每个顶点指定颜色,一个三角形就只有三种颜色。纹理把颜色从几何中分离出来,放在单独的图像里,只确定表面上的每个点指向该图像的哪个位置。这个坐标就是 UV,由光栅化器逐像素插值。
其中有两点特别重要。一是透视校正。如果直接在屏幕坐标上混合 UV,带透视的表面上图案会扭曲,因为屏幕上均匀的间隔在三维空间中并不均匀。二是缩小。当一个像素覆盖几十个纹素时,只挑其中一个无法代表图案,相机稍微一动,画面就会闪烁。mipmap 就是它的答案。
步骤
- 在
/root/texture中放置工具箱。 /root/texture/tex.py和out/checker.png— 棋盘格图案。/root/texture/sample.py— 最近邻采样。/root/texture/bilinear.py— 双线性采样。/root/texture/out/affine.png— 不做校正的插值。/root/texture/out/correct.png和diff.png— 透视校正。/root/texture/out/mip0.png、mip1.png、out/07-mip.txt— 缩小与 mipmap。
参考
- 运行
nohup python3 -m http.server 8080 -d /root/texture/out &后,在网页预览中打开http://localhost:8080/。并排查看 affine.png 与 correct.png,是本实验的关键场景。 - 常见错误 1:双线性采样时没有减去 0.5。纹素的中心位于
(i+0.5)/폭(占位符为宽度)处,漏掉这半格,图像就会偏移。 - 常见错误 2:对负 UV 用
int()截断。int(-0.3)为 0,而floor(-0.3)为 -1,会错开一格。
放置绘图工具箱
按示例原样保存 /root/texture/gfxlib.py,并用 /root/texture/check.py 绘制测试图案,生成 /root/texture/out/00-check.png。图案是在 64x64 黑色背景上,从 (0,0) 到 (63,63) 绘制白色(255,255,255) 对角线,再叠加从 (0,32) 到 (63,32) 的红色(255,0,0) 水平线。
从本实验开始不再重新实现 PNG 编码器。我们把第一个实验中手写的同类代码作为工具提供,因为这里要学习的不是文件格式。
实验 Pod 没有卷,上一个实验创建的文件不会保留。因此每个实验都从重新放置工具箱开始。
创建 Canvas(w, h, bg),用 line(x0, y0, x1, y1, rgb) 绘制两条线,再用 write_png(path) 保存。必须最后绘制水平线,交点 (32,32) 才会是红色。
本实验中用它把纹理输出为图像,并生成贴了图案的方块。
棋盘格纹理
在 /root/texture/tex.py 中设置 SIZE = 64、CELL = 8、LIGHT = (240,240,240)、DARK = (40,40,60),并编写按行优先存放 64x64 个纹素的 TEX 列表和 texel(i, j) 函数。格子的颜色在 ((i//CELL) + (j//CELL)) % 2 == 0 时为 LIGHT,否则为 DARK,texel 用 % SIZE 对索引做环绕。然后输出为 /root/texture/out/checker.png(64x64)。
由于按行优先存放,TEX[j * SIZE + i] 就是 (i, j) 处的纹素。这与前面课程中的像素排布规则相同。
texel 对索引做环绕,就是重复环绕模式。在这里确定一次,采样函数就不必再关心范围。
选择棋盘格,是因为它最容易看出扭曲和闪烁,所以长期以来一直被用作纹理相关的测试图像。
64x64 的图像配 8 像素的格子,横竖各有八个格子。
最近邻采样
在 /root/texture/sample.py 中编写 sample_nearest(u, v)。把 i = floor(u * SIZE)、j = floor(v * SIZE) 取整后,返回 texel(i, j)。范围之外的 UV 由 texel 的环绕处理。
使用 math.floor 并转换为 int。对负 UV 只用 int() 会向 0 截断,从而错开一格——int(-0.3) 为 0,而 floor(-0.3) 为 -1。
环绕由 texel 内部的 % SIZE 完成。Python 的 % 对负数也会给出非负的余数,所以直接就是重复模式。
u = 1.5 应得到与 u = 0.5 相同的结果,这意味着图案是重复的。
双线性采样
在 /root/texture/bilinear.py 中编写 sample_bilinear(u, v)。先令 x = u*SIZE - 0.5、y = v*SIZE - 0.5,再求出 i0 = floor(x)、fx = x - i0,按 fx、fy 的比例混合四个纹素 (i0,j0)、(i0+1,j0)、(i0,j0+1)、(i0+1,j0+1),返回混合后的三个实数。
减去 0.5 是关键。因为纹素的中心位于 (i+0.5)/SIZE。漏掉这半格,图像就会偏移半个纹素,不放大就很难察觉。
混合公式如下。
c00*(1-fx)*(1-fy) + c10*fx*(1-fy) + c01*(1-fx)*fy + c11*fx*fy
四个权重之和为 1,所以四个纹素颜色相同时,结果就是那个颜色。可以用这一点检验实现。
在恰好位于纹素中心的 UV(例如 0.5/SIZE)处,应当直接得到该纹素的颜色。
不做校正直接贴图
用 /root/texture/quad.py 给方块贴上图案,生成 /root/texture/out/affine.png(256x256,黑色背景)。四个顶点按屏幕坐标、w、uv 的顺序依次为 (30,40)/1.0/(0,0)、(226,90)/4.0/(1,0)、(226,166)/4.0/(1,1)、(30,216)/1.0/(0,1),三角形为 (0,1,2) 和 (0,2,3)。uv 直接用重心坐标插值,并用最近邻采样取色。
可以把 w 看作到相机的距离。右侧两个顶点的 w 为 4,意味着它们在更远的位置。
这一步故意忽略这一点,在屏幕上均匀地混合 uv,所以图案会扭曲。早期主机游戏中地面图案出现波动,就是这个原因。
由于方块被拆成两个三角形,扭曲的方向会在对角线处折断。这种折断是这种方式的典型症状。
重心坐标和遍历包围盒的做法,与前面课程中实现的相同。
透视校正插值
用插值 u/w、v/w、1/w,再用后者去除前两者的方式重新绘制同一个方块,生成 /root/texture/out/correct.png,并把两张图的像素差放大三倍,输出为 /root/texture/out/diff.png。
校正公式如下。
iw = b0/w0 + b1/w1 + b2/w2
u = (b0*u0/w0 + b1*u1/w1 + b2*u2/w2) / iw
变化的只是插值的对象,插值本身不变。要点是:在屏幕坐标中呈线性的量不是 uv,而是 u/w 和 1/w。
差异图像对每个通道计算 min(255, abs(a - b) * 3) 即可。放大三倍,是因为直接相减会很暗,不容易看清。
请并排查看两张图。未校正的一侧,图案在对角线处折断;校正后的一侧,则呈现出越远越密集的自然效果。
缩小与 mipmap
用 /root/texture/mip.py 创建 64x64 的一个纹素一格的棋盘((i+j)%2==0 时为 LIGHT,否则为 DARK),再通过 2x2 平均得到 32x32 的一级 mip。把这两张纹理分别在 128x128 的屏幕上以 u = (x+0.5)/128*5、v = (y+0.5)/128*5 做最近邻采样,生成 /root/texture/out/mip0.png 和 mip1.png,并把水平相邻像素的通道平均绝对差,以 energy0=、energy1=、level1_uniform=yes|no 的形式写入 /root/texture/out/07-mip.txt。
一个纹素一格的棋盘是频率最高的图案。只做一次 2x2 平均,两个亮纹素和两个暗纹素就会混合,所有纹素变成同一种颜色。所以 level1_uniform 为 yes。
能量按如下方式计算——对所有 y 以及 x=0..126,把 |픽셀(x+1,y) - 픽셀(x,y)|(占位符为像素值)的三个通道相加,再除以(个数 × 3)。
energy0 会超过 90,energy1 恰好为 0。这个差值就是闪烁的大小。相机稍微一动,energy0 一侧的图案会整个变掉,而 energy1 一侧保持不变。
mipmap 多用三分之一内存,换来的就是这种稳定性。