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 剔除的判定与代价 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。