7.2 直线光栅化的误差界

半像素误差界可以写成一条不含除法的整数判据:2*|k*major - i*minor| <= major。本节在 89439 个采样点上穷举验证:Bresenham 与半像素 DDA 的实测 max|e| 恰好是 0.5 像素,floor 取整的 DDA 有 1922 组越界;界在 651 组上被恰好达到,说明它已不能再收紧。

7.2 直线光栅化的误差界

上一节把 DDA 与 Bresenham 的分歧数成了 129 组,但没有回答一个更要紧的问题:凭什么说这组像素「正确」。站内 后处理 关心的是后处理链的 API 与吞吐,那是像素着色之后的账;本节关心的是像素还没产生之前的那条线——一条线段的光栅化结果,能不能用一个可证伪的整数不等式来判定。

误差怎么定义

把理想直线参数化:沿主轴走到第 i 格(i = 0..major)时,理想次轴坐标是 i*minor/major,它一般不是整数。光栅器给出的整数是 k_i,于是第 i 步的误差就是 e_i = k_i - i*minor/major(单位:像素)。「这条线看起来是对的」翻译成数字,就是要求每个 |e_i| 都不超过半个像素——半个像素是「这个像素比隔壁更像理想直线」的分界;超过它,画出来的就不是这条直线。

把界写成整数判据

|e_i| <= 1/2 里带着除法,直接判定要浮点。两边同乘 2*major(major >= 1,恒为正,不等号方向不变),得到 |e_i| <= 1/2 当且仅当 2*|k_i*major - i*minor| <= major。

左边全是整数乘减,右边是整数。这条判据不需要浮点、不需要开方、不需要 fdiv,逐组穷举都跑得动。注意它同时管住了「每一步」——不是只看终点,而是 major+1 个采样点全部要满足。

穷举:89439 个采样点

major in [1,63]、minor in [0,major] 共 2079 组斜率,每组贡献 major+1 个采样点,合计 89439 个。对每个采样点算 2*|k_i*major - i*minor| 并与 major 比:

实现越界组数实测 max|e|
Bresenham(d > 0)0 / 20790.5000
DDA(+32768 半像素偏置)0 / 20790.5000
DDA(floor 取整)1922 / 20791.0000

第一、二行说明 7.1 的两种实现都真的落在半像素界内;第三行说明 floor 取整不是「稍微差一点」,而是在 92% 的斜率上系统性越界,最坏误差正好 1 个像素。这与 7.1 量到的 1922 组分歧是同一个数字——两个独立的角度(像素集合比对 / 误差判据)互相印证。

界是紧的

只证明「不越界」还不够:一条 |e| <= 100 的判据也不越界,但毫无用处。真正有价值的是界被达到——恰有 651 组斜率使 Bresenham 的某个 i 满足 2*|k_i*major - i*minor| == major,即 |e_i| 精确等于 1/2 像素。最小的例子是 major=2, minor=1:第 1 步落在两像素正中间,误差恰好半个像素。

651 这个数字与 7.1 的平局组数完全一致,也不是巧合:|e_i| = 1/2 的充要条件正是「第 i 步落在两像素中点上」,也就是 7.1 说的平局。界是紧的,不能再收紧——想收紧到 1/3 像素,立刻就会有一批合法实现被判死。

负例:判据必须能被证伪

把判据从 2*|res| <= 1*major 放宽成 2*|res| <= 2*major(等价于 |e| <= 1 像素),再拿 floor DDA 去跑:越界组数从 1922 掉到 0。也就是说,放宽后的判据对错误实现完全失效——它再也分不出 floor 与半像素偏置。同一个判据必须同时做到「接受正确实现」与「拒绝错误实现」,缺了后一半,前一半的 PASS 就不可信。

判据替代不了逐像素比对

半像素界成立不等于像素集合相同。minor = 1 时 Bresenham 与 DDA(+32768) 在 major = 2, 4, 8, 16, 32 上给出不同的像素序列,可是把两边都送进判据,两组都满足 1/2 界——它们只是各自把平局推向了相反的方向。演示 major=8, minor=1:k_bresenham = [0,0,0,0,0,1,1,1,1]、残差 [0,-2,-4,-6,-8,6,4,2,0];k_dda_round = [0,0,0,0,1,1,1,1,1]、残差 [0,-2,-4,-6,8,6,4,2,0]。第 i=4 步两边残差互为相反数(-8 与 +8,除以 2*major=16 就是 ∓1/2),符号不同所以像素不同,但绝对值都等于半个像素。结论很硬:误差界是必要条件,不是充分条件;要断言两个实现一致,还得回到 7.1 那种逐像素的 sha256 比对。

可跑的 S 层

#!/usr/bin/env python3
# file: lantern/sim/cg_raster_07_2_error.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/cg_raster_07_2_error.py
"""7.2 直线光栅化的误差界:把「半像素」写成一条纯整数判据并穷举验证。

第 i 步的误差 e_i = k_i - i*minor/major(像素单位)。两边同乘 2*major 消掉除法:

    2*|k_i*major - i*minor| <= major        <=>        |e_i| <= 1/2

左边是纯整数,故界可以逐组穷举判定,不需要任何浮点。
穷举 major in [1,63]、minor in [0,major]、i in [0,major] 共 89439 个 (i, 斜率) 采样点。
产物:out/cg_raster_07_2_error.csv、out/cg_raster_07_2_error.svg、out/cg_raster_07_2_error.json。
"""

import json
import os
import sys
import xml.etree.ElementTree as ET

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__)))
MAXM = 63


def kseq(major, minor, bias=None):
    """minor 偏移序列 k_i:bias=None 走整数 Bresenham(d > 0 进格,平局不动),
    否则走定点 DDA,取整为 (acc + bias) >> 16。"""
    out = []
    if bias is None:
        d, k = 2 * minor - major, 0
        for _ in range(major + 1):
            out.append(k)
            if d > 0:
                k += 1
                d += 2 * (minor - major)
            else:
                d += 2 * minor
    else:
        step, acc = cglib.fdiv(minor << 16, major << 16), 0
        for _ in range(major + 1):
            out.append((acc + bias) >> 16)
            acc += step
    return out


def residual(k, major, minor):
    """(max|2*(k*major - i*minor)|, 误差的 Q16.16 最小值, 最大值)。"""
    best, emin, emax = 0, 0, 0
    for i, ki in enumerate(k):
        r = ki * major - i * minor
        best = max(best, 2 * abs(r))
        e = cglib.fdiv(r << 16, major << 16)
        emin, emax = min(emin, e), max(emax, e)
    return best, emin, emax


def build_svg(path):
    """误差曲线 + ±1/2 包络(纯 xml.etree,不用任何绘图库)。"""
    W, H, pad = 560, 240, 24
    svg = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"x": "0", "y": "0", "width": str(W), "height": str(H), "fill": "#101014"})
    y0, span = H // 2, (H - 2 * pad) // 2
    for sign in (-1, 1):
        ET.SubElement(svg, "line", {"x1": str(pad), "y1": str(y0 + sign * span // 2),
                                    "x2": str(W - pad), "y2": str(y0 + sign * span // 2),
                                    "stroke": "#ff9a3c", "stroke-dasharray": "4 4"})
    for (major, minor), col in zip([(8, 1), (6, 1), (42, 17)], ["#6ad4ff", "#9dff6a", "#ff6a9d"]):
        pts = []
        for i, ki in enumerate(kseq(major, minor)):
            e = (ki * major - i * minor) / major  # 仅用于绘图,判据本身是纯整数
            pts.append(f"{pad + (W - 2 * pad) * i // major},{y0 - int(e * span)}")
        ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none",
                                        "stroke": col, "stroke-width": "2"})
    label = ET.SubElement(svg, "text", {"x": str(pad), "y": str(pad - 8),
                                        "fill": "#e8e8f0", "font-size": "12"})
    label.text = "e_i 曲线与 +-1/2 包络(斜率 8/1、6/1、42/17)"
    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():
    rows, tight, samples = [], [], 0
    stat = {"bresenham": [0, 0], "dda_round": [0, 0], "dda_floor": [0, 0]}
    for major in range(1, MAXM + 1):
        for minor in range(0, major + 1):
            samples += major + 1
            vals = {}
            for name, k in (("bresenham", kseq(major, minor)),
                            ("dda_round", kseq(major, minor, 32768)),
                            ("dda_floor", kseq(major, minor, 0))):
                vals[name] = residual(k, major, minor)
                stat[name][0] += vals[name][0] > major   # 判据:2*|res| <= major 即 |e| <= 1/2
                stat[name][1] = max(stat[name][1], vals[name][0] / (2 * major))
            if vals["bresenham"][0] == major:            # 界被恰好达到 -> 界是紧的
                tight.append([major, minor, major // 2, vals["bresenham"][2]])
            rows.append((major, minor, vals["bresenham"][0], vals["dda_round"][0], vals["dda_floor"][0],
                         vals["dda_floor"][1], vals["dda_floor"][2]))

    # 逐像素比对:Bresenham 与 DDA(+32768) 的分歧只在平局处,且两边 |e| 都是 1/2
    div = [m for m in range(1, MAXM + 1) if kseq(m, 1) != kseq(m, 1, 32768)]
    kb, kd = kseq(8, 1), kseq(8, 1, 32768)
    res = lambda k: [2 * (k[i] * 8 - i) for i in range(9)]  # noqa: E731

    out = os.path.join(ROOT, "out")
    cglib.write_csv(os.path.join(out, "cg_raster_07_2_error.csv"),
                    ["major", "minor", "res_bresenham", "res_dda_round", "res_dda_floor",
                     "e_floor_min_q16", "e_floor_max_q16"], rows)
    svg = build_svg(os.path.join(out, "cg_raster_07_2_error.svg"))
    with open(os.path.join(out, "cg_raster_07_2_error.json"), "w", encoding="utf-8") as fh:
        json.dump({"max_major": MAXM, "samples": samples, "rows": len(rows),
                   "violations": {n: stat[n][0] for n in stat},
                   "max_abs_e_half": {n: stat[n][1] for n in stat},
                   "tight_cases": len(tight), "tight_examples": tight[:6],
                   "demo_major": 8, "demo_minor": 1, "demo_k_bresenham": kb, "demo_k_dda_round": kd,
                   "demo_residual_bresenham": res(kb), "demo_residual_dda_round": res(kd),
                   "diverge_minor1": div, "svg_bytes": svg}, fh, ensure_ascii=False, indent=2)

    print(f"采样点         : {samples} 个 (i, 斜率),覆盖 {len(rows)} 组 (major, minor)")
    print("判据 2*|k*major - i*minor| <= major  (= |e| <= 1/2 像素):")
    for n in ("bresenham", "dda_round", "dda_floor"):
        print(f"  {n:10s} 越界 {stat[n][0]:5d} 组   实测 max|e| = {stat[n][1]:.4f} 像素")
    print(f"界是紧的       : {len(tight)} 组恰好达到 |e| = 1/2,例 {tight[0][:3]}")
    print(f"分歧只在平局    : minor=1 时 Bresenham 与 DDA(+32768) 分歧的 major = {div}")
    print("  演示 major=8 minor=1 :")
    print(f"    k_bresenham = {kb}  残差 2*(k*major - i*minor) = {res(kb)}")
    print(f"    k_dda_round = {kd}  残差 = {res(kd)}")
    print("    平局处 i=4 两边误差都是 1/2,符号相反;两边都满足界,逐像素比对才分得开")
    print(f"产物           : error.csv {len(rows)} 行 / error.svg {svg} bytes")


if __name__ == "__main__":
    main()

真跑:

# snippet: 复现命令(在 lantern 工作区根执行)
cd $HOME/.cgbook/lantern
/opt/homebrew/bin/python3 sim/cg_raster_07_2_error.py
node --test 'core/test/cg_raster_07_2_error.test.ts'

S 层输出与上表一致:bresenham 与 dda_round 越界 0 组、max|e| = 0.5000,dda_floor 越界 1922 组、max|e| = 1.0000;error.csv 2079 行、error.svg 1096 字节。

P 层:把判据搬进 TypeScript

core/test/cg_raster_07_2_error.test.ts 用 BigInt 重写 ddaLine / bresenhamLine 后复跑同一批穷举,四个断言全绿:越界组数与 Python 侧逐数相等、界在 651 组上被达到、负例 A(界放宽到 1 像素后 floor DDA 全部通过)、负例 B(分歧的 major 全是 2 的幂且两边都满足 1/2 界)。

// file: lantern/core/test/cg_raster_07_2_error.test.ts
// tier: P
// verify: node --test 'core/test/cg_raster_07_2_error.test.ts'
// 7.2 的跨语言断言:误差界 2*|k*major - i*minor| <= major 的穷举验证必须与
// Python 侧 sim/cg_raster_07_2_error.py 逐数一致,且负例必须被同一判据拒绝。

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

import { bresenhamLine, ddaLine } from "../src/lantern/cg_raster_07_line.ts";

type G = {
  max_major: number; samples: number; rows: number;
  violations: Record<string, number>; max_abs_e_half: Record<string, number>;
  tight_cases: number; demo_major: number; demo_minor: number;
  demo_k_bresenham: number[]; demo_k_dda_round: number[];
  demo_residual_bresenham: number[]; demo_residual_dda_round: number[];
  diverge_minor1: number[];
};
type Fn = (a: number, b: number, c: number, d: number) => [number, number][];

const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const g = JSON.parse(readFileSync(join(root, "out", "cg_raster_07_2_error.json"), "utf8")) as G;
/** 取 minor 偏移序列 k_i:斜率 0 <= minor <= major 时主轴是 x,故像素的 y 就是 k_i。 */
const ks = (fn: Fn, major: number, minor: number): number[] => fn(0, 0, major, minor).map((p) => p[1]);
/** 判据:2*|k*major - i*minor| <= num*major 即 |e| <= num/2。num = 1 是半像素界。 */
const holds = (k: number[], major: number, minor: number, num: number): boolean =>
  k.every((ki, i) => 2 * Math.abs(ki * major - i * minor) <= num * major);
const ddaR: Fn = (a, b, c, d) => ddaLine(a, b, c, d, 32768);
const ddaF: Fn = (a, b, c, d) => ddaLine(a, b, c, d, 0);

test("穷举:半像素界在 89439 个采样点上成立,越界组数与 Python 侧一致", () => {
  let samples = 0;
  let rows = 0;
  let vB = 0;
  let vR = 0;
  let vF = 0;
  let tight = 0;
  let maxE = 0;
  for (let major = 1; major <= g.max_major; major += 1) {
    for (let minor = 0; minor <= major; minor += 1) {
      rows += 1;
      samples += major + 1;
      const kb = ks(bresenhamLine, major, minor);
      const kr = ks(ddaR, major, minor);
      if (!holds(kb, major, minor, 1)) vB += 1;
      if (!holds(kr, major, minor, 1)) vR += 1;
      if (!holds(ks(ddaF, major, minor), major, minor, 1)) vF += 1;
      for (const k of [kb, kr]) {
        k.forEach((ki, i) => { maxE = Math.max(maxE, Math.abs(ki * major - i * minor) / major); });
      }
      if (kb.some((ki, i) => 2 * Math.abs(ki * major - i * minor) === major)) tight += 1;
    }
  }
  assert.equal(samples, g.samples);
  assert.equal(rows, g.rows);
  assert.equal(vB, g.violations.bresenham);
  assert.equal(vR, g.violations.dda_round);
  assert.equal(vF, g.violations.dda_floor);
  assert.equal(maxE, g.max_abs_e_half.bresenham);
  assert.equal(tight, g.tight_cases);
  // 界是紧的:恰有 651 组(= 7.1 的平局组数)达到 |e| = 1/2
  assert.ok(tight > 0 && maxE === 0.5, "界必须被达到,否则不是紧的");
});

test("平局处的对称分叉:i=4 两边误差都是 1/2,符号相反", () => {
  const { demo_major: M, demo_minor: m } = g;
  const kb = ks(bresenhamLine, M, m);
  const kr = ks(ddaR, M, m);
  assert.deepEqual(kb, g.demo_k_bresenham);
  assert.deepEqual(kr, g.demo_k_dda_round);
  assert.deepEqual(kb.map((k, i) => 2 * (k * M - i * m)), g.demo_residual_bresenham);
  assert.deepEqual(kr.map((k, i) => 2 * (k * M - i * m)), g.demo_residual_dda_round);
  // 两个残差数组只在平局点分叉,且互为相反数
  assert.deepEqual(kb.map((_, i) => i).filter((i) => kb[i] !== kr[i]), [4]);
  assert.equal(g.demo_residual_bresenham[4], -M);
  assert.equal(g.demo_residual_dda_round[4], M);
});

test("负例 A:把界放宽 1 倍(|e| <= 1)-> 判据不再拒绝 floor DDA", () => {
  let rejectedTight = 0;
  let rejectedLoose = 0;
  for (let major = 1; major <= g.max_major; major += 1) {
    for (let minor = 0; minor <= major; minor += 1) {
      const kf = ks(ddaF, major, minor);
      if (!holds(kf, major, minor, 1)) rejectedTight += 1;   // 1/2 界必须拒绝 floor DDA
      if (!holds(kf, major, minor, 2)) rejectedLoose += 1;   // 放宽到 1 像素后应全部通过
    }
  }
  assert.equal(rejectedTight, g.violations.dda_floor);
  assert.ok(rejectedTight > 0, "1/2 界必须拒绝 floor DDA");
  assert.equal(rejectedLoose, 0, "放宽到 1 像素后判据失效:floor DDA 全部通过");
});

test("负例 B:误差界不能替代逐像素比对(分歧只在平局,两边都满足界)", () => {
  // minor = 1 时两者分歧的 major 全是 2 的幂:平局恰好落在整步长上
  const div: number[] = [];
  for (let major = 1; major <= g.max_major; major += 1) {
    if (JSON.stringify(ks(bresenhamLine, major, 1)) !== JSON.stringify(ks(ddaR, major, 1))) div.push(major);
  }
  assert.deepEqual(div, g.diverge_minor1);
  assert.ok(div.length > 0);
  // 两边都满足 1/2 界,却给出不同像素 —— 故 sha256 比对不可省略
  for (const major of div) {
    assert.ok(holds(ks(bresenhamLine, major, 1), major, 1, 1));
    assert.ok(holds(ks(ddaR, major, 1), major, 1, 1));
  }
});

把这条判据记住:它是第 7 章后面所有几何算法的通用模板——先把「看起来对」翻译成一个不含浮点的整数不等式,再穷举证明它成立、证明它紧、并证明它对错误实现有区分力。下一节把同一个套路用到线宽上。

阅读导航:上一节:7.1 DDA 与 Bresenham · 下一节:7.3 线宽与直线抗锯齿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cg」更多文章

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