10.2 深度冲突与逆转 z

两个几乎重合的面若在深度缓冲里落进同一格,深度测试就分不出前后,像素随视角抖动——这就是深度冲突。本节用两块相距 13/65536 的平行墙,把朴素线性 z、标准透视 z、逆转 z 三种映射的冲突像素数摆在一起,解释逆转 z 为何要把深度测试从 < 翻成 >,并如实说明定点缓冲与浮点缓冲下它的差别。

10.2 深度冲突与逆转 z

上一节算清了「远处 1 个 LSB 有多粗」——$D = 64$ 处 1 个 LSB 折合 $0.124$ 世界单位。这个数直接引出一个后果:如果两个面的间隙小于该处的 1 个 LSB,它们在缓冲里会落进同一格,深度测试再也分不出谁前谁后,像素颜色随相机微动来回翻转。这就是深度冲突(z-fighting)。它和纹理采样时的精度问题是两码事,如果你更关心纹理那侧,可以看 纹理与显存 那篇——那篇讲的是纹理显存管理;本卷讲的是深度缓冲的定点精度与分布。

本节要做的不是「知道有 z-fighting」,而是把它量化:在给定场景下,三种深度映射各有多少像素冲突,逆转 z 凭什么把冲突数压到零,以及——诚实地说——它在本卷的定点缓冲里和浮点缓冲里意味着不同的事。

冲突的定义:两张缓冲的存储码相等

把场景定死:两块平行墙,一块在 $z = -1$,另一块在 $z = -(1 + \text{gap})$,gap $= 13/65536 \approx 1.98\times10^{-4}$ 世界单位。两面各自投影、各自光栅化,得到两张 $64\times48$ 的 Q16 深度缓冲。对每个被两面同时覆盖的像素,如果两张缓冲里的深度值相等,深度测试就无法分辨——这个像素记为一次冲突。

冲突只在重叠像素上定义:两面都覆盖到的像素才有「谁前谁后」的问题。本场景两面都完整覆盖整屏 $3072$ 个像素,重叠也是 $3072$,于是可以逐像素比较。

三种深度映射

同一个世界距离 $D$,不同 z-buffer 方案会算出不同的存储码。本节比较三种:

正向 z   : d = (D - near)/(far - near)          —— 朴素线性 z-buffer(near->0, far->1)
标准透视 : d = far/(far - near)*(1 - near/D)     —— 本卷 project() 给出的映射
逆转 z   : d = (1/D - 1/far)/(1/near - 1/far)   —— near->1, far->0

差别全在「用 $D$ 还是用 $1/D$」:

  • **正向 z(朴素线性)**直接对 $D$ 线性采样。它均匀分配刻度,但透视投影本身是 $1/D$ 的,于是刻度与「投影后的深度」错配——近处浪费、远处不够,是本场景里冲突的元凶。
  • 标准透视对 $1/D$ 线性,正是上一节 project() 的映射,刻度按透视分布。
  • 逆转 z同样对 $1/D$ 线性,但把端点方向翻过来:near 映射到 $1$、far 映射到 $0$。$1/D$ 在 near 附近变化最快,把这段变化放到码值高端,就换来了更好的近处分辨率。

关键约定:逆转 z 的端点方向反了(near $\to 1$ 而不是 $0$),所以「越小越近」不再成立,深度测试必须从 < 翻成 >。忘了翻,近远墙的归属会整体颠倒。

实测:冲突像素数

脚本对每个重叠像素算出三种映射下的存储码,统计两面码值相等的像素数:

canvas/near/far : 64x48  near=32768(0.5)  far=4194304(64.0)  gap=13(0.000198)
覆盖            : 近墙 3072  远墙 3072  重叠 3072  (culled 0/0)
冲突像素(两墙存储码相等,深度测试无法分辨):
  正向 z(线性)  :  3072 / 3072  (1000‰)
  标准透视 z      :     0 / 3072  (0‰)
  逆转 z          :     0 / 3072  (0‰)
严格下降(逆转 < 正向): True
负例(逆转 z 忘翻测试方向): 正确 `>` 保留近墙 3072 像素;错误 `<` 保留近墙 0 像素(= 保留远墙 3072),两者不同 3072 像素
png             : 83 bytes

读法:

  • 正向 z 冲突 3072/3072,即 1000‰。间隙 $1.98\times10^{-4}$ 在 $D \approx 1$ 处小于 1 个线性刻度的宽度,两面在每个像素都落进同一格——这是最坏情况。
  • 标准透视 z 与逆转 z 都是 0 冲突。两者都对 $1/D$ 线性,在这个 $D \approx 1$、near $= 0.5$ 的场景里,$1/D$ 的相邻刻度远细于间隙,于是把两面分开。逆转 z 的冲突数严格小于正向 z(True),满足本节的断言。
  • 负例:逆转 z 下存储码越大越近,正确测试 > 保留较大码 = 近墙($3072$ 个像素全归近墙);若忘了翻、仍用 <,保留较小码 = 远墙,$0$ 个像素归近墙。两者相差 $3072$ 像素——测试方向这一个字符,决定了整屏的归属。

诚实的边界:定点缓冲下的逆转 z

这里必须说清一件事,否则「逆转 z 把冲突从 1000‰ 降到 0」会被误读。

在浮点缓冲里,逆转 z 的经典收益是:$1/D$ 在 near 附近变化最快,而浮点数在接近 $1$ 时尾数精度最高,两者相乘,让近处的有效位数最多——标准透视 z 在 far 附近会严重丢失精度,逆转 z 不会。这是逆转 z 真正的用武之地。

但本卷的缓冲是定点的。定点数在整个 $[0,\text{ONE}]$ 上均匀分格,$1$ 附近与 $0$ 附近的绝对精度一样。所以在定点下,逆转 z 相对标准透视 z 的收益不来自浮点尾数,而来自它与标准透视 z 一样避开了「朴素线性」这个坑。本节把「正向 z」定义为朴素线性,正是为了给出一个真实存在 1000‰ 冲突、且能被逆转 z 严格压低的对照——Python 与 TypeScript 两份实现逐字节相同,但这不是「GPU 与 CPU 逐位相等」,真实 GPU 的浮点行为需要另设容差。要谈逆转 z 在真实硬件上的尾数收益,必须在浮点缓冲上复跑。

可运行产物

下面的脚本渲染两面墙、落盘两张深度缓冲,再逐像素算三种映射的冲突数,并复现「忘翻测试方向」的负例。它复用共享库 cglib.rasterize,不重写覆盖与插值。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_10_2_zfight_revz.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_10_2_zfight_revz.py
"""10.2 深度冲突与逆转 z:两面几乎重合的墙,三种深度映射下的冲突像素数。

场景:两块相距 gap = 13/65536 ≈ 1.98e-4 世界单位的平行墙(一块在 z=-1,另一块在
z=-(1+gap)),各自投影、各自光栅化,得到两张 Q16 深度缓冲。对每个被两面同时覆盖的
像素,把两张缓冲的深度换算成三种**深度映射**下的存储码:

  正向 z   : d = (D-near)/(far-near)                —— 朴素线性 z-buffer(near->0, far->1)
  标准透视 : d = far/(far-near)*(1-near/D)          —— 本卷 project() 给出的映射
  逆转 z   : d = (1/D-1/far)/(1/near-1/far)         —— near->1, far->0

两个面在某个映射下"冲突"<=> 它们的存储码相等(深度测试无法分辨)。断言:
  冲突(逆转 z) < 冲突(正向 z) 严格成立。
逆转 z 的端点方向反了(near->1),深度测试必须从 `<` 翻成 `>`;忘了翻的负例在下面复现。

产物:out/cg_raster_10_2_zfight.json、out/cg_raster_10_2_depth_a.bin、_depth_b.bin、
      out/cg_raster_10_2_conflict_lin.png。
"""

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
INTMAX = np.iinfo(np.int64).max


def make_mvp(tan_half, aspect, near, far):
    view0 = cglib.mat_look_at((0, 0, 0), (0, 0, -ONE), (0, ONE, 0))
    return cglib.mat_mul(cglib.mat_perspective(tan_half, aspect, near, far), view0)


def wall_tris(mvp, zq, hx, hy, w, h, color):
    """一块平行于像平面的墙 -> 两个屏幕空间三角形(屏幕 Q6 + Q16 深度 + 颜色)。"""
    corners = [(-hx, -hy, -zq), (hx, -hy, -zq), (hx, hy, -zq), (-hx, hy, -zq)]
    scr = []
    for c in corners:
        r = cglib.project(mvp, c)
        if r is None:
            return None
        sx, sy = cglib.to_screen(r[0], r[1], w, h)
        scr.append((sx, sy, r[2]))
    tris = []
    for a, b, c in ((0, 1, 2), (0, 2, 3)):
        area = (scr[b][0] - scr[a][0]) * (scr[c][1] - scr[a][1]) - (scr[b][1] - scr[a][1]) * (scr[c][0] - scr[a][0])
        if area <= 0:  # 屏幕空间逆时针为正面;若反了就换环绕
            b, c = c, b
        tris.append(tuple(v for p in (scr[a], scr[b], scr[c]) for v in (p[0], p[1], p[2], *color)))
    return tris


def recover_dist(d, near, far):
    """由 Q16 归一化深度反推世界距离 D(Q16):D = near / (1 - d*(far-near)/far)。"""
    t = ONE - cglib.fmul(int(d), cglib.fdiv(far - near, far))
    if t <= 0:
        return far
    return cglib.fdiv(near, t)


def code_std(d):
    """标准透视:存储码就是归一化深度本身(Q16,1 LSB = 1)。"""
    return int(d)


def code_lin(D, near, far):
    """朴素线性 z-buffer:d = (D-near)/(far-near)。"""
    return ((D - near) << 16) // (far - near)


def code_rev(D, near, far):
    """逆转 z:d = (1/D - 1/far)/(1/near - 1/far),near->1、far->0。"""
    inv_d = cglib.fdiv(ONE, D)
    num = inv_d - cglib.fdiv(ONE, far)
    den = cglib.fdiv(ONE, near) - cglib.fdiv(ONE, far)
    return cglib.fdiv(num, den)


def main():
    with open(os.path.join(ROOT, "config", "cg_raster_10_zfight.json"), encoding="utf-8") as fh:
        cfg = json.load(fh)
    W, H = cfg["canvas"]
    near, far, gap = cfg["nearQ16"], cfg["farQ16"], cfg["gapQ16"]
    z0, hx, hy = cfg["wallZQ16"], cfg["wallHalfXQ16"], cfg["wallHalfYQ16"]
    front, back = tuple(cfg["frontColor"]), tuple(cfg["backColor"])
    aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
    mvp = make_mvp(cfg["tanHalfFovYQ16"], aspect, near, far)

    tris_a = wall_tris(mvp, z0, hx, hy, W, H, front)          # 近墙
    tris_b = wall_tris(mvp, z0 + gap, hx, hy, W, H, back)     # 远墙(差一个 gap)
    _ca, dA, cov_a, cull_a = cglib.rasterize(tris_a, W, H, (0, 0, 0))
    _cb, dB, cov_b, cull_b = cglib.rasterize(tris_b, W, H, (0, 0, 0))
    both = cov_a & cov_b

    # 逐像素换算三种映射的存储码(numpy int64,全部非负 -> `//` 与截断一致)
    def codes(depth_buf, fn):
        out = np.zeros((H, W), dtype=np.int64)
        for py in range(H):
            for px in range(W):
                if not both[py, px]:
                    continue
                out[py, px] = fn(recover_dist(int(depth_buf[py, px]), near, far))
        return out

    cstd_a, cstd_b = codes(dA, code_std), codes(dB, code_std)
    clin_a = codes(dA, lambda D: code_lin(D, near, far))
    clin_b = codes(dB, lambda D: code_lin(D, near, far))
    crev_a = codes(dA, lambda D: code_rev(D, near, far))
    crev_b = codes(dB, lambda D: code_rev(D, near, far))

    n_both = int(both.sum())
    conflict = {
        "std": int(np.sum(both & (cstd_a == cstd_b))),
        "lin": int(np.sum(both & (clin_a == clin_b))),
        "rev": int(np.sum(both & (crev_a == crev_b))),
    }

    # 负例:逆转 z 忘了把深度测试从 `<` 翻成 `>`。
    # 逆转 z 下存储码越大越近;正确测试保留较大码(近墙),忘记翻则保留较小码(远墙)。
    correct_near = int(np.sum(both & (crev_a > crev_b)))   # 正确:`>` 保留较大码 = 近墙(A)
    buggy_near = int(np.sum(both & (crev_a < crev_b)))     # 错误:`<` 保留较小码 = 远墙(B),此处为 0
    neg_flip = int(np.sum(both & ((crev_a > crev_b) != (crev_b > crev_a))))

    # 直观产物:线性映射的冲突掩码
    out = os.path.join(ROOT, "out")
    mask = (both & (clin_a == clin_b)).astype(np.int64)
    png = cglib.save_gray_png(os.path.join(out, "cg_raster_10_2_conflict_lin.png"), mask * 65536)
    with open(os.path.join(out, "cg_raster_10_2_depth_a.bin"), "wb") as fh:
        fh.write(np.where(both, dA, 0).astype(np.int64).astype("<i8").tobytes())
    with open(os.path.join(out, "cg_raster_10_2_depth_b.bin"), "wb") as fh:
        fh.write(np.where(both, dB, 0).astype(np.int64).astype("<i8").tobytes())

    with open(os.path.join(out, "cg_raster_10_2_zfight.json"), "w", encoding="utf-8") as fh:
        json.dump({
            "canvas": [W, H], "near": near, "far": far, "gap": gap,
            "culled": [cull_a, cull_b], "covered": [int(cov_a.sum()), int(cov_b.sum())],
            "overlap": n_both, "conflict": conflict,
            "strict_decrease": conflict["rev"] < conflict["lin"],
            "negative_flip": {"correct_picks_near": correct_near, "buggy_picks_near": buggy_near, "diff": neg_flip},
            "sample_pixel": _sample(clin_a, clin_b, crev_a, crev_b, both),
            "png_bytes": png,
        }, fh, ensure_ascii=False, indent=2)

    print(f"canvas/near/far : {W}x{H}  near={near}({near / ONE})  far={far}({far / ONE})  gap={gap}({gap / ONE:.6f})")
    print(f"覆盖            : 近墙 {int(cov_a.sum())}  远墙 {int(cov_b.sum())}  重叠 {n_both}  (culled {cull_a}/{cull_b})")
    print(f"冲突像素(两墙存储码相等,深度测试无法分辨):")
    print(f"  正向 z(线性)  : {conflict['lin']:>5} / {n_both}  ({conflict['lin'] * 1000 // n_both}‰)")
    print(f"  标准透视 z      : {conflict['std']:>5} / {n_both}  ({conflict['std'] * 1000 // n_both}‰)")
    print(f"  逆转 z          : {conflict['rev']:>5} / {n_both}  ({conflict['rev'] * 1000 // n_both}‰)")
    print(f"严格下降(逆转 < 正向): {conflict['rev'] < conflict['lin']}")
    print(f"负例(逆转 z 忘翻测试方向): 正确 `>` 保留近墙 {correct_near} 像素;"
          f"错误 `<` 保留近墙 {buggy_near} 像素(= 保留远墙 {n_both - buggy_near}),两者不同 {neg_flip} 像素")
    print(f"png             : {png} bytes")
    return 0


def _sample(clin_a, clin_b, crev_a, crev_b, both):
    """取一个重叠像素,打印三种映射下两墙的存储码。"""
    ys, xs = np.where(both)
    if len(ys) == 0:
        return {}
    py, px = int(ys[len(ys) // 2]), int(xs[len(xs) // 2])
    return {"pixel": [px, py], "lin": [int(clin_a[py, px]), int(clin_b[py, px])],
            "rev": [int(crev_a[py, px]), int(crev_b[py, px])]}


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

实测输出:

canvas/near/far : 64x48  near=32768(0.5)  far=4194304(64.0)  gap=13(0.000198)
覆盖            : 近墙 3072  远墙 3072  重叠 3072  (culled 0/0)
冲突像素(两墙存储码相等,深度测试无法分辨):
  正向 z(线性)  :  3072 / 3072  (1000‰)
  标准透视 z      :     0 / 3072  (0‰)
  逆转 z          :     0 / 3072  (0‰)
严格下降(逆转 < 正向): True
负例(逆转 z 忘翻测试方向): 正确 `>` 保留近墙 3072 像素;错误 `<` 保留近墙 0 像素(= 保留远墙 3072),两者不同 3072 像素
png             : 83 bytes

P 层:三种映射跨语言逐位相同

TypeScript 侧把三种存储码公式重写进 core/src/lantern/cg_raster_10_zfight.ts(recoverDist / codeStd / codeLin / codeRev,全程 BigInt,定点除法一律走 fdiv 显式向零截断)。测试 core/test/cg_raster_10_2_zfight_revz.test.ts 读入 S 层落盘的两张 .bin 深度缓冲,复算每个重叠像素的三种映射,断言冲突数与金标准逐位相同:线性 $3072$、标准 $0$、逆转 $0$。

测试还含两个负例,缺一不可:

  • 负例①:逆转 z 忘了翻测试方向。正确 > 让全部 $3072$ 个重叠像素归近墙;错误 < 让 $0$ 个归近墙。断言两者相差 $3072$——证明「翻转方向」是判据的实质,不是装饰。
  • 负例②:把逆转 z 的映射误写成线性(丢掉 $1/D$)。此时冲突数不再下降,与线性一致($3072$),与正确逆转律($0$)不同。断言两者必须不等——证明冲突数的下降确实来自 $1/D$,而不是巧合。

两个负例加上三条正例,本节 5 条测试全部通过。下一节换一个更古老的思路:干脆不做逐像素深度测试,只把三角形按深度排个序、从远到近画,看它和 z-buffer 在什么场景下等价、什么场景下分道扬镳。

阅读导航:上一节:10.1 深度缓冲的精度分布 · 下一节:10.3 画家算法与深度排序 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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