12.2 透视校正插值

顶点上的 UV 直接线性插值到像素是错的:透视投影下屏幕坐标是 x/w,属性在屏幕空间不再线性。正确做法是插值 u/w 与 1/w 再相除。本节在一块近远端 w 相差 2.57 倍的后仰四边形上把两条路径都跑一遍,量出 1923 个像素不同、最大通道偏差 180,再让 WebGL2 用同一份场景复现。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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