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