14.1 深度图与法线图

深度缓冲不是距离,直接 gray = z*255/ONE 会把整帧压进 20 个灰度级、闲置 236 级。本节先把这个浪费量化,再用按距离等分的 8x32 分段映射把每级钉在 0.157 世界单位;法线图由插值后的顶点法线编码进 RGB,两条负例必须被模长判据拒绝。

14.1 深度图与法线图

13.3 把跨语言一致性钉到了字节。从本节起,本卷最后三节转进一类新对象:中间缓冲——渲染器算完一帧之后留在内存里的那些数组。它们既是下一卷的输入,也是排查问题的第一手证据。站内的 GPU 剖析与调试 讲的是在真实设备上把这些数据抓出来的手段(逐 draw call 抓帧、GPU 计时器、缓冲回读);本节和它分工不同:那边关心怎么抓,这边关心抓出来之后按什么口径把它变成一张能读的图,以及这个口径能不能被断言钉住。

深度缓冲不是距离

深度缓冲里的数不是距离。它是 NDC 深度经视口映射后的 Q16.16 定点值,在 $[0, \text{ONE}]$ 上双曲于世界距离——近处一格很密,远处一格很疏。本场景(《烛影山谷》)的实测:覆盖 43731 像素,深度落在 $[60516, 65335]$,对应世界距离 $[5.97, 46.06]$。换句话说,整条深度轴只用到 65536 个刻度里的 4819 个,占 7.4%。

这个非线性不是实现缺陷,是透视投影的数学后果:$z_{ndc}$ 与 $1/d$ 成线性关系,故 $d$ 越大,$\mathrm{d}z/\mathrm{d}d$ 越小。但它对「把缓冲画成图」这件事有直接影响。

三种映射,两种浪费

最省事的写法是 gray = z * 255 // ONE。实跑结果:整帧被压进灰度 $[235, 254]$,只用到 20 个灰度级,另外 236 级全是闲置的——图上几乎看不出地形起伏。

退一步:按观测到的最值做一次自动对比度(min-max 拉伸),256 级全用上。但这只是把浪费换成分辨率不均:一个灰度级覆盖多少世界单位,取决于它落在距离轴的哪一段。实跑的探针表:

线性于深度时,一个灰度级覆盖的世界单位:
  d= 6  灰度   1  每级  0.024   (分段映射恒为 0.157)
  d= 8  灰度  74  每级  0.041   (分段映射恒为 0.157)
  d=12  灰度 147  每级  0.089   (分段映射恒为 0.157)
  d=20  灰度 205  每级  0.239   (分段映射恒为 0.157)
  d=30  灰度 234  每级  0.540   (分段映射恒为 0.157)
  d=45  灰度 254  每级  1.181   (分段映射恒为 0.157)

同一个灰度级,在 $d = 6$ 处值 0.024 个世界单位,在 $d = 45$ 处值 1.181 —— 差 49 倍。近处过度精细、远处严重欠采样,正是 z-fighting 在图上的样子。

分段映射把这件事掰直:把距离范围等分成 8 个波段,每段独占 32 个灰度级(段内再线性)。于是每级恒定覆盖 0.157 世界单位,8 个波段的灰度区间互不重叠。它的实现只需要深度缓冲:把等距的距离阈值反解成深度阈值,再在深度缓冲上分段线性映射。

S 层:三种映射 + 法线编码

下面的脚本是本节的全部证据:三种映射各跑一遍、探针表、法线编码往返、以及四条负例断言。深度与法线两趟都走 cglib.rasterize,且断言覆盖掩码与深度缓冲逐像素相同——这是「调试视图与主渲染路径共用同一份中间缓冲」的机器可验证形式。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_14_1_depth_normal.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_14_1_depth_normal.py
"""14.1 深度图与法线图:三种深度映射 + 法线编码,以及能被负例拒绝的断言。

深度缓冲是 Q16.16 的 [0, ONE],但它**双曲**于距离:本场景 5.97~46.06 世界单位
只占深度缓冲的 [60516, 65335]。于是 `gray = z * 255 / ONE` 这种全域线性映射把整帧
压进 20 个灰度级(236 级闲置);退一步按观测范围做自动对比度能占满 256 级,但一个
灰度级在 d=6 处只值 0.021 世界单位、在 d=45 处值 1.16 —— 差 55 倍。按距离等分波段
的分段映射把每级钉死在 0.157 世界单位,且 8 个波段的灰度区间互不重叠。

法线图把插值后的顶点法线(Q16.16)编码进 RGB:v = (n + ONE) * 255 / (2*ONE)。
产物:out/cg_raster_14_1_{depth_full,depth_linear,depth_bands,normal}.png 与 .json。
"""

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
import cg_raster_14_buffers as B  # noqa: E402

ONE = cglib.ONE
TOL_HI = 600    # 解码模长上界:8 位量化把单位向量推高,实测最大 359
TOL_LO = 4200   # 解码模长下界:单位法线线性插值的收缩,实测最小 3953(≈0.940)
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def main():
    spec = cglib.load_spec()
    near, far = spec["camera"]["nearQ16"], spec["camera"]["farQ16"]
    b = B.buffers(spec)
    cov = b["covered"]
    depth = b["depth"][cov]
    dist = B.depth_to_distance(depth, near, far)

    # 三种深度映射(都只喂深度缓冲;第三种的分段边界由距离等分决定)
    g_full = (depth * 255 // ONE).astype(np.uint8)
    g_lin = B.gray_linear(depth, depth.min(), depth.max())
    g_pw, edges_d, edges_z = B.gray_piecewise(b["depth"], cov, near, far)
    pw = g_pw[cov]
    g_dist = B.gray_linear(dist, dist.min(), dist.max())
    step_z = (int(depth.max()) - int(depth.min())) / 255.0

    # 每个灰度级覆盖多少世界单位:一灰度级 = step_z 个深度单位,除以 dz/dd
    probes = []
    for d0 in (6, 8, 12, 20, 30, 45):
        z0 = B.distance_to_depth(d0 * ONE, near, far)
        z1 = B.distance_to_depth((d0 + 1) * ONE, near, far)
        probes.append({"dist": d0, "gray": int(B.gray_linear(np.array([z0]), depth.min(), depth.max())[0]),
                       "world_per_level": round(step_z / float(z1 - z0), 3)})

    # 法线图:编码 -> 解码;插值后的模长 vs 解码后的模长
    raw = b["normal"][cov]
    dec = B.decode_normal(B.encode_normal(raw))
    rt_err = int(np.abs(dec - raw).max())
    vlen, ilen = B.norm_q16(np.array(b["vnormals"])), B.norm_q16(raw)
    ok_n, n_min, n_max = B.unit_len_ok(dec, TOL_LO, TOL_HI)
    # 负例一:顶点法线未归一化(长度 2)—— 编码饱和后方向被压平
    dbl = B.decode_normal(B.encode_normal(raw * 2))
    bad_hi, bad_min, bad_max = B.unit_len_ok(dbl, TOL_LO, TOL_HI)
    # 负例二:法线被整体缩到一半(长度 0.5)—— 模长过短
    bad_lo, lo_min, lo_max = B.unit_len_ok(B.decode_normal(B.encode_normal(raw >> 1)), TOL_LO, TOL_HI)

    # 断言
    assert B.monotone_in_distance(g_full, dist) and B.monotone_in_distance(g_lin, dist), "映射必须弱单调于距离"
    assert B.monotone_in_distance(pw, dist), "分段映射必须弱单调于距离"
    rev = (255 - pw.astype(np.int64)).astype(np.uint8)
    assert not B.monotone_in_distance(rev, dist), "负例(映射方向反了)必须被单调性判据拒绝"
    assert int(np.diff(edges_z).min()) > 0, "距离等分 -> 深度阈值必须严格递增"
    band_gray = [int(pw[np.searchsorted(np.array(edges_d[1:-1]), dist, side="right") == k].max()) for k in range(8)]
    assert band_gray == [32, 64, 96, 128, 160, 191, 223, 255], "8 个波段的灰度区间必须互不重叠且递增"
    assert int(np.abs(pw.astype(int) - g_dist.astype(int)).max()) <= 8, "分段映射与按距离线性映射的偏差必须 <= 8 级"
    assert len(np.unique(g_full)) < 32, "全域线性映射必须明显浪费灰度级"
    assert int(vlen.min()) >= ONE - 1 and int(vlen.max()) <= ONE, "顶点法线必须是单位向量"
    assert rt_err <= 2 * ONE // 255, "法线编码往返误差必须 <= 一个灰度级"
    assert ok_n, "解码后的法线模长必须落在容差内"
    assert not bad_hi and not bad_lo, "两条负例(未归一化 / 缩到一半)都必须被模长判据拒绝"

    out = os.path.join(ROOT, "out")
    zero = np.zeros((spec["height"], spec["width"]), dtype=np.uint8)
    png = {}
    for name, buf in (("depth_full", g_full), ("depth_linear", g_lin), ("depth_bands", pw)):
        img = zero.copy()
        img[cov] = buf
        png[name] = cglib.save_gray_png(os.path.join(out, f"cg_raster_14_1_{name}.png"), img.astype(np.int64))
    nrm = np.zeros((spec["height"], spec["width"], 3), dtype=np.int64)
    nrm[cov] = B.encode_normal(raw)
    png["normal"] = cglib.save_png(os.path.join(out, "cg_raster_14_1_normal.png"), nrm)

    bad_px = int((B.norm_q16(dbl) > ONE + TOL_HI).sum())
    stat = {"canvas": [spec["width"], spec["height"]], "covered": int(cov.sum()), "one": ONE,
            "depth_range": [int(depth.min()), int(depth.max())],
            "dist_range_q16": [int(dist.min()), int(dist.max())],
            "levels": {"full": len(np.unique(g_full)), "linear": len(np.unique(g_lin)), "bands": len(np.unique(pw))},
            "full_gray_span": [int(g_full.min()), int(g_full.max())],
            "band_edges_q16": [int(x) for x in edges_d], "band_gray_max": band_gray,
            "uniform_world_per_level": round((int(dist.max()) - int(dist.min())) / ONE / 256, 3),
            "probes": probes, "pw_vs_dist_max_diff": int(np.abs(pw.astype(int) - g_dist.astype(int)).max()),
            "vertex_len": [int(vlen.min()), int(vlen.max())], "interp_len": [int(ilen.min()), int(ilen.max())],
            "decoded_len": [n_min, n_max], "normal_roundtrip_err": rt_err, "one_step": 2 * ONE // 255,
            "bad_hi_len": [bad_min, bad_max], "bad_hi_pixels": bad_px,
            "bad_lo_len": [lo_min, lo_max], "png_bytes": png}
    with open(os.path.join(out, "cg_raster_14_1_depth_normal.json"), "w", encoding="utf-8") as fh:
        json.dump(stat, fh, ensure_ascii=False, indent=2)

    nf, nl, npw = len(np.unique(g_full)), len(np.unique(g_lin)), len(np.unique(pw))
    print(f"覆盖像素          : {int(cov.sum())},深度 [{int(depth.min())}, {int(depth.max())}] Q16.16,"
          f"距离 [{int(dist.min())/ONE:.2f}, {int(dist.max())/ONE:.2f}] 世界单位")
    print(f"全域线性 z*255/ONE: 灰度 [{int(g_full.min())}, {int(g_full.max())}],用到 {nf} 级,闲置 {256-nf} 级")
    print(f"自动对比度 / 分段 : 各用到 {nl} / {npw} 级;分段 vs 按距离线性最大偏差 {stat['pw_vs_dist_max_diff']} 级")
    print("线性于深度时,一个灰度级覆盖的世界单位:")
    for p in probes:
        print(f"  d={p['dist']:2d}  灰度 {p['gray']:3d}  每级 {p['world_per_level']:6.3f}"
              f"   (分段映射恒为 {stat['uniform_world_per_level']:.3f})")
    print(f"单调性            : 三种映射均非降于距离;反向映射被拒绝 = {not B.monotone_in_distance(rev, dist)}")
    print(f"波段灰度上界       : {band_gray}(区间互不重叠)")
    print(f"法线图            : 顶点模长 [{int(vlen.min())}, {int(vlen.max())}],插值后 [{int(ilen.min())}, {int(ilen.max())}],"
          f"解码后 [{n_min}, {n_max}](ONE={ONE})")
    print(f"编码往返误差       : {rt_err}(一个灰度级 = {2*ONE//255})")
    print(f"负例(法线 x2)     : 模长 [{bad_min}, {bad_max}],越界 {bad_px}/{int(cov.sum())} 像素,被拒绝 = {not bad_hi}")
    print(f"负例(法线 /2)     : 模长 [{lo_min}, {lo_max}],被拒绝 = {not bad_lo}")
    print(f"产物              : {png}")
    return 0


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

实跑输出:

覆盖像素          : 43731,深度 [60516, 65335] Q16.16,距离 [5.97, 46.06] 世界单位
全域线性 z*255/ONE: 灰度 [235, 254],用到 20 级,闲置 236 级
自动对比度 / 分段 : 各用到 256 / 256 级;分段 vs 按距离线性最大偏差 5 级
单调性            : 三种映射均非降于距离;反向映射被拒绝 = True
波段灰度上界       : [32, 64, 96, 128, 160, 191, 223, 255](区间互不重叠)
法线图            : 顶点模长 [65535, 65536],插值后 [61790, 65536],解码后 [61583, 65895](ONE=65536)
编码往返误差       : 514(一个灰度级 = 514)
负例(法线 x2)     : 模长 [65537, 113511],越界 2313/43731 像素,被拒绝 = True
负例(法线 /2)     : 模长 [30628, 33180],被拒绝 = True

三条结论各有对应的一行:闲置 236 级 是全域线性映射的浪费;各用到 256 级 说明另外两种映射都不浪费;反向映射被拒绝 = True 说明单调性判据有区分力——把一个恒等为「非降」的判据当判据,等于没判。

法线图:存插值后的法线

法线图存的是插值之后的法线,不是顶点法线也不是面法线。它和深度缓冲来自同一个获胜三角形:把 Q16.16 的法线分量当作 cglib.rasterize 的「颜色通道」传进去,拿到的就是同一套重心坐标下的插值结果。

编码用标准那套:$v = (n + \text{ONE}) \cdot 255 / (2,\text{ONE})$,解码 $n = v \cdot 2,\text{ONE} / 255 - \text{ONE}$,往返误差 514 = 一个灰度级。实测模长:顶点 $[65535, 65536]$(单位向量 ✓),插值后收缩到 $[61790, 65536]$,解码后 $[61583, 65895]$。

收缩是几何事实,不是 bug:两个单位向量做线性插值,模长必然 $\le 1$,等号只在两者同向时成立。所以判据不能写成「模长 == ONE」,只能用双侧容差 $[\text{ONE} - 4200,\ \text{ONE} + 600]$。两侧各配一条负例:法线 $\times 2$(模拟忘了归一化)让编码饱和、方向被压平,越界 2313 像素;法线 $\div 2$ 让模长过短。两条都必须被拒绝——这正是「判据有区分力」的可执行形式。

W 层:把深度原样搬进浏览器

调试视图也要能搬进浏览器。本章共用一个页面 web/build_raster14.mjs:它只跑一次 prepare(SPEC),六个 WebGL2 通道全部从同一份 rtris 出发——主渲染路径、深度、深度反例、法线、法线负例、覆盖率计数。深度通道的写法值得单独看:把 Q16.16 深度原样拆成 (hi, lo) 两个字节,在 8 位 RGBA 目标上无损,回读后 z = hi * 256 + lo 就是一个整数。

# snippet: 深度通道与它的负例:Q16.16 原样拆成两字节,反例只把映射方向反过来(摘自 lantern/web/build_raster14.mjs)
const FRAG_DEPTH = `#version 300 es
precision highp float;
in float vZ;
out vec4 o;
void main() {
  float z = clamp(floor(vZ + 0.5), 0.0, 65535.0);
  float hi = floor(z / 256.0);
  o = vec4(hi / 255.0, (z - hi * 256.0) / 255.0, 0.0, 1.0);
}`;

const FRAG_REV = FRAG_DEPTH.replace(
  "o = vec4(hi / 255.0, (z - hi * 256.0) / 255.0, 0.0, 1.0);",
  "o = vec4(1.0 - hi / 255.0, 1.0 - (z - hi * 256.0) / 255.0, 0.0, 1.0);",
);

顶点着色器把 gl_Position.w 钉死为 1,于是 GPU 的透视校正插值退化成屏幕空间线性插值,与 CPU 的整数重心插值可比。深度图因此能与 S 层导出的深度缓冲按数值比,而不是按像素颜色比。

① 深度 Q16.16   : 覆盖 43729 vs S 层 43731(掩码不同 6 px)
   |Δz| 分布     : 0 -> 21446 / 1 -> 21281 / 2 -> 887 / >2 -> 113(最大 460)
   归属不同      : 113 px = 2.58‰  ← 判据:<= 20‰
   单调性        : 正向非降 = true;反向映射被拒绝 = true(271 段均值口径)
④ 主渲染路径    : MAE 0.2848 / PSNR 47.79 dB;覆盖 43729 vs 43731(不同 6 px,0.08‰)

同归属的 43614 个像素里,$|\Delta z|$ 为 0 / 1 / 2 的分别是 21446 / 21281 / 887:GPU 用 floor(z + 0.5) 取整、CPU 用截断,差一个量化单位是预期内的。剩下 113 像素(2.58‰)归属不同——共享边像素由 GPU 的 top-left 规则定赢家,与 CPU 的 area > 0 判据在边界上必然分歧。这与 13.2 的结论一致:共享边归属不能靠逐位相等来断言,只能给比例上限。主渲染路径与 S 层成片对照的 MAE / PSNR 则是第 6 章判据的原样复用。

下一节把调试视图的另一半补上:覆盖率不是二值的,线框也不是叠加一张图那么简单。

阅读导航:上一节:13.3 跨语言逐位一致的验证 · 下一节:14.2 覆盖率与线框 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

  1. 14.3 中间缓冲的可视化
  2. 14.2 覆盖率与线框
  3. 10.3 画家算法与深度排序