2.2 调色板量化与抗锯齿替代

像素风用整数色差账回答「量化亏多少」:16 级色阶条映射到最近调色板色记总误差,再找最优两色对做有序抖动,抖动把误差从 381 降到 141;抗锯齿会造出 36 色而抖动新色数为 0。附可跑的 S 层脚本与 TS 校验器,跨语言逐位一致,测试六条全绿。

2.2 调色板量化与抗锯齿替代

上一节 2.1 分辨率、像素密度与调色板上限 定下 8 个角色 token 与调色板上限;但美术画的是连续色,最终要塞进这 8 个色。塞进去会亏多少,就是本节要算的整数色差账。像素风的技法细节见 《独立游戏像素美术风格指南》 ,本节只算账:量化误差与抗锯齿替代。

量化用的距离是 colorDistInt = (3|dr| + 6|dg| + |db|) // 10——权重 3/6/1 逼近 luma 的 2126/7152/722,末位地板除,是纯整数,跨语言逐位一致。最近邻量化把每个源色映射到距离最小的调色板色;有序抖动则允许在 2×2 块内用两种调色板色交替,人眼平均后得到比单色更接近源色的中间调。

下面这段是 S 层脚本,读同一份 config/st02_pixel.json,造 16 级色阶条,分别算最近邻与抖动的总误差,并给出抗锯齿替代的色数账。

#!/usr/bin/env python3
# file: sim/st02_quantize.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st02_quantize.py
"""2.2 调色板量化与抗锯齿替代(S 层,像素风)。

读 config/st02_pixel.json 与 sp16_themes.json 的 pixel 主题,算「整数色差账」:
① 造一条 16 级色阶条(bg → accent,整数插值),逐级映射到最近调色板色(加权 L1 整数距离),
记总误差;再逐级找**最优两色对**(取整数均值后比距离),记抖动后的总误差——两者的差就是
有序抖动相对最近邻量化的收益;
② 抗锯齿替代:自由混色能造 P(P+1)/2 色,而有序抖动(Bayer)只用调色板内两色,新色数恒为 0。

距离 `colorDistInt = (3|dr| + 6|dg| + |db|) // 10` 是纯整数(权重 3/6/1 逼近 luma),跨语言
逐位一致。PIL 生成「源色阶 / 抖动结果」对照 PNG 并回读。落 out/st02_quantize.json + svg。
"""
import hashlib
import json
import os
import sys
import xml.etree.ElementTree as ET

from PIL import Image, ImageDraw

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
ORDER = ("bg", "surface", "ink", "muted", "accent", "ok", "warn", "danger")
BAYER2 = ((0, 2), (3, 1))  # 2×2 有序抖动矩阵


def color_dist_int(a, b):
    """加权 L1 整数距离:权重 3/6/1 逼近 luma,末位地板除。逐位一致。"""
    return (3 * abs(a[0] - b[0]) + 6 * abs(a[1] - b[1]) + 1 * abs(a[2] - b[2])) // 10


def nearest(s, palette):
    """最近调色板色:距离相同取下标小者(确定性)。返回 (下标, 距离)。"""
    best = None
    for i, p in enumerate(palette):
        d = color_dist_int(s, p)
        if best is None or d < best[1]:
            best = (i, d)
    return best


def best_pair(s, palette):
    """最优两色对:枚举单色与所有无序对(取整数均值),返回 (下标 i, 下标 j, 距离)。"""
    cands = [(i, i, palette[i]) for i in range(len(palette))]
    for i in range(len(palette)):
        for j in range(i + 1, len(palette)):
            cands.append((i, j, [(palette[i][k] + palette[j][k]) // 2 for k in range(3)]))
    best = None
    for i, j, avg in cands:
        key = (color_dist_int(s, avg), i, j)
        if best is None or key < best[0]:
            best = (key, (i, j))
    return best[1][0], best[1][1], best[0][0]


def ramp(a, b, n):
    """整数线性插值:第 i 级 = a + (b − a) × i // (n − 1)。"""
    return [[a[k] + (b[k] - a[k]) * i // (n - 1) for k in range(3)] for i in range(n)]


def gen_quantize(src, pairs, palette, path, size):
    """上排源色阶、下排抖动结果(2×2 Bayer 在调色板内两色间交替,不引入新色)。"""
    n, cell = len(src), 16
    w, h = size
    im = Image.new("RGB", (w, h), (0, 0, 0))
    d = ImageDraw.Draw(im)
    for i, s in enumerate(src):
        d.rectangle([i * cell, 0, (i + 1) * cell - 1, h // 2 - 1], fill=tuple(s))
    for i, (pi, pj) in enumerate(pairs):
        for y in range(h // 2, h):
            for x in range(i * cell, (i + 1) * cell):
                c = palette[pi] if BAYER2[(y - h // 2) % 2][x % 2] < 2 else palette[pj]
                im.putpixel((x, y), tuple(c))
    im.save(path)


def write_svg(rows, path):
    """最近邻 vs 抖动:每级两根条,条长 = 误差 × 12,整数。"""
    W, H = 420, 24 * len(rows) + 28
    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": "#1a1c2c"})
    for i, r in enumerate(rows):
        y = 10 + i * 24
        ET.SubElement(svg, "text", {"x": "8", "y": str(y + 10), "fill": "#f0f0e8",
                                    "font-family": "monospace", "font-size": "10"}).text = f"L{i:02d}"
        ET.SubElement(svg, "rect", {"x": "48", "y": str(y), "width": str(r["err1"] * 12),
                                    "height": "8", "fill": "#ff6b6e"})
        ET.SubElement(svg, "rect", {"x": "48", "y": str(y + 10), "width": str(r["err2"] * 12),
                                    "height": "8", "fill": "#6edc8c"})
    tree = ET.ElementTree(svg)
    ET.indent(tree, space="  ")
    tree.write(path, encoding="utf-8", xml_declaration=True)


def main():
    cfg = json.load(open(os.path.join(ROOT, "config/st02_pixel.json"), encoding="utf-8"))
    themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
    theme = next(t for t in themes["themes"] if t["id"] == cfg["theme"])
    palette = [theme["tokens"][k] for k in ORDER]

    q = cfg["quantize"]
    src = ramp(theme["tokens"][q["fromToken"]], theme["tokens"][q["toToken"]], q["gradientSteps"])
    rows, pairs = [], []
    for s in src:
        ni, err1 = nearest(s, palette)
        pi, pj, err2 = best_pair(s, palette)
        rows.append({"src": s, "nearest": ni, "err1": err1, "pair": [pi, pj], "err2": err2})
        pairs.append((pi, pj))
    total1, total2, n = sum(r["err1"] for r in rows), sum(r["err2"] for r in rows), len(rows)

    p = len(palette)
    aa_colors, dither_new = p * (p + 1) // 2, 0

    gen = next(g for g in cfg["generated"] if g["id"] == "pixel_quantize")
    png = os.path.join(os.path.dirname(ROOT), gen["file"])
    os.makedirs(os.path.dirname(png), exist_ok=True)
    gen_quantize(src, pairs, palette, png, (gen["width"], gen["height"]))
    with Image.open(png) as im:
        info = {"size": list(im.size), "mode": im.mode,
                "colors": len(set(im.convert("RGB").get_flattened_data()))}

    canon = json.dumps({"totalErr1": total1, "totalErr2": total2,
                        "aaColors": aa_colors, "ditherNewColors": dither_new}, separators=(",", ":"))
    report = {"project": cfg["project"], "theme": cfg["theme"], "paletteSize": p, "gradientSteps": n,
              "totalErr1": total1, "totalErr2": total2,
              "meanErr1X1000": total1 * 1000 // n, "meanErr2X1000": total2 * 1000 // n,
              "ditherGain": total1 - total2, "aaColors": aa_colors,
              "ditherPairs": p * (p - 1) // 2, "ditherNewColors": dither_new, "levels": rows,
              "png": info, "canonical": canon,
              "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}

    out = os.path.join(ROOT, "out")
    os.makedirs(out, exist_ok=True)
    with open(os.path.join(out, "st02_quantize.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    write_svg(rows, os.path.join(out, "st02_quantize.svg"))

    print(f"paletteSize={p} gradientSteps={n}")
    print(f"nearest: totalErr={total1} meanX1000={total1 * 1000 // n}")
    print(f"dither : totalErr={total2} meanX1000={total2 * 1000 // n} gain={total1 - total2}")
    print(f"aaColors={aa_colors} ditherPairs={p * (p - 1) // 2} ditherNewColors={dither_new}")
    print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']} colors={info['colors']}")
    print(f"digest={report['digest']}")
    print("wrote out/st02_quantize.json out/st02_quantize.svg out/gen/st02_quantize.png")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/st02_quantize.py:

paletteSize=8 gradientSteps=16
nearest: totalErr=381 meanX1000=23812
dither : totalErr=141 meanX1000=8812 gain=240
aaColors=36 ditherPairs=28 ditherNewColors=0
png 256x64 RGB colors=22
digest=dbdf338e47fea1d5
wrote out/st02_quantize.json out/st02_quantize.svg out/gen/st02_quantize.png

读出来:最近邻量化的总误差是 381(每级均摊 23812 千分比),换成最优两色对抖动后降到 141(8812),收益 240——抖动不是玄学,是能被整数距离量出来的。另一笔账更关键:自由混色(抗锯齿)能造出 P(P+1)/2 = 36 种颜色,直接撑爆 8 色调色板;有序抖动只用调色板内两色,新色数恒为 0。这就是像素风用抖动不用抗锯齿的硬理由。

下面这段是 P 层 TypeScript 内核 core/src/st02/quantize.ts,与 S 脚本逐条对应:整数距离、最近邻、最优两色对、色阶条与抗锯齿色数。

// file: core/src/st02/quantize.ts
// tier: P
// verify: node --test 'core/test/st02_*.test.ts'
// 2.2 调色板量化与抗锯齿替代的 TS 内核:加权 L1 整数距离、最近邻量化、最优两色对抖动。
// 距离度量 `colorDistInt = (3|dr| + 6|dg| + |db|) // 10` 是纯整数(权重 3/6/1 逼近 luma),
// 跨语言逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 装饰器)。

export type RGB = number[];

/** 加权 L1 整数距离:权重 3/6/1 逼近 luma,末位地板除。逐位一致。 */
export function colorDistInt(a: RGB, b: RGB): number {
  return Math.floor(
    (3 * Math.abs(a[0] - b[0]) + 6 * Math.abs(a[1] - b[1]) + 1 * Math.abs(a[2] - b[2])) / 10,
  );
}

/** 最近调色板色:距离相同取下标小者(确定性)。返回 [下标, 距离]。 */
export function nearest(s: RGB, palette: RGB[]): number[] {
  let best: number[] | null = null;
  for (let i = 0; i < palette.length; i++) {
    const d = colorDistInt(s, palette[i]);
    if (best === null || d < best[1]) best = [i, d];
  }
  return best as number[];
}

/** 最优两色对:枚举单色与所有无序对(取整数均值),返回 [下标 i, 下标 j, 距离]。 */
export function bestPair(s: RGB, palette: RGB[]): number[] {
  const cands: number[][] = [];
  for (let i = 0; i < palette.length; i++) cands.push([i, i, palette[i][0], palette[i][1], palette[i][2]]);
  for (let i = 0; i < palette.length; i++) {
    for (let j = i + 1; j < palette.length; j++) {
      cands.push([i, j,
        Math.floor((palette[i][0] + palette[j][0]) / 2),
        Math.floor((palette[i][1] + palette[j][1]) / 2),
        Math.floor((palette[i][2] + palette[j][2]) / 2)]);
    }
  }
  let best: number[] | null = null;
  for (const c of cands) {
    const d = colorDistInt(s, [c[2], c[3], c[4]]);
    if (best === null || d < best[0] || (d === best[0] && (c[0] < best[1] || (c[0] === best[1] && c[1] < best[2])))) {
      best = [d, c[0], c[1]];
    }
  }
  const b = best as number[];
  return [b[1], b[2], b[0]];
}

/** 整数线性插值:第 i 级 = a + (b − a) × i // (n − 1)。 */
export function ramp(a: RGB, b: RGB, n: number): RGB[] {
  const out: RGB[] = [];
  for (let i = 0; i < n; i++) {
    out.push([0, 1, 2].map((k) => a[k] + Math.floor(((b[k] - a[k]) * i) / (n - 1))));
  }
  return out;
}

/** 抗锯齿替代:自由混色可造色数 = P(P+1)/2;抖动只用调色板内两色,新色数恒为 0。 */
export function aaColors(p: number): number {
  return Math.floor((p * (p + 1)) / 2);
}

配套测试 core/test/st02_quantize.test.ts 读 Python 落盘的 out/st02_quantize.json,跑 node --test 'core/test/st02_quantize.test.ts':

✔ 整数距离:权重 3/6/1、末位地板除,逐位一致 (0.458541ms)
✔ 最近邻量化总误差:与 Python 逐位一致 (0.118583ms)
✔ 抖动总误差:最优两色对严格优于最近邻 (0.345625ms)
✔ 抗锯齿替代:自由混色 36 色,抖动新色数 0 (0.069541ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.4505ms)
✔ 负例:抖动若引入调色板外的新色,账就不成立 (0.225792ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0

六条全绿:最近邻总误差与抖动总误差都与 Python 逐位一致,canonical 摘要的 sha256 指纹为 dbdf338e47fea1d5,且抖动严格优于最近邻。负例「抖动若引入调色板外的新色」守住「新色数必须为 0」这条底线——一旦抖动偷偷造了新色,账就不成立。测试全文见工作区 core/test/st02_quantize.test.ts。

自检表

检查项合格标准
距离是整数权重 3/6/1、末位地板除,无浮点
抖动有收益两色对总误差严格小于最近邻
不越调色板抖动结果只用调色板内两色,新色数为 0
边界清晰真实观感标 X,不写成「实测」

调色板与量化都定下后,最后一个整数约束是画布本身:逻辑分辨率能切成多少 tile,一套 tileset 占多少字节。

阅读导航:上一节:2.1 分辨率、像素密度与调色板上限 · 下一节:2.3 像素 tile 与 tile 预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则