11.1 采样定理与走样

渲染的本质是把连续场景采样成离散像素。本节用 256×256 的整数波带片与周期 3 斜条纹,把「点采样把高频折回低频」的走样复现出来,再用整数方差把摩尔纹量成可断言的数字;Nyquist 边界上周期 2 的条纹会塌成一个常数,判据在 Python 与 TypeScript 间逐字节复现。

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 超采样与覆盖率 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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