12.3 纹理过滤与 mipmap

纹理被缩小时,一个像素覆盖多个 texel,采其中一个必然走样。解法是预先造好一整串减半的 mip 链,再按屏幕足迹选级。本节用整数盒式平均构造 32→1 的六级链,用 uv 的有限差分算出每像素 LOD,量出最近邻、双线性与 mip 三种过滤的差异,并用「选错一级」的负例验证判据确实有区分力。

12.3 纹理过滤与 mipmap

12.1 把 UV 落到了 texel 上,12.2 把 UV 正确地插值到了每个像素。剩下最后一个问题:落在 texel 上的那一下该取什么颜色。texel 和像素的数量从来对不上,一个像素可能只盖住半个 texel(放大),也可能一次跨过十几个 texel(缩小)。直接取最近的那个 texel,在放大时是块状色斑,在缩小时是闪烁的噪点。本节解决后者,用的工具是 mipmap。纹理在显存里的排布与驻留策略属于 纹理与显存 的话题,这里只做一件事:把过滤与选级全部写成整数算式。

缩小是最难的方向

放大时,屏幕上一个像素小于一个 texel,双线性插值把四个相邻 texel 按距离加权混合,结果平滑,代价是纹理看起来变糊——这是可接受的取舍。缩小时性质完全不同:屏幕上一个像素要代表纹理上一片区域,此时取任何一个 texel 都是「用一点代表一片」,而第 11 章的采样定理早就说过,采样率低于信号频率两倍时高频成分会折叠成低频——画面上表现为摩尔纹与闪烁,而且摄像机一动就剧烈跳动。

本节用的场景把这个问题放到最大:12.2 的四边形上,UV 从 0 铺到 8(同一张纹理重复 8 遍),纹理是 32×32 的高频棋盘,于是屏幕横向要显示 $32 \times 8 = 256$ 个 texel。近端横向跨约 84 个像素要装下这 256 个 texel,一个像素平均代表 3 个 texel;越往远走压缩越狠,实测逐像素足迹从 3.02 一路涨到 15.00,中位数 4.22。这种情况下最近邻采样出来的结果基本是噪声。

mip 链:一整串预先缩小的纹理

解法朴素得几乎不像技巧:既然问题出在「一次要看好大一片」,那就把这一片提前平均好。mipmap 把原纹理不断减半,形成一串金字塔:

$$\text{L0}=32 \to \text{L1}=16 \to \text{L2}=8 \to \text{L3}=4 \to \text{L4}=2 \to \text{L5}=1$$

每一级由上一级的 $2 \times 2$ 邻域平均而来。这一步在整数域里干净得不能再干净:

$$c_{i,j} = \left\lfloor \frac{c_{2i,2j} + c_{2i,2j+1} + c_{2i+1,2j} + c_{2i+1,2j+1}}{4} \right\rfloor$$

也就是 (a + b + c + d) >> 2。没有浮点、没有除法、没有权重表。mipmap 额外的显存开销是 $1/3$——因为 $\sum 4^{-k} = 1/3$——换来的是任意缩小倍率下都能在一两级之内找到「texel 和像素差不多大」的那一级。

LOD 的定点选择

选哪一级,取决于屏幕足迹:这一像素在 uv 空间里跨了多远。对每个像素,用相邻像素的 uv 差分估计:

$$\text{footprint} = \max\left(\left|\frac{\partial u}{\partial x}\right|, \left|\frac{\partial v}{\partial y}\right|\right) \cdot n \quad (\text{单位:texel})$$

再取以 2 为底的对数,就得到层级:

$$\text{lod} = \lfloor \log_2(\text{footprint}) \rfloor$$

定点实现里 footprint 是 Q16.16 的 texel 数,所以 $\lfloor \log_2 \rfloor$ 可以用整数位长直接求:x.bit_length() - 1 - 16。不用浮点 log2,因为 log2(2^k) 在浮点下未必恰好返回 $k$,而位长是精确的。TypeScript 侧没有 bit_length,改用循环折半(while (t >= 2) t = Math.floor(t / 2)),对 $x < 2^{53}$ 同样是精确的。

可运行产物:六级 mip 链与逐像素 LOD

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_12_3_mip.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_12_3_mip.py
"""12.3 纹理过滤与 mipmap:最近邻 / 双线性 / mip 链的整数构造与定点 LOD 选择。

纹理换成 32x32 的高频棋盘(每 2x2 texel 一格),uv 在 12.2 的四边形上铺 8 遍,
于是屏幕足迹跨过若干 texel —— 这正是 mip 链存在的理由。

  mip 链:下一级 = 上一级 2x2 盒式平均,`(a + b + c + d) >> 2`,全整数
  LOD   :由 uv 缓冲的有限差分给出每像素足迹 footprint(Q16.16 的 texel 数),
          lod = floor(log2(footprint)) - 16,用整数位长求(不用浮点 log)

逐像素的 uv 由 cg_raster_12_3_uvbuf.rasterize_uv 给出(与 12.2 同源的透视校正)。

产物:config/cg_raster_12_3_texture.json、config/cg_raster_12_3_scene.json、
      out/cg_raster_12_3_tex.png、out/cg_raster_12_3_levels.png、
      out/cg_raster_12_3_{nearest,bilinear,mipmap,lodOff}.{rgb,png}、
      out/cg_raster_12_3_lod.csv、out/cg_raster_12_3_filter.json。
"""

import base64
import hashlib
import json
import os
import sys

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
from cg_raster_12_3_uvbuf import rasterize_uv  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE, TEXN, UV_TILES, MONT = cglib.ONE, 32, 8, 34


def make_fine(n):
    """程序化高频棋盘:每 2x2 texel 一格,再叠一层水平色调。全整数。"""
    tex = np.zeros((n, n, 3), dtype=np.int64)
    for ty in range(n):
        for tx in range(n):
            base = (236, 230, 202) if (((tx >> 1) + (ty >> 1)) & 1) == 0 else (44, 52, 84)
            t = (tx * 40) // (n - 1)
            tex[ty, tx] = (base[0], base[1] - t // 2, base[2] + t)
    return tex


def build_mips(tex):
    """2x2 盒式平均:`(a+b+c+d) >> 2`。尺寸一直减半到 1x1。"""
    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 sample_nearest(tex, n, u, v):
    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):
    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)),整数位长求法(x >= 1),不用浮点 log。"""
    return int(x).bit_length() - 1


def compute_lod(uv, covered, W, H, n, levels):
    """逐像素 LOD:足迹 = max(|du/dx|, |dv/dy|) * n(Q16.16 的 texel 数)。"""
    lod_buf = np.zeros((H, W), dtype=np.int64)
    hist, max_lod = [0] * levels, 0
    for py in range(H):
        for px in range(W):
            if not covered[py, px]:
                continue
            if px + 1 < W and covered[py, px + 1]:
                du = uv[py, px + 1, 0] - uv[py, px, 0]
            else:
                du = uv[py, px, 0] - uv[py, px - 1, 0]
            if py + 1 < H and covered[py + 1, px]:
                dv = uv[py + 1, px, 1] - uv[py, px, 1]
            else:
                dv = uv[py, px, 1] - uv[py - 1, px, 1]
            fp = max(abs(du), abs(dv)) * n
            lod = max(0, min(levels - 1, ilog2(fp) - 16)) if fp >= 1 else 0
            lod_buf[py, px] = lod
            hist[lod] += 1
            max_lod = max(max_lod, lod)
    return lod_buf, hist, max_lod


def sha8(arr):
    return hashlib.sha256(np.ascontiguousarray(arr, dtype=np.uint8).tobytes()).hexdigest()


def dump(folder, name, obj):
    with open(os.path.join(ROOT, folder, name), "w", encoding="utf-8") as fh:
        json.dump(obj, fh, ensure_ascii=False, indent=2)


def main():
    tex = make_fine(TEXN)
    chain = build_mips(tex)
    cfg = {"size": TEXN, "tiles": UV_TILES,
           "textureBase64": base64.b64encode(tex.astype(np.uint8).tobytes()).decode("ascii"),
           "textureSha256": sha8(tex),
           "levelSizes": [int(l.shape[0]) for l in chain],
           "levelSha256": [sha8(l) for l in chain]}
    dump("config", "cg_raster_12_3_texture.json", cfg)

    # 复用 12.2 的四边形几何,只把 uv 铺 8 遍
    with open(os.path.join(ROOT, "config", "cg_raster_12_2_texquad.json"), encoding="utf-8") as fh:
        q = json.load(fh)
    W, H, BG = q["width"], q["height"], q["backgroundQ8"]
    verts = [dict(v) for v in q["vertices"]]
    for v in verts:
        v["uQ16"] *= UV_TILES
        v["vQ16"] *= UV_TILES
    dump("config", "cg_raster_12_3_scene.json",
         {"name": "uv-tile-quad", "width": W, "height": H, "backgroundQ8": BG,
          "textureSize": TEXN, "vertices": verts, "triangles": q["triangles"]})

    uv, covered, culled = rasterize_uv(verts, q["triangles"], W, H)
    lod_buf, hist, max_lod = compute_lod(uv, covered, W, H, TEXN, len(chain))
    last, npx = len(chain) - 1, int(covered.sum())

    def render(sampler):
        img = np.zeros((H, W, 3), dtype=np.int64)
        img[:, :] = BG
        for py, px in zip(*np.nonzero(covered)):
            img[py, px] = sampler(int(uv[py, px, 0]), int(uv[py, px, 1]), int(lod_buf[py, px]))
        return img

    def lv(i):
        return chain[min(last, i)]

    imgs = {"nearest": render(lambda u, v, l: sample_nearest(chain[0], TEXN, u, v)),
            "bilinear": render(lambda u, v, l: sample_bilinear(chain[0], TEXN, u, v)),
            "mipmap": render(lambda u, v, l: sample_nearest(lv(l), lv(l).shape[0], u, v)),
            "lodOff": render(lambda u, v, l: sample_nearest(lv(l + 1), lv(l + 1).shape[0], u, v))}

    def err(a, b):
        mse = ((a - b) ** 2).sum() / (npx * 3)
        return np.abs(a - b).sum() / (npx * 3), 10 * np.log10(255 * 255 / mse)

    out = os.path.join(ROOT, "out")
    cglib.save_png(os.path.join(out, "cg_raster_12_3_tex.png"), tex)
    mont = np.zeros((TEXN, len(chain) * MONT, 3), dtype=np.int64)
    for i, level in enumerate(chain):
        rep = TEXN // level.shape[0]
        mont[:, i * MONT:i * MONT + TEXN] = np.repeat(np.repeat(level, rep, 0), rep, 1)
    cglib.save_png(os.path.join(out, "cg_raster_12_3_levels.png"), mont)

    sha = {}
    for name, img in imgs.items():
        sha[name] = sha8(img)
        with open(os.path.join(out, f"cg_raster_12_3_{name}.rgb"), "wb") as fh:
            fh.write(np.clip(img, 0, 255).astype(np.uint8).tobytes())
        cglib.save_png(os.path.join(out, f"cg_raster_12_3_{name}.png"), img)

    cglib.write_csv(os.path.join(out, "cg_raster_12_3_lod.csv"), ["lod", "pixels", "permille"],
                    [[l, hist[l], hist[l] * 1000 // npx] for l in range(len(chain))])

    mae_nb, psnr_nb = err(imgs["nearest"], imgs["bilinear"])
    mae_mn, psnr_mn = err(imgs["mipmap"], imgs["nearest"])
    mae_off, _ = err(imgs["lodOff"], imgs["mipmap"])
    stats = {"canvas": [W, H], "textureSize": TEXN, "tiles": UV_TILES, "levels": len(chain),
             "levelSizes": cfg["levelSizes"], "levelSha256": cfg["levelSha256"],
             "covered": npx, "culled": culled, "maxLod": max_lod, "lodHistogram": hist,
             "maeNearestVsBilinear": round(float(mae_nb), 6),
             "psnrNearestVsBilinear": round(float(psnr_nb), 4),
             "maeMipVsNearest": round(float(mae_mn), 6),
             "psnrMipVsNearest": round(float(psnr_mn), 4),
             "maeOffByOneVsMip": round(float(mae_off), 6), "sha256": sha}
    dump("out", "cg_raster_12_3_filter.json", stats)

    print(f"纹理           : {TEXN}x{TEXN},uv 铺 {UV_TILES} 遍;sha256 {cfg['textureSha256']}")
    print(f"mip 链         : {len(chain)} 级 {cfg['levelSizes']}")
    for i, h in enumerate(cfg["levelSha256"]):
        print(f"  L{i}: {h}")
    print(f"覆盖像素       : {npx}  剔除 {culled}  最大 lod {max_lod}  LOD 直方图 {hist}")
    print(f"最近邻 vs 双线性: MAE {mae_nb:.4f}  PSNR {psnr_nb:.2f} dB")
    print(f"mip  vs 最近邻  : MAE {mae_mn:.4f}  PSNR {psnr_mn:.2f} dB")
    print(f"负例(lod+1)vs mip: MAE {mae_off:.4f}  ← 选错一级必须被量出来")
    for k, v in sha.items():
        print(f"sha256 {k:8s}: {v}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑输出:

mip 链         : 6 级 [32, 16, 8, 4, 2, 1]
  L1: b531547ff7f66f185089439d917beb54a214689b2a9876a4720bd1a27384400c
  L2: 3a12ddd7ec98f536ee7920c12a97657491184ab716a79cf67e1c2c91ffc8ee34
  L5: 35a36ca1697276045d8b71aab6a36d47bf4d8c357bb4983224aabd99fb3bffe6
覆盖像素       : 2426  剔除 0  最大 lod 3  LOD 直方图 [0, 1135, 850, 441, 0, 0]
最近邻 vs 双线性: MAE 36.1675  PSNR 14.62 dB
mip  vs 最近邻  : MAE 43.3949  PSNR 12.50 dB
负例(lod+1)vs mip: MAE 38.9710  ← 选错一级必须被量出来
sha256 nearest : 969c687a3c359df08bb64792bd0ed6b69dbcb56d72136c6574e8ef433adbf876
sha256 bilinear: a86a2f0d2ca3ba6cc8534f45e1378d42203c9e7e15715d912cabad8df50d557d
sha256 mipmap  : e100668dddd537cea0d55c4b861983be8b28bf665247899391825faa3bb0bdbc

LOD 直方图里 lod 0 一个像素都没有:整块四边形都在缩小,最近的那一级也已经是 1。分布是 1135 / 850 / 441,对应 467‰ / 350‰ / 181‰——近端用 L1(16×16),中段用 L2(8×8),远端用 L3(4×4)。这正是 mipmap 想要的:足迹随距离增长,层级随之增长,且始终落在「texel 与像素尺寸相当」的范围内。lod 4 与 lod 5 是 0,说明这块场景还没缩到需要 2×2 或 1×1 的程度——金字塔多出来的两级是给更远的几何准备的,不参与本次计算。

跨语言断言

TypeScript 侧重算整条 mip 链与逐像素 LOD,比对每一级的 sha256 与四张输出缓冲:

// snippet: lantern/core/src/lantern/cg_raster_12_mip.ts(节选)
/** 2x2 盒式平均,尺寸减半到 1x1。与 Python build_mips 逐字对齐。 */
export function mipBuild(tex: Uint8Array, n: number): MipLevel[] {
  const chain: MipLevel[] = [{ n, data: tex }];
  let cur = tex;
  let size = n;
  while (size > 1) {
    const half = size >> 1;
    const next = new Uint8Array(half * half * 3);
    for (let y = 0; y < half; y += 1) {
      for (let x = 0; x < half; x += 1) {
        for (let c = 0; c < 3; c += 1) {
          const s = cur[(y * 2 * size + x * 2) * 3 + c] + cur[(y * 2 * size + x * 2 + 1) * 3 + c]
                  + cur[((y * 2 + 1) * size + x * 2) * 3 + c] + cur[((y * 2 + 1) * size + x * 2 + 1) * 3 + c];
          next[(y * half + x) * 3 + c] = Math.floor(s / 4); // (a+b+c+d) >> 2
        }
      }
    }
    chain.push({ n: half, data: next });
    cur = next;
    size = half;
  }
  return chain;
}

/** floor(log2(x)),循环折半求法;x < 1 时返回 -1(与 Python 的 bit_length()-1 同义)。 */
export function mipLog2(x: number): number {
  let b = -1;
  let t = x;
  while (t >= 1) {
    t = Math.floor(t / 2);
    b += 1;
  }
  return b;
}
✔ 纹理与 mip 链每一级的 sha256 都与 Python 侧逐字节相同
✔ 覆盖像素数与 LOD 直方图与 Python 侧一致
✔ 【核心】最近邻 / 双线性 / mip / 错一级 四张图与 Python 侧逐字节相同
✔ 【负例】mip 选级错一级必须被量出来,且超出容差 MAE 1.0
ℹ tests 4  ℹ pass 4  ℹ fail 0

三种过滤差多少,以及判据为什么可信

同一份场景、同一个采样器、同一个 LOD 缓冲,只换过滤方式,实测:

  • 最近邻 vs 双线性:MAE 36.1675,PSNR 14.62 dB。两者都在 L0 上采,差别只在「取一个」还是「按距离混合四个」。差这么多说明在一个像素要代表 3 个 texel 的缩小下,双线性那点平滑根本不够——它只是把噪声从「跳变」变成「略小的跳变」。
  • mip vs 最近邻:MAE 43.3949,PSNR 12.50 dB。这才是数量级的差别:一个采的是 32×32 上的孤立 texel,另一个采的是 8×8 上的平均值,两者代表的高频内容完全不同。
  • 负例(选错一级,lod+1)vs mip:MAE 38.9710。这个数字是判据的自我检验:如果哪天 LOD 计算里把 ilog2(fp) - 16 写成 ilog2(fp) - 15,画面只是「稍微更糊一点」,肉眼很难分辨,但 MAE 会从 0 跳到 39,远超容差 1.0,判据立刻报警。

这三条差异都远大于 1.0,说明本节用的 MAE/PSNR 判据对「过滤方式变了」这件事有充分区分力。反过来说,如果某个改动让这些数字几乎不动,那要么改动确实无害,要么场景选得太温和——这也是为什么本节把 uv 铺 8 遍、把纹理做成高频棋盘:把要观测的效应放大到判据能看见的程度,是设计测试场景的一部分。下一卷会把 mip 链的构造搬到硬件纹理对象上,并用各向异性过滤处理「一个方向缩得多、另一个方向缩得少」的情形——那是 LOD 取一个数所不能覆盖的。

阅读导航:上一节:12.2 透视校正插值 · 下一节:13.1 定点与浮点的分歧 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 14.1 深度图与法线图