TT Lab
开始
学习 学习路径 课程

着色器与 GPU 管线

把图案贴上去,再取出采样

在 TT Lab 中继续学习

目标

创建纹理,实现两种采样方式,并亲眼看到透视校正插值带来的差异。最后用数字确认缩小时产生的闪烁,以及 mipmap 如何消除它。

为什么重要

如果给每个顶点指定颜色,一个三角形就只有三种颜色。纹理把颜色从几何中分离出来,放在单独的图像里,只确定表面上的每个点指向该图像的哪个位置。这个坐标就是 UV,由光栅化器逐像素插值。

其中有两点特别重要。一是透视校正。如果直接在屏幕坐标上混合 UV,带透视的表面上图案会扭曲,因为屏幕上均匀的间隔在三维空间中并不均匀。二是缩小。当一个像素覆盖几十个纹素时,只挑其中一个无法代表图案,相机稍微一动,画面就会闪烁。mipmap 就是它的答案。

步骤

  1. 在 /root/texture 中放置工具箱。
  2. /root/texture/tex.py 和 out/checker.png — 棋盘格图案。
  3. /root/texture/sample.py — 最近邻采样。
  4. /root/texture/bilinear.py — 双线性采样。
  5. /root/texture/out/affine.png — 不做校正的插值。
  6. /root/texture/out/correct.png 和 diff.png — 透视校正。
  7. /root/texture/out/mip0.png、mip1.png、out/07-mip.txt — 缩小与 mipmap。

参考

放置绘图工具箱

按示例原样保存 /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 多用三分之一内存,换来的就是这种稳定性。