12.2 透视校正插值
12.1 定好了「UV 怎么落到 texel 上」,但还有一个更靠前的坑:顶点上的 UV 是怎么变成每个像素的 UV 的。直觉做法是把 UV 和颜色、深度一样用重心权重线性插值——第 6 章就是这么干的,而且在那里是对的。在透视投影下,这个直觉是错的,错得很明显。本节把错误做法和正确做法都实现一遍,量出差了多少;纹理的显存格式与驻留策略属于 纹理与显存 的范围,这里只管插值这一步的定点算式。
为什么屏幕空间线性插值 UV 是错的
透视投影的除法发生在投影矩阵里:世界坐标乘上 MVP 之后,要除以裁剪空间的 $w$ 才得到 NDC。也就是说
$$x_{\text{screen}} \propto \frac{x_c}{w}, \qquad y_{\text{screen}} \propto \frac{y_c}{w}$$
屏幕坐标是分子除以 $w$ 得到的。而顶点属性(比如 $u$)在世界空间里是线性的,它的分子部分线性,除以 $w$ 之后就不再线性了。$w$ 本身沿三角形变化,所以「屏幕上等距的两点」对应的世界空间距离并不相等。一维类比最清楚:一条从近处伸向远处的走廊,地砖在世界空间里等宽,投影到屏幕上却近端占几十个像素、远端只占几个。在屏幕上把 $u$ 从 0 线性推到 1,等于假设地砖在屏幕上等宽——画出来的纹理会在远端被拉长、近端被压缩,这正是老游戏里「地板纹理随距离扭动」的来源。
屏幕上线性的是什么
关键观察:$u$ 不线性,但 $u/w$ 和 $1/w$ 都是屏幕空间线性的。因为它们都由「世界空间线性量除以 $w$」得到,而这个除法在投影时已经做过了,剩下的插值在屏幕空间就是普通的线性插值。于是正确做法只有三步:逐顶点算出 $u/w$、$v/w$、$1/w$;在屏幕空间用重心权重线性插值这三个量,得到 $\text{interp}(u/w)$ 与 $\text{interp}(1/w)$;最后相除还原
$$u = \frac{\text{interp}(u/w)}{\text{interp}(1/w)}$$
这就是「透视校正插值」的全部内容。GPU 硬件做的就是这件事——所有 varying 默认按这个公式插值,代价是每个像素多一次除法。
两条路径的定点实现
定点版本把上面三步拆成「逐顶点预除」与「逐像素一次除法」:
逐顶点(persp 路径):uo_i = (u_i * ONE) // w_i iw_i = (ONE * ONE) // w_i
逐像素:Uo = (w0*uo_0 + w1*uo_1 + w2*uo_2) // area
Io = (w0*iw_0 + w1*iw_1 + w2*iw_2) // area
u = (Uo * ONE) // Io
linear 路径则是把 uo_i 直接取成 u_i、不做最后那次除法。两条路径共用同一套边函数、同一个双线性采样器、同一个深度缓冲,唯一变量是 uv 的求法,这样量出来的差异才能干净地归因到插值上。量级要留意:uo_i 是 Q16.16,w_i 最大约 $2^{21}$,w0 * uo_0 这类乘积在 $2^{47}$ 上下,三者求和仍在 $2^{53}$ 以内——逐像素层用 numpy 的 int64 或 JS 的 Number 都精确。
场景:一块后仰的四边形
测试场景是一块向后仰的矩形地面,$z = -3 - 2y$。相机在 $(0,1,10)$ 看向 $(0,0,-2)$,于是近端($z=3$)离相机近、远端($z=-9$)离相机远。实测四个顶点的裁剪 $w$ 是 478931 与 1229987,近远之比 2.57 倍——这个比例决定了错误插值的可见程度:$w$ 恒定 1 时两条路径完全等价,$w$ 相差越多差得越远。
纹理用 12.1 生成的 4×4 棋盘。选棋盘而不是渐变,是因为棋盘的高频边界能把「纹理被拉歪」这件事直接暴露成可见的条纹位移,而不是一片糊掉的色块。
可运行产物:两条路径,两个指纹
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_12_2_persp.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_12_2_persp.py
"""12.2 透视校正插值:uv/w 与 1/w 在屏幕空间都线性,相除才是透视校正。
一块朝相机后仰的四边形(近端 w ≈ 7、远端 w ≈ 19,差 2.5 倍),贴 12.1 的棋盘纹理,
同一套整数边函数与双线性采样跑两条路径:
线性 u = interp(u) —— 屏幕空间线性,几何上是错的
透视校正 u = interp(u/w) / interp(1/w) —— 正确
本节最硬的可断言结果:两条路径的差异像素数与最大通道偏差(唯一变量是 uv 的求法)。
产物:config/cg_raster_12_2_texquad.json、out/cg_raster_12_2_persp.{rgb,png}、
out/cg_raster_12_2_linear.{rgb,png}、out/cg_raster_12_2_persp_covered.bin、
out/cg_raster_12_2_tex.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, SUB1, HALF = cglib.ONE, cglib.SUB1, cglib.SUB1 // 2
W, H, BG = 96, 72, (12, 14, 20)
EYE, TARGET, UP = (0, ONE, 10 * ONE), (0, 0, -2 * ONE), (0, ONE, 0)
TAN_HALF = 37837 # tan(30°) ≈ 0.5774,与 scene_spec.json 同口径
NEAR, FAR = ONE // 2, 64 * ONE
# 向后仰的矩形:z = -3 - 2y,近端(z=3)w≈7、远端(z=-9)w≈19
QUAD = [(-5 * ONE, -3 * ONE, 3 * ONE), (5 * ONE, -3 * ONE, 3 * ONE),
(5 * ONE, 3 * ONE, -9 * ONE), (-5 * ONE, 3 * ONE, -9 * ONE)]
UVS = [(0, 0), (ONE, 0), (ONE, ONE), (0, ONE)]
def build_vertices():
"""世界四边形 -> 逐顶点 (屏幕 Q6, 深度 Q16.16, uv Q16.16, 裁剪 w Q16.16, 裁剪坐标)。"""
aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
mvp = cglib.mat_mul(cglib.mat_perspective(TAN_HALF, aspect, NEAR, FAR),
cglib.mat_look_at(EYE, TARGET, UP))
verts = []
for i, p in enumerate(QUAD):
clip = cglib.mat_vec4(mvp, (p[0], p[1], p[2], ONE))
pr = cglib.project(mvp, p)
assert pr is not None, "四边形顶点落到相机后方"
sx, sy = cglib.to_screen(pr[0], pr[1], W, H)
verts.append({"xQ6": sx, "yQ6": sy, "depthQ16": pr[2], "wQ16": pr[3],
"uQ16": UVS[i][0], "vQ16": UVS[i][1], "clipQ16": list(clip)})
return verts
def sample_bilinear(tex, n, u, v):
"""Q16.16 的 uv -> 双线性颜色。texel 中心在 (i+0.5)/n,故先减半 texel。"""
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)
out = []
for ch in range(3):
val = (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)
out.append(val >> 32) # (a+b)(c+d) == ONE*ONE == 2^32,右移 32 即归一化
return out
def rasterize_tex(verts, tris, tex, n, path):
"""整数纹理光栅化:path = 'persp' 走 uv/w 与 1/w,'linear' 直接插值 uv。"""
rgb = np.zeros((H, W, 3), dtype=np.int64)
rgb[:, :] = BG
depth = np.full((H, W), np.iinfo(np.int64).max, dtype=np.int64)
covered = np.zeros((H, W), dtype=bool)
culled = 0
for a, b, c in tris:
va, vb, vc = verts[a], verts[b], verts[c]
x0, y0, x1, y1, x2, y2 = (va["xQ6"], va["yQ6"], vb["xQ6"],
vb["yQ6"], vc["xQ6"], vc["yQ6"])
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
culled += 1
continue
minx, maxx = max(0, min(x0, x1, x2) // SUB1), min(W - 1, max(x0, x1, x2) // SUB1)
miny, maxy = max(0, min(y0, y1, y2) // SUB1), min(H - 1, max(y0, y1, y2) // SUB1)
d0, d1, d2 = va["depthQ16"], vb["depthQ16"], vc["depthQ16"]
if path == "persp": # 逐顶点预除:u/w、v/w、1/w 都是屏幕空间线性的
uo = [cglib.fdiv(v["uQ16"], v["wQ16"]) for v in (va, vb, vc)]
vo = [cglib.fdiv(v["vQ16"], v["wQ16"]) for v in (va, vb, vc)]
iw = [cglib.fdiv(ONE, v["wQ16"]) for v in (va, vb, vc)]
else:
uo = [v["uQ16"] for v in (va, vb, vc)]
vo = [v["vQ16"] for v in (va, vb, vc)]
iw = None
for py in range(miny, maxy + 1):
Y = py * SUB1 + HALF
for px in range(minx, maxx + 1):
X = px * SUB1 + HALF
w0 = (x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1)
if w0 < 0:
continue
w1 = (x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2)
if w1 < 0:
continue
w2 = (x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0)
if w2 < 0:
continue
z = (w0 * d0 + w1 * d1 + w2 * d2) // area
if z >= depth[py, px]:
continue
if path == "persp":
Io = (w0 * iw[0] + w1 * iw[1] + w2 * iw[2]) // area
u = cglib.fdiv((w0 * uo[0] + w1 * uo[1] + w2 * uo[2]) // area, Io)
v = cglib.fdiv((w0 * vo[0] + w1 * vo[1] + w2 * vo[2]) // area, Io)
else:
u = (w0 * uo[0] + w1 * uo[1] + w2 * uo[2]) // area
v = (w0 * vo[0] + w1 * vo[1] + w2 * vo[2]) // area
depth[py, px] = z
covered[py, px] = True
rgb[py, px] = sample_bilinear(tex, n, u, v)
return rgb, depth, covered, culled
def main():
with open(os.path.join(ROOT, "config", "cg_raster_12_1_texture.json"), encoding="utf-8") as fh:
tex_cfg = json.load(fh)
n = tex_cfg["size"]
tex = np.frombuffer(base64.b64decode(tex_cfg["checkerBase64"]), dtype=np.uint8)
tex = tex.reshape(n, n, 3).astype(np.int64)
verts = build_vertices()
keys = ("xQ6", "yQ6", "depthQ16", "wQ16", "uQ16", "vQ16", "clipQ16")
cfg = {"name": "uv-tilt-quad", "width": W, "height": H, "backgroundQ8": list(BG),
"textureSize": n, "textureBase64": tex_cfg["checkerBase64"],
"vertices": [{k: v[k] for k in keys} for v in verts],
"triangles": [[0, 2, 1], [0, 3, 2]]}
with open(os.path.join(ROOT, "config", "cg_raster_12_2_texquad.json"), "w", encoding="utf-8") as fh:
json.dump(cfg, fh, ensure_ascii=False, indent=2)
persp, _, pcov, pcull = rasterize_tex(verts, cfg["triangles"], tex, n, "persp")
linear, _, lcov, _ = rasterize_tex(verts, cfg["triangles"], tex, n, "linear")
diff_px = max_diff = sum_abs = 0
for py in range(H):
for px in range(W):
if not pcov[py, px] and not lcov[py, px]:
continue
d = np.abs(persp[py, px] - linear[py, px])
diff_px += 1 if int(d.max()) > 0 else 0
max_diff = max(max_diff, int(d.max()))
sum_abs += int(d.sum())
covered_n = int(pcov.sum())
mae = sum_abs / (covered_n * 3)
p8 = np.clip(persp, 0, 255).astype(np.uint8)
l8 = np.clip(linear, 0, 255).astype(np.uint8)
out = os.path.join(ROOT, "out")
png_p = cglib.save_png(os.path.join(out, "cg_raster_12_2_persp.png"), persp)
png_l = cglib.save_png(os.path.join(out, "cg_raster_12_2_linear.png"), linear)
for name, buf in (("persp", p8), ("linear", l8), ("persp_covered", pcov.astype(np.uint8))):
ext = "bin" if name.endswith("covered") else "rgb"
with open(os.path.join(out, f"cg_raster_12_2_{name}.{ext}"), "wb") as fh:
fh.write(buf.tobytes())
ws = [v["wQ16"] for v in verts]
stats = {"canvas": [W, H], "covered": covered_n, "culled": pcull,
"wMinQ16": min(ws), "wMaxQ16": max(ws), "wRatioPermille": max(ws) * 1000 // min(ws),
"vertices": [{k: v[k] for k in keys[:6]} for v in verts],
"diffPixels": diff_px, "maxChannelDiff": max_diff, "mae": round(mae, 6),
"sha256": {"persp": hashlib.sha256(p8.tobytes()).hexdigest(),
"linear": hashlib.sha256(l8.tobytes()).hexdigest()}}
with open(os.path.join(out, "cg_raster_12_2_tex.json"), "w", encoding="utf-8") as fh:
json.dump(stats, fh, ensure_ascii=False, indent=2)
print(f"画布 : {W}x{H} 覆盖 {covered_n} 像素 剔除 {pcull} 个三角形")
print(f"裁剪 w : {min(ws)} .. {max(ws)}(近端/远端 = {max(ws) / min(ws):.2f} 倍)")
for i, v in enumerate(verts):
print(f" 顶点 {i}: 屏幕 Q6 ({v['xQ6']:5d},{v['yQ6']:5d}) 深度 {v['depthQ16']:6d} w {v['wQ16']:7d}")
print(f"两条路径差异 : {diff_px} / {covered_n} 像素({diff_px * 1000 // covered_n}‰)")
print(f"最大通道偏差 : {max_diff}")
print(f"MAE : {mae:.4f}")
print(f"sha256 persp : {stats['sha256']['persp']}")
print(f"sha256 linear : {stats['sha256']['linear']}")
print(f"png : persp {png_p} bytes / linear {png_l} bytes")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出:
画布 : 96x72 覆盖 2426 像素 剔除 0 个三角形
裁剪 w : 478931 .. 1229987(近端/远端 = 2.57 倍)
顶点 0: 屏幕 Q6 ( 341, 4163) 深度 61532 w 478931
顶点 1: 屏幕 Q6 ( 5802, 4163) 深度 61532 w 478931
顶点 2: 屏幕 Q6 ( 4135, 1544) 深度 64292 w 1229987
顶点 3: 屏幕 Q6 ( 2008, 1544) 深度 64292 w 1229987
两条路径差异 : 1923 / 2426 像素(792‰)
最大通道偏差 : 180
MAE : 59.5761
sha256 persp : 58ab66a9a29c36dc56bdf3e5fbc8b9e754e8975e4641ed8b1d8afb076b8e214e
sha256 linear : 4064924745576acfaa6db376db6cc32b76942ebc6f2e8154de72e277ece001e3
2426 个覆盖像素里有 1923 个不一样,占 792‰——接近八成。最大通道偏差 180(满值 255),说明不少像素是「浅色格子被采成了深色格子」这种级别的错,不是舍入噪声。这就是不做透视校正的代价。
跨语言断言
TypeScript 侧用 Number 重写同一套算式(乘积上限 $2^{47}$,浮点整数区精确),把两张缓冲逐字节对齐:
✔ 【核心】透视校正路径的像素缓冲 sha256 与 Python 侧逐字节相同
✔ 线性路径的像素缓冲 sha256 与 Python 侧逐字节相同
✔ 【负例】线性路径与透视校正路径必须被判据区分开
ℹ tests 3 ℹ pass 3 ℹ fail 0
负例那条断言 MAE > 1.0:如果哪天有人「顺手」把 fdiv(Uo, Io) 改成 Uo,两侧会同时变错、sha256 断言照样过,只有这条负例会拦住它。
第三种宿主:WebGL2
同一份 config/cg_raster_12_2_texquad.json 交给 WebGL2,就成了三宿主的对照。这里有一个必须做对的前提:顶点着色器要把裁剪空间坐标连同真实的 $w$ 原样交出去,让 gl_Position = clip。若像往常那样先除以 $w$ 再把 $w$ 设成 1,GPU 的 varying 插值就退化成屏幕空间线性——透视校正根本没被触发。
// snippet: lantern/web/build_raster12.mjs 的顶点着色器与负例片段着色器(节选)
// 顶点着色器把裁剪空间坐标原样交给 GPU —— w 不归一化,varying 插值才是透视校正的
in vec4 aClipQ16; in vec2 aUvQ16;
out vec2 vUv; out vec2 vUvW; out float vW;
void main() {
vec4 clip = aClipQ16 / 65536.0;
vec2 uv = aUvQ16 / 65536.0;
vUv = uv; // 正确:交给 GPU 按 1/w 校正后插值
vUvW = uv * clip.w; // 负例用:把 (uv*w) 与 w 分别插值
vW = clip.w;
gl_Position = clip;
}
// 负例片段着色器:o = vec4(texture(uTex, vUvW / vW).rgb, 1.0);
// 两者相除,反推出的正是**屏幕空间线性**的 uv —— 必须被容差判据拒绝
实跑验证输出:
CPU covered : 2426 W 层 covered : 2430
GL 后端 : ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (LLVM 10.0.0) (0x0000C0DE)), SwiftShader driver)
① 页面内 CPU 内核 vs S 层缓冲:不同字节 = 0 / 0
② CPU 定点内核 vs WebGL2(透视校正):
覆盖归属不同 : 4 / 6912 (0.58‰) ← 主判据
最大通道差 : 130
MAE : 0.1636
PSNR : 38.27 dB
③ 负例(GPU 改算屏幕空间线性 uv)vs S 层透视校正:
MAE : 21.0039 PSNR : 14.33 dB ← 必须被同一判据拒绝
判据用的是对舍入不敏感的三个统计量:覆盖归属差异 ≤ 20‰、MAE ≤ 1.0、PSNR ≥ 30 dB。不用「逐位相等」,因为 GPU 的光栅化规则与 CPU 不同:共享边上的像素归属由 top-left rule 决定,浮点插值的舍入方式也不一样,这些都会让个别像素差 1。实测覆盖归属只差 4 个像素(0.58‰)、MAE 0.1636,落在判据内;而负例的 MAE 21.0039 超出 1.0 达 21 倍,被干净地拒绝。对照组更能说明问题:负例与 S 层的线性路径相比,MAE 只有 0.1580——它确实是线性插值,只是用 GPU 算的。
有一点必须写清楚:本机 headless 环境下的 WebGL2 后端是 ANGLE/SwiftShader 软件实现,不是真实 GPU。上面这组数字说明的是「定点内核与软件光栅化器在同一场景下一致」,不能据此宣称真实硬件也如此,那需要 X 档的实机验证。
下一节解决另一头的错:纹理被缩小到一像素跨多格时,采一个 texel 是不够的。
阅读导航:上一节:12.1 UV 与纹理坐标 · 下一节:12.3 纹理过滤与 mipmap 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。