7.3 线宽与直线抗锯齿

线宽不能用开方算距离:把 d <= r 两边平方,得到一条纯整数的覆盖判据 cross^2 <= r^2*|AB|^2,端帽用最近端点的距离平方兜底。本节实测五个线宽的覆盖数、水平与竖直的对称性,并用 N x N 子样本把覆盖率收敛到解析面积 194514 千分像素^2。

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 到整像素)差异像素
13264064
2641321320
39619613264
41282682680
516033626868

覆盖数随线宽严格递增(脚本里是硬断言)。水平线段与竖直线段在同长度下覆盖数相等: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命中数面积(千分像素²)相对偏差
11961960007‰
276819200012‰
431201950002‰
8124801950002‰

解析面积是 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 视锥裁剪 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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