8.3 屏幕空间裁剪
站内 Vulkan 渲染管线初始化 里,裁剪与视口变换是相邻的两个固定功能阶段,中间隔着一条「坐标必须已经落在视口内」的隐含契约。那是 Vulkan 对屏幕空间裁剪的 API 约定;本节要回答的是它下面的实现问题:如果为了省事只裁近平面,投影坐标会飞多远,靠什么把它们拉回视口,代价又是什么。答案是一对纯整数工具——包围盒与剪刀矩形——外加一条能跳过大部分裁剪的保护带规则。
只裁近平面:包围盒会飞多远
8.1 和 8.2 都默认六个平面全裁。但横向四个平面的裁剪是纯开销:它们不会像近平面那样碰到 w 的奇点,不裁也不会算错,只是坐标会跑到视口外面。所以一个常见的设计是只裁近平面,把横向越界交给屏幕空间兜住。
代价有多大?本节场景里,只裁近平面产生 899 个三角形,它们的原始包围盒合计 4922869 个扫描单元,是视口的 64.10 倍,最大的单个三角形占 626864 单元(8 倍多视口)。这些数字里绝大部分是「视口之外的空扫描」——如果光栅化器老老实实按包围盒逐像素算,64 倍的算力全喂给了屏幕外。
对照完整六平面裁剪:811 个三角形,原始包围盒 242728 单元,等于夹紧后的值,越界三角形 0 个。六平面在 3D 里就把越界几何切掉了,投影出来的包围盒天然落在视口内。
整数包围盒 + 剪刀矩形
把坐标拉回视口的工具是整数包围盒夹紧:屏幕坐标是 Q6 定点,x >> 6 就是算术右移取 floor(负数也向负无穷取整,Python 与 BigInt 同义),三个顶点的整数包围盒取 min/max,再夹到 [0, W-1] × [0, H-1]。夹紧后扫描单元从 4922869 降到 244708,仍是视口的 3.19 倍——因为越界三角形虽然被夹回视口,它占的那块视口仍然要扫。
剪刀矩形(scissor rect)是第二层:它是一个闭区间的像素矩形,扫描窗口必须同时落在包围盒和剪刀矩形内。本节用 x < 160(左半屏)做测试:窗内覆盖 22015 像素、窗外 0 像素,且窗内覆盖与全屏路径逐像素一致。这就是 scissor 的语义——它只限制写入范围,不改任何落在窗内的像素。
最关键的一条断言是:包围盒 + 剪刀矩形驱动出来的帧,与直接调用 cglib.rasterize 的参考帧逐字节相同(sha256 都是 ab5f5becb4876e6b…)。这说明包围盒裁剪是纯粹的扫描窗口优化,一个像素都不动——它是「少算」,不是「改算」。扫描了 223753 个单元、覆盖 43730 像素,两个数字来自同一条路径。
保护带:什么时候可以完全不裁
既然屏幕空间能兜住越界,那很多三角形根本不用裁。保护带(guard band)规则是:只要三角形的包围盒越界不超过 G 个像素,就放它过去、不做任何裁剪;超过 G 才真的裁多边形。G = 0 时已经有 726/899(80.7%) 的三角形免裁——因为它们本来就落在视口内;把 G 放宽到 256 像素,免裁比例升到 840/899(93.4%);G = 4096(远超视口尺寸)时 899/899 全部免裁。保护带把「必须裁」的三角形数量压到个位数百分比,这是它在真实硬件里值得占一块状态的原因。
屏幕空间多边形裁剪的代价
保护带之外的多边形还得裁。这一节的裁剪发生在屏幕空间:把 Q6 多边形对 [0, W·SUB1] × [0, H·SUB1] 矩形做 2D Sutherland–Hodgman(四条半平面)。它和 3D 裁剪的区别在于插值介质——3D 里插的是视图空间坐标,屏幕空间里插的是已经投影、已经透视除过的量,深度与颜色只能按屏幕仿射线性插值。
代价实测是:视口矩形裁剪后 811 个三角形、覆盖 43730 像素,覆盖异或 0,颜色差 33059 像素但最大通道差只有 2。2 个色阶就是屏幕空间仿射插值相对 3D 精确插值的舍入偏差上界——小到看不出来,但它是真实存在的,也正是「屏幕空间裁多边形」和「3D 里裁多边形」不能混为一谈的原因。
而只裁近平面 vs 完整六平面两帧的对照更值得记:覆盖异或 0,颜色差 35329 像素、最大通道差 50,深度差 29236 像素。覆盖完全一致,是因为六平面切出来的碎片和原三角形覆盖的是同一批像素;但颜色差到 50,来自那些横跨半个屏幕的巨大越界三角形——屏幕空间仿射插值在它们身上和 3D 精确插值分道扬镳。覆盖是硬约束,颜色是有界近似,这是屏幕空间路径的准确口径。
可跑的 S 层
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_08_3_screen_space_clip.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_08_3_screen_space_clip.py
"""8.3 屏幕空间裁剪:只裁近平面时投影坐标可以飞出视口几个数量级,
整数包围盒 + 剪刀矩形把扫描窗口压回视口,保护带决定要不要真的裁多边形。
对照三条路径:
N 只裁近平面(最小代价,坐标可能远离视口)
B 在 N 之上做屏幕空间整数包围盒裁剪(本节主角)
F 完整六平面裁剪(把横向平面提前在 3D 里裁掉)
产物:out/cg_raster_08_3_screen.json、out/cg_raster_08_3_bbox.csv、
out/cg_raster_08_3_frame.png、out/cg_raster_08_3_scissor.svg
"""
import json
import os
import sys
import time
import numpy as np
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
SUB = cglib.SUB
SUB1 = cglib.SUB1
# 近景挡板(与 8.2 同一块):两条后边在相机之后,只裁近平面时投影坐标会飞到视口之外。
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),
)
GUARDS = (0, 4, 32, 256, 4096)
def rasterize_bbox(tris, width, height, background, scissor=None):
"""屏幕空间整数包围盒 + 剪刀矩形驱动的扫描线光栅化。
与 cglib.rasterize 的唯一区别是包围盒由 screen_bbox/clamp_bbox 显式算出,
并额外受 scissor(闭区间像素矩形)约束。scissor = 全视口时必须逐字节一致。
"""
sx0, sy0, sx1, sy1 = scissor if scissor else (0, 0, width - 1, height - 1)
color = np.empty((height, width, 3), dtype=np.int64)
color[:, :] = background
depth = np.full((height, width), np.iinfo(np.int64).max, dtype=np.int64)
covered = np.zeros((height, width), dtype=bool)
half = SUB1 // 2
culled = cells = 0
for t in tris:
x0, y0, d0 = t[0], t[1], t[2]
x1, y1, d1 = t[6], t[7], t[8]
x2, y2, d2 = t[12], t[13], t[14]
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
culled += 1
continue
minx, miny, maxx, maxy = clip.screen_bbox(((x0, y0), (x1, y1), (x2, y2)))
minx, maxx = max(minx, sx0), min(maxx, sx1)
miny, maxy = max(miny, sy0), min(maxy, sy1)
if minx > maxx or miny > maxy:
continue
cells += (maxx - minx + 1) * (maxy - miny + 1)
PX, PY = np.meshgrid(np.arange(minx, maxx + 1, dtype=np.int64) * SUB1 + half,
np.arange(miny, maxy + 1, dtype=np.int64) * SUB1 + half)
w0 = (x2 - x1) * (PY - y1) - (y2 - y1) * (PX - x1)
w1 = (x0 - x2) * (PY - y2) - (y0 - y2) * (PX - x2)
w2 = (x1 - x0) * (PY - y0) - (y1 - y0) * (PX - x0)
mask = (w0 >= 0) & (w1 >= 0) & (w2 >= 0)
if not mask.any():
continue
z = (w0 * d0 + w1 * d1 + w2 * d2) // area # 分子非负,// 与截断一致
sub_d = depth[miny:maxy + 1, minx:maxx + 1]
take = mask & (z < sub_d)
if not take.any():
continue
sub_c = color[miny:maxy + 1, minx:maxx + 1]
sub_v = covered[miny:maxy + 1, minx:maxx + 1]
for ch in range(3):
val = (w0 * t[3 + ch] + w1 * t[9 + ch] + w2 * t[15 + ch]) // area
sub_c[:, :, ch] = np.where(take, val, sub_c[:, :, ch])
sub_d[:] = np.where(take, z, sub_d)
sub_v[:] = sub_v | take
return color, depth, covered, culled, cells
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)
plist = [p for _n, p in planes]
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]
polys = clip.build_polys(vv, list(tris) + [(nv, nv + 1, nv + 2), (nv, nv + 2, nv + 3)],
list(vcolors) + [p[3:] for p in PANEL])
rows_near, pjs_near = clip.clip_path_polys(proj, polys, plist[0:1], W, H) # 只裁近平面
rows_full, _ = clip.clip_path_polys(proj, polys, plist, W, H) # 完整六平面
# 1. 包围盒统计:只裁近平面时坐标飞出视口多少
bb_near, bb_full = clip.bbox_stats(rows_near, W, H), clip.bbox_stats(rows_full, W, H)
assert bb_near["raw_cells"] > 10 * W * H, "只裁近平面时必须产生明显越界的包围盒"
assert bb_near["clamped_cells"] < bb_near["raw_cells"]
overhang = bb_full["raw_cells"] - bb_full["clamped_cells"]
assert overhang < bb_full["raw_cells"] // 50, f"六平面裁剪后只允许边界像素的零星越界,实测 {overhang}"
# 2. 屏幕空间包围盒裁剪不改变任何像素
f_ref = cglib.rasterize(rows_near, W, H, bg)
color, depth, covered, culled, cells = rasterize_bbox(rows_near, W, H, bg)
px, sha_bbox = clip.sha_rgb(color)
_, sha_ref = clip.sha_rgb(f_ref[0])
assert sha_bbox == sha_ref, "整数包围盒裁剪必须与 cglib.rasterize 逐字节相同"
assert culled == f_ref[3]
assert cells <= bb_near["clamped_cells"], "实际扫过的单元数必然不超过全量夹紧统计"
# 3. 剪刀矩形:窗外一个像素都不许碰
scissor = (0, 0, W // 2 - 1, H - 1)
_c2, _d2, v2, _cu, _ce = rasterize_bbox(rows_near, W, H, bg, scissor)
assert not v2[:, W // 2:].any(), "剪刀矩形之外不得有任何覆盖像素"
assert bool((v2[:, :W // 2] == covered[:, :W // 2]).all()), "剪刀矩形之内覆盖必须一致"
scissor_pixels = int(v2.sum())
# 4. 视口矩形裁剪(2D Sutherland–Hodgman):覆盖必须一致
rect = (0, 0, W * SUB1, H * SUB1)
rows_rect = []
for pj in pjs_near:
q = clip.clip_poly_rect(pj, rect)
if len(q) >= 3:
rows_rect.extend(clip.fan_triangles(q))
f_rect = cglib.rasterize(rows_rect, W, H, bg)
cmp_rect = clip.diff_stats(f_ref, f_rect)
# 5. 完整六平面裁剪 vs 只裁近平面 + 屏幕空间包围盒
f_full = cglib.rasterize(rows_full, W, H, bg)
_, sha_full = clip.sha_rgb(f_full[0])
cmp_full = clip.diff_stats(f_ref, f_full)
# 6. 保护带:允许越界 G 像素时,多少三角形可以完全不裁
guard_rows = []
for g in GUARDS:
accepted = 0
for t in rows_near:
bb = clip.screen_bbox(((t[0], t[1]), (t[6], t[7]), (t[12], t[13])))
if -g <= bb[0] and -g <= bb[1] and bb[2] <= W - 1 + g and bb[3] <= H - 1 + g:
accepted += 1
guard_rows.append({"guard_px": g, "guard_q6": g * SUB1, "accepted": accepted,
"accept_permille": accepted * 1000 // max(1, len(rows_near))})
out = os.path.join(ROOT, "out")
png_bytes = cglib.save_png(os.path.join(out, "cg_raster_08_3_frame.png"), color)
cglib.write_csv(os.path.join(out, "cg_raster_08_3_bbox.csv"),
["path", "triangles", "raw_cells", "clamped_cells", "offscreen_triangles",
"worst_raw_cells"],
[["near_only", bb_near["triangles"], bb_near["raw_cells"],
bb_near["clamped_cells"], bb_near["offscreen_triangles"],
bb_near["worst_raw_cells"]],
["six_plane", bb_full["triangles"], bb_full["raw_cells"],
bb_full["clamped_cells"], bb_full["offscreen_triangles"],
bb_full["worst_raw_cells"]]])
svg_bytes, svg_nodes = svg.scissor_svg(os.path.join(out, "cg_raster_08_3_scissor.svg"),
W, H, bb_near, guard_rows)
with open(os.path.join(out, "cg_raster_08_3_frame.rgb"), "wb") as fh:
fh.write(px.tobytes())
with open(os.path.join(out, "cg_raster_08_3_screen.json"), "w", encoding="utf-8") as fh:
json.dump({
"viewport": [W, H], "subpixel_bits": SUB, "viewport_cells": W * H,
"bbox": {"near_only": bb_near, "six_plane": bb_full},
"scissor": {"rect_pixels": list(scissor), "covered_inside": scissor_pixels,
"covered_outside": 0},
"frames": {
"near_only": {"covered": int(f_ref[2].sum()), "sha256": sha_ref,
"triangles": len(rows_near), "culled": f_ref[3]},
"near_bbox": {"covered": int(covered.sum()), "sha256": sha_bbox,
"scan_cells": cells},
"near_rect_clip": {"covered": int(f_rect[2].sum()), "triangles": len(rows_rect)},
"six_plane": {"covered": int(f_full[2].sum()), "sha256": sha_full,
"triangles": len(rows_full), "culled": f_full[3],
"png_bytes": png_bytes}},
"near_vs_six": cmp_full, "near_vs_rect_clip": cmp_rect, "guard_band": guard_rows,
"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} = {W * H} 扫描单元,Q6 子像素(SUB={SUB})")
print(f"只裁近平面 : {bb_near['triangles']} 三角形,原始包围盒 "
f"{bb_near['raw_cells']} 单元 = {bb_near['raw_cells'] / (W * H):.2f} 倍视口,"
f"最大单个 {bb_near['worst_raw_cells']} 单元")
print(f" 夹紧到视口后 {bb_near['clamped_cells']} 单元"
f"({bb_near['clamped_cells'] / (W * H):.2f} 倍视口),"
f"完全在视口外 {bb_near['offscreen_triangles']} 个")
print(f"完整六平面 : {bb_full['triangles']} 三角形,原始 {bb_full['raw_cells']} 单元"
f"(= 夹紧后,越界 {bb_full['offscreen_triangles']} 个)")
print(f"包围盒裁剪帧 : 扫描 {cells} 单元 / {int(covered.sum())} 覆盖像素 / "
f"sha256 {sha_bbox[:16]}…")
print(f"参考帧 : {int(f_ref[2].sum())} 覆盖像素 / sha256 {sha_ref[:16]}…"
f" 逐字节相同: {sha_bbox == sha_ref}")
print(f"剪刀矩形 : 只写 x<{W // 2},窗内 {scissor_pixels} 像素、窗外 0 像素,窗内覆盖一致")
print(f"视口矩形裁剪 : {len(rows_rect)} 三角形 / 覆盖 {int(f_rect[2].sum())} 像素 / "
f"覆盖异或 {cmp_rect['coverage_xor']} / 颜色差 {cmp_rect['color_diff_pixels']} 像素"
f"(最大 {cmp_rect['max_channel_diff']})")
print(f"六平面 vs 近平面: 覆盖异或 {cmp_full['coverage_xor']} 像素,"
f"颜色差 {cmp_full['color_diff_pixels']} 像素(最大 {cmp_full['max_channel_diff']}),"
f"深度差 {cmp_full['depth_diff_pixels']} 像素")
print("保护带(越界不超过 G 像素即免裁):")
for g in guard_rows:
print(f" G={g['guard_px']:>4} px ({g['guard_q6']:>7} Q6) 免裁 {g['accepted']:>3}/"
f"{bb_near['triangles']} 三角形({g['accept_permille'] / 10:.1f}%)")
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_3_screen_space_clip.py
node --test 'core/test/cg_raster_08_3_screen_space_clip.test.ts'
实跑输出:
viewport : 320x240 = 76800 扫描单元,Q6 子像素(SUB=6)
只裁近平面 : 899 三角形,原始包围盒 4922869 单元 = 64.10 倍视口,最大单个 626864 单元
夹紧到视口后 244708 单元(3.19 倍视口),完全在视口外 118 个
完整六平面 : 811 三角形,原始 242728 单元(= 夹紧后,越界 0 个)
包围盒裁剪帧 : 扫描 223753 单元 / 43730 覆盖像素 / sha256 ab5f5becb4876e6b…
参考帧 : 43730 覆盖像素 / sha256 ab5f5becb4876e6b… 逐字节相同: True
剪刀矩形 : 只写 x<160,窗内 22015 像素、窗外 0 像素,窗内覆盖一致
视口矩形裁剪 : 811 三角形 / 覆盖 43730 像素 / 覆盖异或 0 / 颜色差 33059 像素(最大 2)
六平面 vs 近平面: 覆盖异或 0 像素,颜色差 35329 像素(最大 50),深度差 29236 像素
保护带(越界不超过 G 像素即免裁):
G= 0 px ( 0 Q6) 免裁 726/899 三角形(80.7%)
G= 4 px ( 256 Q6) 免裁 728/899 三角形(80.9%)
G= 32 px ( 2048 Q6) 免裁 755/899 三角形(83.9%)
G= 256 px ( 16384 Q6) 免裁 840/899 三角形(93.4%)
G=4096 px ( 262144 Q6) 免裁 899/899 三角形(100.0%)
SVG : 5334 bytes / 45 节点(已回读校验)
elapsed : 392.3 ms
P 层:逐字节相同与两条负例
core/test/cg_raster_08_3_screen_space_clip.test.ts 用 BigInt 复刻 rasterizeScissor,九条测试全绿,其中三条是核心:包围盒统计与近平面/六平面两组数字逐项相同;rasterizeScissor 的 sha256 等于 rasterize 的 sha256,都等于 ab5f5bec…;以及剪刀矩形窗内覆盖一致、窗外为零。
负例 A:剪刀矩形写反。 把 [0, 0, W-1, H-1] 写成 [W-1, H-1, 0, 0](左右/上下颠倒),minx > maxx 使每个三角形的扫描窗口都为空,覆盖像素数直接归零。这条抓的是「剪刀矩形必须是规范化闭区间」这个隐含前提。
负例 B:把矩形当成像素单位而不是 Q6。 屏幕空间裁剪矩形的上界是 W·SUB1 = 20480(Q6),如果误写成 W = 320,绝大多数多边形会被裁到几乎不剩,覆盖量崩塌。这条抓的是单位口径——屏幕空间里深度、颜色是「像素级」的,坐标却是「Q6 级」的,两者混用是这类实现最常见的错。
三条路径到这里就齐了:8.1 讲怎么在 3D 里裁,8.2 讲裁完怎么重建与对账,8.3 讲怎么用屏幕空间把剩下的越界兜住。下一章进入光栅化器内部的另一条判据——环绕顺序与法线朝向。
阅读导航:上一节:8.2 近平面裁剪与多边形重建 · 下一节:9.1 环绕顺序与法线朝向 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。