8.1 纹理坐标与寻址模式

UV 是一对 Q16.16 定点数,落格子就是一次算术右移。本节把 wrap / clamp / mirror / border 四种寻址全做成整数运算——位与、钳位、向下取整的模、越界短路——再用一张自描述纹理把采样结果反查回 texel:64 列里只有 12 列四种模式给出同一结果。

8.1 纹理坐标与寻址模式

第 7 章把颜色的进出管成了整数,接下来该给表面贴东西了。贴图的第一步不是过滤、也不是 mip,而是坐标:纹理是一张 $n \times n$ 的表,怎么把表上的格子和三角形上的点对应起来。先和 纹理与显存 划清边界——那篇讲纹理在显存里的格式、压缩与驻留,属于资源管理;本卷讲的是把 UV 落到 texel 上这一步的定点实现,全部在整数域完成。本节只做一件事:给定一个可能越界的 $u$,把它变成一个合法的 texel 索引。

UV 是 Q16.16,落格子是一次算术右移

纹理约定把整张表映射到单位区间,$u = 0$ 是左边界、$u = 1$ 是右边界。落格子的规则是 $i = \lfloor u \cdot n \rfloor$。在 Q16.16 里 $u$ 存成一个整数、$1.0$ 就是 65536,于是这条式子写成一次乘法加一次移位:

# snippet: 落格子 —— Q16.16 的 u 乘 n 之后一次算术右移
def texel_index(u, n):
    return (u * n) >> 16

这里有个容易踩的点:>> 必须是算术右移(向下取整),不能写成整除。$u = -1.0$ 时 (-65536 * 16) >> 16 = -16;换成向零截断的除法,同样的输入在不同宿主上会得到 -16 或 -15,四种寻址模式的分支就会分叉。这正是本书反复强调「降精度一律用 >>」的原因。

另外 $i$ 是未寻址的索引,可以取任意整数:$u = -1.5$ 得到 $-24$,$u = +3.0$ 得到 $+48$。把「算索引」和「寻址」拆成两步,换模式时才不用碰插值出来的 $u$。那为什么不干脆保证 $u \in [0, 1)$?因为保证不了:插值、动画偏移、透视除法都会把它推出去,程序化地形与滚动水面更是主动让 $u$ 跑到几十。与其在每个调用点钳位,不如把越界处理做成一条独立的整数规则。

四种寻址模式的整数实现

四种模式都是把整数 $i$ 映回 $[0, n)$ 的函数,全部只用整数运算:

模式规则整数实现
wrap周期回绕i & (n - 1)
clamp钳到边界min(max(i, 0), n - 1)
mirror三角波折返k = i mod 2n;k < n ? k : 2n - 1 - k
border越界即边界色不落到任何 texel,返回 $-1$

wrap 用位与而不是取模,前提是 $n$ 是 2 的幂:补码下 $-1 \mathbin{&} 15 = 15$、$-16 \mathbin{&} 15 = 0$,正是 $\text{mod } n$ 的非负余数。尺寸不是 2 的幂时必须显式取模,而且要用向下取整的模——C 系的 % 对负数给出负余数、Python 的 % 给出非负余数,这一处不一致足以让两个宿主分道扬镳。

mirror 是四种里唯一有「形状」的:它在 $[0, 2n)$ 上是一个周期三角波,前半段 $0 \to n-1$ 递增、后半段 $n \to 2n-1$ 递减。所以 $u = +1.0$ 给出 texel $15$,$u = -1.0$ 也给出 texel $15$——纹理在边界处折返,两侧的相邻像素指向同一个 texel,接缝上看不到跳变。clamp 则相反:越界之后所有 $u$ 都塌到同一个 texel,视觉上就是把边缘那一条拉伸出去。border 最干脆,越界就和纹理无关,直接给一个常量色,实现上甚至不需要纹理。

同一条 u 序列上的四模式对照

取 $u$ 从 $-1.5$ 到 $+3.5$ 的一条探针序列,$n = 16$,把「未寻址索引 $\to$ 四种寻址结果」并列出来:

$u$未寻址 texelwrapclampmirrorborder
$-1.500$$-24$$8$$0$$8$$-1$
$-1.000$$-16$$0$$0$$15$$-1$
$-0.500$$-8$$8$$0$$7$$-1$
$0.000$$0$$0$$0$$0$$0$
$0.500$$8$$8$$8$$8$$8$
$1.000$$16$$0$$15$$15$$-1$
$1.500$$24$$8$$15$$7$$-1$
$0.99998$$15$$15$$15$$15$$15$

只有 $u \in [0, 1)$ 那几行四种模式才一致,一越界立刻分叉;wrap 在 $\pm 1.0$ 都给 texel $0$(周期性的必然),mirror 在这两点都给 $15$(折返的必然),border 只要越界就和纹理无关。把同样的对照铺满 64 列($u$ 从 $-1.5$ 到 $+3.5$ 均匀取 64 个点),量出来的分歧是:四种模式给出同一结果的列只有 12 / 64;wrap 与 clamp 在 49 / 64 列上不同;wrap 与 mirror 在 32 / 64 列上不同(正好一半,因为 mirror 的周期是 wrap 的两倍);任何含 border 的组合都有 52 / 64 列不同。所以「随便挑一种寻址」不是风格问题,它改变了三分之二采样点的落点。

自描述纹理:让颜色反查出 texel

要验证上面这些数字,得能从采样出来的颜色反查出到底寻址到了哪个 texel。做法是把纹理本身做成自描述的:R 通道是整数哈希噪声 cglib._hash_noise(tx, ty, SEED) >> 8(取值 $[0, 255]$),G 通道编码列号(第 $j$ 列取 $\lfloor j \cdot 255/15 \rfloor = 17j$),B 通道编码行号。反查就是两次除法:g * 15 // 255 得到列、b * 15 // 255 得到行——对照表不靠肉眼,直接从像素缓冲里读回来。

边界色取 $(16, 16, 22)$,它必须和任何 texel 都不同,这要求 R 通道上没有任何一个 texel 恰好等于 $16$。实测这张 $16 \times 16$ 纹理的 R 通道共出现 166 个不同取值(满值域 256),不含 16,所以「R 等于 16 就等价于越界」在本张纹理上成立。换成 $32 \times 32$ 的噪声就未必了——这套技巧依赖「哨兵值不出现在数据里」,用之前先查一遍,不要假定。

内核(S 层)

下面是本节真正在跑的那段代码:程序化生成自描述纹理、把 $u$ 变成 texel 索引、按四种模式寻址并采样,最后把对照表与可视化落盘。

#!/usr/bin/env python3
# file: lantern/sim/cg_shade_08_1_wrap.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_shade_08_1_wrap.py
"""8.1 纹理坐标与寻址模式:UV -> texel 的整数换算,wrap / clamp / mirror / border。

uv 用 Q16.16(ONE = 65536)。未寻址的 texel 索引是 `(u * n) >> 16` —— 算术右移
即 floor,对负数同样成立(这一点后面四处寻址的差别全在负半轴上)。四种寻址模式
全是整数运算:

  wrap  : i & (n - 1)                —— n 为 2 的幂;补码下对负数给出正确回绕
  clamp : min(max(i, 0), n - 1)
  mirror: k = i mod 2n(向下取整的模);k < n ? k : 2n - 1 - k
  border: 越界即返回边界色,不落到任何 texel

纹理是**自描述**的:R 通道是整数哈希噪声(`cglib._hash_noise`),G 通道编码列号、
B 通道编码行号。于是从采样出来的颜色能反查出到底寻址到了哪个 texel —— 对照表不靠
肉眼,靠 `g // 17` 与 `b // 17` 就能读回来。不引入任何外部素材。

产物:config/cg_shade_08_1_texture.json、out/cg_shade_08_1_tex.png、
      out/cg_shade_08_1_addr.png、out/cg_shade_08_1_addr.csv、out/cg_shade_08_1_addr.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, N, SEED = cglib.ONE, 16, 0x5EED
COLS, STRIP, SCALE = 64, 10, 6
MODES = ("wrap", "clamp", "mirror", "border")
BORDER = (16, 16, 22)
# u ∈ [-1.5, 3.5):负半轴跨 -1.5 到 0,正半轴跨 0 到 3.5,四种模式全部拉开
U0, U1 = -3 * ONE // 2, 7 * ONE // 2
PROBES = (-3 * ONE // 2, -ONE, -ONE // 2, 0, ONE // 2, ONE, 3 * ONE // 2, 2 * ONE, 3 * ONE, 65535)


def make_texture(n):
    """自描述程序化纹理:R = 整数哈希噪声,G = 列号,B = 行号(可反查 texel)。"""
    tex = np.zeros((n, n, 3), dtype=np.int64)
    last = n - 1
    for ty in range(n):
        for tx in range(n):
            tex[ty, tx] = (cglib._hash_noise(tx, ty, SEED) >> 8,
                           (tx * 255) // last, (ty * 255) // last)
    return tex


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


def addr(i, n, mode):
    """整数 texel 索引 -> [0, n) 的寻址;border 越界返回 -1。"""
    if mode == "wrap":
        return i & (n - 1)
    if mode == "clamp":
        return 0 if i < 0 else (n - 1 if i > n - 1 else i)
    if mode == "mirror":
        p = 2 * n
        k = i - (i // p) * p  # 向下取整的模(显式写出,不用裸 %)
        return k if k < n else p - 1 - k
    return i if 0 <= i < n else -1  # border


def sample(tex, n, u, mode):
    """取第 n//2 行、按 mode 寻址后的 texel 颜色;border 越界给边界色。"""
    j = addr(texel_index(u, n), n, mode)
    return list(BORDER) if j < 0 else [int(v) for v in tex[n // 2, j]]


def decode(rgb):
    """从自描述纹理的颜色反查被寻址的 texel:G = 列、B = 行(0 为边界色)。"""
    return (rgb[1] * 15 // 255, rgb[2] * 15 // 255) if rgb[0] != BORDER[0] else (-1, -1)


def main():
    tex = make_texture(N)
    t8 = tex.astype(np.uint8)
    cfg = {"size": N, "seed": SEED, "borderQ8": list(BORDER),
           "textureBase64": base64.b64encode(t8.tobytes()).decode("ascii"),
           "textureSha256": hashlib.sha256(t8.tobytes()).hexdigest()}
    with open(os.path.join(ROOT, "config", "cg_shade_08_1_texture.json"), "w", encoding="utf-8") as fh:
        json.dump(cfg, fh, ensure_ascii=False, indent=2)

    # 对照表:COLS 列 x 4 模式,逐列取同一行的 texel
    table, rows = [], []
    for mode in MODES:
        for x in range(COLS):
            u = U0 + ((U1 - U0) * x) // COLS
            i = texel_index(u, N)
            j = addr(i, N, mode)
            rgb = sample(tex, N, u, mode)
            table.extend(rgb)
            rows.append([mode, u, i, j, rgb[0], rgb[1], rgb[2]])

    # 可视化:上半是放大纹理,下半四条带分别是四种寻址模式
    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] = tex[y // SCALE, x // SCALE]
    for m, mode in enumerate(MODES):
        for x in range(COLS):
            u = U0 + ((U1 - U0) * x) // COLS
            img[top + m * STRIP:top + (m + 1) * STRIP, x] = sample(tex, N, u, mode)

    probes = []
    for mode in MODES:
        for u in PROBES:
            i = texel_index(u, N)
            j = addr(i, N, mode)
            probes.append({"mode": mode, "uQ16": u, "texel": i, "addr": j,
                           "rgb": sample(tex, N, u, mode)})

    out = os.path.join(ROOT, "out")
    png_tex = cglib.save_png(os.path.join(out, "cg_shade_08_1_tex.png"), tex)
    png_addr = cglib.save_png(os.path.join(out, "cg_shade_08_1_addr.png"), img)
    cglib.write_csv(os.path.join(out, "cg_shade_08_1_addr.csv"),
                    ["mode", "uQ16", "texel", "addr", "r", "g", "b"], rows)

    tbl = np.array(table, dtype=np.uint8).tobytes()
    stats = {"textureSize": N, "seed": SEED, "columns": COLS, "uRangeQ16": [U0, U1],
             "modes": list(MODES), "probes": probes,
             "tableBytes": len(tbl), "tableSha256": hashlib.sha256(tbl).hexdigest(),
             "textureSha256": cfg["textureSha256"]}
    with open(os.path.join(out, "cg_shade_08_1_addr.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"纹理        : {N}x{N} 自描述(R=哈希噪声,G=列,B=行)  sha256 {cfg['textureSha256'][:32]}…")
    print(f"u 区间      : [{U0 / ONE:+.3f}, {U1 / ONE:+.3f})  {COLS} 列  边界色 {BORDER}")
    print("四模式对照(u -> texel -> 寻址 -> 反查列/行):")
    for mode in MODES:
        seg = [r for r in rows if r[0] == mode]
        txt = "  ".join(f"u={r[1] / ONE:+.3f}:t{r[2]:+d}->{r[3]:+d}" for r in seg[::13])
        print(f"  {mode:7s} {txt}")
    print("探针(同一条 u 序列,四模式并列):")
    for k in range(len(PROBES)):
        cell = "  ".join(f"{m}={probes[k + i * len(PROBES)]['addr']:+d}"
                         for i, m in enumerate(MODES))
        print(f"  u={PROBES[k] / ONE:+.4f} texel={probes[k]['texel']:+4d}  {cell}")
    print(f"采样表      : {len(tbl)} 字节  sha256={stats['tableSha256']}")
    print(f"png         : tex {png_tex} B / addr {png_addr} B")
    return 0


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

实跑输出(/opt/homebrew/bin/python3 sim/cg_shade_08_1_wrap.py):

纹理        : 16x16 自描述(R=哈希噪声,G=列,B=行)  sha256 d70761ee61d64e81826ee5c912af95f7…
u 区间      : [-1.500, +3.500)  64 列  边界色 (16, 16, 22)
探针(同一条 u 序列,四模式并列):
  u=-1.5000 texel= -24  wrap=+8  clamp=+0  mirror=+8  border=-1
  u=-1.0000 texel= -16  wrap=+0  clamp=+0  mirror=+15  border=-1
  u=-0.5000 texel=  -8  wrap=+8  clamp=+0  mirror=+7  border=-1
  u=+0.0000 texel=  +0  wrap=+0  clamp=+0  mirror=+0  border=+0
  u=+0.5000 texel=  +8  wrap=+8  clamp=+8  mirror=+8  border=+8
  u=+1.0000 texel= +16  wrap=+0  clamp=+15  mirror=+15  border=-1
  u=+1.5000 texel= +24  wrap=+8  clamp=+15  mirror=+7  border=-1
  u=+2.0000 texel= +32  wrap=+0  clamp=+15  mirror=+0  border=-1
  u=+3.0000 texel= +48  wrap=+0  clamp=+15  mirror=+15  border=-1
  u=+1.0000 texel= +15  wrap=+15  clamp=+15  mirror=+15  border=+15
采样表      : 768 字节  sha256=853034aa03e72e52020a80a296864514f472fa751d62788847f264e65a3baab2

跨宿主断言(P 层)

TypeScript 侧的寻址内核与上面这段 Python 逐字对齐。两处需要特别小心:(u * n) >> 16 在 JS 里只对 32 位有效,必须显式写 Math.floor((u * n) / 65536)(Math.floor 对负数同样是向下取整,与算术右移同义);mirror 的模要用向下取整的版本,JS 的 % 带符号,得自己补一步。

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

export const WRAP_ONE = 65536;

export type WrapMode = "wrap" | "clamp" | "mirror" | "border";

export const WRAP_MODES: WrapMode[] = ["wrap", "clamp", "mirror", "border"];

/** 越界时 border 给出的边界色,与 Python 侧 BORDER 一致。 */
export const WRAP_BORDER: [number, number, number] = [16, 16, 22];

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

/** 整数 texel 索引 -> [0, n) 的寻址;border 越界返回 -1。 */
export function wrapAddr(i: number, n: number, mode: WrapMode): number {
  if (mode === "wrap") return i & (n - 1);
  if (mode === "clamp") return i < 0 ? 0 : i > n - 1 ? n - 1 : i;
  if (mode === "mirror") {
    const p = 2 * n;
    const k = i - Math.floor(i / p) * p; // 向下取整的模,与 Python 的 `i // p` 同义
    return k < n ? k : p - 1 - k;
  }
  return i >= 0 && i < n ? i : -1;
}

/** 取第 n>>1 行、按 mode 寻址后的 texel 颜色;border 越界给边界色。 */
export function wrapSample(
  tex: Uint8Array,
  n: number,
  u: number,
  mode: WrapMode,
): [number, number, number] {
  const j = wrapAddr(wrapTexelIndex(u, n), n, mode);
  if (j < 0) return [WRAP_BORDER[0], WRAP_BORDER[1], WRAP_BORDER[2]];
  const o = ((n >> 1) * n + j) * 3;
  return [tex[o], tex[o + 1], tex[o + 2]];
}

/** 四模式逐列采样出的 RGB 表:与 Python 侧 main() 的 table 构造逐字对齐。 */
export function wrapTable(
  tex: Uint8Array,
  n: number,
  cols: number,
  u0: number,
  u1: number,
): Uint8Array {
  const out = new Uint8Array(WRAP_MODES.length * cols * 3);
  let k = 0;
  for (const mode of WRAP_MODES) {
    for (let x = 0; x < cols; x += 1) {
      const u = u0 + Math.floor(((u1 - u0) * x) / cols);
      const [r, g, b] = wrapSample(tex, n, u, mode);
      out[k] = r;
      out[k + 1] = g;
      out[k + 2] = b;
      k += 3;
    }
  }
  return out;
}

四条断言全绿:纹理字节 sha256 相同、四模式采样表的 sha256 与 Python 逐字节相同(853034aa…3baab2)、10 个探针逐条一致,以及一条负例——$u = -1.0$ 时四种寻址必须给出 $[0, 0, 15, -1]$ 四个互不相同的结果(new Set(got).size >= 3)。如果有人把 mirror 误写成 wrap,表 sha 会变、负例也会红——这就是负例存在的意义。

小结

寻址模式是纹理管线的第一道分水岭:同一条 $u$ 序列,四种规则给出完全不同的 texel,64 列里只有 12 列碰巧一致。四种规则的整数实现各只有几行,但每一行都藏着宿主差异——算术右移对负数、位与的前提是 2 的幂、% 的符号。下一节进入过滤:当像素和 texel 不再一一对应时,一个像素该取几个 texel,以及 texel 太少时该造多少级金字塔。

阅读导航:上一节:7.3 色彩管理的整数化 · 下一节:8.2 过滤与 mipmap 的构造 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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