8.2 过滤与 mipmap 的构造

从最近邻到三线性:双线性用四个 Q16.16 权重累加后一次 >> 32 降精度;mip 链是逐级 (a+b+c+d) >> 2,奇数尺寸要决定最后一行列是保留还是截断;LOD 用整数位长求 floor(log2)。对 4×4 超采样参考解,最近邻 MAE 54.41、mipmap 14.15、三线性 10.56。

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$126126$+0$
L1$10 \times 10$126126$+0$
L2$5 \times 5$125125$+0$
L3$3 \times 3$127124$+3$
L4$2 \times 2$129124$+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 抖动 ↓耗时
nearest54.41172.82033.9 ms
bilinear39.39042.51427.7 ms
mipmap14.14501.20758.0 ms
trilinear10.56130.566013.8 ms
lodOff(负例)16.99600.32447.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 各向异性过滤 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cg」更多文章

  1. 16.3 交付卷三
  2. 16.2 三种消费的联调
  3. 16.1 着色管线的装配