10.1 深度缓冲的精度分布

深度缓冲内部只有 65537 个刻度,一个刻度值多少世界距离却随距离按 D² 增长。本节从 5.3 的相机量程视角切回缓冲内部,量化 Q16.16 的最小可分辨深度差、塌陷距离与误差传播,再看逐像素定点插值的截断误差如何沿距离放大,并让 Python 与 TypeScript 两份实现逐字节对齐。

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 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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