5.3 视锥与近远平面

视锥六平面的解析构造与近远平面:从 MVP 直接抽出六平面并用 45 个采样点核对判定,解释近平面不能取 0(深度值会塌到 1),用纯 xml.etree 生成 depth(D)=far/(far-near)·(1-near/D) 的精度分布 SVG,量化给出首个倍程独占约 503‰ 深度量程的结论。

5.3 视锥与近远平面

视锥是投影矩阵的几何化身:一个以相机为顶点、由六个平面围成的截头四棱锥。站内的 GPU 剔除与可见性系统 用视锥做 CPU/GPU 的可见性判定;本节把它拆开——六平面怎么读出来、近平面为什么绝不能取 0、以及深度精度怎么分布。

视锥的六个平面

Gribb–Hartmann 方法指出:视锥的六个平面可以直接从 MVP 矩阵的四行线性组合出来。设 MVP 的四行是 r0..r3(齐次裁剪坐标 = MVP·v,点在视锥内当且仅当 -w <= x,y,z <= w),则左/右平面是 r3±r0,下/上平面是 r3±r1,近/远平面是 r3±r2。

脚本从《烛影山谷》的 MVP 抽出六平面(Q16.16,内部满足 a·x + b·y + c·z + d >= 0):

平面[a b c d]化简后的判据
left[85134, 0, -65536, 0]x >= -0.7699·D(D = -z)
right[-85134, 0, -65536, 0]x <= 0.7699·D
bottom[0, 113512, -65536, 0]y >= -0.5773·D
top[0, -113512, -65536, 0]y <= 0.5773·D
near[0, 0, -132104, -66052]D >= 0.5
far[0, 0, 1032, 66052]D <= 64.0

判据里的 0.7699 = tan(fovY/2)·aspect、0.5773 = tan(fovY/2),与 5.1 节的量级完全对上;近平面判据 D >= 0.5、远平面判据 D <= 64.0 正是 spec 的 nearQ16/farQ16。为验证抽取无误,脚本在视锥内外采样 45 个点,用六平面判定与 project()(非 None 且 |ndc| <= 1 且 depth 属于 [0, ONE])各判一次,45/45 一致。

近平面为什么不能取 0

深度映射有一个闭式:depth(D) = far/(far-near) · (1 - near/D)。把 near 推向 0,1 - near/D 就趋于 1,于是 depth(D) 恒等于 far/far = 1 —— 所有距离的深度都塌到同一个值,深度缓冲彻底失效:所有片元深度相等,z 比较退化成「先到先得」。脚本把 near 设成 0 重算:

近平面取 0 时(depth 恒为 ONE,深度缓冲失效):
  D= 1  depth=65536
  D= 4  depth=65536
  D=16  depth=65536
  D=64  depth=65536

工程上 near 要尽可能大——大到近处不再被裁掉为止。这不是审美偏好,而是下面这条精度曲线的直接后果。

深度缓冲的精度分布

把 depth(D) 对 D 求导:d(depth)/dD = far/(far-near) · near/D²,与 D² 成反比。也就是说,越近的地方,同样一段世界距离占用越多深度值。按距离的倍程统计,16 位深度的量程分配是:

距离区间深度范围(Q16)占用量程
0.5 – 10 -> 33026503‰
1 – 233026 -> 49539251‰
2 – 449539 -> 57795125‰
4 – 857795 -> 6192362‰
8 – 1661923 -> 6398731‰
16 – 3263987 -> 6501915‰
32 – 6465019 -> 655357‰

第一条最触目:从 near = 0.5 到 2·near = 1.0,仅仅一个倍程就吃掉了 503‰ 的量程——超过一半的深度精度花在了相机最近的一小段距离上。之后每翻一番,占用就减半:251、125、62、31、15、7。把量程想成 65536 个格子,近处一格只代表 2.839e-05 个世界单位,而到了 far = 64 附近,一格代表 0.125 个世界单位:

距离 D1 个深度 LSB 约等于
12.839e-05
40.00048263
160.0078125
640.125

这正是不给近平面设下限就会出事的根源:两个相距 1 mm 的面片,近处能分开、远处会被量化到同一格——这就是第 10 章的深度冲突(z-fighting);逆转 z 把近处的高精度让给远处,是同一分布的另一端做法。

脚本把这条曲线落成 SVG(纯标准库 xml.etree 手写并回读校验,本机没有 matplotlib),产物 8941 bytes / 57 节点:out/cg_raster_05_3_depth_precision.svg。

S 层脚本(六平面抽取、精度分布、SVG 生成):

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_05_3_depth_precision.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_05_3_depth_precision.py
"""5.3 视锥与近远平面:视锥六平面的解析构造、近平面不能取 0 的原因,
以及深度缓冲的精度分布 depth(D) = far/(far-near)*(1 - near/D)。

近平面越靠近 0,越多深度量程被挤到相机附近 —— 第 10 章「深度冲突」的伏笔。

产物(SVG 全部由标准库 xml.etree 手写并回读校验,本机无 matplotlib):
  out/cg_raster_05_3_depth_precision.svg  左:depth-D 曲线;右:各倍程占用量程
  out/cg_raster_05_3_depth_curve.csv / _octaves.csv / _frustum.json
"""

import json
import math
import os
import sys
import xml.etree.ElementTree as ET

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
SVG_NS = "http://www.w3.org/2000/svg"


def frustum_planes(mvp):
    """Gribb–Hartmann:从 MVP 直接抽出视锥六平面(Q16.16)。

    世界点 p 在视锥内 <=> 对六平面都有 plane·(p,1) >= 0。
    """
    r0, r1, r2, r3 = mvp[0:4], mvp[4:8], mvp[8:12], mvp[12:16]
    return {
        "left": [r3[i] + r0[i] for i in range(4)],
        "right": [r3[i] - r0[i] for i in range(4)],
        "bottom": [r3[i] + r1[i] for i in range(4)],
        "top": [r3[i] - r1[i] for i in range(4)],
        "near": [r3[i] + r2[i] for i in range(4)],
        "far": [r3[i] - r2[i] for i in range(4)],
    }


def plane_eval(pl, p):
    """Q16.16 平面与 Q16.16 世界点求值(齐次 w = ONE),保留符号。"""
    return pl[0] * p[0] + pl[1] * p[1] + pl[2] * p[2] + pl[3] * ONE


def inside(planes, p):
    return all(plane_eval(pl, p) >= 0 for pl in planes.values())


def project_inside(mvp, p):
    """用 project() 独立判定:非 None 且 |ndc|<=1 且 depth in [0,ONE]。"""
    r = cglib.project(mvp, p)
    if r is None:
        return False
    nx, ny, d, _w = r
    return -ONE <= nx <= ONE and -ONE <= ny <= ONE and 0 <= d <= ONE


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, distance_q16):
    """世界距离(Q16)-> 归一化深度(Q16,[0, ONE]);相机后方返回 None。"""
    r = cglib.project(mvp, (0, 0, -distance_q16))
    return None if r is None else r[2]


def build_svg(path, curve, octaves, near_f, far_f):
    """纯 xml.etree 手写 SVG:左 depth-D 曲线(对数 x),右各倍程占用量程。"""
    W, H = 760, 380
    svg = ET.Element("svg", {"xmlns": SVG_NS, "width": str(W), "height": str(H),
                             "viewBox": f"0 0 {W} {H}"})

    def text(x, y, s, size=12, anchor="start", weight="normal"):
        e = ET.SubElement(svg, "text", {"x": f"{x:.1f}", "y": f"{y:.1f}",
                                        "font-family": "sans-serif", "font-size": str(size),
                                        "text-anchor": anchor, "font-weight": weight, "fill": "#333"})
        e.text = s

    text(W / 2, 24, "深度精度分布:depth(D) = far/(far-near)·(1 - near/D)", 16, "middle", "bold")

    # 左:depth-D 曲线,x 轴对数
    ax0, ax1, ay0, ay1 = 70, 370, 50, 320
    lx0, lx1 = math.log10(near_f), math.log10(far_f)
    ax = lambda d: ax0 + (math.log10(d) - lx0) / (lx1 - lx0) * (ax1 - ax0)
    ay = lambda v: ay1 - v * (ay1 - ay0)
    ET.SubElement(svg, "rect", {"x": str(ax0), "y": str(ay0), "width": str(ax1 - ax0),
                                "height": str(ay1 - ay0), "fill": "none", "stroke": "#999"})
    for k in range(5):
        v = k / 4
        ET.SubElement(svg, "line", {"x1": str(ax0), "y1": f"{ay(v):.1f}", "x2": str(ax1),
                                    "y2": f"{ay(v):.1f}", "stroke": "#eee"})
        text(ax0 - 8, ay(v) + 4, f"{v:.2f}", 10, "end")
    pts = " ".join(f"{ax(d):.1f},{ay(dep / ONE):.1f}" for d, dep in curve)
    ET.SubElement(svg, "polyline", {"points": pts, "fill": "none", "stroke": "#c0392b", "stroke-width": "2"})
    for d in (0.5, 1, 2, 4, 8, 16, 32, 64):
        if near_f <= d <= far_f:
            text(ax(d), ay1 + 16, f"{d:g}", 10, "middle")
            ET.SubElement(svg, "line", {"x1": f"{ax(d):.1f}", "y1": str(ay1),
                                        "x2": f"{ax(d):.1f}", "y2": f"{ay1 + 4:.1f}", "stroke": "#999"})
    text((ax0 + ax1) / 2, ay1 + 36, "世界距离 D(对数轴)", 12, "middle")
    text(20, (ay0 + ay1) / 2, "归一化深度", 12, "middle")
    text((ax0 + ax1) / 2, ay0 - 12, "depth(D) 曲线", 13, "middle", "bold")

    # 右:各倍程占用的深度量程
    bx0, bx1, by0, by1 = 450, 730, 50, 320
    ET.SubElement(svg, "rect", {"x": str(bx0), "y": str(by0), "width": str(bx1 - bx0),
                                "height": str(by1 - by0), "fill": "none", "stroke": "#999"})
    bw = (bx1 - bx0) / len(octaves)
    maxv = max(o["share_permille"] for o in octaves)
    for i, o in enumerate(octaves):
        h = (o["share_permille"] / maxv) * (by1 - by0 - 20)
        x = bx0 + i * bw + 3
        ET.SubElement(svg, "rect", {"x": f"{x:.1f}", "y": f"{by1 - h:.1f}", "width": f"{bw - 6:.1f}",
                                    "height": f"{h:.1f}", "fill": "#2980b9"})
        text(x + (bw - 6) / 2, by1 - h - 4, str(o["share_permille"]), 9, "middle", "bold")
        text(x + (bw - 6) / 2, by1 + 14, o["label"], 8, "middle")
    text((bx0 + bx1) / 2, ay0 - 12, "各倍程占用深度量程(‰)", 13, "middle", "bold")
    text((bx0 + bx1) / 2, by1 + 34, "距离倍程 [near·2^k, near·2^(k+1))", 11, "middle")

    ET.indent(svg, space="  ")
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    root = ET.parse(path).getroot()  # 回读校验
    assert root.tag.endswith("svg"), root.tag
    return os.path.getsize(path), len(list(root.iter()))


def main():
    spec = cglib.load_spec()
    cam = spec["camera"]
    tan_half, near, far = cam["tanHalfFovYQ16"], cam["nearQ16"], cam["farQ16"]
    near_f, far_f = near / ONE, far / ONE
    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. 六平面:解析抽取 vs project() 独立判定
    planes = frustum_planes(mvp)
    agree = total = 0
    samples = []
    for dq in (ONE, 2 * ONE, 8 * ONE, 32 * ONE, 63 * ONE):
        for xq in (0, 8 * ONE, 32 * ONE):
            for yq in (0, 6 * ONE, 24 * ONE):
                p = (xq, yq, -dq)
                total += 1
                if inside(planes, p) == project_inside(mvp, p):
                    agree += 1
                else:
                    samples.append({"p": p, "planes": inside(planes, p), "project": project_inside(mvp, p)})

    # 2. 深度映射:曲线采样 + 各倍程占用
    curve = [(near_f * (far_f / near_f) ** (i / 240),
              depth_at(mvp, round(near_f * (far_f / near_f) ** (i / 240) * ONE))) for i in range(241)]
    octaves = []
    for k in range(7):
        d0, d1 = near_f * (2 ** k), near_f * (2 ** (k + 1))
        if d1 > far_f:
            break
        dep0, dep1 = depth_at(mvp, round(d0 * ONE)), depth_at(mvp, round(d1 * ONE))
        octaves.append({"label": f"{d0:g}–{d1:g}", "d0": d0, "d1": d1,
                        "depth0": dep0, "depth1": dep1, "share_permille": (dep1 - dep0) * 1000 // ONE})

    # 3. 世界距离分辨率:一个深度 LSB 对应多少世界距离(数值差分,步长随距离放大)
    def dd_at(dq):
        step = max(ONE // 4, dq // 64)
        return (depth_at(mvp, dq + step) - depth_at(mvp, dq - step)) / (2.0 * step)

    reso = []
    for d in (1, 2, 4, 8, 16, 32, 64):
        dd = dd_at(d * ONE)
        reso.append({"distance": d, "world_per_depth_lsb": round((1.0 / dd) / ONE, 8) if dd > 0 else None})

    # 4. 近平面取 0 的演示
    mvp0 = make_mvp(tan_half, aspect, 0, far)
    near_zero = [{"distance": d, "depth": depth_at(mvp0, d * ONE)} for d in (1, 4, 16, 64)]

    cglib.write_csv(os.path.join(out, "cg_raster_05_3_depth_curve.csv"),
                    ["distance", "depth_q16"], [[f"{d:.6f}", dep] for d, dep in curve])
    cglib.write_csv(os.path.join(out, "cg_raster_05_3_octaves.csv"),
                    ["octave", "d0", "d1", "depth0", "depth1", "share_permille"],
                    [[o["label"], f"{o['d0']:g}", f"{o['d1']:g}", o["depth0"], o["depth1"],
                      o["share_permille"]] for o in octaves])
    svg_bytes, svg_nodes = build_svg(os.path.join(out, "cg_raster_05_3_depth_precision.svg"),
                                     curve, octaves, near_f, far_f)
    with open(os.path.join(out, "cg_raster_05_3_frustum.json"), "w", encoding="utf-8") as fh:
        json.dump({
            "near": near_f, "far": far_f, "depthBits": spec["depthBits"],
            "frustum_planes": planes,
            "plane_vs_project_agreement": {"agree": agree, "total": total, "mismatch": samples},
            "octaves": octaves, "resolution": reso, "near_zero_demo": near_zero,
            "svg": {"bytes": svg_bytes, "nodes": svg_nodes},
        }, fh, ensure_ascii=False, indent=2)

    print(f"near/far      : {near_f} / {far_f}   depthBits={spec['depthBits']}")
    print("视锥六平面(Q16.16,[a b c d],内部满足 a·x+b·y+c·z+d >= 0):")
    for k, pl in planes.items():
        print(f"  {k:<7}: {pl}")
    print(f"平面判定 vs project() 一致: {agree}/{total}  (不一致 {len(samples)})")
    print("各倍程占用的深度量程(‰):")
    for o in octaves:
        print(f"  {o['label']:>10}  depth {o['depth0']} -> {o['depth1']}  占 {o['share_permille']}‰")
    print("世界距离分辨率(1 个深度 LSB 对应多少世界单位):")
    for r in reso:
        print(f"  D={r['distance']:>2}  {r['world_per_depth_lsb']} 世界单位")
    print("近平面取 0 时(depth 恒为 ONE,深度缓冲失效):")
    for r in near_zero:
        print(f"  D={r['distance']:>2}  depth={r['depth']}")
    print(f"SVG           : {svg_bytes} bytes / {svg_nodes} 节点(已回读校验)")
    return 0


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

P 层核心断言(完整文件 core/test/cg_raster_05_3_depth_precision.test.ts):

// snippet: 5.3 P 层核心断言
const GOLDEN_OCTAVE_PERMILLE = [503, 251, 125, 62, 31, 15, 7];

test("各倍程占用的深度量程与 Python 侧逐位相同", () => {
  const shares: number[] = [];
  for (let k = 0; k < 7; k += 1) {
    const d0 = NEAR * (1n << BigInt(k));
    const d1 = NEAR * (1n << BigInt(k + 1));
    shares.push(Number(((depthAt(d1) - depthAt(d0)) * 1000n) / ONE));
  }
  assert.deepEqual(shares, GOLDEN_OCTAVE_PERMILLE);
});

test("近平面取 0:所有距离的深度都塌到 ONE,深度缓冲失效", () => {
  const m0 = makeMvp(0n);
  for (const d of [1, 4, 16, 64]) assert.equal(depthAt(fFromInt(d), m0), ONE);
});
node --test 'core/test/cg_raster_05_3_depth_precision.test.ts'
ℹ tests 6   ℹ pass 6   ℹ fail 0

阅读导航:上一节:5.2 透视与正交投影 · 下一节:6.1 边函数与覆盖判定 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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