7.3 线宽与直线抗锯齿
7.1 画的是「一条没有宽度的线」,7.2 证明了它的误差界;但真实渲染里线是有宽度的,而且边缘不能是硬台阶。站内 后处理 把抗锯齿当成后处理链上的一个 pass 来谈(FXAA、TAA 之类),那是先有硬边缘再事后补救;本节走的是相反的路——在覆盖判据里就把宽度和覆盖率一次算对,因为对线段来说这件事有一个干净的整数解法。
线宽难在哪
把线加粗,直觉做法是「沿法线方向偏移半个线宽再连线」。但法线方向要归一化,归一化要开方,开方一旦落到逐像素路径上,Python 与 TypeScript 就不再有「逐字节相同」可言——sqrt 的末位在不同实现里没有统一约定。
所以线宽的正确问法不是「怎么偏移」,而是「这个像素中心到底在不在加粗后的线段上」。这是一个点与线段的距离判定,而距离判定可以完全避开开方。
把距离判据写成整数
记线段 AB、像素中心 P、半宽 r = w*32(Q6 定点,1 像素 = 64 单位)。垂足落在 AB 内部时,点到直线的距离是
d = |cross(P-A, B-A)| / |B-A|
要求 d <= r。两边平方并把 |B-A| 乘过去,除法与开方同时消失:
cross(P-A, B-A)^2 <= r^2 * |B-A|^2
左边是叉积的平方,右边是半径平方乘长度平方,全是整数。量级也要交代清楚:64×64 画布下 Q6 坐标 ≤ 2^12,叉积 ≤ 2^25,平方 ≤ 2^50 < 2^53,所以逐像素层用 Number / int64 就够了,不需要 BigInt——这正是 BRIEF §3.2 那条量级证明在具体算法上的一次落地。
端帽:垂足落在段外
上面那条判据只在垂足落在线段内部时成立。垂足跑出段外时,最近点就是端点本身,距离退化成到端点的欧氏距离,同样平方即可:
垂足 t <= 0 -> |P-A|^2 <= r^2
垂足 t >= |AB|^2 -> |P-B|^2 <= r^2
t = dot(P-A, B-A) 与 |AB|^2 = dot(B-A, B-A) 都是整数,比较不用除法。这三段拼起来就是圆帽胶囊(round cap capsule):中间是矩形体,两头是半圆。它比方形端帽更贵一点,但省掉了「端帽方向怎么选」这个额外的自由度,也是 SVG 与 Canvas 的 stroke-linecap: round 的语义。
实测:五个线宽
在 64×64 画布上画一条 (8,12) 到 (56,52) 的斜线(Q6 坐标),半宽 r = w*32:
| 线宽 w | 半宽 r (Q6) | 覆盖像素 | 负例(半宽 floor 到整像素) | 差异像素 |
|---|---|---|---|---|
| 1 | 32 | 64 | 0 | 64 |
| 2 | 64 | 132 | 132 | 0 |
| 3 | 96 | 196 | 132 | 64 |
| 4 | 128 | 268 | 268 | 0 |
| 5 | 160 | 336 | 268 | 68 |
覆盖数随线宽严格递增(脚本里是硬断言)。水平线段与竖直线段在同长度下覆盖数相等:w=3 时两边都是 196——这是判据对方向不敏感的直接证据。
负例:把半宽取整
上表最后两列是同一判据的负例。如果把半宽从 w*32(Q6 精确半宽)改成「先取整到整像素再乘 64」,即 floor(w/2)*64:
- 偶数线宽(w=2、4)差异恰好为 0。这不是巧合:
floor(w/2)*64与w*32在w为偶数时数值完全相同,两种取整在这一档上不可区分。 - 奇数线宽(w=1、3、5)必须分叉,差异分别是 64、64、68 像素。
w=1最极端:半宽被取整成 0,整条线彻底消失(覆盖数 0)。
这个负例的意义不只是「抓到一个错」,而是划出了一条边界:「半宽取整」这类错误只在奇数线宽上暴露。如果你的测试用例全用 w=2 或 w=4,判据再严也发现不了它——这正是「判据必须配负例」的具体形状。
抗锯齿:N×N 子样本
抗锯齿换个思路:不再问「像素中心在不在里面」,而是把每个像素切成 N×N 个子样本,数有多少个落在胶囊内,覆盖率就是命中数(取值 0..N^2)。alpha = cov*255//N^2,分子非负,所以 // 与向零截断完全一致(BRIEF §3.3 允许的唯一一种整除用法)。
取 N ∈ {1,2,4,8} 跑同一条 w=3 的斜线,并与解析面积比对(解析值 = 矩形体 w*L 加两个半圆帽 0.7854*w^2,用 fdiv 构造 pi/4 的 Q16.16 常量):
| N | 命中数 | 面积(千分像素²) | 相对偏差 |
|---|---|---|---|
| 1 | 196 | 196000 | 7‰ |
| 2 | 768 | 192000 | 12‰ |
| 4 | 3120 | 195000 | 2‰ |
| 8 | 12480 | 195000 | 2‰ |
解析面积是 194514 千分像素²。四档的偏差都在千分之十几以内,且 N=8 的偏差(2‰)严格小于 N=1(7‰)——脚本把这条单调性写成了断言。
有意思的是 N=2 比 N=1 更差(12‰ 对 7‰)。这不是 bug:N=1 时子样本恰好落在像素中心,而这条斜线的中心采样本来就系统性偏向一侧,偏差方向恰好与「子样本太少」的偏差互相抵消了一部分;N=2 打散了这个巧合,露出真实的离散误差。采样数增加不保证单调变好,只保证收敛——这也是为什么这类判据必须写成「N 最大时最接近解析值」,而不是「N 越大越接近」。
N=8 的覆盖率缓冲里,非零像素 284 个,最大值 64/64(整格被覆盖)。这个缓冲同时喂给 SVG:xml.etree 逐像素画 6×6 的灰度方块,写盘后用 ET.fromstring 回读一遍,作为 S 档的合法性断言。
可跑的 S 层
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_07_3_width.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_07_3_width.py
"""7.3 线宽与直线抗锯齿:整数距离判据、圆帽胶囊、覆盖率整数化。
线宽 w 的覆盖判据不用开方:像素中心 P 到线段 AB 的距离 d 满足
d <= r <=> cross(P-A, B-A)^2 <= r^2 * |B-A|^2(垂足在段内);
垂足在段外时退化为到最近端点的距离平方比较,即圆帽(round cap)。
r = w*32 是 Q6 半宽,全程整数;64x64 画布下 cross^2 <= 2^50 < 2^53,Number 精确。
抗锯齿把像素切成 N x N 子样本,覆盖率 = 命中数 / N^2,alpha = cov*255//N^2
(分子非负,故 `//` 与向零截断一致);N in {1,2,4,8} 时总面积收敛到解析值。
产物:out/cg_raster_07_3_width.png、out/cg_raster_07_3_coverage.csv、
out/cg_raster_07_3_line.svg、out/cg_raster_07_3_width.json。
"""
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
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
W, H = 64, 64
SUB1 = cglib.SUB1
WIDTHS = [1, 2, 3, 4, 5]
AA_N = [1, 2, 4, 8]
SEG = (8 * SUB1, 12 * SUB1, 56 * SUB1, 52 * SUB1) # 一条 48x40 的斜线
FLAT_H = (8 * SUB1, 32 * SUB1, 56 * SUB1, 32 * SUB1)
FLAT_V = (32 * SUB1, 8 * SUB1, 32 * SUB1, 56 * SUB1)
def capsule(px, py, ax, ay, bx, by, r2):
"""P 是否落在以 AB 为轴、半径平方为 r2 的圆帽胶囊内(纯整数,无开方)。"""
vx, vy = bx - ax, by - ay
wx, wy = px - ax, py - ay
len2 = vx * vx + vy * vy
t = vx * wx + vy * wy
if t <= 0:
return wx * wx + wy * wy <= r2
if t >= len2:
ux, uy = px - bx, py - by
return ux * ux + uy * uy <= r2
cross = vx * wy - vy * wx
return cross * cross <= r2 * len2
def r2_of(w, floor_half=False):
"""半宽的平方:正确口径 r = w*32(Q6);floor_half=True 是负例(半宽取整到整像素)。"""
return ((w >> 1) * SUB1) ** 2 if floor_half else ((w * SUB1) >> 1) ** 2
def hard(seg, w, floor_half=False):
"""硬覆盖(每像素 1 个样本,取像素中心 px*64 + 32)。"""
ax, ay, bx, by = seg
r2 = r2_of(w, floor_half)
m = np.zeros((H, W), dtype=bool)
for py in range(H):
for px in range(W):
m[py, px] = capsule(px * SUB1 + 32, py * SUB1 + 32, ax, ay, bx, by, r2)
return m
def aa(seg, w, n):
"""N x N 子样本覆盖率,返回 0..n*n 的整数缓冲。"""
ax, ay, bx, by = seg
r2 = r2_of(w)
off = [(2 * j + 1) * SUB1 // (2 * n) for j in range(n)] # 非负分子,// 与截断一致
cov = np.zeros((H, W), dtype=np.int64)
for py in range(H):
for px in range(W):
c = 0
for oy in off:
for ox in off:
if capsule(px * SUB1 + ox, py * SUB1 + oy, ax, ay, bx, by, r2):
c += 1
cov[py, px] = c
return cov
def analytic_area_q16(seg, w):
"""解析面积(Q16.16 像素^2):矩形体 w*L 加两个半圆帽 0.7854*w^2。"""
dx = (seg[2] - seg[0]) >> 6
dy = (seg[3] - seg[1]) >> 6
length = cglib.fsqrt(cglib.fmul(dx << 16, dx << 16) + cglib.fmul(dy << 16, dy << 16))
body = cglib.fmul(w << 16, length)
quarter_pi = (7854 << 16) // 10000 # pi/4 = 0.7854 的 Q16.16 常量(非负整数构造)
cap = cglib.fmul(quarter_pi, cglib.fmul(w << 16, w << 16))
return body + cap
def build_svg(path, cov):
"""覆盖率网格(纯 xml.etree):只画非零覆盖的像素,灰度 = 覆盖率。"""
s = 6
svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg",
"width": str(W * s), "height": str(H * s)})
ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W * s),
"height": str(H * s), "fill": "#101014"})
for py in range(H):
for px in range(W):
c = int(cov[py, px])
if c == 0:
continue
v = 32 + 223 * c // 64
ET.SubElement(svg, "rect", {"x": str(px * s), "y": str(py * s), "width": str(s),
"height": str(s), "fill": "#%02x%02x%02x" % (v, v, v)})
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
ET.fromstring(open(path, encoding="utf-8").read()) # S 档断言:SVG 必须能被解析
return os.path.getsize(path)
def main():
counts = {w: int(hard(SEG, w).sum()) for w in WIDTHS}
assert all(counts[WIDTHS[i]] < counts[WIDTHS[i + 1]] for i in range(len(WIDTHS) - 1)), \
"硬覆盖像素数必须随线宽严格递增"
assert int(hard(FLAT_H, 3).sum()) == int(hard(FLAT_V, 3).sum()), "水平与竖直同长线段的覆盖数必须相等"
wrong = {w: int(hard(SEG, w, True).sum()) for w in WIDTHS}
wdiff = {w: int((hard(SEG, w) != hard(SEG, w, True)).sum()) for w in WIDTHS}
assert any(wrong[w] != counts[w] for w in WIDTHS), "负例必须与金标准分叉"
rows, cov_by_n = [], {}
for n in AA_N:
cov = aa(SEG, 3, n)
assert cov.min() >= 0 and cov.max() <= n * n, "覆盖率必须落在 [0, N^2]"
cov_by_n[n] = cov
rows.append((n, int(cov.sum()), int(cov.sum()) * 1000 // (n * n)))
expect = analytic_area_q16(SEG, 3) * 1000 >> 16
dev = [abs(a - expect) * 1000 // expect for _, _, a in rows]
assert dev[-1] < dev[0], "N 增大必须让总面积更接近解析值"
out = os.path.join(ROOT, "out")
strip = np.zeros((H, W, 3), dtype=np.int64)
strip[:, :] = (16, 16, 20)
for k, w in enumerate(WIDTHS):
band = hard((8 * SUB1, (12 + 10 * k) * SUB1, 56 * SUB1, (12 + 10 * k) * SUB1), w)
strip[band] = (232 - 30 * k, 200 - 20 * k, 120 + 20 * k)
png_w = cglib.save_png(os.path.join(out, "cg_raster_07_3_width.png"), strip)
cglib.write_csv(os.path.join(out, "cg_raster_07_3_coverage.csv"),
["n", "hits", "area_milli_px", "deviation_milli"],
[(n, h, a, d) for (n, h, a), d in zip(rows, dev)])
svg = build_svg(os.path.join(out, "cg_raster_07_3_line.svg"), cov_by_n[8])
with open(os.path.join(out, "cg_raster_07_3_width.json"), "w", encoding="utf-8") as fh:
json.dump({"canvas": [W, H], "segment": list(SEG), "widths": WIDTHS,
"hard_counts": [counts[w] for w in WIDTHS],
"wrong_floor_halfwidth_counts": [wrong[w] for w in WIDTHS],
"wrong_diff_pixels": [wdiff[w] for w in WIDTHS],
"flat_h_v_counts": [int(hard(FLAT_H, 3).sum()), int(hard(FLAT_V, 3).sum())],
"aa": [{"n": n, "hits": h, "area_milli_px": a, "deviation_milli": d}
for (n, h, a), d in zip(rows, dev)],
"analytic_area_milli_px": expect,
"cov8_row_12": [int(v) for v in cov_by_n[8][12]],
"cov8_max": int(cov_by_n[8].max()), "cov8_nonzero": int((cov_by_n[8] > 0).sum()),
"png_width_bytes": png_w, "svg_bytes": svg},
fh, ensure_ascii=False, indent=2)
print(f"画布 {W}x{H},线段 {tuple(v // SUB1 for v in SEG)},半宽 r = w*32 (Q6)")
print("硬覆盖像素数(圆帽胶囊,纯整数距离判据):")
for w in WIDTHS:
print(f" w={w} r={(w * SUB1) >> 1:3d} Q6 covered={counts[w]:4d} "
f"负例(半宽取整到整像素)={wrong[w]:4d} 差 {wdiff[w]:4d} 像素")
print(f"水平 vs 竖直(w=3) : {int(hard(FLAT_H, 3).sum())} == {int(hard(FLAT_V, 3).sum())}(同长必等)")
print(f"抗锯齿收敛(w=3,解析面积 {expect} 千分像素^2):")
for (n, h, a), d in zip(rows, dev):
print(f" N={n} hits={h:6d} 面积 {a:6d} 千分像素^2 相对偏差 {d:3d}‰")
print(f"N=8 覆盖率 : 非零像素 {int((cov_by_n[8] > 0).sum())},最大 {int(cov_by_n[8].max())}/64")
print(f"产物 : width.png {png_w}B / coverage.csv / line.svg {svg}B")
if __name__ == "__main__":
main()
真跑:
# snippet: 复现命令(在 lantern 工作区根执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/cg_raster_07_3_width.py
node --test 'core/test/cg_raster_07_3_width.test.ts'
P 层:同一判据的跨语言复现
core/test/cg_raster_07_3_width.test.ts 用 BigInt 版 capsule 重跑上面全部数字,五个断言全绿:硬覆盖数与 Python 侧逐数相等且随线宽严格递增;水平/竖直覆盖数相等且面积大于 w*L;抗锯齿总面积落在 [0, N^2] 且 N=8 偏差最小;负例 A(半宽 floor 到整像素,奇数线宽必须分叉、偶数线宽必须恰好等价、w=1 必须归零);负例 B(去掉端帽判断退化成无限长带,覆盖数必须膨胀)。P 与 S 的关系在这里是同一条整数判据的两种语言实现,不是「GPU 与 CPU 的容差比对」——后者要到第 11 章 MSAA 那节才出现,且必须显式标注容差。
三个小节合起来,第 7 章给了光栅化的第一个完整闭环:归一化(7.1)→ 可证伪的误差界(7.2)→ 把同一个整数套路推广到线宽与覆盖率(7.3)。下一章换到三角形的边函数,那套工具会更接近 GPU 里真实跑的东西。
阅读导航:上一节:7.2 直线光栅化的误差界 · 下一节:8.1 视锥裁剪 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。