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$ | 未寻址 texel | wrap | clamp | mirror | border |
|---|---|---|---|---|---|
| $-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 的构造 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。