12.1 UV 与纹理坐标

纹理在显存里是一张二维表,UV 就是把这张表贴到三角形上的那套坐标。本节把 uv 也做成 Q16.16 定点数,用整数移位把连续坐标落到具体 texel,并实现 repeat / clamp / mirror 三种寻址——它们分别是位与、钳位与向下取整的模。纹理不引入任何第三方素材,由整数棋盘与渐变程序化生成。

12.1 UV 与纹理坐标

第 11 章把「一个像素该不该涂」讲完了,但涂什么颜色还没定。从本节开始给三角形贴图。第一步是坐标:纹理在显存里是一张二维表,怎么把表上的点和三角形上的点对应起来?答案是一套归一化的二维坐标,记作 $(u,v)$,读作 UV。这里要和 纹理与显存 划清边界:那篇讲纹理在显存里的格式、压缩与驻留策略,属于资源管理;本卷讲的是把 UV 落到 texel 上这一步的定点实现——坐标怎么表示、寻址怎么做、采样怎么算,全部在整数域完成。

纹理是一张表,UV 是一套坐标

纹理是一个 $n \times n$ 的颜色数组,第 $i$ 行第 $j$ 列那个元素叫一个 texel(纹理元素),不是像素。像素是屏幕的最小单位,texel 是纹理的最小单位,两者数量通常不相等,这正是过滤与 mipmap 要解决的问题(12.3)。

UV 约定把整张纹理映射到单位正方形:$u=0$ 是纹理左边界,$u=1$ 是右边界;$v=0$ 是上边界,$v=1$ 是下边界。三角形顶点上各挂一个 $(u,v)$,插值出来的 $(u,v)$ 就告诉光栅化器「这个像素该去表里的哪个位置取色」。这套坐标与三角形的形状、大小、朝向都无关——同一个模型换一张纹理,只要顶点 UV 不变,贴图方式就不变。

关键在于 $u \in [0,1)$ 是一个连续量,而表只有 $n$ 个离散格子。落格子的规则是:

$$i = \lfloor u \cdot n \rfloor$$

这一步把连续坐标变成整数索引。本节的全部工作,就是把这条规则和它周围的边界情形做成纯整数运算。

Q16.16 的 uv 与整数取址

和本卷前面几章一样,uv 用 Q16.16 定点数表示:ONE = 65536 代表 1.0。于是 $u \in [0,1)$ 对应 [0, 65536),一个 texel 的宽度是 $65536 / n$。取址那一步写成:

texel_index(u) = (u * n) >> 16

>> 是算术右移,对非负数就是除以 $2^{16}$ 并向下取整,与 $\lfloor u \cdot n \rfloor$ 严格同义。这里选右移而不是除法,不只是习惯:右移在 Python 与 JS 里对负数同样给出向下取整的结果,而 C 的整数除法是向零截断——两者在负 $u$ 上会分道扬镳。把这条规则固定成右移,跨语言逐位一致就少了一个坑。至于为什么是 16 位小数:屏幕空间里 uv 的变化率不大,16 位小数足够分辨一个 texel 的千分之一,而 u * n 的量级是 $2^{16} \cdot n$,$n$ 取到 4096 也才 $2^{28}$,完全落在 32 位整数里——精度与量程两边都留了余量。

三种寻址模式:repeat / clamp / mirror

取址得到的是未寻址的整数索引,可能越界:顶点 UV 是 0 到 1,但插值出来可能因为浮点/定点舍入略微超出,而平铺(同一张纹理在一面墙上重复铺 N 遍)则会有意把 $u$ 用到 3.0、7.0。三种标准寻址模式把它映射回 $[0,n)$:

模式语义整数实现
repeat平铺,超出部分绕回i & (n - 1)
clamp拉伸边缘,超出部分取边界 texelmin(max(i, 0), n - 1)
mirror镜像平铺,来回折返k = i mod 2n;k < n ? k : 2n - 1 - k

repeat 之所以能用位与,前提是 $n$ 是 2 的幂——n - 1 就是低位的全 1 掩码。本卷纹理边长一律取 16、32,这条前提成立。更值得注意的是负数:$i = -1$ 时 -1 & 15 == 15,$-16$ 时 -16 & 15 == 0,与「绕回」的语义完全一致。这不是巧合,是补码表示的性质——Python 与 JS 的位与都按无限位补码解释,故两者结果相同。

mirror 里的 mod 必须用向下取整的模(k = i - floor(i / 2n) * 2n),不能用向零截断的模:$i = -1$ 时前者给 $2n-1$,后者给 $-1$。Python 的 % 本来就是向下取整,JS 与 C 的 % 是截断——这条差异在 12.3 的 TypeScript 侧要显式写成 i - Math.floor(i / p) * p。

纹理从哪来:程序化生成

本卷不引入任何第三方素材,纹理全部由整数算式生成。本节生成两张 16×16 的纹理:一张双渐变,R 随 $x$ 线性上升、G 随 $y$ 上升、B 随 $x$ 下降,即 gx = tx * 255 // 15、gy = ty * 255 // 15、(gx, gy, 255 - gx),三通道互相独立,插值出错时一眼能看出是哪个方向歪了;一张 4×4 棋盘,由 ((tx >> 2) + (ty >> 2)) & 1 决定取浅色 (232,226,198) 还是深色 (52,62,94),高对比度让「差一格」的寻址错误无处藏身。

两张纹理都以 base64 写进 config/cg_raster_12_1_texture.json,12.2 与 12.3 直接复用,不必重新生成。用程序化纹理还有一个附带好处:纹理由整数算式定义,任何语言都能重算出逐字节相同的副本,跨语言断言才有意义。

可运行产物:三条寻址带与它们的指纹

下面的脚本生成两张纹理,并把 u ∈ [-1, 3) 均匀取样 64 列,三条带分别按 repeat / clamp / mirror 寻址后取同一行的 texel 颜色。三条带的差异就是三种寻址的差异,一眼可见。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_12_1_uv.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_12_1_uv.py
"""12.1 UV 与纹理坐标:Q16.16 的 uv、程序化纹理、三种寻址模式的整数实现。

uv 用 Q16.16 表示,[0, ONE) 恰好覆盖一整张纹理;未寻址的 texel 索引 = floor(u * n),
即 `(u * n) >> 16`(算术右移 = 向下取整,负数也成立)。三种寻址模式全是整数运算:

  repeat: i & (n - 1)              —— n 为 2 的幂;补码下对负数同样给出正确回绕
  clamp : min(max(i, 0), n - 1)
  mirror: k = i mod 2n(向下取整的模);k < n ? k : 2n - 1 - k

纹理一律程序化生成(本脚本不读任何外部素材):一张 16x16 的水平/垂直双渐变,
一张 16x16 的 4x4 棋盘。两张都以 base64 落进 config/,供 12.2 / 12.3 复用。

产物:config/cg_raster_12_1_texture.json、out/cg_raster_12_1_tex.png、
      out/cg_raster_12_1_addr.png、out/cg_raster_12_1_addr.csv、out/cg_raster_12_1_uv.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

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
N = 16  # 纹理边长(2 的幂,repeat 才能用 & (N-1))
SCALE = 4  # 可视化放大倍数
COLS = 64  # 寻址可视化每行的采样列数
STRIP = 16  # 每条寻址带的像素高度
MODES = ("repeat", "clamp", "mirror")
PROBES = (-5 * ONE // 2, -5 * ONE // 4, -ONE // 2, 0, ONE // 2, 65535, ONE,
          3 * ONE // 2, 11 * ONE // 4, 3 * ONE)


def make_gradient(n):
    """程序化双渐变纹理:R 随 x 升、G 随 y 升、B 随 x 降。全整数。"""
    tex = np.zeros((n, n, 3), dtype=np.int64)
    last = n - 1
    for ty in range(n):
        for tx in range(n):
            gx = (tx * 255) // last
            gy = (ty * 255) // last
            tex[ty, tx] = (gx, gy, 255 - gx)
    return tex


def make_checker(n):
    """程序化棋盘纹理:4x4 方块棋盘,两个整数常量色。"""
    tex = np.zeros((n, n, 3), dtype=np.int64)
    for ty in range(n):
        for tx in range(n):
            c = ((tx >> 2) + (ty >> 2)) & 1
            tex[ty, tx] = (232, 226, 198) if c == 0 else (52, 62, 94)
    return tex


def addr(i, n, mode):
    """把任意整数 texel 索引映射到 [0, n) 的整数寻址。"""
    if mode == "repeat":
        return i & (n - 1)
    if mode == "clamp":
        return 0 if i < 0 else (n - 1 if i > n - 1 else i)
    p = 2 * n
    k = i - (i // p) * p  # 向下取整的模(Python 的 % 即此语义,此处显式写出)
    return k if k < n else p - 1 - k


def texel_index(u, n):
    """Q16.16 的 u -> 未寻址的 texel 索引:floor(u * n) == (u * n) >> 16。"""
    return (u * n) >> 16


def main():
    grad = make_gradient(N)
    check = make_checker(N)
    g8, c8 = grad.astype(np.uint8), check.astype(np.uint8)

    cfg = {
        "size": N,
        "gradientBase64": base64.b64encode(g8.tobytes()).decode("ascii"),
        "checkerBase64": base64.b64encode(c8.tobytes()).decode("ascii"),
        "sha256": {
            "gradient": hashlib.sha256(g8.tobytes()).hexdigest(),
            "checker": hashlib.sha256(c8.tobytes()).hexdigest(),
        },
    }
    cfg_path = os.path.join(ROOT, "config", "cg_raster_12_1_texture.json")
    with open(cfg_path, "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, ensure_ascii=False, indent=2)

    # 寻址可视化:上半是放大后的棋盘纹理,下半三条带分别是 repeat / clamp / mirror
    top = N * SCALE
    img = np.zeros((top + len(MODES) * STRIP, COLS, 3), dtype=np.int64)
    for y in range(top):
        for x in range(COLS):
            img[y, x] = check[y // SCALE, x // SCALE]

    table = []  # 三条带逐列采样出的 RGB,展平成字节表(P 层比对这个表的 sha256)
    rows = []
    for m, mode in enumerate(MODES):
        for x in range(COLS):
            u = ((x - 16) * 4 * ONE) // COLS  # u 在 [-1, 3) 上均匀取值
            i = texel_index(u, N)
            j = addr(i, N, mode)
            col = check[N // 2, j]
            table.extend(int(v) for v in col)
            img[top + m * STRIP:top + (m + 1) * STRIP, x] = col
            if x % 8 == 0:
                rows.append([mode, u, i, j, int(col[0]), int(col[1]), int(col[2])])

    probes = []
    for mode in MODES:
        for u in PROBES:
            i = texel_index(u, N)
            j = addr(i, N, mode)
            col = check[N // 2, j]
            probes.append({"mode": mode, "u_q16": u, "texel": i, "addr": j,
                           "rgb": [int(v) for v in col]})

    out = os.path.join(ROOT, "out")
    png_tex = cglib.save_png(os.path.join(out, "cg_raster_12_1_tex.png"), check)
    png_addr = cglib.save_png(os.path.join(out, "cg_raster_12_1_addr.png"), img)
    cglib.write_csv(os.path.join(out, "cg_raster_12_1_addr.csv"),
                    ["mode", "u_q16", "texel", "addr", "r", "g", "b"], rows)

    tbl = np.array(table, dtype=np.uint8).tobytes()
    stats = {
        "textureSize": N,
        "columns": COLS,
        "uRangeQ16": [-ONE, 3 * ONE],
        "probes": probes,
        "sampledTableBytes": len(tbl),
        "sampledTableSha256": hashlib.sha256(tbl).hexdigest(),
        "textureSha256": cfg["sha256"],
    }
    with open(os.path.join(out, "cg_raster_12_1_uv.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"纹理           : {N}x{N},repeat 掩码 = {N - 1}(0x{N - 1:02x})")
    print(f"gradient sha256: {cfg['sha256']['gradient']}")
    print(f"checker  sha256: {cfg['sha256']['checker']}")
    print(f"寻址可视化     : {COLS} 列 x {len(MODES)} 带,u ∈ [-1, 3)")
    for mode in MODES:
        row = [r for r in rows if r[0] == mode][:5]
        txt = "  ".join(f"u={r[1] / ONE:+.3f}->t{r[2]:+d}->{r[3]}" for r in row)
        print(f"  {mode:7s} {txt}")
    print("探针(u = -2.5 与 +3.0 两端):")
    for p in (probes[0], probes[9], probes[10], probes[19], probes[20], probes[29]):
        print(f"  {p['mode']:7s} u_q16={p['u_q16']:8d} texel={p['texel']:3d} addr={p['addr']:2d} rgb={p['rgb']}")
    print(f"采样表         : {len(tbl)} 字节  sha256={stats['sampledTableSha256']}")
    print(f"png            : tex {png_tex} bytes / addr {png_addr} bytes")
    return 0


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

实跑输出:

纹理           : 16x16,repeat 掩码 = 15(0x0f)
gradient sha256: 2c8790f0aeee76e5a1e4f63a9a5e446471ca65a8918ec06943c785f8faa629fa
checker  sha256: 519973fcae1d2835d4da3ff3b725c2a3fd42f4811e66046bc0c2f95c328d1561
寻址可视化     : 64 列 x 3 带,u ∈ [-1, 3)
  repeat  u=-1.000->t-16->0  u=-0.500->t-8->8  u=+0.000->t+0->0  u=+0.500->t+8->8  u=+1.000->t+16->0
  clamp   u=-1.000->t-16->0  u=-0.500->t-8->0  u=+0.000->t+0->0  u=+0.500->t+8->8  u=+1.000->t+16->15
  mirror  u=-1.000->t-16->15  u=-0.500->t-8->7  u=+0.000->t+0->0  u=+0.500->t+8->8  u=+1.000->t+16->15
采样表         : 576 字节  sha256=3a02f1001313206e4ce83aa9797bf0a60216cd95887c074ca27f660d79d4e43d

三条带在 $u \in [0,1)$ 内完全重合(未越界时三种寻址是同一件事),越界后才分开。$u = -0.5$ 处 texel 索引是 $-8$:repeat 绕到 8,clamp 贴到 0,mirror 折到 7。三个数字互不相同,这就是「寻址」这件事的可断言内容。

跨语言:同一张表,两种语言

TypeScript 侧把 addr 与 texel_index 重写一遍,逐列算出同一张 576 字节的采样表,用 sha256 与 Python 侧对齐。

// file: lantern/core/src/lantern/cg_raster_12_uv.ts
// tier: P
// verify: node --test 'core/test/cg_raster_12_*.test.ts'
// 12.1 的寻址内核:与 Python 侧 sim/cg_raster_12_1_uv.py 的 addr / texel_index 逐字对齐。
//
// 量级:u <= 3 * 2^16,乘 n = 16 后 <= 2^22 < 2^53,故逐像素层用 Number 即可。
// `(u * n) >> 16` 在 JS 里只对 32 位有效,故显式写 Math.floor((u * n) / 65536):
// 对负数 Math.floor 同样是向下取整,与 Python 的算术右移 `>>` 同义。
// repeat 用 `i & (n - 1)`:n 是 2 的幂,补码下对负数给出与 Python 相同的回绕。

export const UV_ONE = 65536;

export type AddrMode = "repeat" | "clamp" | "mirror";

/** Q16.16 的 u -> 未寻址的 texel 索引:floor(u * n)。 */
export function uvTexelIndex(u: number, n: number): number {
  return Math.floor((u * n) / UV_ONE);
}

/** 任意整数 texel 索引 -> [0, n) 的整数寻址。 */
export function uvAddrIndex(i: number, n: number, mode: AddrMode): number {
  if (mode === "repeat") return i & (n - 1);
  if (mode === "clamp") return i < 0 ? 0 : i > n - 1 ? n - 1 : i;
  const p = 2 * n;
  const k = i - Math.floor(i / p) * p; // 向下取整的模,与 Python 的 `%` 同义
  return k < n ? k : p - 1 - k;
}

/** 取纹理第 row 行、按 mode 寻址后的 texel,返回 [r, g, b]。 */
export function uvSampleRow(
  tex: Uint8Array,
  n: number,
  row: number,
  u: number,
  mode: AddrMode,
): [number, number, number] {
  const j = uvAddrIndex(uvTexelIndex(u, n), n, mode);
  const o = (row * n + j) * 3;
  return [tex[o], tex[o + 1], tex[o + 2]];
}

/** 三条带逐列采样出的 RGB 表:与 Python 侧 main() 的 table 构造逐字对齐。 */
export function uvAddrTable(
  tex: Uint8Array,
  n: number,
  cols: number,
  modes: AddrMode[],
): Uint8Array {
  const row = n >> 1;
  const out = new Uint8Array(modes.length * cols * 3);
  let k = 0;
  for (const mode of modes) {
    for (let x = 0; x < cols; x += 1) {
      const u = Math.floor(((x - 16) * 4 * UV_ONE) / cols);
      const [r, g, b] = uvSampleRow(tex, n, row, u, mode);
      out[k] = r;
      out[k + 1] = g;
      out[k + 2] = b;
      k += 3;
    }
  }
  return out;
}
✔ 纹理字节与 Python 侧逐字节相同(sha256)
✔ 【核心】三条寻址带的采样表 sha256 与 Python 侧逐字节相同
✔ 探针表逐条一致:texel 索引、寻址结果与颜色
✔ 【负例】三种寻址模式在 u 越界时必须给出不同结果
ℹ tests 4  ℹ pass 4  ℹ fail 0

uvAddrTable 里那个 Math.floor(((x - 16) * 4 * UV_ONE) / cols) 是刻意写成「先乘后除」的:Python 侧是 ((x - 16) * 4 * ONE) // COLS,两者都是先算出精确整数再取整。如果改写成 ((x - 16) * 4 / cols) * UV_ONE,中间那步除法会产生浮点余数,$x=17$ 这类点上就会差 1 个 Q16.16 单位——在 $u$ 接近 texel 边界时足以让索引差一格。定点代码里,先乘后除不是风格问题,是正确性问题。

负例那一条断言的是三种模式不能退化成同一个函数:$u=3.0$、$n=16$ 时 texel 索引为 48,repeat 给 0、clamp 给 15、mirror 给 15,三者至少两个不同。少了这条,就算把 addr 写成 return i,只要两侧同样错,sha256 断言依然会通过。

下一节把这套坐标真正用起来:顶点上挂 UV,插值到每个像素,看「屏幕空间线性插值 UV」为什么是错的。

阅读导航:上一节:11.3 MSAA 的 CPU 实现 · 下一节:12.2 透视校正插值 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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