10.1 图标网格与描边规则
图标是美术资产里最不起眼、却最容易被放大看的一类:它小,所以任何 1 像素的偏差都显眼;它多,所以任何一条含糊的约定都会在几百个文件后失控。行业里讲性能预算的那篇短文(game-performance-budget-mobile-pc-2026 )把「贴图别太大、drawcall 别太多」的道理说得很清楚,它管的是引擎侧的账;本节不重复那套框架,而是做美术侧的一件事:把图标的网格与描边变成三组整数参数,并写一个能判「这个图标落没落在该落的地方」的校验器。纹理显存与压缩格式的账另有归属(见 graphics-texture-memory ),本章只负责把图标画在正确的坐标上。
这一节的核心主张只有一句:图标的网格、描边、安全区,必须能被机器判,而不是靠画师手感。
10.1.1 为什么图标值得单独一节
《星屑旅店》的图标来源很杂:内部画师、外包、以及从既有主题包改色。三个人、六周、几百个图标,如果只给一句「都画在 24×24 上」,会稳定地长出三类事故:
- 越界:描边向外扩了一圈,实际占了 26×26,导入图集时和邻格图标粘连。
- 描边过粗:有人用 4px 描边,在 24 网格上占掉六分之一,缩小到 16px 时糊成一坨。
- 像素不对齐:1px 描边画在 1× 上,中心线落在两行像素之间,导出后发虚。
这三类都不是审美问题,是工程问题——它们的共同点是:能用整数算术判出来。所以本节的落点不是「建议怎么画」,而是「写一个 lint,让不合规的图标直接构建失败」。
10.1.2 参数表:一份能被两处消费的配置
规则写成 JSON,作为 Python 与 TypeScript 的共同输入。下面这个块是 D 层(静态 schema),它不产出数字,只定义判据:
{
"project": "stardust-inn",
"specVersion": 1,
"note": "图标网格与描边规则。所有量都是整数:24 设计网格、cell=4 的分区格、padding=4 的安全区、描边宽度阶 [1,2,3]、整数倍率 [1,2,3,4]。外框 = 中心 ± 半宽 ± ceil(stroke/2)。含故意违规项,用于负例断言。",
"grid": 24,
"cell": 4,
"padding": 4,
"strokeLadder": [1, 2, 3],
"scales": [1, 2, 3, 4],
"icons": [
{ "id": "icon_coin-gold_v01", "cx": 12, "cy": 12, "hw": 6, "hh": 6, "stroke": 2, "scale": 1 },
{ "id": "icon_coin-silver_v01", "cx": 12, "cy": 12, "hw": 6, "hh": 6, "stroke": 2, "scale": 2 },
{ "id": "icon_heart-full_v01", "cx": 12, "cy": 12, "hw": 7, "hh": 6, "stroke": 2, "scale": 2 },
{ "id": "icon_star-line_v01", "cx": 12, "cy": 12, "hw": 7, "hh": 7, "stroke": 1, "scale": 2 },
{ "id": "icon_star-thin_v01", "cx": 12, "cy": 12, "hw": 7, "hh": 7, "stroke": 1, "scale": 1 },
{ "id": "icon_bell-bold_v01", "cx": 12, "cy": 12, "hw": 6, "hh": 6, "stroke": 3, "scale": 2 },
{ "id": "icon_bag-wide_v01", "cx": 12, "cy": 12, "hw": 7, "hh": 5, "stroke": 2, "scale": 2 },
{ "id": "icon_bag-toobig_v01", "cx": 12, "cy": 12, "hw": 8, "hh": 5, "stroke": 2, "scale": 2 },
{ "id": "icon_gem-fat_v01", "cx": 12, "cy": 12, "hw": 6, "hh": 6, "stroke": 4, "scale": 2 },
{ "id": "icon_lamp-tall_v01", "cx": 12, "cy": 12, "hw": 5, "hh": 8, "stroke": 2, "scale": 2 },
{ "id": "icon_key-small_v01", "cx": 12, "cy": 12, "hw": 5, "hh": 5, "stroke": 2, "scale": 1 },
{ "id": "icon_leaf-mono_v01", "cx": 12, "cy": 12, "hw": 6, "hh": 6, "stroke": 1, "scale": 3 },
{ "id": "icon_tip-left_v01", "cx": 8, "cy": 12, "hw": 3, "hh": 3, "stroke": 2, "scale": 2 },
{ "id": "icon_tip-right_v01", "cx": 16, "cy": 12, "hw": 3, "hh": 3, "stroke": 2, "scale": 2 }
]
}
参数表里每个数都是整数:grid=24 是设计网格,cell=4 把它切成 6×6 的分区格,padding=4 是四边安全区(可用区 16×16),strokeLadder=[1,2,3] 是允许的描边宽度,scales=[1,2,3,4] 是整数倍率。没有浮点,是刻意的:只要两侧读同一份 JSON、按同样的整数运算比较,结论就能逐位一致。
10.1.3 三条规则与「落在哪一格」
规则一共三条,全部是整数比较:
| 码 | 判据 | 直觉 |
|---|---|---|
E_STROKE | 描边 ∉ strokeLadder | 4px 描边不在宽度阶里 |
E_BOUNDS | 外框越出 [padding, grid-padding] | 含描边后占位超过了安全区 |
E_CRISP | stroke × scale 为奇数 | 1px@1× 的中心线落在两行像素之间 |
外框由中心与半描边推出:outer = [cx - hw - hs, cy - hh - hs, cx + hw + hs, cy + hh + hs],其中 hs = ceil(stroke/2) = (stroke+1)//2。奇数描边向上取整,是为了让外框始终落在整数坐标上——这一条本身也是整数纪律。
「落在哪一格」由 cell_of(x, y) = row × cols + col 给出,row = y // cell、col = x // cell,并把结果夹到 [0, cols²-1]。图标再给出外框覆盖的格区间与格数 cellSpan。于是一个偏左的图标(cx=8)锚点格是 20,居中的是 21,偏右的是 22——锚点格是图标在网格里的「地址」。
10.1.4 S 层:Python 网格判定脚本
下面这段是 S 层脚本,读上面那份配置,逐图标算外框、设备像素描边、锚点格与格区间,判三条规则,落盘 out/sp10_icon_grid.json:
#!/usr/bin/env python3
# file: sim/sp10_icon_grid.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp10_icon_grid.py
"""图标网格与描边规则(S 层,第 10 章)。
读 config/sp10_icon_grid.json:24 设计网格、cell 分区、padding 安全区、描边宽度阶、
整数倍率。逐图标判 E_STROKE(描边不在宽度阶内)/ E_BOUNDS(外框越出安全区)/
E_CRISP(设备像素描边为奇数)。并算出「落在哪一格」:中心锚点格 + 外框覆盖的格区间。
全整数 → 与 TS 逐位一致。落盘 out/sp10_icon_grid.json。
"""
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/sp10_icon_grid.json")
OUT = os.path.join(ROOT, "out")
def half_stroke(stroke: int) -> int:
"""偶数描边外扩 stroke/2,奇数向上取整 ((s+1)//2)。全整数。"""
return (stroke + 1) // 2
def clamp(v: int, lo: int, hi: int) -> int:
return lo if v < lo else (hi if v > hi else v)
def cell_of(x: int, y: int, cell: int, cols: int) -> int:
"""点 (x,y) 的行主序格号,夹到 [0, cols*cols-1]。"""
return clamp(y // cell, 0, cols - 1) * cols + clamp(x // cell, 0, cols - 1)
def analyse(icon: dict, cfg: dict) -> dict:
grid, cell, pad = cfg["grid"], cfg["cell"], cfg["padding"]
cols = grid // cell
hs = half_stroke(icon["stroke"])
ol, ot = icon["cx"] - icon["hw"] - hs, icon["cy"] - icon["hh"] - hs
orr, ob = icon["cx"] + icon["hw"] + hs, icon["cy"] + icon["hh"] + hs
codes = []
if icon["stroke"] not in cfg["strokeLadder"]:
codes.append("E_STROKE")
if ol < pad or ot < pad or orr > grid - pad or ob > grid - pad:
codes.append("E_BOUNDS")
device = icon["stroke"] * icon["scale"]
if device % 2 != 0:
codes.append("E_CRISP")
r_lo, r_hi = clamp(ot // cell, 0, cols - 1), clamp(ob // cell, 0, cols - 1)
c_lo, c_hi = clamp(ol // cell, 0, cols - 1), clamp(orr // cell, 0, cols - 1)
return {
"id": icon["id"], "outer": [ol, ot, orr, ob], "deviceStroke": device,
"anchorCell": cell_of(icon["cx"], icon["cy"], cell, cols),
"cellRows": [r_lo, r_hi], "cellCols": [c_lo, c_hi],
"cellSpan": (r_hi - r_lo + 1) * (c_hi - c_lo + 1),
"codes": sorted(codes),
}
def main() -> int:
cfg = json.load(open(CFG, encoding="utf-8"))
os.makedirs(OUT, exist_ok=True)
rows = [analyse(i, cfg) for i in cfg["icons"]]
violations = sorted(
({"id": r["id"], "code": c} for r in rows for c in r["codes"]),
key=lambda v: (v["id"], v["code"]))
counts = {}
for v in violations:
counts[v["code"]] = counts.get(v["code"], 0) + 1
grid = cfg["grid"]
summary = {
"project": cfg["project"], "grid": grid, "cell": cfg["cell"], "padding": cfg["padding"],
"cols": grid // cfg["cell"], "liveArea": grid - 2 * cfg["padding"],
"iconCount": len(rows), "violationCount": len(violations), "counts": counts,
"icons": rows, "violations": violations,
}
with open(os.path.join(OUT, "sp10_icon_grid.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"grid={grid} cell={cfg['cell']} cols={summary['cols']} liveArea={summary['liveArea']}")
print(f"iconCount={len(rows)} violations={len(violations)}")
for code in sorted(counts):
print(f" {code}={counts[code]}")
print("wrote out/sp10_icon_grid.json")
return 0
if __name__ == "__main__":
sys.exit(main())
10.1.5 P 层:TS 校验器
同一份配置,TypeScript 侧独立实现一遍。它不 import Python 的任何东西,只读同一份 JSON:
// file: core/src/sp10/iconGrid.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 图标网格与描边规则的 TS 侧实现:与 sim/sp10_icon_grid.py 共用 config/sp10_icon_grid.json。
// 规则与阈值全是整数,故两侧结果逐位一致。只允许可擦除 TS 语法(禁 enum/namespace)。
export type Icon = {
id: string; cx: number; cy: number; hw: number; hh: number; stroke: number; scale: number;
};
export type IconGridConfig = {
grid: number; cell: number; padding: number; strokeLadder: number[]; scales: number[]; icons: Icon[];
};
export type IconRow = {
id: string; outer: number[]; deviceStroke: number; anchorCell: number;
cellRows: number[]; cellCols: number[]; cellSpan: number; codes: string[];
};
/** 偶数描边外扩 stroke/2,奇数向上取整 ((s+1)//2)。 */
export function halfStroke(stroke: number): number {
return Math.floor((stroke + 1) / 2);
}
export function clamp(v: number, lo: number, hi: number): number {
return v < lo ? lo : v > hi ? hi : v;
}
/** 点 (x,y) 的行主序格号,夹到 [0, cols*cols-1]。 */
export function cellOf(x: number, y: number, cell: number, cols: number): number {
return clamp(Math.floor(y / cell), 0, cols - 1) * cols + clamp(Math.floor(x / cell), 0, cols - 1);
}
export function analyseIcon(icon: Icon, cfg: IconGridConfig): IconRow {
const cols = Math.floor(cfg.grid / cfg.cell);
const hs = halfStroke(icon.stroke);
const ol = icon.cx - icon.hw - hs, ot = icon.cy - icon.hh - hs;
const orr = icon.cx + icon.hw + hs, ob = icon.cy + icon.hh + hs;
const codes: string[] = [];
if (!cfg.strokeLadder.includes(icon.stroke)) codes.push("E_STROKE");
if (ol < cfg.padding || ot < cfg.padding || orr > cfg.grid - cfg.padding || ob > cfg.grid - cfg.padding) {
codes.push("E_BOUNDS");
}
const device = icon.stroke * icon.scale;
if (device % 2 !== 0) codes.push("E_CRISP");
const rLo = clamp(Math.floor(ot / cfg.cell), 0, cols - 1);
const rHi = clamp(Math.floor(ob / cfg.cell), 0, cols - 1);
const cLo = clamp(Math.floor(ol / cfg.cell), 0, cols - 1);
const cHi = clamp(Math.floor(orr / cfg.cell), 0, cols - 1);
return {
id: icon.id, outer: [ol, ot, orr, ob], deviceStroke: device,
anchorCell: cellOf(icon.cx, icon.cy, cfg.cell, cols),
cellRows: [rLo, rHi], cellCols: [cLo, cHi],
cellSpan: (rHi - rLo + 1) * (cHi - cLo + 1), codes: codes.sort(),
};
}
export function analyseAll(cfg: IconGridConfig): IconRow[] {
return cfg.icons.map((i) => analyseIcon(i, cfg));
}
10.1.6 P 层测试:读 Python 的产物做对照
「跨语言一致」不能靠「两边写得像」。测试读的是 Python 落盘的 JSON,再断言 TS 复算出的逐图标结果与它逐条相同:
// file: core/test/sp10_icon_grid.test.ts
// tier: P
// verify: node --test 'core/test/sp10_*.test.ts'
// 跨语言对照:读 sim/sp10_icon_grid.py 落盘的 out/sp10_icon_grid.json,断言 TS 侧
// analyseAll 复算出的逐图标外框/格位/违规与之一致(整数类,逐位一致)。
// 带负例断言:越界、描边过粗、像素不对齐必须真的 fail。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { analyseAll, cellOf, halfStroke } from "../src/sp10/iconGrid.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp10_icon_grid.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp10_icon_grid.json"), "utf8"));
const digest = (ls: string[]): string =>
createHash("sha256").update(ls.join("\n")).digest("hex").slice(0, 16);
test("跨语言:TS 逐图标结果 == Python(外框/格位/违规,逐位一致)", () => {
const mine = analyseAll(cfg);
assert.equal(mine.length, py.icons.length);
for (let i = 0; i < mine.length; i += 1) {
const a = mine[i], b = py.icons[i];
assert.equal(a.id, b.id);
assert.deepEqual(a.outer, b.outer, `${a.id} 外框`);
assert.equal(a.deviceStroke, b.deviceStroke);
assert.equal(a.anchorCell, b.anchorCell);
assert.deepEqual(a.cellRows, b.cellRows);
assert.deepEqual(a.cellCols, b.cellCols);
assert.equal(a.cellSpan, b.cellSpan);
assert.deepEqual(a.codes, b.codes);
}
const d = digest(py.violations.map((v: { id: string; code: string }) => `${v.id}:${v.code}`));
console.log(`icon-grid digest = ${d}`);
});
test("负例断言:越界 / 描边过粗 / 像素不对齐必须 fail", () => {
const byId = new Map(analyseAll(cfg).map((r) => [r.id, r.codes]));
assert.ok(byId.get("icon_bag-toobig_v01")?.includes("E_BOUNDS"), "宽袋越界应报 E_BOUNDS");
assert.ok(byId.get("icon_lamp-tall_v01")?.includes("E_BOUNDS"), "高灯越界应报 E_BOUNDS");
assert.ok(byId.get("icon_gem-fat_v01")?.includes("E_STROKE"), "stroke=4 应报 E_STROKE");
assert.ok(byId.get("icon_star-thin_v01")?.includes("E_CRISP"), "1px@1x 应报 E_CRISP");
assert.ok(byId.get("icon_leaf-mono_v01")?.includes("E_CRISP"), "1px@3x=3px 应报 E_CRISP");
});
test("正例:合规图标零违例;格位与半描边取整锁死", () => {
const byId = new Map(analyseAll(cfg).map((r) => [r.id, r.codes]));
for (const id of ["icon_coin-gold_v01", "icon_star-line_v01", "icon_bell-bold_v01", "icon_key-small_v01"]) {
assert.deepEqual(byId.get(id), [], `${id} 不应有违例`);
}
assert.equal(cellOf(12, 12, 4, 6), 21);
assert.equal(cellOf(8, 12, 4, 6), 20);
assert.equal(cellOf(16, 12, 4, 6), 22);
assert.equal(halfStroke(1), 1);
assert.equal(halfStroke(2), 1);
assert.equal(halfStroke(3), 2);
});
10.1.7 实跑输出与跨语言 digest
S 层 python3 sim/sp10_icon_grid.py 的真实输出:
grid=24 cell=4 cols=6 liveArea=16
iconCount=14 violations=5
E_BOUNDS=2
E_CRISP=2
E_STROKE=1
wrote out/sp10_icon_grid.json
P 层 node --test 'core/test/sp10_*.test.ts':pass 8 / fail 0。把每条违例按 id:code 排序拼接取 sha256 前 16 位,两侧算出的图标 digest 都是 f39e4f48be0ebd1e——这是整数类,逐位一致(不是「容差内一致」;容差那套只适用于色彩派生量,见第 4 章)。
14 个图标里 5 条违例,逐条对照能看出校验器真的在判东西:
| 图标 | 违例 | 说明 |
|---|---|---|
icon_star-thin_v01 | E_CRISP | 1px@1×,设备像素描边为 1,是奇数 |
icon_leaf-mono_v01 | E_CRISP | 1px@3×,设备像素描边为 3,仍为奇数 |
icon_bag-toobig_v01 | E_BOUNDS | hw=8 加半描边后外框到 21,越过安全区上界 20 |
icon_lamp-tall_v01 | E_BOUNDS | hh=8,纵向外框到 21,同样越界 |
icon_gem-fat_v01 | E_STROKE | 描边 4 不在宽度阶 [1,2,3] 内 |
10.1.8 两个常被争论的点
描边为什么必须是偶数(在设备像素上)。 一条描边是画在路径中心线上的:中心线落在整数坐标时,奇数宽度的描边会跨越半行像素,导出后两侧各糊一点。偶数宽度则恰好覆盖整数像素行。这就是 E_CRISP 的由来——它不要求描边「好看」,只要求它对齐。
「落在哪一格」有什么用。 它给每个图标一个整数地址,下游的图集打包(10.2)与批次划分(10.3)都要按这个地址来摆放与分组。没有地址,就没有可复现的图集。
下一节把这张网格接到图集装箱上:图标有了确定的尺寸与格位,接下来要把它们装进 1024×1024 的图集,并算出一个千分比的装箱率。
阅读导航:上一节:9.3 UI 状态与交互反馈规范 · 下一节:10.2 图集打包与装箱率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。