2.3 像素 tile 与 tile 预算
前两节定下显示档位、调色板上限与量化误差,最后一块整数账是画布本身:逻辑分辨率能切成多少 tile,一套 tileset 占多少字节、在 1024×1024 图集里占多少。像素风的 tile 网格是这套账的天然单位,也是 2.1 的整数缩放、2.2 的调色板共同的落点。素材与技法仍见 《独立游戏像素美术风格指南》 ,本节只做 tile 的整数预算。
tile 预算有三笔:① 场景 tile 数 = cols × rows,以及逻辑屏能铺几整格(逻辑宽 // tileW,余下的边缘像素是「半格」);② tileset 字节 = 唯一 tile 数 × tileW × tileH × bpp;③ 图集占用千分比 = 唯一 tile 数 × tileW × tileH × 1000 // (atlasW × atlasH)。全整数、全地板除。
下面这段是 S 层脚本,读同一份 config/st02_pixel.json 算三笔 tile 账,并 PIL 生成 tile 表 PNG(只用调色板内颜色)后回读断言颜色数不超调色板。
#!/usr/bin/env python3
# file: sim/st02_tiles.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st02_tiles.py
"""2.3 像素 tile 与 tile 预算(S 层,像素风)。
读 config/st02_pixel.json 与 sp16_themes.json 的 pixel 主题,算像素 tile 的整数账:
① 每个场景的 tile 网格 = cols × rows,以及逻辑分辨率能铺多少整格(地板除,余下的边缘像素
是「半格」,像素风里宁可留空也不半格);
② tileset 字节 = 唯一 tile 数 × tileW × tileH × bpp,以及唯一 tile 数相对预算的余量;
③ 图集占用千分比 = 唯一 tile 数 × tileW × tileH × 1000 // (atlasW × atlasH)。
全整数、全地板除,跨语言逐位一致。PIL 依同一份 spec 生成 tile 表 PNG(只用调色板内颜色),
回读尺寸/模式/颜色数并断言颜色数 ≤ 调色板上限。落 out/st02_tiles.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")
def scene_tiles(sc, tw, th, lw, lh):
"""场景 tile 网格与「逻辑屏能铺几格」:全整数地板除,余下的边缘像素是半格。"""
return {"id": sc["id"], "cols": sc["cols"], "rows": sc["rows"],
"tiles": sc["cols"] * sc["rows"], "screenCols": lw // tw, "screenRows": lh // th,
"edgeX": lw - (lw // tw) * tw, "edgeY": lh - (lh // th) * th}
def tileset_bytes(unique, tw, th, bpp):
return unique * tw * th * bpp
def atlas_permille(unique, tw, th, aw, ah):
return unique * tw * th * 1000 // (aw * ah)
def gen_tilesheet(palette, unique, tw, th, cols, path, size):
"""tile 表:每格一个 tile,只用调色板内颜色(填充 + 外框 + 角点)。"""
w, h = size
im = Image.new("RGB", (w, h), tuple(palette[0]))
d = ImageDraw.Draw(im)
for i in range(unique):
cx, cy = (i % cols) * tw, (i // cols) * th
d.rectangle([cx, cy, cx + tw - 1, cy + th - 1], fill=tuple(palette[(i % 4) + 4]))
d.rectangle([cx, cy, cx + tw - 1, cy + th - 1], outline=tuple(palette[1]))
d.point((cx, cy), fill=tuple(palette[2]))
im.save(path)
def write_svg(rows, path):
"""各场景 tile 数:条长 = tiles // 4,整数。"""
W, H = 420, 34 * len(rows) + 26
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 = 12 + i * 34
ET.SubElement(svg, "text", {"x": "8", "y": str(y + 12), "fill": "#f0f0e8",
"font-family": "monospace", "font-size": "11"}).text = \
f"{r['id']} {r['cols']}x{r['rows']}={r['tiles']}"
ET.SubElement(svg, "rect", {"x": "8", "y": str(y + 18), "width": str(r["tiles"] // 4),
"height": "10", "fill": "#5ac8b4"})
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]
t = cfg["tile"]
lw, lh = cfg["display"]["baseLogicalW"], cfg["display"]["baseLogicalH"]
scenes = [scene_tiles(s, t["tileW"], t["tileH"], lw, lh) for s in cfg["scenes"]]
tb = tileset_bytes(t["uniqueTiles"], t["tileW"], t["tileH"], t["bpp"])
permille = atlas_permille(t["uniqueTiles"], t["tileW"], t["tileH"], t["atlasW"], t["atlasH"])
slots = (t["atlasW"] // t["tileW"]) * (t["atlasH"] // t["tileH"])
gen = next(g for g in cfg["generated"] if g["id"] == "pixel_tilesheet")
png = os.path.join(os.path.dirname(ROOT), gen["file"])
os.makedirs(os.path.dirname(png), exist_ok=True)
gen_tilesheet(palette, t["uniqueTiles"], t["tileW"], t["tileH"],
gen["width"] // t["tileW"], 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()))}
assert info["colors"] <= len(palette), "tile 表颜色数超出调色板"
canon = json.dumps({"totalTiles": sum(s["tiles"] for s in scenes), "tilesetBytes": tb,
"atlasPermille": permille, "slots": slots}, separators=(",", ":"))
report = {"project": cfg["project"], "theme": cfg["theme"],
"tile": {"w": t["tileW"], "h": t["tileH"], "bpp": t["bpp"]}, "scenes": scenes,
"totalTiles": sum(s["tiles"] for s in scenes), "uniqueTiles": t["uniqueTiles"],
"uniqueBudget": t["uniqueBudget"], "uniqueHeadroom": t["uniqueBudget"] - t["uniqueTiles"],
"tilesetBytes": tb, "atlasSlots": slots, "atlasPermille": permille, "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_tiles.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
write_svg(scenes, os.path.join(out, "st02_tiles.svg"))
print(f"tile={t['tileW']}x{t['tileH']} bpp={t['bpp']} logical={lw}x{lh} "
f"screenGrid={lw // t['tileW']}x{lh // t['tileH']}")
for s in scenes:
print(f" {s['id']:<10} {s['cols']}x{s['rows']}={s['tiles']:<4} edge={s['edgeX']}x{s['edgeY']}")
print(f"totalTiles={report['totalTiles']} unique={t['uniqueTiles']}/{t['uniqueBudget']} "
f"headroom={report['uniqueHeadroom']}")
print(f"tilesetBytes={tb} atlasSlots={slots} atlasPermille={permille}")
print(f"png {info['size'][0]}x{info['size'][1]} {info['mode']} colors={info['colors']}")
print(f"digest={report['digest']}")
print("wrote out/st02_tiles.json out/st02_tiles.svg out/gen/st02_tilesheet.png")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st02_tiles.py:
tile=16x16 bpp=4 logical=320x180 screenGrid=20x11
lobby 20x11=220 edge=0x4
guestroom 20x11=220 edge=0x4
garden 40x22=880 edge=0x4
totalTiles=1320 unique=240/256 headroom=16
tilesetBytes=245760 atlasSlots=4096 atlasPermille=58
png 256x240 RGB colors=6
digest=6a02c695befc8873
wrote out/st02_tiles.json out/st02_tiles.svg out/gen/st02_tilesheet.png
读出来:逻辑屏 320×180 按 16×16 切成 20×11 格,纵向余 4 像素是半格(像素风里宁可留空)。三个场景共 1320 个 tile;tileset 只有 240 个唯一 tile(预算 256,余量 16),字节数 245760(≈240 KB,bpp=4)。放进 1024×1024 图集(4096 槽)只占 58‰——tile 复用率极高,这正是像素风省内存的原因。tile 表 PNG 只有 6 种颜色,稳稳落在 8 色调色板内。
把三笔账串起来看:逻辑分辨率决定网格粒度,网格粒度决定唯一 tile 数,唯一 tile 数决定 tileset 字节与图集占用。改逻辑分辨率会同时改这三项——这也是 2.1 说「先定逻辑分辨率」的原因。
「半格」是像素风特有的取舍:纵向 180 不能被 16 整除,余 4 像素。这里的选择是留空(黑边)而不是让 tile 变高——一旦允许非整数格,2.1 定下的整数缩放就白定了。
下面这段是 P 层 TypeScript 内核 core/src/st02/tiles.ts,与 S 脚本逐条对应:场景网格、tileset 字节、图集千分比与槽位。
// file: core/src/st02/tiles.ts
// tier: P
// verify: node --test 'core/test/st02_*.test.ts'
// 2.3 像素 tile 与 tile 预算的 TS 内核:场景 tile 网格、tileset 字节、图集占用千分比。
// 全整数、全地板除,跨语言逐位一致。只允许可擦除 TS 语法(禁 enum / namespace / 装饰器)。
export type Scene = { id: string; cols: number; rows: number };
export type SceneTiles = {
id: string; cols: number; rows: number; tiles: number;
screenCols: number; screenRows: number; edgeX: number; edgeY: number;
};
/** 场景 tile 网格与「逻辑屏能铺几格」:全整数地板除,余下的边缘像素是半格。 */
export function sceneTiles(sc: Scene, tileW: number, tileH: number, lw: number, lh: number): SceneTiles {
const cols = Math.floor(lw / tileW);
const rows = Math.floor(lh / tileH);
return {
id: sc.id, cols: sc.cols, rows: sc.rows, tiles: sc.cols * sc.rows,
screenCols: cols, screenRows: rows,
edgeX: lw - cols * tileW, edgeY: lh - rows * tileH,
};
}
/** tileset 字节 = 唯一 tile 数 × tileW × tileH × bpp。 */
export function tilesetBytes(unique: number, tileW: number, tileH: number, bpp: number): number {
return unique * tileW * tileH * bpp;
}
/** 图集占用千分比 = 唯一 tile 数 × tileW × tileH × 1000 // (atlasW × atlasH)。 */
export function atlasPermille(unique: number, tileW: number, tileH: number, aw: number, ah: number): number {
return Math.floor((unique * tileW * tileH * 1000) / (aw * ah));
}
/** 图集槽位数 = (atlasW // tileW) × (atlasH // tileH)。 */
export function atlasSlots(tileW: number, tileH: number, aw: number, ah: number): number {
return Math.floor(aw / tileW) * Math.floor(ah / tileH);
}
配套测试 core/test/st02_tiles.test.ts 读 Python 落盘的 out/st02_tiles.json,跑 node --test 'core/test/st02_tiles.test.ts':
✔ 场景 tile 网格:cols × rows 与边缘半格,逐位一致 (0.837042ms)
✔ tileset 字节与图集千分比:全整数地板除,逐位一致 (0.091458ms)
✔ 唯一 tile 数在预算内,且余量与 Python 一致 (0.087917ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.335125ms)
✔ 负例:地板除锁死——非整除的千分比不得四舍五入 (0.080417ms)
ℹ tests 5
ℹ pass 5
ℹ fail 0
五条全绿:场景网格、tileset 字节、图集千分比与槽位都与 Python 逐位一致,canonical 摘要的 sha256 指纹为 6a02c695befc8873。负例「地板除锁死」守住 240×256×1000 // 1048576 = 58——若用四舍五入会得 59,账就不可复算。测试全文见工作区 core/test/st02_tiles.test.ts。
自检表
| 检查项 | 合格标准 |
|---|---|
| 网格是整数 | 场景 tile 数 = cols × rows,边缘半格显式记录 |
| 字节可复算 | tileset 字节 = 唯一 tile × tileW × tileH × bpp |
| 图集不越界 | 占用千分比 = 面积比地板除,唯一 tile 数不超预算 |
| 边界清晰 | 真实 tile 美术观感标 X,不写成「实测」 |
至此像素风的三笔整数账齐了:显示档位(2.1)、量化误差(2.2)、tile 预算(2.3)。下一章换一种完全不同的风格——2D 手绘与矢量,那里没有 tile 网格,线宽与色块成了新的整数约束。
阅读导航:上一节:2.2 调色板量化与抗锯齿替代 · 下一节:3.1 扁平色块与线宽规范 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。