8.1 视锥裁剪

视锥六个面在视图空间解析写成四条整数系数,与投影矩阵同源;Sutherland–Hodgman 的交点用 fdiv 定标、算术右移降精度,于是面积守恒成为精确整数恒等式。本节实测六平面系数、487 个顶点的分类、英雄三角形 3 裁成 5 的逐步计数,以及裁剪前后两帧覆盖异或为 0。

8.1 视锥裁剪

站内 Vulkan 渲染管线初始化 把裁剪当成管线里的一个固定阶段来讲:三角形在裁剪空间被近平面切开,切出来的顶点由驱动补齐,应用程序看不到这一步。那是 Vulkan 裁剪阶段的 API 契约;本节要回答的是更靠下的一层问题——这条契约在纯整数算术里能不能被完整实现,并且被逐位断言。答案是可以:把视锥的六个面在视图空间解析写成四条系数,Sutherland–Hodgman 的每一步交点都用 fdiv 定标、用算术右移降精度,整条路径没有一个浮点数,于是「裁剪不改变任何可见像素」就从一句直觉变成一条可证伪的等式。

六个平面写在视图空间

裁剪空间里的视锥是一个单位立方体(-w <= x,y,z <= w),六个面写起来很整齐,但它的坐标是投影之后的齐次量,w 可能为负,判断「在内」要先处理符号。视图空间反而更直接:相机在原点看向 -z,近平面就是 z <= -near,远平面是 z >= -far,左右上下由 f = 1/tan(fovY/2) 与 r = f/aspect 定出。每个面写成 (a, b, c, d),内部满足 a·x + b·y + c·z + d >= 0(w = ONE):

near    ( 0,  0, -ONE, -near)     -z - near >= 0
far     ( 0,  0,  ONE,  far)      z + far >= 0
left    ( r,  0, -ONE,     0)     r·x - z >= 0
right   (-r,  0, -ONE,     0)    -r·x - z >= 0
bottom  ( 0,  f, -ONE,     0)     f·y - z >= 0
top     ( 0, -f, -ONE,     0)    -f·y - z >= 0

关键在于 f 与 r 用的就是 mat_perspective 里那两个常数(fdiv(ONE, tanHalfFovY) 与 fdiv(f, aspect)),所以这六个平面与投影矩阵同源,不会出现「平面判定在内、投影后却落到 NDC 之外」的自相矛盾。本场景实测的系数是 near (0, 0, -65536, -32768)、far (0, 0, 65536, 4194304)、left (85134, 0, -65536, 0)、right (-85134, 0, -65536, 0)、bottom (0, 113512, -65536, 0)、top (0, -113512, -65536, 0);注意 r = 85134 与 f = 113512 不是整数——它们是 fdiv 向零截断后的 Q16.16 值,这正是「同源」的代价,也是它必须和投影矩阵用同一个 fdiv 的原因。

顶点分类与三角形分类

平面求值 plane_eval 不做任何降精度,直接累加 pl[0]*x + pl[1]*y + pl[2]*z + pl[3]*ONE,保留完整符号——因为 Q16.16 的乘积可达 2^33 量级,用 Number 会溢出,必须走 Python 的任意精度整数。487 个场景顶点里,六平面全通过的有 397 个,其余 90 个至少被一个平面拒绝;逐平面看,left 与 right 各拒绝 43 个、bottom 拒绝 29 个,而 near 与 far 一个都不拒绝——这说明《烛影山谷》的相机把整个场景都装进了深度范围,本节要验证的裁剪在这一帧上是「近/远平面的恒等变换 + 侧面的真实裁剪」。

三角形按三个顶点的符号分成三类:某平面同时拒绝三点就是 out,三点全在六面内是 in,其余是 straddle。896 个三角形实测 in = 726、straddle = 52、out = 118。

面积守恒是一条整数恒等式

裁剪最容易写错的地方是交点。Sutherland–Hodgman 对单平面裁剪时,内部多边形与外部多边形共用同一批交点元组,于是把两个多边形的 shoelace 相加,交点之间那两条内部边恰好反向抵消,留下的是原多边形的 shoelace:

area2x_raw(原) == area2x_raw(内部) + area2x_raw(外部)

area2x_raw 累加的是未降精度的 x0*y1 - x1*y0(Q32.32),所以这是一条精确的整数恒等式,不是近似。只要交点是用同一组 fdiv/fmul 算出来再同时塞进两侧,它就必然成立——这也正是它适合做断言的原因:任何「内部一套交点、外部另一套」的实现都会立刻打破它。

英雄三角形的实测值是 2A(原) = 637534208 = 2A(近内) 478150656 + 2A(近外) 159383552,精确成立。近平面一步之所以特别干净,是因为这个三角形的 V0/V1 深度让交点参数恰好是 t = 1/2,且坐标差是偶数,fmul 没有舍入。走完六平面之后 2A = 473623303,外部件合计 163915001,残差 -4096——不为零,但远小于一个 Q16.16 面积单位(65536)。残差的来源是后续平面上的逐分量 fmul 舍入:交点本身仍然精确落在原边上,但它的坐标已经被降过精度,于是下一次 area2x_raw 会看到一点点偏移。把「精确」用在单步、把「有界残差」用在多步,是这套断言的分工。

裁剪到底改不改变可见像素

这是本节的主断言。只跑一个裁剪帧是不够的——必须有一个同一条代码路径上不裁剪的帧做对照,否则任何差异都分不清是裁剪造成的还是路径不同造成的。所以脚本跑四帧:

帧路径三角形覆盖像素sha256(前 16 位)
direct合成 MVP 直通(不裁剪,不投影分解)89643731bac659770178037a…
noclip顺序 P∘V 投影,不裁剪896437308262b444bdd15161…
nearfar只裁近、远平面896437308262b444bdd15161…
sixplane六平面全裁806437302de7bb0a9d30cd73…

两个结论各自独立。第一,nearfar 与 noclip 逐字节相同:本场景近/远平面拒绝 0 个顶点,裁剪必须是恒等变换——这条断言的价值是它会把「裁剪把不该动的东西动了」这类错误直接暴露。第二,sixplane 与 noclip 的覆盖异或为 0,颜色差 8316 个像素(最大通道差 2),深度差 2503 个像素(最大 2)。覆盖完全相同,颜色只在边界上有 1~2 个色阶的差——差异来自把跨越侧平面的三角形切成更小的片之后,屏幕空间的仿射插值在片内重新分配了重心权重,而这一切都没有改变「哪个像素属于哪个面」。

至于 direct 与 noclip 差的那 1 个像素(43731 vs 43730),是另一件事:direct 把 MVP 合成成一个矩阵再乘顶点,noclip 先乘视图矩阵、再乘投影矩阵,两次 fmul 的舍入位置不同,边界上有一个像素的覆盖归属翻转。它不是裁剪造成的,但正好说明为什么「逐字节相同」这种断言必须先把路径钉死。

可跑的 S 层

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_08_1_frustum_clip.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_08_1_frustum_clip.py
"""8.1 视锥裁剪:视图空间六平面的定点构造、顶点与三角形分类、六平面
Sutherland–Hodgman 裁剪、面积守恒、以及「裁剪是否改变可见像素」的四帧对照。

产物:out/cg_raster_08_1_clip.json、out/cg_raster_08_1_planes.csv、
      out/cg_raster_08_1_frame.png、out/cg_raster_08_1_frustum.svg
"""

import json
import os
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import cg_raster_08_clip as clip  # noqa: E402
import cg_raster_08_svg as svg  # noqa: E402
import render_raster  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ONE = cglib.ONE
GOLDEN_SHA256 = "bac659770178037ad3f6a6ad184945a96ff37a7fe4d754ef1ebc5b0aaa538ced"

# 英雄三角形(视图空间,相机在原点看向 -z):V0 在近平面外,V2 在左平面外。
# V0/V1 的深度使近平面交点参数恰为 t = 1/2,且坐标差为偶数 —— 交点落在原边上,面积守恒精确。
HERO = (
    (8192, 4096, -16384, 200, 90, 70),
    (16384, 16384, -49152, 90, 180, 220),
    (-40960, 8192, -49152, 200, 200, 190),
)


def main():
    t0 = time.perf_counter()
    spec = cglib.load_spec()
    W, H = spec["width"], spec["height"]
    bg = spec["backgroundQ8"]
    cam = spec["camera"]
    near, far, tan_half = cam["nearQ16"], cam["farQ16"], cam["tanHalfFovYQ16"]
    aspect = cglib.fdiv(cglib.f_from_int(W), cglib.f_from_int(H))
    view = cglib.mat_look_at(tuple(cam["eyeQ16"]), tuple(cam["targetQ16"]), tuple(cam["upQ16"]))
    proj = cglib.mat_perspective(tan_half, aspect, near, far)
    mvp = cglib.mat_mul(proj, view)

    planes = clip.view_planes(near, far, tan_half, aspect)
    plist, names = [p for _n, p in planes], [n for n, _p in planes]

    verts, tris, vcolors = render_raster.build_scene(spec)
    vv = [cglib.mat_vec4(view, (v[0], v[1], v[2], ONE))[:3] for v in verts]

    # 1. 顶点分类:每个平面拒绝多少顶点;六平面全通过多少。再按三角形归类
    reject_by_plane = [0] * len(plist)
    inside_flags = []
    for p in vv:
        ds = [clip.plane_eval(pl, p) for pl in plist]
        for k, d in enumerate(ds):
            if d < 0:
                reject_by_plane[k] += 1
        inside_flags.append(all(d >= 0 for d in ds))
    verts_in = sum(inside_flags)
    kinds = [clip.classify_tri((vv[a], vv[b], vv[c]), plist) for a, b, c in tris]
    n_in, n_out, n_straddle = kinds.count("in"), kinds.count("out"), kinds.count("straddle")

    # 2. 英雄三角形:六平面逐个裁剪 + 面积守恒
    hero_poly = [tuple(v) for v in HERO]
    hero_clip, cuts = clip.clip_poly(hero_poly, plist)
    a_orig, a_final = clip.area2x_raw(hero_poly), clip.area2x_raw(hero_clip)
    a_final_out = sum(clip.area2x_raw(c) for c in cuts)
    residual = a_orig - (a_final + a_final_out)
    near_in, near_out = clip.clip_poly_plane(hero_poly, planes[0][1])
    a_near_in, a_near_out = clip.area2x_raw(near_in), clip.area2x_raw(near_out)
    near_exact = a_orig == a_near_in + a_near_out
    assert near_exact, "近平面裁剪必须面积守恒(精确整数等式)"
    assert abs(residual) < ONE, f"六平面裁剪的面积残差 {residual} 超出一个 Q16.16 面积单位"
    step_counts, cur = [], hero_poly
    for i, pl in enumerate(plist):
        cur, _out = clip.clip_poly_plane(cur, pl)
        step_counts.append({"plane": names[i], "verts_after": len(cur)})
        if not cur:
            break

    # 3. 四帧对照:合成 MVP 直通 / 顺序 P∘V 不裁剪 / 只裁近远 / 六平面全裁
    direct_rows = []
    for a, b, c in tris:
        p = [cglib.project(mvp, verts[i]) for i in (a, b, c)]
        if any(q is None for q in p):
            continue
        s = [cglib.to_screen(q[0], q[1], W, H) + (q[2],) for q in p]
        direct_rows.append((s[0][0], s[0][1], s[0][2], *vcolors[a],
                            s[1][0], s[1][1], s[1][2], *vcolors[b],
                            s[2][0], s[2][1], s[2][2], *vcolors[c]))
    polys = clip.build_polys(vv, tris, vcolors)
    f_direct = cglib.rasterize(direct_rows, W, H, bg)
    rows_noclip, _ = clip.project_path(proj, polys, W, H)
    rows_nearfar, _ = clip.clip_path(proj, polys, plist[0:2], W, H)
    rows_six, emptied = clip.clip_path(proj, polys, plist, W, H)
    f_noclip = cglib.rasterize(rows_noclip, W, H, bg)
    f_nearfar = cglib.rasterize(rows_nearfar, W, H, bg)
    f_six = cglib.rasterize(rows_six, W, H, bg)

    px, csha = clip.sha_rgb(f_six[0])
    _, dsha = clip.sha_rgb(f_direct[0])
    _, nsha = clip.sha_rgb(f_noclip[0])
    _, ffsha = clip.sha_rgb(f_nearfar[0])
    assert dsha == GOLDEN_SHA256, "合成 MVP 直通路径必须复现卷一基准 sha256"
    assert ffsha == nsha, "近/远平面在本场景拒绝 0 个顶点,裁剪必须是恒等变换"
    cmp_nf = clip.diff_stats(f_noclip, f_nearfar)
    cmp_six = clip.diff_stats(f_noclip, f_six)
    assert cmp_nf["coverage_xor"] == 0 and cmp_nf["color_diff_pixels"] == 0
    assert cmp_six["coverage_xor"] == 0, "六平面裁剪不得改变任何像素的覆盖归属"
    assert cmp_six["max_channel_diff"] <= 4 and cmp_six["max_depth_diff"] <= 4

    # 4. 落盘:JSON / CSV / PNG / 裸 RGB / SVG(SVG 写盘后回读校验)
    out = os.path.join(ROOT, "out")
    png_bytes = cglib.save_png(os.path.join(out, "cg_raster_08_1_frame.png"), f_six[0])
    cglib.write_csv(os.path.join(out, "cg_raster_08_1_planes.csv"),
                    ["plane", "a", "b", "c", "d", "rejected_vertices"],
                    [[names[k], plist[k][0], plist[k][1], plist[k][2], plist[k][3],
                      reject_by_plane[k]] for k in range(len(plist))])
    svg_bytes, svg_nodes = svg.frustum_svg(os.path.join(out, "cg_raster_08_1_frustum.svg"),
                                           planes, near / ONE, far / ONE, planes[2][1][0] / ONE,
                                           vv, inside_flags, hero_poly, hero_clip)
    with open(os.path.join(out, "cg_raster_08_1_frame.rgb"), "wb") as fh:
        fh.write(px.tobytes())
    with open(os.path.join(out, "cg_raster_08_1_clip.json"), "w", encoding="utf-8") as fh:
        json.dump({
            "viewport": [W, H], "near": near, "far": far, "aspect": aspect,
            "planes": {n: list(p) for n, p in planes}, "plane_order": names,
            "reject_by_plane": dict(zip(names, reject_by_plane)),
            "vertices": {"total": len(verts), "inside": verts_in,
                         "outside": len(verts) - verts_in},
            "triangles": {"total": len(tris), "in": n_in, "straddle": n_straddle,
                          "out": n_out, "emptied": emptied, "fan": len(rows_six)},
            "hero": {"input": [list(v) for v in hero_poly],
                     "clipped": [list(v) for v in hero_clip],
                     "cut_areas2x_raw": [clip.area2x_raw(c) for c in cuts],
                     "step_vertex_counts": step_counts,
                     "area2x_raw": {"orig": a_orig, "near_in": a_near_in,
                                    "near_out": a_near_out, "near_exact": near_exact,
                                    "final": a_final, "final_out": a_final_out,
                                    "residual": residual,
                                    "orig_q16": clip.area2x_q16(hero_poly),
                                    "final_q16": clip.area2x_q16(hero_clip)}},
            "frames": {
                "direct": {"covered": int(f_direct[2].sum()), "sha256": dsha,
                           "triangles": len(direct_rows)},
                "noclip": {"covered": int(f_noclip[2].sum()), "sha256": nsha,
                           "triangles": len(rows_noclip)},
                "nearfar": {"covered": int(f_nearfar[2].sum()), "sha256": ffsha,
                            "triangles": len(rows_nearfar)},
                "sixplane": {"covered": int(f_six[2].sum()), "sha256": csha,
                             "culled": f_six[3], "triangles": len(rows_six),
                             "png_bytes": png_bytes}},
            "noclip_vs_nearfar": cmp_nf, "noclip_vs_sixplane": cmp_six,
            "svg": {"bytes": svg_bytes, "nodes": svg_nodes},
            "ms": round((time.perf_counter() - t0) * 1000, 1),
        }, fh, ensure_ascii=False, indent=2)

    print(f"viewport       : {W}x{H}  near={near} far={far} aspect={aspect}")
    print("视锥六平面(视图空间,内部满足 a·x+b·y+c·z+d >= 0):")
    for k, n in enumerate(names):
        print(f"  {n:<7}: {plist[k]}   拒绝顶点 {reject_by_plane[k]}")
    print(f"顶点分类       : {verts_in} 在内 / {len(verts)} 总({len(verts) - verts_in} 在外)")
    print(f"三角形分类     : in={n_in} straddle={n_straddle} out={n_out} / {len(tris)} 总")
    print(f"英雄三角形     : 3 顶点 -> 六平面后 {len(hero_clip)} 顶点,各步 "
          f"{[s['verts_after'] for s in step_counts]}")
    print(f"面积守恒       : 2A(原)={a_orig} = 2A(近内)={a_near_in} + 2A(近外)={a_near_out}"
          f"(精确 {near_exact});六平面后 2A={a_final},外部合计={a_final_out},"
          f"残差={residual}")
    print(f"合成 MVP 直通  : {len(direct_rows)} 三角形 / {int(f_direct[2].sum())} 覆盖像素 / "
          f"sha256 {dsha[:16]}…(= 卷一基准)")
    print(f"顺序 P∘V 不裁剪: {len(rows_noclip)} 三角形 / {int(f_noclip[2].sum())} 覆盖像素 / "
          f"sha256 {nsha[:16]}…")
    print(f"只裁近远平面   : {int(f_nearfar[2].sum())} 覆盖像素 / sha256 {ffsha[:16]}…  "
          f"与不裁剪逐字节相同: {ffsha == nsha}")
    print(f"六平面全裁     : {len(rows_six)} 三角形 / {int(f_six[2].sum())} 覆盖像素 / "
          f"culled={f_six[3]}")
    print(f"                 sha256 {csha}")
    print(f"六平面 vs 不裁剪: 覆盖异或 {cmp_six['coverage_xor']} 像素,"
          f"颜色差 {cmp_six['color_diff_pixels']} 像素(最大 {cmp_six['max_channel_diff']}),"
          f"深度差 {cmp_six['depth_diff_pixels']} 像素(最大 {cmp_six['max_depth_diff']})")
    print(f"SVG            : {svg_bytes} bytes / {svg_nodes} 节点(已回读校验)")
    print(f"elapsed        : {round((time.perf_counter() - t0) * 1000, 1)} ms")
    return 0


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

真跑:

# snippet: 复现命令(在 lantern 工作区根执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/cg_raster_08_1_frustum_clip.py
node --test 'core/test/cg_raster_08_1_frustum_clip.test.ts'

实跑输出:

viewport       : 320x240  near=32768 far=4194304 aspect=87381
顶点分类       : 397 在内 / 487 总(90 在外)
三角形分类     : in=726 straddle=52 out=118 / 896 总
英雄三角形     : 3 顶点 -> 六平面后 5 顶点,各步 [4, 4, 5, 5, 5, 5]
面积守恒       : 2A(原)=637534208 = 2A(近内)=478150656 + 2A(近外)=159383552(精确 True);六平面后 2A=473623303,外部合计=163915001,残差=-4096
合成 MVP 直通  : 896 三角形 / 43731 覆盖像素 / sha256 bac659770178037a…(= 卷一基准)
顺序 P∘V 不裁剪: 896 三角形 / 43730 覆盖像素 / sha256 8262b444bdd15161…
只裁近远平面   : 43730 覆盖像素 / sha256 8262b444bdd15161…  与不裁剪逐字节相同: True
六平面全裁     : 806 三角形 / 43730 覆盖像素 / culled=276
                 sha256 2de7bb0a9d30cd73d12d9e4f301a635c267c1bf71adb12f9ae1e24a1947c9c2b
六平面 vs 不裁剪: 覆盖异或 0 像素,颜色差 8316 像素(最大 2),深度差 2503 像素(最大 2)
SVG            : 37592 bytes / 501 节点(已回读校验)
elapsed        : 180.3 ms

culled=276 是另一个值得停一下的数字:六平面裁剪之后有 276 个三角形在屏幕空间的有向面积为零或负,被光栅化器当背面剔掉。它们大多是被侧平面切成细长条之后的退化三角形——裁剪不会凭空制造可见面积,但会让一些原本跨越视口边界的三角形在屏幕空间里翻向。

P 层:同一套数字的跨语言复现

core/test/cg_raster_08_1_frustum_clip.test.ts 用 BigInt 重写了一遍 viewPlanes / planeEval / clipPolyPlane / area2xRaw,把上面每个数字都当成金标准逐项比对:六个平面系数、reject_by_plane 的六个计数、397/726/52/118 的分类、英雄三角形 3→5 的逐步顶点数、近平面一步的精确面积守恒、以及六平面帧的 sha256。八条测试全绿。

两条负例是这套断言真正起作用的地方。负例 A 把近平面的法线取反成 (0, 0, ONE, -near)(内部变成相机后方),分类立刻崩坏、摘要分叉——它验证的是平面系数一旦写反,后续所有统计都会变,不会「恰好还是对的」。负例 B 把交点参数 t 从 fdiv 换成浮点比例再取整,交点落位与定点结果分叉。这里有个细节:英雄三角形的近平面 t 恰好是 1/2,浮点与定点在这一步给出完全相同的交点,所以负例 B 必须换到左平面上做(那里的 t ≈ 61966/65536 不是二进制有限小数),否则负例自己就是恒等变换、抓不到任何东西。

第 8 章从这里开始:视锥裁剪给出「怎么把多边形裁成多边形」,接下来 8.2 要回答裁出来的多边形怎么重建、面积怎么对账,8.3 则要处理只裁近平面时投影坐标飞出视口的烂摊子。

阅读导航:上一节:7.3 线宽与直线抗锯齿 · 下一节:8.2 近平面裁剪与多边形重建 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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