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) |
|---|---|---|---|---|---|
| near | 3 → 4 | 3 | 2 | 是 | 0 |
| far | 4 → 4 | 0 | 0 | 是 | 0 |
| left | 4 → 5 | 3 | 2 | 是 | 32768 |
| right | 5 → 6 | 3 | 2 | 是 | 16384 |
| bottom | 6 → 6 | 0 | 0 | 是 | 0 |
| top | 6 → 6 | 0 | 0 | 是 | 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 | 丢弃 2 | 43730 | 8262b444bdd15161… |
裁剪式(clip_path) | 811 | 清空 118 | 43730 | d5152126b3cb1a34… |
| 无挡板基准 | 896 | — | 43730 | 8262b444bdd15161… |
两个数字值得看。第一,丢弃式帧与「挡板根本不存在」的帧逐字节相同——挡板的两条后边在相机之后,两个三角形都被整块丢掉,屏幕上什么都不剩,等于这块挡板从未被提交。第二,裁剪式帧与丢弃式帧覆盖异或为 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 屏幕空间裁剪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。