11.1 采样定理与走样
渲染器做的事,说到底是把一个连续的场景变成一张离散的像素网格——也就是采样。采样定理说得很清楚:如果信号的最高频率超过采样频率的一半(Nyquist 频率),点采样就会把这个高频折回成低频,屏幕上出现原本不存在的结构,这就是走样。站内的 抗锯齿方法 讲的是 MSAA / TAA 在图形 API 里怎么配、怎么取样本、工程上怎么取舍;本卷从整数边函数推覆盖率,把采样与抗锯齿做成可断言的整数。本节先用一份高频图案把走样复现出来,再用纯整数把它量出来。
走样不是「图像糊了」,而是「出现了假的低频」。这一点用一份程序化生成的图案最容易看清。
采样:把连续信号变成数列
采样定理说的是:一个最高频率为 $f_{\max}$ 的信号,只要采样频率 $f_s > 2 f_{\max}$,就能由样本无失真地重建。反过来,一旦 $f_s \le 2 f_{\max}$,高于 $f_s/2$(Nyquist 频率)的分量就会被折回到低频——重建出来的是一个频率更低、原信号里根本没有的分量。
在光栅化里,采样点就是像素中心,采样频率就是像素网格的密度。所以「一个像素采一个样本」意味着像素尺寸决定了 Nyquist 频率:比两个像素还细的结构,画出来一定是错的。正确的降采样做法是先低通再抽取——先把高频滤掉,再每隔若干点取一个;直接点采样(只抽取、不低通)就是走样。
波带片:一份高频图案,两种抽取
波带片(zone plate)是一份经典的测试图案:$z(i,j) = ((i^2+j^2) \div k) \bmod 2$。它是同心环,环的间距随半径变小,也就是频率随半径升高——从中心往外,图案会依次跨过 Nyquist 频率。
下面把它当作一份 $256\times256$ 的「连续信号」,降到 $64\times64$ 时用两种抽取方式:点采样(每隔 4 个取 1 个)与盒式平均(每 $4\times4$ 求平均)。同一份信号,两种结果的逐像素平均绝对差是 119/255——接近半个满量程。点采样版本里那些波浪状的摩尔纹,就是被折回来的高频。
周期 3 条纹:把走样量成整数方差
视觉上的摩尔纹还不够硬。要把它变成可断言的东西,换一个周期恰好的图案:周期 3 的斜条纹 $s(i,j) = 255 \iff (i+j) \bmod 3 = 0$。抽取步长是 4,与周期 3 互质,于是点采样在 64 网格上留下一条真实的周期 3 条纹——这是采样频率与图案频率之差造成的拍频。
用整数方差衡量:真值($4\times4$ 盒式平均)只落在 $79..95$ 之间,方差 56;点采样在 $0/255$ 之间跳,方差 14453。258 倍。这不是舍入误差,是走样真的造出了结构。
Nyquist 边界:周期 2 的条纹塌成常数
最极端的情形是图案周期恰好是步长的约数。周期 2 的条纹 $s(i) = 255 \iff i \bmod 2 = 0$,以步长 4 点采样:每个采样点都落在同相位上,整条信号塌成一个常数 255。而 4 点盒式平均给出正确的均匀灰 127。高频在点采样下完全变成了 DC。
可运行产物:一份图案,两种抽取
下面这个脚本把上面所有数字变成可复现的输出。它只用 numpy 的整数运算生成图案与做抽取,落盘用共享底座 cglib 的 save_gray_png;variance_int 的分子分母全整数(分子非负,// 与截断一致,两侧可复现)。
#!/usr/bin/env python3
# file: lantern/sim/cg_raster_11_1_aliasing.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_11_1_aliasing.py
"""11.1 采样定理与走样:同一份高频图案,点采样 vs 盒式平均。
一份 256x256 的整数「波带片」(zone plate) 图案当作连续信号;把它降到 64x64 时,
「每隔 4 个取 1 个」(点采样) 会把高频折回成低频的摩尔纹,而「每 4x4 求平均」
(先低通再抽取) 不会。定量用周期 3 的斜条纹:点采样在 64 网格上留下真实的周期 3
条纹(方差大),盒式平均把它压回近似均匀(方差小)。1D 微用例:周期 2 的条纹以
步长 4 点采样会塌成一个常数。所有量都是整数;numpy.fft 只在分析阶段做旁证。
产物:out/cg_raster_11_1_hi.png / _point.png / _box.png / _variance.csv / _aliasing.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__)))
FINE, OUT, STEP, K = 256, 64, 4, 32
def zone_plate(n, k=K):
"""整数波带片:z(i,j) = 255 if ((i*i+j*j)//k) even else 0。同心环频率随半径升高。"""
idx = np.arange(n, dtype=np.int64)
ii, jj = np.meshgrid(idx, idx, indexing="ij")
d2 = ii * ii + jj * jj
return np.where((d2 // k) % 2 == 0, 255, 0).astype(np.uint8)
def stripes(n, period):
"""周期 period 的斜条纹:255 if ((i+j) % period == 0) else 0。"""
idx = np.arange(n, dtype=np.int64)
ii, jj = np.meshgrid(idx, idx, indexing="ij")
return np.where(((ii + jj) % period) == 0, 255, 0).astype(np.uint8)
def point_downsample(img, step):
return img[::step, ::step].copy()
def box_downsample(img, step):
"""盒式平均:整数求和后整除。分子非负,`//` 与截断一致。"""
n = img.shape[0] // step
s = img.astype(np.int64).reshape(n, step, n, step).sum(axis=(1, 3))
return (s // (step * step)).astype(np.uint8)
def variance_int(img):
"""整数方差:Var = E[x^2] - E[x]^2,分子分母全整数(floor 到整数)。"""
x = img.astype(np.int64)
n = x.size
ss, s = int((x * x).sum()), int(x.sum())
return (ss * n - s * s) // (n * n) # = Var,整数(分子非负)
def sha256(buf):
return hashlib.sha256(np.ascontiguousarray(buf).tobytes()).hexdigest()
def main():
hi = zone_plate(FINE)
point = point_downsample(hi, STEP)
box = box_downsample(hi, STEP)
zone_mae = int(np.abs(point.astype(np.int64) - box.astype(np.int64)).mean())
# 定量:周期 3 的斜条纹。步长 4 与周期 3 互质,点采样在 64 网格上留下周期 3 条纹
st = stripes(FINE, 3)
st_point = point_downsample(st, STEP)
st_box = box_downsample(st, STEP)
v_point = variance_int(st_point)
v_box = variance_int(st_box)
# 1D 微用例:周期 2 的条纹,步长 4 点采样 -> 塌成常数;4 点盒式平均 -> 均匀灰
fine_s = np.where(np.arange(FINE) % 2 == 0, 255, 0).astype(np.uint8)
s_point = fine_s[::STEP]
s_box = fine_s.reshape(OUT, STEP).astype(np.int64).sum(axis=1) // STEP
s_point_unique = int(np.unique(s_point).size)
out = os.path.join(ROOT, "out")
cglib.save_gray_png(os.path.join(out, "cg_raster_11_1_hi.png"), hi.astype(np.int64))
cglib.save_gray_png(os.path.join(out, "cg_raster_11_1_point.png"), point.astype(np.int64))
cglib.save_gray_png(os.path.join(out, "cg_raster_11_1_box.png"), box.astype(np.int64))
with open(os.path.join(out, "cg_raster_11_1_variance.csv"), "w", encoding="utf-8") as fh:
fh.write("pattern,method,var_int\n")
fh.write(f"stripes_period3,point,{v_point}\n")
fh.write(f"stripes_period3,box,{v_box}\n")
meta = {
"fine": FINE, "out": OUT, "step": STEP, "k": K,
"hi_sha256": sha256(hi), "point_sha256": sha256(point), "box_sha256": sha256(box),
"zone_mae": zone_mae,
"stripe_point_sha256": sha256(st_point), "stripe_box_sha256": sha256(st_box),
"stripe_point_var_int": v_point, "stripe_box_var_int": v_box,
"stripe_point_min": int(st_point.min()), "stripe_point_max": int(st_point.max()),
"stripe_box_min": int(st_box.min()), "stripe_box_max": int(st_box.max()),
"nyquist_point_value": int(s_point[0]), "nyquist_point_unique": s_point_unique,
"nyquist_box_value": int(s_box[0]),
"nyquist_point_sha256": sha256(s_point), "nyquist_box_sha256": sha256(s_box.astype(np.uint8)),
}
with open(os.path.join(out, "cg_raster_11_1_aliasing.json"), "w", encoding="utf-8") as fh:
json.dump(meta, fh, ensure_ascii=False, indent=2)
print(f"波带片 : {FINE}x{FINE}(k={K},环频率随半径升高) 降到 {OUT}x{OUT}(步长 {STEP})")
print(f" 点采样 vs 盒式平均 MAE : {zone_mae} / 255 <- 同一份连续信号,两种抽取结果差这么多")
print("周期 3 斜条纹(步长 4 与周期 3 互质,点采样会留下周期 3 条纹):")
print(f" 点采样 : 取值 [{st_point.min()}, {st_point.max()}] 方差={v_point}")
print(f" 盒式平均 : 取值 [{st_box.min()}, {st_box.max()}] 方差={v_box}")
print(f" 方差比 : {v_point} / {max(v_box, 1)} = {round(v_point / max(v_box, 1), 1)}x <- 走样制造了真实条纹")
print(f"1D 条纹(周期 2,步长 {STEP}):")
print(f" 点采样 : 取值 {meta['nyquist_point_value']},不同取值数={s_point_unique} <- 塌成常数(走样到 DC)")
print(f" 盒式平均 : 取值 {meta['nyquist_box_value']} <- 正确的低通结果")
print(f"sha256 point : {meta['point_sha256'][:32]}...")
print(f"sha256 box : {meta['box_sha256'][:32]}...")
if __name__ == "__main__":
main()
sha256 落在像素缓冲上(不是 PNG 容器字节——PIL 的压缩参数与自写编码器不同,容器字节本就不可比)。实跑输出:
波带片 : 256x256(k=32,环频率随半径升高) 降到 64x64(步长 4)
点采样 vs 盒式平均 MAE : 119 / 255 <- 同一份连续信号,两种抽取结果差这么多
周期 3 斜条纹(步长 4 与周期 3 互质,点采样会留下周期 3 条纹):
点采样 : 取值 [0, 255] 方差=14453
盒式平均 : 取值 [79, 95] 方差=56
方差比 : 14453 / 56 = 258.1x <- 走样制造了真实条纹
1D 条纹(周期 2,步长 4):
点采样 : 取值 255,不同取值数=1 <- 塌成常数(走样到 DC)
盒式平均 : 取值 127 <- 正确的低通结果
sha256 point : c95e75440071ff5a1ce29fee6b141eab...
sha256 box : f3e51e59f9b37e552f4a10a467969c98...
跨语言断言:同一个抽取,两种语言
要让这些数字当基准,它们得能在另一种语言里逐字节复现。下面的 TypeScript 内核用 Number 重写波带片、条纹、两种抽取与整数方差;坐标平方 $\le 2^{17}$、盒式求和的分子 $\le 4080$,全在浮点精确整数区内,故与 Python 的 int64 逐字节相同。
// file: lantern/core/src/lantern/cg_raster_11_1_aliasing.ts
// tier: P
// verify: node --test 'core/test/cg_raster_11_*.test.ts'
// 11.1 的采样内核:与 Python 侧 sim/cg_raster_11_1_aliasing.py 逐字对齐。
//
// 全部走整数(Number 的精确整数区):256*256 的坐标平方 <= 2^17,d2//k <= 2^12,
// 盒式求和的分子 <= 16*255 = 4080,远小于 2^53。故 Python 的 int64 与 TS 的 Number
// 逐字节相同。pointDownsample 与 boxDownsample 的抽取顺序必须与 numpy 一致:
// 行优先,step 块内求和(轴 1、3),`//` 在非负分子上等价于 Math.floor。
export const FINE = 256;
export const OUT = 64;
export const STEP = 4;
export const K = 32;
/** 整数波带片:255 if ((i*i+j*j)//k) 为偶,否则 0。与 numpy meshgrid(indexing="ij") 同序。 */
export function zonePlate(n: number, k = K): Uint8Array {
const img = new Uint8Array(n * n);
for (let i = 0; i < n; i += 1) {
for (let j = 0; j < n; j += 1) {
const d2 = i * i + j * j;
img[i * n + j] = Math.floor(d2 / k) % 2 === 0 ? 255 : 0;
}
}
return img;
}
/** 周期 period 的斜条纹:255 if ((i+j) % period == 0)。 */
export function stripes(n: number, period: number): Uint8Array {
const img = new Uint8Array(n * n);
for (let i = 0; i < n; i += 1) {
for (let j = 0; j < n; j += 1) {
img[i * n + j] = (i + j) % period === 0 ? 255 : 0;
}
}
return img;
}
/** 点采样:取 (0, step, 2*step, ...) 的行与列。 */
export function pointDownsample(img: Uint8Array, n: number, step: number): Uint8Array {
const m = Math.floor(n / step);
const out = new Uint8Array(m * m);
for (let i = 0; i < m; i += 1) {
for (let j = 0; j < m; j += 1) out[i * m + j] = img[i * step * n + j * step];
}
return out;
}
/** 盒式平均:step x step 块内求和后整除(分子非负,floor 与截断一致)。 */
export function boxDownsample(img: Uint8Array, n: number, step: number): Uint8Array {
const m = Math.floor(n / step);
const out = new Uint8Array(m * m);
for (let i = 0; i < m; i += 1) {
for (let j = 0; j < m; j += 1) {
let acc = 0;
for (let di = 0; di < step; di += 1) {
for (let dj = 0; dj < step; dj += 1) acc += img[(i * step + di) * n + j * step + dj];
}
out[i * m + j] = Math.floor(acc / (step * step));
}
}
return out;
}
/** 整数方差 Var = E[x^2] - E[x]^2(floor 到整数,分子非负)。 */
export function varianceInt(img: Uint8Array): number {
const n = img.length;
let ss = 0;
let s = 0;
for (let i = 0; i < n; i += 1) {
ss += img[i] * img[i];
s += img[i];
}
return Math.floor((ss * n - s * s) / (n * n));
}
node --test 'core/test/cg_raster_11_*.test.ts' 的实测输出:
✔ 波带片:点采样与盒式平均缓冲的 sha256 与 Python 侧逐字节相同
✔ 走样定量:周期 3 条纹点采样方差远大于盒式平均(Python 侧同值)
✔ Nyquist 微用例:周期 2 条纹以步长 4 点采样塌成常数
✔ 负例:把盒式平均误写成点采样,sha256 必须与金标准不同
ℹ tests 4 ℹ pass 4 ℹ fail 0
缓冲的 sha256 与 Python 侧逐字节相同;最后一条是负例——把盒式平均误写成点采样,sha256 与方差都必须与金标准不同,否则判据无区分力。走样既然能量成整数,下一节就给它一个整数解:超采样。
阅读导航:上一节:10.3 画家算法与深度排序 · 下一节:11.2 超采样与覆盖率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。