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 画家算法与深度排序 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。