2.1 分辨率、像素密度与调色板上限
《独立游戏像素美术风格指南》
讲的是 Aseprite 里的技法与素材资源——怎么画、用什么笔刷、去哪找参考;本章不讲这些。本章只做像素侧的整数账:逻辑分辨率与整数缩放、调色板上限、量化误差、tile 预算。分工一句话:那篇教你怎么画,本章算你画得起多少。上一节 1.3 风格选择与项目约束
把风格选择落成项目约束,本节把 pixel 主题包(config/sp16_themes.json 的 pixel 项)里的 8 个色彩 token 换算成三笔能对账的整数;色彩空间的底层原理见 图形色彩管理管线
,本章不重推。
像素风的全部约束都能写成整数,这是它相对其它风格最硬的一点:
| 约束 | 为什么是整数 | 本节落在哪 |
|---|---|---|
| 缩放倍率 | 非整数缩放会撕裂像素,只能 ×2 / ×3 / ×4 | 显示档位表 |
| 调色板上限 | 颜色是计数的,不是「差不多」 | 调色板用量 |
| 对比度 ×10 | WCAG 是浮点,但 ×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 调色板量化与抗锯齿替代 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。