2.1 分辨率、像素密度与调色板上限

像素风的显示档位与调色板上限全是整数账:逻辑分辨率 320×180 配整数缩放倍率与黑边余量、8 个色彩 token 加明暗 ramp 的调色板用量、5 对 WCAG 对比度 ×10。本节给出可跑的 S 层脚本与 TS 校验器,跨语言逐位一致。

2.1 分辨率、像素密度与调色板上限

《独立游戏像素美术风格指南》 讲的是 Aseprite 里的技法与素材资源——怎么画、用什么笔刷、去哪找参考;本章不讲这些。本章只做像素侧的整数账:逻辑分辨率与整数缩放、调色板上限、量化误差、tile 预算。分工一句话:那篇教你怎么画,本章算你画得起多少。上一节 1.3 风格选择与项目约束 把风格选择落成项目约束,本节把 pixel 主题包(config/sp16_themes.json 的 pixel 项)里的 8 个色彩 token 换算成三笔能对账的整数;色彩空间的底层原理见 图形色彩管理管线 ,本章不重推。

像素风的全部约束都能写成整数,这是它相对其它风格最硬的一点:

约束为什么是整数本节落在哪
缩放倍率非整数缩放会撕裂像素,只能 ×2 / ×3 / ×4显示档位表
调色板上限颜色是计数的,不是「差不多」调色板用量
对比度 ×10WCAG 是浮点,但 ×10 取整后是整数对比度对

逻辑分辨率是 art 画布,屏幕分辨率是它乘一个整数倍率的结果。逻辑分辨率定得越小,单个像素越大、越「像素」。本例的逻辑分辨率是 320×180(16:9),显示时用 scale = 屏高 // 逻辑高 求整数倍率;除不尽的部分是黑边(letterbox),绝不用小数倍率去填。

下面这段是 D 层输入,把显示档位、调色板预算、量化输入与 tile 预算记成一份数据。它是本章三节的 S 层账与 P 层校验器的共同输入,也是「一处生成」的 spec。数值是建模输入,真实屏幕观感需真实显示设备,命令行不可复现(X)。

{
  "version": 1,
  "project": "stardust-inn",
  "theme": "pixel",
  "note": "像素风整数账输入:显示档位 / 调色板预算 / 量化输入 / tile 预算。判据全是整数(倍率、颜色数、字节、千分比、对比度 ×10),除法一律整数地板除。tokens 段与 config/sp16_themes.json 的 pixel 主题逐字一致,供「一处生成」回读。",
  "display": {
    "baseLogicalW": 320, "baseLogicalH": 180,
    "screens": [
      { "id": "phone-hd", "w": 960, "h": 540 },
      { "id": "desktop-hd", "w": 1280, "h": 720 },
      { "id": "desktop-fhd", "w": 1920, "h": 1080 },
      { "id": "tablet-ultra", "w": 2340, "h": 1080 }
    ]
  },
  "palette": { "limit": 32, "shadeFamilies": 4, "shadeRampSteps": 3 },
  "quantize": { "gradientSteps": 16, "fromToken": "bg", "toToken": "accent" },
  "tile": { "tileW": 16, "tileH": 16, "bpp": 4, "uniqueBudget": 256, "uniqueTiles": 240, "atlasW": 1024, "atlasH": 1024 },
  "scenes": [
    { "id": "lobby", "cols": 20, "rows": 11 },
    { "id": "guestroom", "cols": 20, "rows": 11 },
    { "id": "garden", "cols": 40, "rows": 22 }
  ],
  "tokens": {
    "bg": [26, 28, 44], "surface": [42, 46, 68], "ink": [240, 240, 232], "muted": [150, 160, 190],
    "accent": [255, 209, 102], "ok": [110, 220, 140], "warn": [255, 150, 70], "danger": [255, 96, 110]
  },
  "atlas": { "width": 1024, "height": 1024, "cell": 16, "icons": 240 },
  "generated": [
    { "id": "pixel_palette", "file": "prism/out/gen/st02_palette.png", "width": 192, "height": 48, "mode": "RGB", "tokenCount": 8, "distinctColors": 8 },
    { "id": "pixel_tilesheet", "file": "prism/out/gen/st02_tilesheet.png", "width": 256, "height": 240, "mode": "RGB" },
    { "id": "pixel_quantize", "file": "prism/out/gen/st02_quantize.png", "width": 256, "height": 64, "mode": "RGB" }
  ]
}

下面这段是 S 层脚本,读 config/st02_pixel.json 与 sp16_themes.json 的 pixel 主题,算整数缩放倍率、调色板用量与 5 对对比度,并 PIL 生成色卡 PNG 回读尺寸/模式/颜色数。

#!/usr/bin/env python3
# file: sim/st02_resolution.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st02_resolution.py
"""2.1 分辨率、像素密度与调色板上限(S 层,像素风)。

读 config/st02_pixel.json,并从 sp16_themes.json 取 pixel 主题、从 visual_spec.json 取
对比度对与下限,算三笔整数账:① 每块屏的整数缩放 scale = 屏高 // 逻辑高与黑边余量;
② 调色板用量 = token 数 + 明暗族 × (ramp 步数 − 1);③ pixel 主题 5 对 WCAG 对比度 ×10。
整数类逐位一致;色彩派生量只在对比度内部出现,输出即整数。PIL 依同一份 spec 生成色卡 PNG
并回读尺寸/模式/颜色数。落 out/st02_resolution.json + svg 供 P 层对照。
"""
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)
# sRGB→linear 与 WCAG 常数:与 sim/consume.py / core/src/sp00/spec.ts 同源
TH, DIV, OFF, SCA, GAM = 0.04045, 12.92, 0.055, 1.055, 2.4
LR, LG, LB = 0.2126, 0.7152, 0.0722


def lin(c):
    s = c / 255
    return s / DIV if s <= TH else ((s + OFF) / SCA) ** GAM


def contrast10(a, b):
    """WCAG 对比度 ×10 取整 → 整数类,逐位一致。"""
    f = lambda c: LR * lin(c[0]) + LG * lin(c[1]) + LB * lin(c[2])
    hi, lo = sorted((f(a), f(b)), reverse=True)
    return round(((hi + 0.05) / (lo + 0.05)) * 10)


def fit(scr, lw, lh):
    """整数缩放:scale = 屏高 // 逻辑高;余量是缩放后填不满的黑边像素。"""
    s = scr["h"] // lh
    return {"id": scr["id"], "scale": s, "usedW": lw * s, "usedH": lh * s,
            "remW": scr["w"] - lw * s, "remH": scr["h"] - lh * s}


def palette_used(pal, ntok):
    """调色板用量:token + 明暗族各补 (ramp 步数 − 1) 个色阶,全整数。"""
    return ntok + pal["shadeFamilies"] * (pal["shadeRampSteps"] - 1)


def gen_palette(tokens, path, size):
    """色卡:每个 token 占等宽一列,颜色数 == token 数。"""
    w, h = size
    im = Image.new("RGB", (w, h), tuple(next(iter(tokens.values()))))
    d = ImageDraw.Draw(im)
    for i, c in enumerate(tokens.values()):
        d.rectangle([i * w // len(tokens), 0, (i + 1) * w // len(tokens) - 1, h - 1], fill=tuple(c))
    im.save(path)


def write_svg(fits, path):
    """整数缩放条:条长 = 倍率 × 40,整数。"""
    W, H = 420, 40 * len(fits) + 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": "#1a1c2c"})
    for i, f in enumerate(fits):
        y = 12 + i * 40
        ET.SubElement(svg, "text", {"x": "8", "y": str(y + 14), "fill": "#f0f0e8",
                                    "font-family": "monospace", "font-size": "12"}).text = \
            f"{f['id']} x{f['scale']} rem={f['remW']}x{f['remH']}"
        ET.SubElement(svg, "rect", {"x": "8", "y": str(y + 20), "width": str(f["scale"] * 40),
                                    "height": "10", "fill": "#ffd166"})
    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"))
    spec = json.load(open(os.path.join(ROOT, "config/visual_spec.json"), encoding="utf-8"))
    theme = next(t for t in themes["themes"] if t["id"] == cfg["theme"])
    tokens = theme["tokens"]
    assert tokens == cfg["tokens"], "config.tokens 与 pixel 主题不一致"

    lw, lh = cfg["display"]["baseLogicalW"], cfg["display"]["baseLogicalH"]
    fits = [fit(s, lw, lh) for s in cfg["display"]["screens"]]
    letterboxed = [f["id"] for f in fits if f["remW"] > 0 or f["remH"] > 0]
    used = palette_used(cfg["palette"], len(tokens))
    pairs = [{"pair": f"{a}/{b}", "contrast10": contrast10(tokens[a], tokens[b])}
             for a, b in spec["contrastPairs"]]
    min_c, floor = min(p["contrast10"] for p in pairs), spec["contrastFloor10"]

    gen = next(g for g in cfg["generated"] if g["id"] == "pixel_palette")
    png = os.path.join(os.path.dirname(ROOT), gen["file"])
    os.makedirs(os.path.dirname(png), exist_ok=True)
    gen_palette(tokens, 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({"fits": [[f["scale"], f["remW"], f["remH"]] for f in fits],
                        "paletteUsed": used, "minContrast10": min_c}, separators=(",", ":"))
    report = {"project": cfg["project"], "theme": cfg["theme"], "logical": [lw, lh],
              "screens": fits, "letterboxed": letterboxed,
              "palette": {"limit": cfg["palette"]["limit"], "used": used,
                          "headroom": cfg["palette"]["limit"] - used,
                          "shadeFamilies": cfg["palette"]["shadeFamilies"],
                          "shadeRampSteps": cfg["palette"]["shadeRampSteps"]},
              "contrast": pairs, "minContrast10": min_c, "contrastFloor10": floor, "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_resolution.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    write_svg(fits, os.path.join(out, "st02_resolution.svg"))

    print(f"logical={lw}x{lh} screens={len(fits)} letterboxed={len(letterboxed)}")
    for f in fits:
        print(f"  {f['id']:<14} scale=x{f['scale']:<2} used={f['usedW']}x{f['usedH']} "
              f"rem={f['remW']}x{f['remH']}")
    print(f"palette limit={cfg['palette']['limit']} used={used} "
          f"headroom={cfg['palette']['limit'] - used}")
    print(f"minContrast10={min_c} floor10={floor} pairs={[p['contrast10'] for p in pairs]}")
    print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']} colors={info['colors']}")
    print(f"digest={report['digest']}")
    print("wrote out/st02_resolution.json out/st02_resolution.svg out/gen/st02_palette.png")
    return 0


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

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

logical=320x180 screens=4 letterboxed=1
  phone-hd       scale=x3  used=960x540 rem=0x0
  desktop-hd     scale=x4  used=1280x720 rem=0x0
  desktop-fhd    scale=x6  used=1920x1080 rem=0x0
  tablet-ultra   scale=x6  used=1920x1080 rem=420x0
palette limit=32 used=16 headroom=16
minContrast10=45 floor10=45 pairs=[147, 65, 117, 117, 45]
png 192x48 RGB colors=8
digest=e03c9e31d45303f0
wrote out/st02_resolution.json out/st02_resolution.svg out/gen/st02_palette.png

四块屏的倍率全是整数(letterboxed=1 只有 tablet-ultra 因 2340 宽而留 420 黑边)。palette limit=32 used=16 是调色板账:8 个角色 token 加 4 个明暗族各补 2 个色阶,刚好用掉一半。minContrast10=45 恰好压在下限 floor10=45 上——pixel 主题的 danger/surface 只勉强达标,这是像素风低对比度倾向的真实代价,也是后面 2.2 要处理的量化误差的来源。

下面这段是 P 层 TypeScript 内核 core/src/st02/resolution.ts。它把上面的账变成可断言的函数:screenFit 用整数地板除求倍率,paletteUsed 求调色板用量,contrast10 复用与 sim/consume.py / core/src/sp00/spec.ts 同源的 sRGB 与 WCAG 亮度常数。

// file: core/src/st02/resolution.ts
// tier: P
// verify: node --test 'core/test/st02_*.test.ts'
// 2.1 分辨率、像素密度与调色板上限的 TS 内核:整数缩放、调色板用量、WCAG 对比度 ×10。
// 缩放倍率与颜色数是整数类,逐位一致;对比度内部是浮点(与 sim/consume.py 同源常数),
// 但输出为整数 ×10,跨语言同样逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 装饰器)。

export const SRGB_THRESHOLD = 0.04045;
export const SRGB_DIVISOR = 12.92;
export const SRGB_OFFSET = 0.055;
export const SRGB_SCALE = 1.055;
export const SRGB_GAMMA = 2.4;
export const LUMA_R = 0.2126;
export const LUMA_G = 0.7152;
export const LUMA_B = 0.0722;

export type RGB = number[];
export type Screen = { id: string; w: number; h: number };
export type Fit = {
  id: string; scale: number; usedW: number; usedH: number; remW: number; remH: number;
};

export function srgbToLinear(c: number): number {
  const s = c / 255;
  return s <= SRGB_THRESHOLD
    ? s / SRGB_DIVISOR
    : Math.pow((s + SRGB_OFFSET) / SRGB_SCALE, SRGB_GAMMA);
}

export function luminance(rgb: RGB): number {
  return (
    LUMA_R * srgbToLinear(rgb[0]) +
    LUMA_G * srgbToLinear(rgb[1]) +
    LUMA_B * srgbToLinear(rgb[2])
  );
}

/** WCAG 对比度 ×10 取整 → 整数类,逐位一致。 */
export function contrast10(a: RGB, b: RGB): number {
  const la = luminance(a);
  const lb = luminance(b);
  const hi = Math.max(la, lb);
  const lo = Math.min(la, lb);
  return Math.round(((hi + 0.05) / (lo + 0.05)) * 10);
}

/** 整数缩放:scale = 屏高 // 逻辑高;余量是缩放后填不满的黑边像素。 */
export function screenFit(scr: Screen, lw: number, lh: number): Fit {
  const scale = Math.floor(scr.h / lh);
  const usedW = lw * scale;
  const usedH = lh * scale;
  return { id: scr.id, scale, usedW, usedH, remW: scr.w - usedW, remH: scr.h - usedH };
}

/** 调色板用量:角色 token + 明暗族各补 (ramp 步数 − 1) 个色阶,全整数。 */
export function paletteUsed(tokenCount: number, shadeFamilies: number, shadeRampSteps: number): number {
  return tokenCount + shadeFamilies * (shadeRampSteps - 1);
}

配套测试 core/test/st02_resolution.test.ts 读 Python 落盘的 out/st02_resolution.json(不是同一份源码),跑 node --test 'core/test/st02_resolution.test.ts':

✔ 整数缩放:scale = 屏高 // 逻辑高,与 Python 逐位一致 (0.825125ms)
✔ 调色板用量:token + 明暗族 ramp 补色,逐位一致 (0.086666ms)
✔ 对比度 ×10:整数类逐位一致,且不低于下限 (0.124042ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.307416ms)
✔ 负例:黑边判定——填不满的屏必须余量非零 (0.091291ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0

五条全绿,其中「跨语言」一条比的是 canonical 摘要与 sha256 指纹 e03c9e31d45303f0——整数类逐位一致。色彩派生量只在 contrast10 内部出现,输出即整数 ×10,因此同样逐位一致;本节的账里没有需要容差断言的浮点量。测试全文见工作区 core/test/st02_resolution.test.ts。

自检表

检查项合格标准
倍率是整数每块屏 scale = 屏高 // 逻辑高,不出现小数倍率
黑边显式填不满的屏余量非零,不用拉伸掩盖
调色板可对账用量 = token + 明暗族 × (ramp − 1),不超上限
边界清晰真实屏幕观感标 X,不写成「实测」

像素风的账全落在整数上,但「把任意颜色塞进有限调色板」会引入误差——下一节算这笔误差,并说明像素风为什么用抖动而不是抗锯齿。

阅读导航:上一节:1.3 风格选择与项目约束 · 下一节:2.2 调色板量化与抗锯齿替代 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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