8.2 近平面裁剪与多边形重建

近平面裁剪会把三角形裁成四边形甚至五边形,重建它靠的是扇形三角化的整数恒等式:三个扇形三角形的面积和精确等于多边形面积,逆序则精确变号。本节实测英雄三角形 3→4→5 的逐步计数、面积比恰为 t^2 = 1/4 的解析解,以及「丢弃 vs 裁剪」两帧覆盖异或为 0 而颜色差 171。

8.2 近平面裁剪与多边形重建

站内 Vulkan 渲染管线初始化 把裁剪描述成一个「顶点会被切开、属性按参数插值补齐」的黑盒;本节要把这个黑盒打开:一个三角形在近平面附近会变成四边形甚至五边形,而这个多边形必须被重新拆成三角形才能进光栅化器。那是 Vulkan 裁剪阶段 API 的语义;本节给的是 Sutherland–Hodgman 在整数算术下的完整实现与对账——裁出来的多边形顶点数守恒、面积守恒、朝向保持,三条都能写成整数等式,任何一条被破坏都说明实现错了。

近平面为什么必须单独处理

六个平面里,近平面是唯一一个「不裁就会出事」的面。远平面、左右、上下都可以靠屏幕空间包围盒兜住(8.3 会量化这件事),但近平面背后是投影的奇点:w 会穿过 0 变号,x/w 会从正无穷跳到负无穷。所以近平面裁剪不是优化,是正确性前提。

本节换一个更狠的英雄三角形:V0 放在相机之后(z = +1,即 +65536),V1、V2 在近平面之内。这样 V0 引出的两条边都被近平面切开,三角形直接掉一个顶点、多两个交点,变成四边形。

逐步表:3 -> 4 -> 5

裁剪是逐平面做的,每一步的输出是下一步的输入。脚本把每一步的顶点数、外部顶点数、交点数与面积残差都记下来:

平面顶点数外部顶点交点顶点数守恒面积残差(Q32.32)
near3 → 432是0
far4 → 400是0
left4 → 532是32768
right5 → 632是16384
bottom6 → 600是0
top6 → 600是0

「顶点数守恒」是一条组合恒等式,与坐标无关:len(内部) + len(外部) == 输入的顶点数 + 2 × 交点数——每穿过一次平面边界就产生一个交点,同时进入内部和外部两个多边形。far、bottom、top 三步的顶点数没变,是因为这一步的输入多边形整体落在平面内侧,裁剪器仍然走了一遍,输出就是输入的副本。

面积守恒与解析解

近平面这一步是精确的:2A(原) = -6442450944 = 2A(近内) -4831838208 + 2A(切掉) -1610612736,残差为 0。为负是因为英雄三角形在 x–y 视图空间里是顺时针绕向,有向面积为负——这不影响任何结论,8.2 全程保留符号,朝向是否保持才是要断言的东西。

更漂亮的是解析解能对上。被切掉的那部分是一个与原三角形相似的小三角形,相似比就是交点参数 t,面积比因此是 t²。这个三角形两条边与近平面的交点参数恰好都是 t = 32768/65536 = 1/2,于是

2A(切掉) == 2A(原) × t × t / (ONE × ONE)      // t = ONE/2,比例恰为 1/4

脚本把这条当成断言跑:2A(切掉) = -1610612736,而 -6442450944 / 4 = -1610612736,逐位相等。t = 1/2 是精心挑的——它是二进制有限小数,fmul 不会引入舍入,所以「解析解」和「整数实现」在这一步可以要求精确相等而不是「误差小于多少」。这跟 8.1 里「单步精确、多步有界残差」是同一个分工:把一个可精确验证的切片挑出来,让它承担强断言。

走完六平面,2A 从 -6442450944 变成 -3755696244,外部件合计 -2686803852,残差 49152(小于一个 Q16.16 面积单位),朝向保持为真。

重建:扇形三角化的整数恒等式

裁出来的五边形要重新拆成三角形。凸多边形用扇形三角化:取第 0 个顶点当扇心,依次连 (0, i, i+1),五个顶点给出三个三角形。它之所以适合做断言,是因为面积上有一条精确恒等式:

Σ area2x_raw(fan_i) == area2x_raw(多边形)

实测三个扇形三角形的面积和是 -4562592680,五边形的 area2x_raw 也是 -4562592680,逐位相等。原因是扇形三角化的内部对角线在 shoelace 求和里成对反向抵消,剩下的正是多边形自身的外边界——和 8.1 里「内部 + 外部 = 原多边形」是同一条代数事实的另一种用法。

把顶点顺序写反会精确变号:逆序之后 area2x_raw = +4562592680。这不是一个小细节——光栅化器用有向面积的符号做背面剔除(area <= 0 即剔),所以顺序写反的后果不是「面积差一点」,而是整块多边形消失。P 层的负例正是抓这一点。

丢弃 vs 裁剪

真实渲染器里还有一条更省事的路径:任何顶点落在近平面之后的三角形,整块丢弃。对绝大多数场景它工作得不错,代价是近平面附近会凭空出现一圈洞。本节用一块「近景挡板」把这个代价量化出来:一块贴在视轴下方的平板,两条后边落在相机之后。

路径三角形丢弃/清空覆盖像素sha256(前 16 位)
丢弃式(project_path)896丢弃 2437308262b444bdd15161…
裁剪式(clip_path)811清空 11843730d5152126b3cb1a34…
无挡板基准896—437308262b444bdd15161…

两个数字值得看。第一,丢弃式帧与「挡板根本不存在」的帧逐字节相同——挡板的两条后边在相机之后,两个三角形都被整块丢掉,屏幕上什么都不剩,等于这块挡板从未被提交。第二,裁剪式帧与丢弃式帧覆盖异或为 0,但颜色差 35334 个像素、最大通道差 171。覆盖没变是因为挡板只挡住本来就被地形挡住的像素;颜色差这么大,是因为裁剪式把挡板沿近平面切开后,那些被切出来的碎片确实通过了深度测试,把一部分像素重画了一遍。「丢弃」丢掉的不只是几何,还有一整个可见性事件。

裁剪顺序无关

六平面按什么顺序裁,结果应当一样。脚本用三种顺序跑同一份场景:canonical(near→far→left→right→bottom→top)、reversed(完全逆序)、lateral_first(先四个侧面再近远)。三帧的覆盖异或都是 0;颜色差分别是 0 / 27474(最大 1)/ 33914(最大 2)。顺序确实会改变交点的舍入路径,所以帧的 sha256 并不相同(d5152126… / eb1ea5c8… / dbd25b04…),但它改不动覆盖归属,颜色差也压在 2 个色阶以内。这条断言的价值在于:如果有人把「裁剪顺序无关」理解成「帧逐字节相同」,他会得到一个永远失败的测试;正确的口径是覆盖不变、颜色有界。

可跑的 S 层

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_08_2_near_plane_reconstruct.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_08_2_near_plane_reconstruct.py
"""8.2 近平面裁剪与多边形重建:三角形 3 -> 四边形 4 -> 五边形 5 的完整路径、
顶点数守恒、面积守恒与解析解对照、朝向保持、扇形三角化的整数恒等式,
以及「丢弃 vs 裁剪」的像素差对照。

产物:out/cg_raster_08_2_reconstruct.json、out/cg_raster_08_2_steps.csv、
      out/cg_raster_08_2_frame.png、out/cg_raster_08_2_hero.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

# 英雄三角形(视图空间):V0 在相机之后(z = +1),V1/V2 在近平面之内。
# 两条边与近平面的交点参数都恰好是 1/2,且坐标差为偶数,故 fmul 无舍入:
# 近平面一步的面积守恒是精确整数等式,被切掉的小三角形与原三角形相似、面积比恰为 1/4。
HERO = (
    (131072, 0, 65536, 210, 80, 60),           # V0:相机之后
    (65536, -16384, -131072, 70, 160, 220),    # V1:近平面之内
    (-131072, 32768, -131072, 220, 200, 120),  # V2:近平面之内、左平面之外
)

# 近景挡板:一块贴在相机视轴下方的平板,两条后边落在相机之后。
# 丢弃式处理会让它整块消失;裁剪式处理把它沿近平面切开,露出被它挡住的场景。
# 顶点顺序决定屏幕空间朝向:逆时针为正面,写反了整块挡板会被背面剔除。
PANEL = (
    (-262144, -32768, 65536, 235, 180, 110),
    (-262144, -32768, -1310720, 160, 120, 80),
    (262144, -32768, -1310720, 160, 120, 80),
    (262144, -32768, 65536, 235, 180, 110),
)

ORDERS = {"canonical": ("near", "far", "left", "right", "bottom", "top"),
          "reversed": ("top", "bottom", "right", "left", "far", "near"),
          "lateral_first": ("left", "right", "bottom", "top", "near", "far")}


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)
    planes = clip.view_planes(near, far, tan_half, aspect)
    pname, names, plist = {n: p for n, p in planes}, [n for n, _p in planes], [p for _n, p in planes]

    # ---- 1. 英雄三角形:3 -> 4 -> 5,逐平面步骤与顶点数守恒 ----
    hero_poly = [tuple(v) for v in HERO]
    hero_final, steps = clip.step_table(hero_poly, plist, names)
    near_in, near_out = clip.clip_poly_plane(hero_poly, pname["near"])
    left_in, _left_out = clip.clip_poly_plane(near_in, pname["left"])
    assert (len(hero_poly), len(near_in), len(left_in)) == (3, 4, 5)
    assert all(s["conserved"] for s in steps), "顶点数守恒失败"

    # 面积守恒:近平面一步精确(t = 1/2 且坐标差为偶数,fmul 无舍入)
    a_orig, a_near, a_near_out = (clip.area2x_raw(hero_poly), clip.area2x_raw(near_in),
                                  clip.area2x_raw(near_out))
    near_exact = a_orig == a_near + a_near_out
    # 解析解:被切掉的小三角形与原三角形相似,比值 t = 1/2,面积比 t^2 = 1/4
    d0 = clip.plane_eval(pname["near"], hero_poly[0])
    t_q16 = cglib.fdiv(d0, d0 - clip.plane_eval(pname["near"], hero_poly[1]))
    t_ratio_exact = a_near_out == (a_orig * (t_q16 * t_q16)) // (ONE * ONE)
    assert near_exact and t_q16 == ONE // 2 and t_ratio_exact
    # 六平面整体:残差必须小于 1 个 Q16.16 面积单位,且朝向保持
    a_final = clip.area2x_raw(hero_final)
    a_total_out = sum(s["area2x_out"] for s in steps)
    residual = a_orig - a_final - a_total_out
    assert abs(residual) < ONE, residual
    assert (a_final > 0) == (a_orig > 0), "裁剪必须保持多边形朝向"

    # ---- 2. 多边形重建:扇形三角化的整数恒等式 ----
    fan_raw = sum(clip.area2x_raw([left_in[0], left_in[i], left_in[i + 1]])
                  for i in range(1, len(left_in) - 1))
    fan_exact = fan_raw == clip.area2x_raw(left_in)
    reversed_raw = clip.area2x_raw(list(reversed(left_in)))
    assert fan_exact, (fan_raw, clip.area2x_raw(left_in))
    assert reversed_raw == -clip.area2x_raw(left_in), "逆序必须让有向面积变号(会被背面剔除)"

    # ---- 3. 场景:标准场景 + 近景挡板 ----
    verts, tris, vcolors = render_raster.build_scene(spec)
    nv = len(verts)
    vv = [cglib.mat_vec4(view, (v[0], v[1], v[2], ONE))[:3] for v in verts] + [p[:3] for p in PANEL]
    ptris = [(nv, nv + 1, nv + 2), (nv, nv + 2, nv + 3)]
    all_colors = list(vcolors) + [p[3:] for p in PANEL]
    polys = clip.build_polys(vv, list(tris) + ptris, all_colors)
    rows_drop, dropped = clip.project_path(proj, polys, W, H)
    rows_clip, emptied = clip.clip_path(proj, polys, plist, W, H)
    rows_base, _ = clip.project_path(proj, polys[:len(tris)], W, H)
    f_drop = cglib.rasterize(rows_drop, W, H, bg)
    f_clip = cglib.rasterize(rows_clip, W, H, bg)
    f_base = cglib.rasterize(rows_base, W, H, bg)
    px, clip_sha = clip.sha_rgb(f_clip[0])
    _, drop_sha = clip.sha_rgb(f_drop[0])
    _, base_sha = clip.sha_rgb(f_base[0])
    cmp_drop = clip.diff_stats(f_drop, f_clip)
    assert cmp_drop["color_diff_pixels"] > 0, "近景挡板必须改变可见像素,否则对照无意义"
    assert clip_sha != drop_sha
    assert int(f_drop[2].sum()) == int(f_base[2].sum()), "丢弃式路径与无挡板场景必须同覆盖"
    assert drop_sha == base_sha, "丢弃式路径必须与完全移除挡板的场景逐字节相同"

    # ---- 4. 裁剪顺序无关性:三种平面顺序的帧对照 ----
    order_frames = {}
    for key, seq in ORDERS.items():
        r, e = clip.clip_path(proj, polys, [pname[n] for n in seq], W, H)
        f = cglib.rasterize(r, W, H, bg)
        order_frames[key] = (f, clip.sha_rgb(f[0])[1], len(r), e)
    o_cmp = {k: clip.diff_stats(order_frames["canonical"][0], order_frames[k][0])
             for k in ORDERS if k != "canonical"}
    for k, v in o_cmp.items():
        assert v["coverage_xor"] == 0 and v["max_channel_diff"] <= 4, (k, v)

    # ---- 5. 落盘 ----
    out = os.path.join(ROOT, "out")
    png_bytes = cglib.save_png(os.path.join(out, "cg_raster_08_2_frame.png"), f_clip[0])
    cglib.write_csv(os.path.join(out, "cg_raster_08_2_steps.csv"),
                    ["plane", "verts_before", "verts_after", "verts_out", "crossings",
                     "conserved", "area2x_before", "area2x_after", "area2x_out", "residual"],
                    [[s["plane"], s["verts_before"], s["verts_after"], s["verts_out"],
                      s["crossings"], int(s["conserved"]), s["area2x_before"], s["area2x_after"],
                      s["area2x_out"], s["residual"]] for s in steps])
    svg_bytes, svg_nodes = svg.hero_svg(os.path.join(out, "cg_raster_08_2_hero.svg"),
                                        hero_poly, near_in, left_in, steps, near)
    with open(os.path.join(out, "cg_raster_08_2_frame.rgb"), "wb") as fh:
        fh.write(px.tobytes())
    with open(os.path.join(out, "cg_raster_08_2_reconstruct.json"), "w", encoding="utf-8") as fh:
        json.dump({
            "viewport": [W, H], "near": near, "far": far, "aspect": aspect,
            "hero": {"input": [list(v) for v in hero_poly], "steps": steps,
                     "near_clip": [list(v) for v in near_in],
                     "left_clip": [list(v) for v in left_in],
                     "vertex_counts": [3, 4, 5, len(hero_final)],
                     "t_q16": t_q16, "t_ratio": t_q16 / ONE,
                     "area2x_raw": {"orig": a_orig, "near_in": a_near, "near_out": a_near_out,
                                    "final": a_final, "total_out": a_total_out,
                                    "near_exact": near_exact, "t_ratio_exact": t_ratio_exact,
                                    "residual": residual},
                     "orientation_kept": (a_final > 0) == (a_orig > 0),
                     "fan_raw": fan_raw, "fan_exact": fan_exact, "reversed_raw": reversed_raw},
            "panel": {"view_space": [list(p) for p in PANEL], "triangles": len(ptris),
                      "steps": [{k: s[k] for k in ("plane", "verts_before", "verts_after",
                                                   "crossings")}
                                for s in clip.step_table([tuple(p) for p in PANEL], plist,
                                                         names)[1]]},
            "frames": {
                "drop": {"covered": int(f_drop[2].sum()), "sha256": drop_sha,
                         "triangles": len(rows_drop), "dropped_triangles": dropped},
                "clip": {"covered": int(f_clip[2].sum()), "sha256": clip_sha,
                         "triangles": len(rows_clip), "emptied": emptied,
                         "culled": f_clip[3], "png_bytes": png_bytes},
                "base_without_panel": {"covered": int(f_base[2].sum()), "sha256": base_sha,
                                       "triangles": len(rows_base)}},
            "drop_vs_clip": cmp_drop,
            "order_frames": {k: {"covered": int(v[0][2].sum()), "sha256": v[1],
                                 "triangles": v[2], "emptied": v[3]}
                             for k, v in order_frames.items()},
            "order_vs_canonical": o_cmp,
            "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(f"英雄三角形     : {len(hero_poly)} -> {len(near_in)} -> {len(left_in)} 顶点,"
          f"近平面 t = {t_q16}/{ONE} = {t_q16 / ONE}")
    print("逐平面步骤:")
    for s in steps:
        print(f"  {s['plane']:<7} {s['verts_before']} -> {s['verts_after']} 顶点"
              f"(外部 {s['verts_out']},交点 {s['crossings']},守恒 {s['conserved']},"
              f"面积残差 {s['residual']})")
    print(f"面积守恒       : 2A(原)={a_orig} = 2A(近内)={a_near} + 2A(切掉)={a_near_out}  "
          f"精确={near_exact};解析比 t^2=1/4 吻合={t_ratio_exact}")
    print(f"六平面整体     : 2A(原)={a_orig} -> 2A(最终)={a_final},外部合计={a_total_out},"
          f"残差={residual},朝向保持={(a_final > 0) == (a_orig > 0)}")
    print(f"扇形重建       : 3 个三角形面积和 {fan_raw} == 五边形 {clip.area2x_raw(left_in)}"
          f"(精确 {fan_exact});逆序 {reversed_raw}")
    print(f"丢弃式路径     : {len(rows_drop)} 三角形 / 丢弃 {dropped} 个 / "
          f"{int(f_drop[2].sum())} 覆盖像素 / sha256 {drop_sha[:16]}…")
    print(f"裁剪式路径     : {len(rows_clip)} 三角形 / 空 {emptied} 个 / "
          f"{int(f_clip[2].sum())} 覆盖像素 / culled={f_clip[3]} / sha256 {clip_sha[:16]}…")
    print(f"无挡板基准     : {int(f_base[2].sum())} 覆盖像素 / sha256 {base_sha[:16]}…"
          f"(丢弃式与它逐字节相同: {drop_sha == base_sha})")
    print(f"丢弃 vs 裁剪   : 覆盖异或 {cmp_drop['coverage_xor']} 像素,颜色差 "
          f"{cmp_drop['color_diff_pixels']} 像素(最大通道差 {cmp_drop['max_channel_diff']}),"
          f"深度差 {cmp_drop['depth_diff_pixels']} 像素")
    for k, v in o_cmp.items():
        print(f"顺序 {k:<14} vs canonical: 覆盖异或 {v['coverage_xor']},"
              f"颜色差 {v['color_diff_pixels']} 像素(最大 {v['max_channel_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_2_near_plane_reconstruct.py
node --test 'core/test/cg_raster_08_2_near_plane_reconstruct.test.ts'

实跑输出:

viewport       : 320x240  near=32768 far=4194304 aspect=87381
英雄三角形     : 3 -> 4 -> 5 顶点,近平面 t = 32768/65536 = 0.5
面积守恒       : 2A(原)=-6442450944 = 2A(近内)=-4831838208 + 2A(切掉)=-1610612736  精确=True;解析比 t^2=1/4 吻合=True
六平面整体     : 2A(原)=-6442450944 -> 2A(最终)=-3755696244,外部合计=-2686803852,残差=49152,朝向保持=True
扇形重建       : 3 个三角形面积和 -4562592680 == 五边形 -4562592680(精确 True);逆序 4562592680
丢弃式路径     : 896 三角形 / 丢弃 2 个 / 43730 覆盖像素 / sha256 8262b444bdd15161…
裁剪式路径     : 811 三角形 / 空 118 个 / 43730 覆盖像素 / culled=276 / sha256 d5152126b3cb1a34…
无挡板基准     : 43730 覆盖像素 / sha256 8262b444bdd15161…(丢弃式与它逐字节相同: True)
丢弃 vs 裁剪   : 覆盖异或 0 像素,颜色差 35334 像素(最大通道差 171),深度差 35095 像素
顺序 reversed       vs canonical: 覆盖异或 0,颜色差 27474 像素(最大 1)
顺序 lateral_first  vs canonical: 覆盖异或 0,颜色差 33914 像素(最大 2)
SVG            : 4330 bytes / 34 节点(已回读校验)
elapsed        : 294.7 ms

P 层:把每一条恒等式搬过去

core/test/cg_raster_08_2_near_plane_reconstruct.test.ts 用 BigInt 重建整条路径,九条测试全绿:逐步表逐项相同、近平面一步的面积守恒与 t² 解析比、扇形三角化的整数恒等式与逆序变号、近景挡板的 4→4→5→6 步骤、裁剪式与丢弃式两帧的 sha256、三种平面顺序的帧对照,外加两条负例。

负例一:重建时顶点顺序写反。 把扇形三角化的绕向整体反转(等价于重建时把 [v0, v1, v2] 写成 [v0, v2, v1]),正序扇形覆盖 4726 个像素、逆序覆盖 0 个,全部被背面剔除;整帧层面覆盖量也从 43730 崩塌到不足五分之一。这正是前面那条「逆序精确变号」的直接后果。

负例二:交点参数 t 用浮点。 把 fdiv 换成 Number 比例再取整,交点落位与定点结果分叉。这条负例必须在左平面上做:英雄三角形的近平面 t 恰好是 1/2,浮点和定点在那里给出完全一样的交点,只有左平面(t ≈ 61966/65536)才能把差异暴露出来。给一个「恰好落在二进制有限小数上」的算例做负例,是这类定点测试最容易踩的坑。

8.1 解决了「怎么裁」,8.2 解决了「裁完怎么重建、怎么对账」。但这两节都默认横向平面也被裁了;如果为了省事只裁近平面呢?投影坐标会飞出视口几个数量级——这就是 8.3 要处理的问题。

阅读导航:上一节:8.1 视锥裁剪 · 下一节:8.3 屏幕空间裁剪 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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