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 直通(不裁剪,不投影分解) | 896 | 43731 | bac659770178037a… |
| noclip | 顺序 P∘V 投影,不裁剪 | 896 | 43730 | 8262b444bdd15161… |
| nearfar | 只裁近、远平面 | 896 | 43730 | 8262b444bdd15161… |
| sixplane | 六平面全裁 | 806 | 43730 | 2de7bb0a9d30cd73… |
两个结论各自独立。第一,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 近平面裁剪与多边形重建 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。