10.1 图标网格与描边规则

把图标从「画在 24 网格上」变成「机器可判」:讲清网格尺寸、描边宽度阶、安全区内边距三组整数参数,用一份可跑的 Python + TypeScript 判定每个图标落在网格里的哪一格,并让越界、描边过粗、像素不对齐三类缺陷都真的 fail。

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描边 ∉ strokeLadder4px 描边不在宽度阶里
E_BOUNDS外框越出 [padding, grid-padding]含描边后占位超过了安全区
E_CRISPstroke × 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_v01E_CRISP1px@1×,设备像素描边为 1,是奇数
icon_leaf-mono_v01E_CRISP1px@3×,设备像素描边为 3,仍为奇数
icon_bag-toobig_v01E_BOUNDShw=8 加半描边后外框到 21,越过安全区上界 20
icon_lamp-tall_v01E_BOUNDShh=8,纵向外框到 21,同样越界
icon_gem-fat_v01E_STROKE描边 4 不在宽度阶 [1,2,3] 内

10.1.8 两个常被争论的点

描边为什么必须是偶数(在设备像素上)。 一条描边是画在路径中心线上的:中心线落在整数坐标时,奇数宽度的描边会跨越半行像素,导出后两侧各糊一点。偶数宽度则恰好覆盖整数像素行。这就是 E_CRISP 的由来——它不要求描边「好看」,只要求它对齐。

「落在哪一格」有什么用。 它给每个图标一个整数地址,下游的图集打包(10.2)与批次划分(10.3)都要按这个地址来摆放与分组。没有地址,就没有可复现的图集。

下一节把这张网格接到图集装箱上:图标有了确定的尺寸与格位,接下来要把它们装进 1024×1024 的图集,并算出一个千分比的装箱率。

阅读导航:上一节:9.3 UI 状态与交互反馈规范 · 下一节:10.2 图集打包与装箱率 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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