8.2 过滤与 mipmap 的构造
上一节把 $u$ 落到了 texel 上,但落完还有个更基本的问题没回答:一个像素到底该取几个 texel。当三角形在屏幕上只占几个像素、纹理却有几十格时,一个像素对应几十个 texel,挑其中一个就是抽样走样。本节处理这个问题。仍然和 纹理与显存 划清边界:那篇管纹理怎么存、怎么压、怎么驻留;本节管的是过滤核怎么算、mip 链怎么造、LOD 怎么选,全部在整数域。
链条上有三个独立决定:过滤核(取几个 texel、怎么加权)、金字塔(预先算好几级缩小版)、层级选择(这一像素该用哪一级)。三个都做对了,走样才压得住。
双线性:四个 Q16.16 权重,一次 >> 32
最近邻的规则是「$u$ 落在哪格就取哪格」,代价是纹理被放大时出现方块,缩小时剧烈闪烁。双线性把它换成四个相邻 texel 的加权平均。关键在采样点的对齐:texel 的中心不在格点 $i / n$,而在 $(i + 0.5) / n$。把这件事写进公式,就是把 $u \cdot n$ 先减掉半个 texel:
$$f_u = u \cdot n - \tfrac{1}{2}, \qquad i_u = \lfloor f_u \rfloor, \qquad g_u = f_u - i_u$$
$g_u, g_v \in [0, 1)$ 是两个分数位,四个权重就是互补对 $(1-g_u, g_u) \otimes (1-g_v, g_v)$。Q16.16 下它们是 ONE - gu、gu、ONE - gv、gv,四个权重之和恰好是 $1$——严格地说是 ONE * ONE,也就是 $2^{32}$。
这一点决定了降精度只能做一次:
$$\text{out} = \Big(\sum_{k} c_k \cdot w_k\Big) \gg 32$$
如果偷懒写成「先算 $u$ 方向的两个加权和并 >> 16,再对结果 >> 16」,那会在每个中间项上各丢一次低位,四个 texel 各损失最多 1 个色阶,累加起来就是可见的偏暗。累加式的单次 >> 32 只在最后丢一次。逐像素层的量级是 $\le 2^{48}$,离 $2^{53}$ 还远,用 Number 承载就够了。
TypeScript 侧还有一处陷阱:>> 32 在 JS 里没有意义(移位只对 32 位有效),必须显式写成除法:
// snippet: lantern/core/src/lantern/cg_shade_08_filter.ts
export function sampleBilinear(tex, n, u, v) {
const fu = u * n - F_ONE / 2; // texel 中心在 (i + 0.5) / n
const fv = v * n - F_ONE / 2;
const iu = Math.floor(fu / F_ONE); // 不用 `>> 16`:JS 的移位只对 32 位有效
const iv = Math.floor(fv / F_ONE);
const gu = fu - iu * F_ONE; // 两个 Q16.16 的分数位
const gv = fv - iv * F_ONE;
const a = F_ONE - gu, b = gu, c = F_ONE - gv, d = gv;
const x0 = iu & (n - 1), x1 = (iu + 1) & (n - 1); // n 为 2 的幂,位与即回绕
const y0 = iv & (n - 1), y1 = (iv + 1) & (n - 1);
for (let ch = 0; ch < 3; ch += 1) {
const s = tex[(y0 * n + x0) * 3 + ch] * a * c
+ tex[(y0 * n + x1) * 3 + ch] * b * c
+ tex[(y1 * n + x0) * 3 + ch] * a * d
+ tex[(y1 * n + x1) * 3 + ch] * b * d;
out[ch] = Math.floor(s / 4294967296); // 四个权重累加后一次 >> 32
}
return out;
}
有一个可以直接验的推论:当 $u$ 正好落在 texel 中心 $(i + 0.5)/n$ 时,$g_u = g_v = 0$,双线性必须逐位等于该点的最近邻。内核的自检里就有这一条($n = 8$,$u = v = 0.4375 = (3 + 0.5)/8$,两者都给出 [177, 141, 240])。这条等式一旦不成立,说明中心对齐或权重配对写错了。
mip 链:逐级 (a + b + c + d) >> 2
双线性解决了放大,但解决不了缩小:$u$ 每像素跨 8 个 texel 时,双线性只看 2 个,剩下 6 个的信息全丢了,画面照样闪。标准做法是预先造一串缩小版——mip 链,第 $L$ 级的尺寸是 $n / 2^L$,直到 $1 \times 1$。
构造就是逐级做 $2 \times 2$ 盒式平均:
$$\text{next}[y, x] = \big(\text{cur}[2y, 2x] + \text{cur}[2y, 2x+1] + \text{cur}[2y+1, 2x] + \text{cur}[2y+1, 2x+1]\big) \gg 2$$
本节用的 $32 \times 32$ 纹理造出 6 级,尺寸 [32, 16, 8, 4, 2, 1]。每一级的 sha256 都落盘了,是跨宿主比对的抓手之一。
尺寸是 2 的幂时这条式子干净利落,但奇数尺寸要做一个决定:下一级是 $\lceil n/2 \rceil$ 还是 $\lfloor n/2 \rfloor$?前者要钳位索引(最后一行/列参与两次平均),保留全部数据;后者直接丢掉最后一行/列。这不是理论问题——$n$ 是奇数就一定会遇到,$32 \to 16 \to 8 \to 4 \to 2 \to 1$ 全程偶数只是运气好。
把一条 $20 \times 20$ 的链两种做法都跑出来,逐级均值是:
| 级 | 尺寸 | 钳位均值 | 截断均值 | 差 |
|---|---|---|---|---|
| L0 | $20 \times 20$ | 126 | 126 | $+0$ |
| L1 | $10 \times 10$ | 126 | 126 | $+0$ |
| L2 | $5 \times 5$ | 125 | 125 | $+0$ |
| L3 | $3 \times 3$ | 127 | 124 | $+3$ |
| L4 | $2 \times 2$ | 129 | 124 | $+5$ |
前三级尺寸都是偶数,两种做法完全一致;从 $5 \times 5 \to 3 \times 3$ 开始分叉,越往下差得越多。截断版系统性偏暗,因为它丢掉了每一行/列里最靠右、最靠下的数据,而那些数据恰恰是哈希噪声里独立的一份。$+5$ 个色阶不算大,但它是单方向的漂移,不是噪声——mip 链的最粗几级会被用在最远、最平的表面上,那里正是大色块,偏暗最容易被看出来。
LOD:整数位长 + 分段线性
选级要算 $\log_2$ 的足迹。足迹的定义是「这一像素在纹理空间里跨了多少个 texel」,取两个屏幕方向里较大的那个:
$$\text{fp} = \max\big(|\partial u / \partial x|,, |\partial v / \partial y|\big) \cdot n$$
$\text{fp}$ 是 Q16.16 的 texel 数,$1$ 个 texel 就是 65536。整数部分用位长求,不碰浮点:int(x).bit_length() - 1 就是 $\lfloor \log_2 x \rfloor$。分数部分用分段线性近似——以 $2^b$ 为断点,断点之间按 $x / 2^b$ 线性插值:
$$\text{lod} = (b - 16) \ll 16 ;+; \big((\text{fp} - 2^b) \ll 16\big) \big/ 2^b$$
实测这个近似相对真 $\log_2$ 的最大偏差是 0.0860 级(在 $1.5$ 个 texel 处最差:近似给 $0.5$ 级,真值 $0.585$ 级)。0.086 级意味着纹理被多糊或少糊不到 9%,视觉上不可分辨,但换来的是全整数、可逐位复现。如果换成浮点 log2,这一步立刻变成不可跨宿主断言的。
本节场景里 LOD 的分布是 [0, 1135, 850, 441, 0, 0]:第 0 级是空的,说明纹理铺得足够密,2426 个覆盖像素里没有一个跨的 texel 少于 2 个;主力在第 1 级和第 2 级,最粗用到第 3 级。换句话说,如果没有 mip 链,这 2426 个像素全部要在第 0 级上硬采样——那就是下面要走样的量。
内核(S 层)
本节真正在跑的那段代码,落在 sim/cg_shade_08_2_kernel.py。它只含四个原语:噪声生成、mip 链(含奇数尺寸的两种做法)、采样(最近邻 / 双线性 / 三线性)、LOD(整数位长 + 分段线性)。驱动脚本负责场景、参考解与统计,不在正文里占篇幅。
#!/usr/bin/env python3
# file: lantern/sim/cg_shade_08_2_kernel.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_08_2_kernel.py
"""8.2 的内核:最近邻 / 双线性 / mip 链 / 三线性,全部在整数域完成。
被 sim/cg_shade_08_2_filter_mip.py 调用;单独成文件是为了让正文里的 `file:` 块
就是本节真正在跑的那段代码。
1. **双线性**:texel 中心在 (i + 0.5) / n,四个权重是 Q16.16 的互补对,累加后
`>> 32` 降精度(不是两次 `>> 16`,那会在每个中间项上各丢一次低位)。
2. **mip 链**:下一级 = 上一级 2x2 盒式平均 `(a+b+c+d) >> 2`。尺寸为 2 的幂时
干净利落;**奇数尺寸**要决定最后一行/列怎么办 —— 这里两种做法都给:`(n+1)//2`
且索引钳位(保留边界),对 `n//2` 截断(丢掉最后一行/列)。
3. **LOD 选择**:`floor(log2)` 用整数位长求(不用浮点 log);分数部分是分段线性
近似。本文件不含浮点。
"""
import os
import sys
import numpy as np
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib # noqa: E402
ONE = cglib.ONE
def make_noise(n, seed=0x0C7A):
"""程序化高频纹理:每 2x2 texel 共享一个整数哈希值(`cglib._hash_noise`)。"""
tex = np.zeros((n, n, 3), dtype=np.int64)
for ty in range(n):
for tx in range(n):
for ch in range(3):
tex[ty, tx, ch] = cglib._hash_noise(tx >> 1, ty >> 1, seed + ch) >> 8
return tex
def build_mips(tex):
"""2x2 盒式平均:`(a+b+c+d) >> 2`,尺寸减半到 1x1(n 为 2 的幂)。"""
chain, cur = [tex], tex
while cur.shape[0] > 1:
cur = (cur[0::2, 0::2] + cur[0::2, 1::2] + cur[1::2, 0::2] + cur[1::2, 1::2]) >> 2
chain.append(cur)
return chain
def build_mips_odd(tex, clamp):
"""奇数尺寸的 mip 链:clamp=True 保留边界((n+1)//2 且索引钳位),
clamp=False 直接 n//2 截断 —— 后者会丢掉最后一行/列。"""
chain, cur = [tex], tex
while cur.shape[0] > 1:
n = cur.shape[0]
m = (n + 1) // 2 if clamp else n // 2
idx = (np.minimum(2 * np.arange(m) + 1, n - 1) if clamp else 2 * np.arange(m) + 1)
cur = (cur[np.ix_(2 * np.arange(m), 2 * np.arange(m))]
+ cur[np.ix_(2 * np.arange(m), idx)]
+ cur[np.ix_(idx, 2 * np.arange(m))]
+ cur[np.ix_(idx, idx)]) >> 2
chain.append(cur)
return chain
def sample_nearest(tex, n, u, v):
"""最近邻:`(u * n) >> 16` 就是 floor(u * n),`& (n - 1)` 即 repeat 回绕。"""
iu, iv = ((u * n) >> 16) & (n - 1), ((v * n) >> 16) & (n - 1)
return [int(tex[iv, iu, ch]) for ch in range(3)]
def sample_bilinear(tex, n, u, v):
"""texel 中心在 (i+0.5)/n;四个 Q16.16 权重累加后 `>> 32` 降精度。"""
fu, fv = u * n - (ONE >> 1), v * n - (ONE >> 1)
iu, iv = fu >> 16, fv >> 16
gu, gv = fu - (iu << 16), fv - (iv << 16)
a, b, c, d = ONE - gu, gu, ONE - gv, gv
x0, x1 = iu & (n - 1), (iu + 1) & (n - 1)
y0, y1 = iv & (n - 1), (iv + 1) & (n - 1)
return [(int(tex[y0, x0, ch]) * a * c + int(tex[y0, x1, ch]) * b * c
+ int(tex[y1, x0, ch]) * a * d + int(tex[y1, x1, ch]) * b * d) >> 32
for ch in range(3)]
def ilog2(x):
"""floor(log2(x)):整数位长求法,不用浮点 log。"""
return int(x).bit_length() - 1
def lod_q16(fp):
"""log2(fp) 的 Q16.16 分段线性近似:以 2^b 为断点,断点间线性插值。"""
if fp < 1:
return 0
b = ilog2(fp)
return ((b - 16) << 16) + (((fp - (1 << b)) << 16) // (1 << b))
def trilinear(chain, lod, u, v):
"""相邻两级各双线性一次,再按分数位长插值。"""
l0 = max(0, min(len(chain) - 2, lod >> 16))
f = lod - (l0 << 16)
c0 = sample_bilinear(chain[l0], chain[l0].shape[0], u, v)
c1 = sample_bilinear(chain[l0 + 1], chain[l0 + 1].shape[0], u, v)
return [(c0[ch] * (ONE - f) + c1[ch] * f) >> 16 for ch in range(3)]
if __name__ == "__main__":
t = make_noise(8)
ch = build_mips(t)
c = ONE * 7 // 16 # u = 0.4375 = (3 + 0.5) / 8,正落在 texel 中心
print("8x8 链尺寸 :", [int(l.shape[0]) for l in ch])
print("链尾均值 :", [int(l.mean()) for l in ch])
print("texel 中心双线性 :", sample_bilinear(t, 8, c, c))
print("同点最近邻 :", sample_nearest(t, 8, c, c))
print("二者相等 :", sample_bilinear(t, 8, c, c) == sample_nearest(t, 8, c, c))
print("lod_q16(1 texel) :", lod_q16(ONE), " (2 texel):", lod_q16(2 * ONE))
print("lod_q16(1.5 texel):", lod_q16(3 * ONE // 2), " 真值 log2(1.5) = 0.585 级")
自检输出(/opt/homebrew/bin/python3 sim/cg_shade_08_2_kernel.py):
8x8 链尺寸 : [8, 4, 2, 1]
链尾均值 : [132, 132, 131, 131]
texel 中心双线性 : [177, 141, 240]
同点最近邻 : [177, 141, 240]
二者相等 : True
lod_q16(1 texel) : 0 (2 texel): 65536
lod_q16(1.5 texel): 32768 真值 log2(1.5) = 0.585 级
走样量:对 4×4 超采样参考解
走样不能靠看。本节用超采样参考解当标尺:每个覆盖像素在 L0 上取 $4 \times 4 = 16$ 个子样本双线性平均,得到的图当作「这一像素真正应该是什么颜色」,再让四种过滤各自与它算 MAE。
场景是 12.2 那张倾斜四边形($96 \times 72$),UV 铺 8 遍,覆盖 2426 像素(350‰)。四种过滤加一条负例:
| 模式 | MAE ↓ | 半 texel 抖动 ↓ | 耗时 |
|---|---|---|---|
nearest | 54.4117 | 2.8203 | 3.9 ms |
bilinear | 39.3904 | 2.5142 | 7.7 ms |
mipmap | 14.1450 | 1.2075 | 8.0 ms |
trilinear | 10.5613 | 0.5660 | 13.8 ms |
lodOff(负例) | 16.9960 | 0.3244 | 7.7 ms |
| 参考解($4 \times 4$) | — | — | 183.2 ms |
读这张表要注意三件事。第一,最近邻到 mipmap 的跨度是 3.8 倍(54.41 → 14.15),这就是「缩小时不建金字塔」的代价;双线性只把 54.41 压到 39.39,说明它在缩小时几乎不管用——过滤核再精致也补不了被丢掉的 texel。第二,三线性比 mipmap 再好 25%(14.15 → 10.56),代价是耗时涨 72%(8.0 → 13.8 ms),因为它要采两级。第三,抖动(半 texel 平移后的 MAE)不能当主判据:负例 lodOff 的抖动只有 0.3244,比正确的 mipmap(1.2075)还小——因为它选粗了一级、更糊,糊的东西平移一下当然更不变。所以主判据必须是「相对超采样参考解的 MAE」,抖动只能当辅证。
lodOff 是本节刻意留的负例:它和 mipmap 唯一的区别是层级选了 lod_i + 1,也就是整体偏粗一级。MAE 从 14.1450 变成 16.9960,差 2.85,超过 1.0 的容差判据——判据抓得住这类错误。任何让选级系统性偏移一级的实现缺陷(位长算错、分段线性断点错位、忘记减 16)都会落在这条负例的量级上。
跨宿主断言(P 层)
TypeScript 侧 core/src/lantern/cg_shade_08_filter.ts 与上面的 Python 内核逐字对齐,测试 core/test/cg_shade_08_2_filter.test.ts 有五条断言:
✔ 自描述纹理与 mip 链逐字节相同(含逐级 sha256)
✔ 【核心】覆盖数、LOD 直方图与整数 LOD 选择一致
✔ 【核心】五种过滤模式的像素缓冲 sha256 与 Python 侧逐字节相同
✔ 4x4 超采样参考解逐字节相同,且 MAE 表一致
✔ 负例:LOD 选错一级必须被 1.0 的容差判据抓住
ℹ tests 5 ℹ pass 5 ℹ fail 0
断言的对象是像素缓冲,不是 PNG 容器字节——PNG 里含时间戳与压缩实现细节,拿它做跨宿主比对是假精确。缓冲区比对上,五个模式加参考解共六个 sha256 全部相同,MAE 表逐项对齐到 $10^{-4}$ 以内(落盘时四舍五入到 4 位)。
最后一条负例还顺手记了一个反直觉的事实:lodOff 的抖动比 mipmap 小。测试里把这条写成断言(flickerHalfTexel.lodOff < flickerHalfTexel.mipmap),是为了把「抖动不是质量指标」这件事钉在代码里,而不是只写在正文里。
小结
三个决定各自独立:双线性管放大(中心对齐 + 单次 >> 32)、mip 链管缩小(逐级 >> 2,奇数尺寸要选保留还是截断)、LOD 管选级(整数位长 + 分段线性,偏差 0.086 级)。做全之后,走样量从 54.41 降到 10.56。但还有一个方向没处理:mip 链是各向同性的,两个方向一起减半;当足迹是细长条时,按大方向选级会糊掉小方向,按小方向选级又留下走样。下一节就做这件事。
阅读导航:上一节:8.1 纹理坐标与寻址模式 · 下一节:8.3 各向异性过滤 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。