8.3 屏幕空间裁剪

只裁近平面时投影坐标会飞出视口 64 倍,整数包围盒加剪刀矩形把扫描窗口压回视口且逐字节不改像素;保护带 G=0 就能让 80.7% 的三角形免裁,G=256 提到 93.4%。本节实测原始包围盒 4922869 单元、夹紧后 244708、扫描 223753 单元覆盖 43730 像素。

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 环绕顺序与法线朝向 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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