11.2 超采样与覆盖率

把像素切成 N×N 个小格,覆盖率就是落在三角形内的子样本数——0 到 N² 的整数计数。本节用一个斜边三角形,把 2×2 与 4×4 超采样的覆盖率、整数颜色混合与误差随 N 的下降全部写成可断言整数,并与 TypeScript 逐字节对齐。

11.2 超采样与覆盖率

上一节把走样量成了整数。这一节给它一个整数解:超采样——一个像素不再只采一个样本,而是在像素内部取 $N \times N$ 个子样本,把落在三角形内的子样本数记成覆盖率。站内的 抗锯齿方法 讲的是 MSAA / TAA 在图形 API 里怎么配样本、怎么在带宽与质量之间取舍;本卷从整数边函数推覆盖率,把采样与抗锯齿做成可断言的整数。关键一步是把覆盖率写成 0 到 $N^2$ 的整数计数,颜色按这个计数整数混合。

覆盖率是一个整数

一个像素就是一个 $1\times1$ 的方块。把它切成 $N\times N$ 个小格,每个小格取一个样本点,覆盖率就是这个像素里落在三角形内的样本数——整数 $0..N^2$。

样本点的位置必须精确到整数:第 $k$ 个样本($k=0..N-1$)在一维上的中心取 $(2k+1)\cdot 64 / (2N)$,全是 Q6 整数。$N=1$ 时是 32(像素中心),$N=2$ 时是 16 与 48,$N=4$ 时是 8、24、40、56。这些位置与第 6 章的边函数判据完全一致,只是把像素中心的 $+32$ 换成了 $+o_k$。

颜色按覆盖率整数混合

拿到覆盖率 $c$($0..N^2$)后,颜色按覆盖率线性混合:

$$c_{\text{ch}} = \frac{f_{g,\text{ch}} \cdot c + b_{g,\text{ch}} \cdot (N^2 - c)}{N^2}$$

分子非负,// 与截断一致,两侧可复现。$N=1$ 时每个像素只有前景或背景两级;$N=2$ 有 5 级;$N=4$ 有 17 级——灰阶数正好是 $N^2+1$。这就是「边缘不再是锯齿,而是渐变」的来源:覆盖率越细,边缘能表示的中间灰越多。

误差随 N 下降

以 $16\times16 = 256$ 个子样本的覆盖率为参考,量 $N=1/2/4$ 的覆盖误差(permille):$N=1$ 是 20‰、只有 2 级灰;$N=2$ 是 6‰、5 级;$N=4$ 是 1‰、17 级。每加一倍子样本,误差大约降到三分之一——这是把覆盖率当成面积估计时的收敛速度,而它全部发生在整数里。

一条扫描行

看一条横穿斜边的扫描行 py=12:$N=1$ 的覆盖率只有 0/1 两级(1 1 1 … 1 0 0 0),$N=4$ 出现 $0..16$ 的完整渐变(16 16 16 … 16 15 7 0 0)。覆盖率的不变式也钉死了:$\text{cov}_4 \in [0,16]$,全画布覆盖子样本总和 $= 2970$。

可运行产物:一个三角形,一张覆盖率表

下面这个脚本只做一件事:拿一个斜边三角形,分别算 $N=1/2/4$ 的覆盖率,按覆盖率整数混合出颜色,再和 $16\times16$ 参考比误差。抽取顺序、像素中心、// 截断都与共享底座 cglib 完全一致。

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_11_2_supersample.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_11_2_supersample.py
"""11.2 超采样与覆盖率:把覆盖率写成 0..N 的整数计数,颜色按覆盖率整数混合。

一个三角形,像素中心取 px*64+32;NxN 子样本的位置是 (2k+1)*64/(2N)。覆盖率 = 落在
三角形内的子样本数(整数 0..N*N),颜色 = (fg*cov + bg*(N*N-cov)) // (N*N)(整数混合)。
以 16x16 子样本(256 个)的覆盖率为参考,量 N=1/2/4 的覆盖误差与灰度级数。
产物:out/cg_raster_11_2_n1.png / _n2.png / _n4.png / _cov4.png / _edge.csv / _supersample.json。
"""

import hashlib
import json
import os
import sys

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 = 32, 24
BG, FG = (12, 14, 20), (220, 96, 64)
SUB1 = cglib.SUB1
# 一个带大斜边的三角形(像素单位)
A, B, C = (4, 3), (27, 7), (9, 20)
REF_N = 16


def tri_q6():
    (ax, ay), (bx, by), (cx, cy) = A, B, C
    return ax * SUB1, ay * SUB1, bx * SUB1, by * SUB1, cx * SUB1, cy * SUB1


def offsets(n):
    """NxN 子样本相对像素左上角的 Q6 偏移:中心取 (2k+1)*64/(2N)。"""
    return [(2 * k + 1) * SUB1 // (2 * n) for k in range(n)]


def coverage(n):
    """返回 (H, W) 的整数覆盖计数(0..n*n)。"""
    x0, y0, x1, y1, x2, y2 = tri_q6()
    area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0)
    cov = np.zeros((H, W), dtype=np.int64)
    if area <= 0:
        return cov
    for oy in offsets(n):
        py = np.arange(H, dtype=np.int64) * SUB1 + oy
        for ox in offsets(n):
            px = np.arange(W, dtype=np.int64) * SUB1 + ox
            PX, PY = np.meshgrid(px, py)
            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)
            cov += ((w0 >= 0) & (w1 >= 0) & (w2 >= 0)).astype(np.int64)
    return cov


def blend(cov, n):
    """按覆盖率整数混合:c = (fg*cov + bg*(nn-cov)) // nn(分子非负,floor 与截断一致)。"""
    nn = n * n
    out = np.empty((H, W, 3), dtype=np.int64)
    for ch in range(3):
        out[:, :, ch] = (FG[ch] * cov + BG[ch] * (nn - cov)) // nn
    return out


def sha256(a):
    return hashlib.sha256(np.ascontiguousarray(a).tobytes()).hexdigest()


def main():
    cov = {n: coverage(n) for n in (1, 2, 4, REF_N)}
    ref = cov[REF_N] * 1000 // (REF_N * REF_N)  # 参考覆盖率(permille)
    mae = {n: int(np.abs(cov[n] * 1000 // (n * n) - ref).mean()) for n in (1, 2, 4)}
    levels = {n: int(np.unique(cov[n]).size) for n in (1, 2, 4)}
    imgs = {n: blend(cov[n], n) for n in (1, 2, 4)}
    total_subs = {n: int(cov[n].sum()) for n in (1, 2, 4, REF_N)}

    out = os.path.join(ROOT, "out")
    for n in (1, 2, 4):
        cglib.save_png(os.path.join(out, f"cg_raster_11_2_n{n}.png"), imgs[n])
    cglib.save_gray_png(os.path.join(out, "cg_raster_11_2_cov4.png"), cov[4] * (255 // 16))

    # 一条横穿斜边的扫描行:看覆盖率从 0 爬到 N*N
    row = 12
    cglib.write_csv(os.path.join(out, "cg_raster_11_2_edge.csv"),
                    ["px", "cov_n1", "cov_n2", "cov_n4"],
                    [[px, int(cov[1][row, px]), int(cov[2][row, px]), int(cov[4][row, px])]
                     for px in range(W)])

    meta = {
        "canvas": [W, H], "triangle": [list(A), list(B), list(C)],
        "ref_n": REF_N,
        "coverage_mae_permille": {f"n{n}": mae[n] for n in (1, 2, 4)},
        "levels": {f"n{n}": levels[n] for n in (1, 2, 4)},
        "covered_subsamples": {f"n{n}": total_subs[n] for n in (1, 2, 4, REF_N)},
        "cov2_sha256": sha256(cov[2].astype(np.uint8)), "cov4_sha256": sha256(cov[4].astype(np.uint8)),
        "rgb2_sha256": sha256(imgs[2].astype(np.uint8)),
        "rgb4_sha256": sha256(imgs[4].astype(np.uint8)),
        "cov4_min": int(cov[4].min()), "cov4_max": int(cov[4].max()),
        "edge_px_n4": [int(cov[4][row, px]) for px in range(W)],
        "edge_px_n1": [int(cov[1][row, px]) for px in range(W)],
    }
    with open(os.path.join(out, "cg_raster_11_2_supersample.json"), "w", encoding="utf-8") as fh:
        json.dump(meta, fh, ensure_ascii=False, indent=2)

    print(f"画布 {W}x{H}  三角形 {A} {B} {C}  参考子样本 {REF_N}x{REF_N}={REF_N*REF_N}")
    print("覆盖率(0..N*N 整数)与灰度级数:")
    for n in (1, 2, 4):
        print(f"  N={n}x{n} : 覆盖误差(vs 参考)={mae[n]}‰  灰度级数={levels[n]:2d}  "
              f"覆盖子样本总和={total_subs[n]}")
    print(f"覆盖率不变式 : cov4 in [{cov[4].min()}, {cov[4].max()}] ⊆ [0, 16]")
    print(f"扫描行 py={row} 的覆盖率 n1 -> n4:")
    print("  " + " ".join(f"{int(cov[1][row, px])}" for px in range(8, 22)))
    print("  " + " ".join(f"{int(cov[4][row, px]):2d}" for px in range(8, 22)))
    print(f"sha256 cov4  : {meta['cov4_sha256'][:32]}...")
    print(f"sha256 rgb4  : {meta['rgb4_sha256'][:32]}...")


if __name__ == "__main__":
    main()

实跑输出:

画布 32x24  三角形 (4, 3) (27, 7) (9, 20)  参考子样本 16x16=256
覆盖率(0..N*N 整数)与灰度级数:
  N=1x1 : 覆盖误差(vs 参考)=20‰  灰度级数= 2  覆盖子样本总和=186
  N=2x2 : 覆盖误差(vs 参考)=6‰  灰度级数= 5  覆盖子样本总和=743
  N=4x4 : 覆盖误差(vs 参考)=1‰  灰度级数=17  覆盖子样本总和=2970
覆盖率不变式 : cov4 in [0, 16] ⊆ [0, 16]
扫描行 py=12 的覆盖率 n1 -> n4:
  1 1 1 1 1 1 1 1 1 1 1 0 0 0
  16 16 16 16 16 16 16 16 16 16 15  7  0  0
sha256 cov4  : 73b368b9e1f946424b4bca9934d14fd4...
sha256 rgb4  : c67c08cddbf92772bae2d161d92b88ba...

跨语言断言:覆盖率与混合缓冲的 sha256

下面的 TypeScript 内核用 Number 重写覆盖率与整数混合。覆盖率用 Uint16Array($16\times16=256$ 会超出一个字节),比 sha256 时降到 Uint8Array,与 Python 侧 hash(cov.astype(uint8)) 对齐。

// file: lantern/core/src/lantern/cg_raster_11_2_supersample.ts
// tier: P
// verify: node --test 'core/test/cg_raster_11_*.test.ts'
// 11.2 的超采样内核:与 Python 侧 sim/cg_raster_11_2_supersample.py 逐字对齐。
//
// 逐像素层用 Number:屏幕 Q6 坐标 <= 32*64 = 2^11,边函数 <= 2 * 2^22 = 2^23 < 2^53。
// 子样本偏移 (2k+1)*64/(2N) 全是整数(N=1/2/4 时分别为 32 / 16,48 / 8,24,40,56)。
// 覆盖判据与 cglib.rasterize 一致:三个边函数均 >= 0;像素中心同式。`//` 与 Math.floor
// 在非负分子(覆盖率、颜色混合)上等价,故 Python int64 与 TS Number 逐字节相同。

export const W = 32;
export const H = 24;
export const SUB1 = 64;
export const BG: readonly number[] = [12, 14, 20];
export const FG: readonly number[] = [220, 96, 64];
export const TRI: readonly number[] = [4 * SUB1, 3 * SUB1, 27 * SUB1, 7 * SUB1, 9 * SUB1, 20 * SUB1];
export const REF_N = 16;

/** NxN 子样本相对像素左上角的 Q6 偏移:中心取 (2k+1)*64/(2N)。 */
export function offsets(n: number): number[] {
  const out: number[] = [];
  for (let k = 0; k < n; k += 1) out.push(Math.floor(((2 * k + 1) * SUB1) / (2 * n)));
  return out;
}

/** 整数覆盖计数(0..n*n):像素内 n*n 个子样本中落在三角形内的个数。 */
export function coverage(n: number): Uint16Array {
  const [x0, y0, x1, y1, x2, y2] = TRI;
  const cov = new Uint16Array(W * H);
  const area = (x1 - x0) * (y2 - y0) - (y1 - y0) * (x2 - x0);
  if (area <= 0) return cov;
  const offs = offsets(n);
  for (const oy of offs) {
    for (const ox of offs) {
      for (let py = 0; py < H; py += 1) {
        const Y = py * SUB1 + oy;
        for (let px = 0; px < W; px += 1) {
          const X = px * SUB1 + ox;
          const w0 = (x2 - x1) * (Y - y1) - (y2 - y1) * (X - x1);
          const w1 = (x0 - x2) * (Y - y2) - (y0 - y2) * (X - x2);
          const w2 = (x1 - x0) * (Y - y0) - (y1 - y0) * (X - x0);
          if (w0 >= 0 && w1 >= 0 && w2 >= 0) cov[py * W + px] += 1;
        }
      }
    }
  }
  return cov;
}

/** 按覆盖率整数混合:c = (fg*cov + bg*(nn-cov)) // nn。 */
export function blend(cov: Uint16Array, n: number): Uint8Array {
  const nn = n * n;
  const rgb = new Uint8Array(W * H * 3);
  for (let p = 0; p < W * H; p += 1) {
    const c = cov[p];
    for (let ch = 0; ch < 3; ch += 1) {
      rgb[p * 3 + ch] = Math.floor((FG[ch] * c + BG[ch] * (nn - c)) / nn);
    }
  }
  return rgb;
}

/** 覆盖误差(permille):与参考覆盖率之差的绝对值均值。 */
export function coverageMaePermille(cov: Uint16Array, n: number, ref: Uint16Array): number {
  let s = 0;
  for (let p = 0; p < W * H; p += 1) {
    const a = Math.floor((cov[p] * 1000) / (n * n));
    const b = Math.floor((ref[p] * 1000) / (REF_N * REF_N));
    s += Math.abs(a - b);
  }
  return Math.floor(s / (W * H));
}

node --test 'core/test/cg_raster_11_*.test.ts' 的实测输出:

✔ 子样本偏移:N=1/2/4 的中心取 (2k+1)*64/(2N),全是整数
✔ 覆盖率整数计数与混合缓冲的 sha256 与 Python 侧逐字节相同
✔ 覆盖率不变式与统计量:0..N*N、误差随 N 下降、灰度级数上升
✔ 扫描行:N=1 只有 0/1,N=4 出现 0..16 的渐变
✔ 负例:把子样本位置写错(全部落在像素中心),sha256 必须与金标准不同
ℹ tests 5  ℹ pass 5  ℹ fail 0

覆盖计数与混合缓冲的 sha256 与 Python 侧逐字节相同。最后一条是负例:把所有子样本都放在像素中心(等于退化成 $N=1$),sha256 与灰度级数都必须与金标准不同。覆盖率既然是整数,下一节就能让每个子样本各带一份深度,把它变成 MSAA。

阅读导航:上一节:11.1 采样定理与走样 · 下一节:11.3 MSAA 的 CPU 实现 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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