10.1 深度缓冲的精度分布
第 6 章确立了「深度越小越近、全部归一化到 $[0, \text{ONE}]$」的约定,第 7 到第 9 章把光栅化的每一道关都拆开看了一遍。到这一章,我们把镜头对准深度缓冲本身:它到底能分辨多细的距离差?如果你对显存与纹理采样的开销更感兴趣,可以先看 纹理与显存 那篇——那篇讲的是纹理显存管理;本卷讲的是深度缓冲的定点精度与分布。
直觉上,Q16.16 的深度缓冲有 $2^{16}$ 个刻度,看起来「够用」。但把一个刻度换算回世界距离时,它会随距离迅速变粗——远平面附近的 1 个刻度可能对应几十厘米,近平面附近却不到一毫米。本节要把这条「变粗曲线」算成可验证的数,并说清它如何限制逐像素的定点插值。
与 5.3 的分工:量程怎么分,还是刻度值多少
第 5 章 5.3 节也从精度讲起,但角度不同,这里必须先划清边界,否则读者会以为在重复。
- 5.3 的视角在相机参数:给定 near / far,各倍程距离占归一化量程 $[0,1]$ 的比例是多少。它回答「量程怎么分」——近处吃掉多少区间、远处还剩多少。
- 本节的视角在缓冲内部:不改变相机参数,只问「缓冲里相邻两个整数刻度之间,隔着多少世界距离」。它回答「1 个刻度值多少世界单位」,以及这个值如何沿距离传播、如何与逐像素插值精度相互作用。
前者是分配问题,后者是分辨率问题——互补,不重叠。
缓冲内部只有 65537 个刻度
本卷的深度用 Q16.16 定点数,$\text{ONE} = 65536$。归一化深度被夹在 $[0, \text{ONE}]$ 里,所以缓冲能表示的深度值是 $65537$ 个离散刻度($0$ 到 $65536$ 全含),相邻刻度之间相差 $1$ 个 LSB,即 $1/65536$。
相机把世界距离 $D$ 映射到归一化深度,用的是本卷 project() 给出的透视映射;把它反解即得从深度回推距离的公式。$D = \text{near}$ 时 $\text{depth} = 0$,$D = \text{far}$ 时 $\text{depth} = \text{ONE}$:
depth(D) = far/(far - near) * (1 - near/D)
D(d) = near / (1 - d*(far - near)/far)
两个公式都是整数可算的——project() 内部全程走 Q16.16 的 fmul / fdiv,fdiv 是显式向零截断的定点除法,不依赖任何裸 / 的语义假设。
精度分布:一个刻度的世界分辨率按 D² 增长
把 $D(d)$ 对 $d$ 求导,得到「1 个深度 LSB 对应多少世界距离」的解析式:
ΔD = D² * (far - near) / (ONE * far * near)
关键是那个 $D^2$:分辨率随距离平方级变粗。本节用的相机是 near $= 0.5$、far $= 64$(Q16 下为 $32768$ / $4194304$)。下表列出各倍程距离处 1 个 LSB 的世界分辨率:
D= 0.5 depth= 0 0 Q16 = 0.000007570 世界单位
D= 1.0 depth= 33026 1 Q16 = 0.000030279 世界单位
D= 2.0 depth= 49539 7 Q16 = 0.000121117 世界单位
D= 4.0 depth= 57795 31 Q16 = 0.000484467 世界单位
D= 8.0 depth= 61923 127 Q16 = 0.001937866 世界单位
D= 16.0 depth= 63987 508 Q16 = 0.007751465 世界单位
D= 32.0 depth= 65019 2032 Q16 = 0.031005859 世界单位
D= 64.0 depth= 65535 8128 Q16 = 0.124023438 世界单位
读法:距离每翻一倍,1 个 LSB 的世界分辨率大约翻四倍($0.0000303 \to 0.000121 \to 0.000484 \dots$,正好是 $4\times$)。$D = 0.5$ 处 1 个 LSB 是 $7.6$ 微米,$D = 64$ 处却是 $0.124$ 世界单位——粗了约 1.6 万倍。这就是深度冲突(下一节的主题)的根源:远处两个面在缓冲里可能落在同一格。
塌陷距离:多远的间隙就分辨不了了
有了分辨率,就能回答一个更实用的问题:给定两个面的世界间隙 gap,它们在多远处会因为间隙小于 1 个 LSB 而「塌陷」到同一格?条件是 world_per_lsb(D) < gap,等价于 D < sqrt(gap * ONE * far * near / (far - near)),右端即塌陷距离 $D^$。脚本用整数二分求它(全程 << 与 //,不引入浮点):间隙 $0.001$ 世界可分辨到 $D^ = 5.77$,间隙 $0.01$ 到 $18.17$,间隙 $0.1$ 到 $57.47$,间隙 $1.0$ 到 $181.73$(超出 far,全程可分辨)。这条式子把「多大的缝、在多远处开始冲突」变成了一个可预先计算的判据。
与逐像素定点插值的交互
深度值不是直接从顶点取来的,而是逐像素插出来的。本卷的插值是重心坐标加权、再整除面积:z = (w0*d0 + w1*d1 + w2*d2) // area,其中 w0 + w1 + w2 == area。分子非负(权重 $\ge 0$、深度 $\ge 0$),所以这里的 // 与「向零截断」一致;又因权重和恰好等于面积,精确有理数与整数结果之差恒在 $[0, 1)$ 个 LSB 内——截断误差不超过 1 个 LSB。
但这个「不超过 1 个 LSB」是缓冲单位下的界。换算到世界单位,它同样被 $D^2$ 放大:远处像素的 1 LSB 截断误差,折算成世界坐标可能远大于近处。脚本在一个 $48\times36$ 的三角形上遍历所有覆盖像素,实测最大截断误差 $0.9986$ 个 LSB(压在上界之内),对应到该像素所在距离的世界误差是 $9.1\times10^{-5}$。两个量一起看,说明:缓冲单位下的插值误差是有界的,但世界单位下的误差随距离放大——远处插值细不细,取决于该处 1 个 LSB 有多粗。
可运行产物
下面的脚本把上面每一个数都算出来:256 点深度阶梯(含 sha256 指纹)、各倍程的世界分辨率、解析式与有限差分的交叉验证、塌陷距离的整数二分,以及定点插值误差界。它只复用共享库 cglib,不重写投影与光栅化。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_10_1_depth_precision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_10_1_depth_precision.py
"""10.1 深度缓冲的精度分布:Q16.16 刻度随距离的衰减,以及它与定点插值精度的交互。
本节看的是**缓冲内部**:Q16.16 在 [0, ONE] 上有 ONE+1 个离散刻度(1 个 LSB = 1/65536),
但把 1 个 LSB 换算回世界距离时,可分辨的最小深度差随距离按 D^2 增长。这与 5.3
「相机参数视角下各倍程占量程的比例」是两个角度:5.3 问「量程怎么分」,本节问
「1 个刻度值多少世界单位、误差怎么沿距离传播、它如何限制逐像素的定点插值」。
产物:out/cg_raster_10_1_precision.json(含 256 点深度阶梯的 sha256)、
out/cg_raster_10_1_precision.csv。
"""
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 = cglib.ONE
SUB1, HALF = cglib.SUB1, cglib.SUB1 // 2
def make_mvp(tan_half, aspect, near, far):
"""规范针孔:眼在原点、看 -z;世界点 (0,0,-D) 距相机 D。"""
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 depth_at(mvp, dq):
"""世界距离(Q16)-> 归一化深度(Q16,[0, ONE]);相机后方返回 None。"""
r = cglib.project(mvp, (0, 0, -dq))
return None if r is None else r[2]
def dist_from_depth(d, near, far):
"""由归一化深度反推世界距离(Q16):D = near / (1 - d*(far-near)/far)。"""
t = ONE - cglib.fmul(d, cglib.fdiv(far - near, far))
return cglib.fdiv(near, t)
def world_per_lsb_q16(dq, near, far):
"""1 个深度 LSB 对应多少世界距离(Q16 单位)。
解析式:ΔD = D^2*(far-near)/(ONE*far*near)。这里全程整数:
num = (FAR-NEAR)*Dq^2,den = FAR*NEAR,结果再 >> 16。
"""
num = (far - near) * dq * dq
den = far * near
return (num // den) >> 16
def collapse_distance_q16(gap_q16, near, far):
"""给定世界间隙 gap(Q16),返回仍可分辨的最大距离 D*(Q16):D^2 < gap*ONE*far*near/(far-near)。"""
limit = (gap_q16 << 16) * far * near // (far - near)
lo, hi = 1, 1
while hi * hi < limit:
hi <<= 1
while lo < hi:
mid = (lo + hi + 1) >> 1
if mid * mid <= limit:
lo = mid
else:
hi = mid - 1
return lo
def interpolation_error(near, far):
"""逐像素定点插值的误差界:interp = (w0*d0 + w1*d1 + w2*d2) // area。
分子非负(权重 >= 0、深度 >= 0)故 `//` 与截断一致。误差 = 精确有理数 - 整数,恒在 [0, 1) LSB。
再把该 LSB 误差换算到像素所在距离的世界单位,看误差如何随距离放大。
"""
W, H = 48, 36
A, B, C = (2 * SUB1, 2 * SUB1), (46 * SUB1, 4 * SUB1), (6 * SUB1, 34 * SUB1)
d0, d1, d2 = 3001, 49993, 20011
tri = (A[0], A[1], d0, 200, 120, 60, B[0], B[1], d1, 200, 120, 60, C[0], C[1], d2, 200, 120, 60)
color, depth, covered, _cull = cglib.rasterize([tri], W, H, (0, 0, 0))
area = (B[0] - A[0]) * (C[1] - A[1]) - (B[1] - A[1]) * (C[0] - A[0])
best_err, best_world, best_px = -1.0, 0, None
max_world = 0
for py in range(H):
for px in range(W):
if not covered[py, px]:
continue
X, Y = px * SUB1 + HALF, py * SUB1 + HALF
w0 = (C[0] - B[0]) * (Y - B[1]) - (C[1] - B[1]) * (X - B[0])
w1 = (A[0] - C[0]) * (Y - C[1]) - (A[1] - C[1]) * (X - C[0])
w2 = (B[0] - A[0]) * (Y - A[1]) - (B[1] - A[1]) * (X - A[0])
if w0 < 0 or w1 < 0 or w2 < 0:
continue
num = w0 * d0 + w1 * d1 + w2 * d2
z = num // area
err = num / area - z # in [0, 1) LSB
dq = dist_from_depth(int(depth[py, px]), near, far)
world = err * world_per_lsb_q16(dq, near, far) / ONE
if err > best_err:
best_err, best_world, best_px = err, world, (px, py)
if world > max_world:
max_world = world
return {
"canvas": [W, H], "vertices": [list(A), list(B), list(C)], "depths": [d0, d1, d2],
"area": area, "max_err_lsb": round(best_err, 6), "max_err_world": round(best_world, 8),
"max_err_pixel": list(best_px), "max_world_err": round(max_world, 8),
}
def main():
spec = cglib.load_spec()
cam = spec["camera"]
tan_half, near, far = cam["tanHalfFovYQ16"], cam["nearQ16"], cam["farQ16"]
aspect = cglib.fdiv(cglib.f_from_int(spec["width"]), cglib.f_from_int(spec["height"]))
mvp = make_mvp(tan_half, aspect, near, far)
out = os.path.join(ROOT, "out")
# 1. 深度阶梯:256 个对数均匀距离 -> 归一化深度(Q16)
n = 256
dists = [round(near * (far / near) ** (k / (n - 1))) for k in range(n)]
ramp = [depth_at(mvp, dq) for dq in dists]
assert all(v is not None for v in ramp), "所有采样距离都应在相机前方"
monotone = all(ramp[k + 1] >= ramp[k] for k in range(n - 1))
strict = sum(1 for k in range(n - 1) if ramp[k + 1] > ramp[k])
in_range = all(0 <= v <= ONE for v in ramp)
ramp_bytes = b"".join(int(v).to_bytes(4, "little") for v in ramp)
ramp_sha = hashlib.sha256(ramp_bytes).hexdigest()
# 2. 精度分布:各倍程距离处「1 个深度 LSB = 多少世界距离」
octaves = []
d = near
while d <= far:
wq = world_per_lsb_q16(d, near, far)
wf = ((far - near) * d * d / (far * near)) / ONE / ONE
octaves.append({
"distance_q16": d, "distance": round(d / ONE, 6),
"depth_q16": depth_at(mvp, d),
"world_per_lsb_q16": wq, "world_per_lsb_f": round(wf, 9),
})
d *= 2
# 3. 解析式 vs 有限差分:确认世界分辨率就是 D^2 律
fd = []
for d in (near, 2 * near, 4 * near, 8 * near, 16 * near, 32 * near, 64 * near):
if d > far:
continue
step = max(ONE // 8, d // 32)
dd = (depth_at(mvp, d + step) - depth_at(mvp, d - step))
analytic = world_per_lsb_q16(d, near, far)
numeric = (2 * step) // dd if dd > 0 else 0
fd.append({"distance": round(d / ONE, 4), "analytic_q16": analytic, "numeric_q16": numeric})
# 4. 塌陷距离:多大的世界间隙在多远之外就分辨不了
collapse = []
for gap in (0.001, 0.01, 0.1, 1.0):
gq = round(gap * ONE)
dstar = collapse_distance_q16(gq, near, far)
collapse.append({"gap": gap, "gap_q16": gq, "collapse_distance_q16": dstar,
"collapse_distance": round(dstar / ONE, 4), "beyond_far": dstar >= far})
interp = interpolation_error(near, far)
cglib.write_csv(os.path.join(out, "cg_raster_10_1_precision.csv"),
["distance", "depth_q16", "world_per_lsb_q16", "world_per_lsb_f"],
[[o["distance"], o["depth_q16"], o["world_per_lsb_q16"], o["world_per_lsb_f"]] for o in octaves])
with open(os.path.join(out, "cg_raster_10_1_precision.json"), "w", encoding="utf-8") as fh:
json.dump({
"near": near, "far": far, "one": ONE, "levels": ONE + 1,
"ramp_dist_q16": dists, "ramp_depth_q16": ramp, "ramp_sha256": ramp_sha,
"monotone": monotone, "strict_increases": strict, "in_range": in_range,
"octaves": octaves, "finite_diff": fd, "collapse": collapse, "interp": interp,
}, fh, ensure_ascii=False, indent=2)
print(f"near/far : {near} / {far}(Q16),缓冲刻度数 = ONE+1 = {ONE + 1}")
print(f"深度阶梯 : {n} 点,单调不减={monotone},严格上升 {strict}/{n - 1},值域 [0,ONE]={in_range}")
print(f"深度阶梯 sha256 : {ramp_sha}")
print("精度分布(1 个深度 LSB 对应多少世界距离):")
for o in octaves:
print(f" D={o['distance']:>7} depth={o['depth_q16']:>6} {o['world_per_lsb_q16']:>8} Q16"
f" = {o['world_per_lsb_f']:.9f} 世界单位")
print("解析式 vs 有限差分(世界分辨率,Q16):")
for r in fd:
print(f" D={r['distance']:>7} 解析 {r['analytic_q16']:>8} 差分 {r['numeric_q16']:>8}")
print("塌陷距离(间隙小于 1 个 LSB 时两个面无法分辨):")
for c in collapse:
print(f" gap={c['gap']:>6} 世界单位 可分辨到 D* = {c['collapse_distance']}"
f"{' (超出 far,全程可分辨)' if c['beyond_far'] else ''}")
print(f"定点插值误差 : 最大 {interp['max_err_lsb']:.6f} LSB(< 1 LSB)"
f" @像素 {interp['max_err_pixel']},换算世界误差最大 {interp['max_world_err']:.8f}")
print(f"csv/json : out/cg_raster_10_1_precision.{{csv,json}}")
return 0
if __name__ == "__main__":
sys.exit(main())
实测输出:
near/far : 32768 / 4194304(Q16),缓冲刻度数 = ONE+1 = 65537
深度阶梯 : 256 点,单调不减=True,严格上升 255/255,值域 [0,ONE]=True
深度阶梯 sha256 : eb72e4ae0a60d79a6c72fb0036075dbb3172d8c2e4ca4c6a2daa00c6dfb2fb8f
精度分布(1 个深度 LSB 对应多少世界距离):
D= 0.5 depth= 0 0 Q16 = 0.000007570 世界单位
D= 1.0 depth= 33026 1 Q16 = 0.000030279 世界单位
D= 2.0 depth= 49539 7 Q16 = 0.000121117 世界单位
D= 4.0 depth= 57795 31 Q16 = 0.000484467 世界单位
D= 8.0 depth= 61923 127 Q16 = 0.001937866 世界单位
D= 16.0 depth= 63987 508 Q16 = 0.007751465 世界单位
D= 32.0 depth= 65019 2032 Q16 = 0.031005859 世界单位
D= 64.0 depth= 65535 8128 Q16 = 0.124023438 世界单位
解析式 vs 有限差分(世界分辨率,Q16):
D= 0.5 解析 0 差分 0
D= 1.0 解析 1 差分 1
D= 2.0 解析 7 差分 7
D= 4.0 解析 31 差分 31
D= 8.0 解析 127 差分 127
D= 16.0 解析 508 差分 508
D= 32.0 解析 2032 差分 2016
塌陷距离(间隙小于 1 个 LSB 时两个面无法分辨):
gap= 0.001 世界单位 可分辨到 D* = 5.7671
gap= 0.01 世界单位 可分辨到 D* = 18.1681
gap= 0.1 世界单位 可分辨到 D* = 57.47
gap= 1.0 世界单位 可分辨到 D* = 181.7306 (超出 far,全程可分辨)
定点插值误差 : 最大 0.998571 LSB(< 1 LSB) @像素 [21, 4],换算世界误差最大 0.00009142
csv/json : out/cg_raster_10_1_precision.{csv,json}
三点读法:
- 深度阶梯 256 点严格上升 255/255、全部落在 $[0,\text{ONE}]$。透视映射是单调的,不会出现「更远反而深度更小」的翻转;
sha256是这 256 个 Q16 值的字节指纹,供 P 层逐字节复算。 - 解析式与有限差分吻合。除 $D=32$ 处差分 $2016$ 与解析 $2032$ 差 $16$(有限差分步长与定点舍入带来的偏差,量级 $1%$ 以内),其余各行逐值相同。这证明「分辨率 $\propto D^2$」不是拟合出来的经验式,而是从映射反解出的解析律。
- 插值误差 $0.9986 < 1$ 个 LSB。缓冲单位下的界被严格遵守;世界误差 $9.1\times10^{-5}$ 是它在特定距离处的折算值。
P 层:同一份阶梯跨语言逐字节一致
TypeScript 侧把同一套映射与分辨率公式重写了一遍(core/src/lantern/cg_raster_10_depth.ts),并用 core/test/cg_raster_10_1_depth_precision.test.ts 复算 S 层落盘的 256 点阶梯,做 sha256 逐字节比对——Python 与 TypeScript 两份实现逐字节相同(不是「GPU 与 CPU 逐位相等」,那需要另设容差)。测试还断言:单调不减、值域 $[0,\text{ONE}]$、各倍程分辨率满足 $D^2$ 律、塌陷距离与金标准一致。
最后一条是负例:把 $D^2$ 律误写成线性律(分辨率 $\propto D$),断言结果必须与金标准不符。没有这个负例,「逐字节相同」的 PASS 就可能是恒真的——判据本身要有区分力。5 条测试全部通过(node --test 'core/test/cg_raster_10_*.test.ts',15 条里属于本节的 5 条)。
下一节把这里算出的「分辨率随距离变粗」直接推到它的后果:两个面落进同一格时会发生什么,以及逆转 z 为什么能缓解它。
阅读导航:上一节:9.3 双面材质 · 下一节:10.2 深度冲突与逆转 z 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。