9.1 环绕顺序与法线朝向

屏幕空间边函数 E(A,B,C) 的符号就是三角形的朝向。本节在《烛影山谷》的 896 个三角形上实测 606 正面、290 背面、0 退化,与光栅化器剔除的 290 完全一致;再证明世界空间几何法线与视线方向的点积是同一条判据,并用 det<0 的镜像矩阵说明手性反转时法线必须跟着翻。

9.1 环绕顺序与法线朝向

站内讲剔除的文章已经不少:/graphics-gpu-driven-rendering-lod/ 那篇讲的是 GPU 驱动剔除——把剔除搬到 GPU 上、按 LOD 分块做批量可见性判定,关注的是「怎么让 GPU 少接活」。本卷要问的是它下面那一层:一个三角形的朝向,究竟靠什么判定? 答案是一段整数边函数的符号。这一节就把这个符号讲到底,并说明它与世界空间法线朝向是同一件事的两种写法。

先把结论摆出来:对三角形 A、B、C,边函数

E(A, B, C) = (Bx - Ax) * (Cy - Ay) - (By - Ay) * (Cx - Ax)

恰好是三角形 A B C 有向面积的两倍。这个值大于零、等于零、小于零,就分别对应屏幕空间逆时针、退化、顺时针三种环绕。光栅化器只需要看它的符号:area > 0 就画,area <= 0 就跳过——这就是背面剔除的全部判定逻辑。

为什么是「一个整数」,而不是一次点积

GPU 上有两种常见的朝向判定:一种在世界空间算法线方向与视线方向的点积,一种在屏幕空间算有向面积的符号。它们不是两个判据,是同一个判据。原因很直接:投影是线性的(透视除法只做统一的缩放),而 to_screen 把 NDC 的 y 轴翻向下,屏幕空间的一次「翻转 + 缩放」会把手性整体取反。于是世界空间里「法线指向哪一侧」这件事,到了屏幕空间就变成「顶点绕行的方向」——两者只差一个固定的符号。

本套的约定是:屏幕空间 y 轴向下时,逆时针环绕(area > 0)是正面。 这条约定写在 sim/cglib.py 的 build_terrain 注释里,也写在光栅化循环的第一行:

# snippet: 光栅化循环开头的剔除判定(节选自 lantern/sim/cglib.py)
area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
if area <= 0:
    culled += 1
    continue  # 背面剔除(屏幕空间逆时针为正面)

注意这里是 <= 0 而不是 < 0:退化三角形(面积恰为零)也一并剔除,因为它既占不了像素,又会在后续的重心插值里制造除零。

判据的两种写法,实测等价

为了证明这不是一句约定俗成的口号,我们把它算两遍:一遍用屏幕空间的面积符号,一遍用世界空间的几何法线。后者取

N = (B - A) x (C - A)        # 几何法线,由环绕顺序定方向
V = A - eye                  # 从相机指向三角形的视线向量
正面 <=> dot(N, V) > 0

在这份场景的 896 个三角形上,两种写法给出的正面/背面分类完全一致(不一致数 0)。为什么是 > 0 而不是 < 0?因为 to_screen 把 y 翻向下、顺带反转了手性,所以按右手定则从环绕顺序算出的法线,指向的是背对相机的那一侧。这个符号方向容易记反,所以本套的做法是:判定永远只用屏幕空间的面积符号,世界空间法线只用来着色。

可运行产物:逐三角形分类 + 背面可视化

下面这个脚本读 config/scene_spec.json,把 896 个三角形逐个分类,落盘朝向掩码、分类表、一张背面可视化 PNG 和一张由纯 xml.etree 手写的 SVG 示意图。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_09_1_winding.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_09_1_winding.py
"""9.1 环绕顺序与法线朝向:屏幕空间有向面积的符号就是朝向本身。

对《烛影山谷》的 896 个三角形逐个算屏幕空间边函数 E(A,B,C):
  area > 0  屏幕空间逆时针(y 轴向下)  -> 正面
  area <= 0 屏幕空间顺时针 / 退化        -> 背面,被剔除
再独立地用世界空间几何法线 N = (B-A)x(C-A) 与视线方向 V = A - eye 判定同一件事:
实测 896 个三角形上 area > 0 <-> dot(N, V) > 0 完全一致(不一致数 0),
因为 to_screen 把 y 轴翻向下、同时反转了手性,右手法线因此指向背对相机的一侧。
最后用镜像矩阵 M = diag(-1, 1, 1)(det = -1)证明:手性一变,环绕符号整体反转,
且真正的几何法线 n' = -M·n,朴素的 M·n 指向反方向。

产物:out/cg_raster_09_1_winding.json、out/cg_raster_09_1_faces.csv、
      out/cg_raster_09_1_facemask.bin、out/cg_raster_09_1_winding.svg、
      out/cg_raster_09_1_backfaces.png
"""

import json
import os
import sys
import xml.etree.ElementTree as ET

import numpy as np

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import cglib  # noqa: E402
import render_raster as rr  # noqa: E402

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SVG_NS = "http://www.w3.org/2000/svg"
ONE, SUB1 = cglib.ONE, cglib.SUB1


def area2x(xy):
    """E(A,B,C) = (B-A)x(C-A):三角形 A、B、C 有向面积的 2 倍。"""
    (ax, ay), (bx, by), (cx, cy) = xy
    return (bx - ax) * (cy - ay) - (by - ay) * (cx - ax)


def make_mvp(spec):
    cam = spec["camera"]
    w, h = spec["width"], spec["height"]
    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(cam["tanHalfFovYQ16"], aspect, cam["nearQ16"], cam["farQ16"])
    return cglib.mat_mul(proj, view)


def build_svg(path, demo):
    """左:y 轴向下的逆时针(area > 0);右:顺时针(area < 0)。"""
    svg = ET.Element("svg", {"xmlns": SVG_NS, "width": "560", "height": "240",
                             "viewBox": "0 0 560 240"})

    def txt(x, y, s, size=12, fill="#222"):
        ET.SubElement(svg, "text", {"x": str(x), "y": str(y), "font-size": str(size),
                                    "fill": fill, "font-family": "monospace"}).text = s

    txt(16, 24, "screen space: x right, y down", 12, "#666")
    for i, (ox, name, col) in enumerate(((40, "area > 0  front", "#3a7"), (320, "area <= 0  back", "#c33"))):
        pts = f"{ox},60 {ox + 150},80 {ox + 30},170"
        ET.SubElement(svg, "polygon", {"points": pts, "fill": "none", "stroke": col,
                                       "stroke-width": "2"})
        for k, (dx, dy) in enumerate(((0, 0), (150, 20), (30, 110))):
            ET.SubElement(svg, "circle", {"cx": str(ox + dx), "cy": str(60 + dy), "r": "4",
                                          "fill": col})
            txt(ox + dx + 8, 56 + dy, "ABC"[k], 12, col)
        txt(ox, 200, name, 13, col)
        txt(ox, 220, f"E = {demo[i]}", 12, "#666")
    ET.indent(svg, space="  ")
    ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
    return ET.parse(path).getroot().tag.endswith("svg")


def main():
    spec = cglib.load_spec()
    w, h = spec["width"], spec["height"]
    eye = tuple(spec["camera"]["eyeQ16"])
    mvp = make_mvp(spec)

    verts, tris, vcolors = rr.build_scene(spec)
    scr = []
    for v in verts:
        p = cglib.project(mvp, v)
        scr.append(None if p is None else (*cglib.to_screen(p[0], p[1], w, h), p[2]))

    rtris, world, dropped = [], [], 0
    for a, b, c in tris:
        sa, sb, sc = scr[a], scr[b], scr[c]
        if sa is None or sb is None or sc is None:
            dropped += 1
            continue
        rtris.append((*sa, *vcolors[a], *sb, *vcolors[b], *sc, *vcolors[c]))
        world.append((verts[a], verts[b], verts[c]))

    mesh_of = [0] * len(rtris)
    for i in range(512, len(rtris)):  # 地形 16x16x2 = 512 个三角形,其后依次是三个球
        mesh_of[i] = 1 + (i - 512) // 128

    rows, mask, mismatch = [], bytearray(), 0
    front = back = degen = 0
    for i, t in enumerate(rtris):
        xy = ((t[0], t[1]), (t[6], t[7]), (t[12], t[13]))
        a2 = area2x(xy)
        A, B, C = world[i]
        n = cglib.v_cross(cglib.v_sub(B, A), cglib.v_sub(C, A))  # 几何法线,未归一化
        v = cglib.v_sub(A, eye)  # 从相机指向三角形的视线向量
        d = cglib.v_dot(n, v)
        front_scr = a2 > 0
        front_world = d > 0  # 实测:屏幕空间正面 <-> 右手法线背对相机
        if front_scr != front_world:
            mismatch += 1
        mask.append(1 if front_scr else 0)
        front += front_scr
        back += a2 <= 0
        degen += a2 == 0
        rows.append((i, mesh_of[i], a2, 1 if front_scr else -1, d, 1 if front_world else -1))

    color, _depth, covered, culled = cglib.rasterize(rtris, w, h, spec["backgroundQ8"])
    assert culled == back, (culled, back)

    # 背面可视化:把背面三角形反绕后涂红,与正面一起光栅化(深度缓冲决定可见性)
    dbg = []
    for i, t in enumerate(rtris):
        c = (200, 60, 60) if mask[i] == 0 else tuple(t[k] for k in (3, 4, 5))
        if mask[i] == 0:  # 反绕 -> area > 0,不再被剔除
            dbg.append((t[0], t[1], t[2], *c, t[12], t[13], t[14], *c, t[6], t[7], t[8], *c))
        else:
            dbg.append((t[0], t[1], t[2], *c, t[6], t[7], t[8], *c, t[12], t[13], t[14], *c))
    dbg_color, _d, dbg_cov, dbg_cull = cglib.rasterize(dbg, w, h, spec["backgroundQ8"])

    # 镜像演示:M = diag(-1, 1, 1),det = -1,手性反转
    A, B, C = world[0]
    n = cglib.v_cross(cglib.v_sub(B, A), cglib.v_sub(C, A))
    flip = lambda p: (-p[0], p[1], p[2])
    Am, Bm, Cm = flip(A), flip(B), flip(C)
    nm = cglib.v_cross(cglib.v_sub(Bm, Am), cglib.v_sub(Cm, Am))
    naive = flip(n)
    mirror = {"n": list(n), "n_mirrored": list(nm), "naive_M_n": list(naive),
              "dot_naive_true": cglib.v_dot(naive, nm),
              "dot_n_true": cglib.v_dot(n, nm)}

    out = os.path.join(ROOT, "out")
    png = cglib.save_png(os.path.join(out, "cg_raster_09_1_backfaces.png"), dbg_color)
    cglib.write_csv(os.path.join(out, "cg_raster_09_1_faces.csv"),
                    ["tri", "mesh", "area2x", "screen_winding", "normal_dot_view", "world_facing"],
                    rows[:64])
    with open(os.path.join(out, "cg_raster_09_1_facemask.bin"), "wb") as fh:
        fh.write(bytes(mask))
    demo = (area2x(((40, 60), (190, 80), (70, 170))), area2x(((320, 60), (470, 80), (350, 170))))
    svg_ok = build_svg(os.path.join(out, "cg_raster_09_1_winding.svg"), demo)
    stats = {
        "canvas": [w, h], "triangles_projected": len(rtris), "triangles_dropped": dropped,
        "front": front, "back": back, "degenerate": degen, "culled_by_rasterizer": culled,
        "winding_vs_normal_mismatch": mismatch,
        "pixels_covered": int(covered.sum()),
        "backface_debug_pixels": int(dbg_cov.sum()), "backface_debug_culled": dbg_cull,
        "backface_red_pixels": int(((dbg_color[:, :, 0] == 200) & (dbg_color[:, :, 1] == 60)).sum()),
        "mesh_back": [sum(1 for r in rows if r[1] == m and r[3] < 0) for m in range(4)],
        "mesh_front": [sum(1 for r in rows if r[1] == m and r[3] > 0) for m in range(4)],
        "mirror": mirror, "svg_ok": svg_ok,
    }
    with open(os.path.join(out, "cg_raster_09_1_winding.json"), "w", encoding="utf-8") as fh:
        json.dump(stats, fh, ensure_ascii=False, indent=2)

    print(f"triangles      : {len(rtris)} projected / {dropped} dropped")
    print(f"front (area>0) : {front}")
    print(f"back  (area<=0): {back}   degenerate: {degen}")
    print(f"rasterizer cull: {culled}  (与背面计数一致:{culled == back})")
    print(f"winding vs 世界法线朝向 不一致数: {mismatch}")
    print(f"mesh front/back: terrain {stats['mesh_front'][0]}/{stats['mesh_back'][0]}  "
          f"sphere0 {stats['mesh_front'][1]}/{stats['mesh_back'][1]}  "
          f"sphere1 {stats['mesh_front'][2]}/{stats['mesh_back'][2]}  "
          f"sphere2 {stats['mesh_front'][3]}/{stats['mesh_back'][3]}")
    print(f"covered        : {stats['pixels_covered']}")
    print(f"背面可视化     : 红像素 {stats['backface_red_pixels']} / 覆盖 {stats['backface_debug_pixels']}"
          f" (culled={dbg_cull})")
    print(f"mirror         : n={mirror['n']}  n'={mirror['n_mirrored']}")
    print(f"                 naive M·n={mirror['naive_M_n']}  dot(naive, n')={mirror['dot_naive_true']}"
          f"  dot(n, n')={mirror['dot_n_true']}")
    print(f"svg            : cg_raster_09_1_winding.svg (valid={svg_ok})")
    print(f"png            : cg_raster_09_1_backfaces.png ({png} bytes)")
    return 0


if __name__ == "__main__":
    sys.exit(main())

跑一次:

# snippet: 本机实跑(工作区 lantern/)
cd lantern
/opt/homebrew/bin/python3 sim/cg_raster_09_1_winding.py
triangles      : 896 projected / 0 dropped
front (area>0) : 606
back  (area<=0): 290   degenerate: 0
rasterizer cull: 290  (与背面计数一致:True)
winding vs 世界法线朝向 不一致数: 0
mesh front/back: terrain 398/114  sphere0 73/55  sphere1 71/57  sphere2 64/64
covered        : 43731
背面可视化     : 红像素 2482 / 覆盖 43731 (culled=0)
mirror         : n=[79728, -495616, -47966]  n'=[79728, 495616, 47965]
                 naive M·n=[-79728, -495616, -47966]  dot(naive, n')=-3880196  dot(n, n')=-3686209
svg            : cg_raster_09_1_winding.svg (valid=True)
png            : cg_raster_09_1_backfaces.png (5075 bytes)

606 + 290 = 896,退化 0 个;而光栅化器自己剔除的数目也是 290——两条路径给出的剔除数必须逐位相同,这是本节最直接的回归信号。mesh front/back 一行把分类摊到四个网格上:地形 398/114,三个八面体细分球分别是 73/55、71/57、64/64。球体接近对半,符合「从任意方向看一个封闭凸体,约一半面朝向相机」的直觉。

跨语言核对:掩码逐字节相同

S 层把 896 个三角形的朝向写成一个 896 字节的掩码(正面 1、背面 0)。P 层用 TypeScript 从同一份 config/scene_spec.json 重算这个掩码,比对 sha256:

// snippet: core/test/cg_raster_09_1_winding.test.ts(节选)
const mask = windingMask(prepared.rtris);
assert.equal(mask.length, g.triangles_projected);
assert.equal(mask.length, 896);
assert.equal(sha(mask), sha(new Uint8Array(maskFile)));   // 与 Python 侧逐字节相同

// 负例:反绕顶点必须让正面三角形变成背面(被剔除)
for (const t of prepared.rtris) {
  const a = signedArea2x(t.xy);
  const b = signedArea2x([t.xy[0], t.xy[2], t.xy[1]]);
  assert.equal(a, -b, "反绕后面积必须严格变号");
}

断言的对象是像素无关的整数掩码,所以它比像素缓冲更早、更精确地暴露问题:只要两侧对环绕符号的理解差一点点,sha256 立刻分叉。

手性反转:det < 0 的模型矩阵

最后一个坑值得单独说:镜像矩阵会反转手性。取 M = diag(-1, 1, 1),它的行列式 det(M) = -1。对第 0 个三角形做这个变换,实测:

量值
原始几何法线 n[79728, -495616, -47966]
镜像后几何法线 n'[79728, 495616, 47965]
朴素的 M·n[-79728, -495616, -47966]
dot(M·n, n')-3880196(反号)

也就是说,镜像后的真实几何法线是 n' = -M·n,而不是 M·n。如果代码里把法线当成普通向量用 M 直接变换(或者把法线属性存下来却忘了随负行列式翻符号),它就会指向反方向——dot(M·n, n') < 0 就是这个 bug 的指纹。至于环绕顺序,镜像会把它整体反转:原来的正面全部变成背面。所以镜像变换的正确处理是「法线翻符号,环绕跟着翻」,两者必须一起动,只动一个就会露馅。这条在卷二讲法线变换(cg-shade-03-1-normal-transform)时还会回来。

这组数字各是什么

606 / 290 是本节的中心结果:896 个三角形里 606 个正面进光栅化,290 个背面被剔除,与第 1 章记录的基线剔除数 290 逐位相同。

不一致数 0 证明屏幕空间面积符号与世界空间法线朝向是同一条判据,只是差一个固定符号。

红像素 2482 来自那张背面可视化图:把 290 个背面三角形反绕后涂红、与正面一起光栅化,深度缓冲决定谁在前。红色只出现在正面没盖住的地方——它是「如果关掉剔除,会多画出来多少东西」的直观版本,第 9.2 节会把它换成一个可比的数字。

镜像演示的三个向量是本节唯一的符号陷阱:n' = -M·n,朴素的 M·n 与真实几何法线点积为负,说明它指反了。

阅读导航:上一节:8.3 屏幕空间裁剪 · 下一节:9.2 剔除的判定与代价 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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