12.2 圆角与柔和过渡规范
站内 游戏美术风格指南
把「圆润、柔和」讲成了感觉。本节不重复那套口号,而是把它压成五个可校验的整数:圆角半径、柔边宽、过渡带数、模糊半径、描边宽。上一节 12.1 高明度低饱和调色板
定的是「底色」的整数边界,本节定的是「形状」的整数边界,两者同属 cozy-clean 主题包。圆角与柔边的粒度沿用 2D 手绘侧的 unit 阶梯写法,见 3.1 扁平色块与线宽规范
;九宫格可拉伸的边界另见卷一 9.2 九宫格可拉伸
。本节只做参数与账,真实抗锯齿与模糊的观感需真实环境,命令行不可复现(X)。
五类整数约束
「柔和」不是「模糊一点」,而是「只能取这几个值、且互相整除」。unit = 2 是所有阶梯的粒度:
| 量 | 规范形态 | 为什么必须整数 |
|---|---|---|
| 圆角半径 | 阶梯 [0, 4, 8, 12](px) | 圆角不超块短边 1/2,否则画不出圆角矩形 |
| 柔边宽 | 阶梯 [2, 4, 6](px) | 柔边宽是 unit 的倍数,降采样后才不落半像素 |
| 过渡带数 | [2, 3, 4](整数) | 柔边宽必须被带数整除,每带等宽整数像素 |
| 模糊半径 | 阶梯 [4, 8, 12](px) | 模糊是 unit 的倍数,半径不同则柔度不同 |
| 描边宽 | ≤ 2(px) | 柔和风格禁粗描边;粗黑边是「硬」的信号 |
最容易被忽略的是过渡带整除:柔边宽 6 px 分 3 带,每带 2 px,是整数;柔边宽 5 px 分 2 带,每带 2.5 px,屏幕上就会有的带 3 像素、有的带 2 像素,同一条柔边深浅不匀。规范因此写成 softEdge % bands == 0,而不是「带数尽量小」。
S 层:圆角与柔边规范账
下面这段是 S 层脚本。它读本章的 config/st12_rounded.json(五类阶梯与待检资产块表)与 config/sp16_themes.json(取 cozy-clean 实例),对每块资产跑六类整数检查,并依同一份 spec 用 PIL 真生成圆角样本 PNG:
{
"unit": 2,
"radii": [0, 4, 8, 12],
"softEdges": [2, 4, 6],
"blurRadii": [4, 8, 12],
"strokeMax": 2
}
#!/usr/bin/env python3
# file: sim/st12_rounded.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st12_rounded.py
"""清新/治愈:圆角与柔和过渡的整数规范账(第 12 章 12.2,S 层)。
读 config/st12_rounded.json(本章整数规范)+ config/sp16_themes.json(取 cozy-clean 实例)。
五类整数检查:**圆角阶梯**(圆角在阶梯内且不超块短边 1/2)、**柔边阶梯**(柔边宽取阶梯值)、
**过渡带整除**(柔边宽必须被过渡带数整除,每带等宽整数像素)、**模糊阶梯**(模糊半径取阶梯值)、
**描边上限**(柔和风格 strokeWidth 不超 strokeMax,禁粗描边)。并依同一份 spec 用 PIL 真生成
圆角样本 PNG。落 out/st12_rounded.json 供 P 层对照。全部整数类,逐位一致。
"""
import csv
import hashlib
import json
import os
import sys
from PIL import Image, ImageDraw
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GEN = os.path.join(ROOT, "out", "gen")
def audit_block(b, cfg):
errs = []
if b["radius"] not in cfg["radii"]:
errs.append("E_RADIUS_LADDER")
if b["radius"] > min(b["w"], b["h"]) // 2:
errs.append("E_RADIUS_CAP")
if b["softEdge"] not in cfg["softEdges"]:
errs.append("E_SOFT_EDGE_LADDER")
if b["softEdge"] % b["bands"] != 0:
errs.append("E_BAND_STEP")
if b["blur"] not in cfg["blurRadii"]:
errs.append("E_BLUR_LADDER")
if b["stroke"] > cfg["strokeMax"]:
errs.append("E_STROKE_HARD")
return errs
def gen_rounded_sample(tokens, radii, path):
"""圆角样本:同一画布并列四种允许圆角,直观对照弧度阶梯(像素级,非笔触)。"""
cell, gap = 40, 8
w = len(radii) * cell + (len(radii) + 1) * gap
im = Image.new("RGB", (w, cell + 2 * gap), tuple(tokens["bg"]))
d = ImageDraw.Draw(im)
for i, r in enumerate(radii):
x = gap + i * (cell + gap)
d.rounded_rectangle([x, gap, x + cell - 1, gap + cell - 1], radius=r,
fill=tuple(tokens["surface"]), outline=tuple(tokens["accent"]), width=1)
im.save(path)
def main() -> int:
cfg = json.load(open(os.path.join(ROOT, "config/st12_rounded.json"), encoding="utf-8"))
themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
th = next(t for t in themes["themes"] if t["id"] == cfg["themeId"])
tokens = th["tokens"]
violations, rows = [], []
for b in cfg["blocks"]:
errs = audit_block(b, cfg)
violations += [{"id": b["id"], "code": c} for c in errs]
rows.append({"id": b["id"], "kind": b["kind"], "radius": b["radius"], "softEdge": b["softEdge"],
"bands": b["bands"], "blur": b["blur"], "stroke": b["stroke"], "ok": not errs})
counts = {}
for v in violations:
counts[v["code"]] = counts.get(v["code"], 0) + 1
band_steps = sorted({b["softEdge"] // b["bands"] for b in cfg["blocks"]
if b["softEdge"] % b["bands"] == 0})
canon = json.dumps({"blockCount": len(cfg["blocks"]), "violationCount": len(violations),
"radiusLadder": cfg["radii"], "softEdges": cfg["softEdges"],
"strokePermille": th["strokePermille"]},
separators=(",", ":"), ensure_ascii=False)
summary = {"project": cfg["project"], "themeId": cfg["themeId"], "unit": cfg["unit"],
"radii": cfg["radii"], "softEdges": cfg["softEdges"], "blurRadii": cfg["blurRadii"],
"strokeMax": cfg["strokeMax"], "strokePermille": th["strokePermille"],
"detailPermille": th["detailPermille"], "blockCount": len(cfg["blocks"]),
"bandSteps": band_steps, "violationCount": len(violations), "counts": counts,
"violations": violations, "rows": rows, "canonical": canon,
"digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16]}
os.makedirs(GEN, exist_ok=True)
json.dump(summary, open(os.path.join(ROOT, "out/st12_rounded.json"), "w", encoding="utf-8"),
ensure_ascii=False, indent=2, sort_keys=True)
with open(os.path.join(ROOT, "out/st12_rounded.csv"), "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(["id", "kind", "radius", "softEdge", "bands", "blur", "stroke", "ok"])
for r in rows:
wr.writerow([r["id"], r["kind"], r["radius"], r["softEdge"], r["bands"], r["blur"],
r["stroke"], str(r["ok"]).lower()])
gen_rounded_sample(tokens, cfg["radii"], os.path.join(GEN, "st12_rounded_sample.png"))
print(f"radii={cfg['radii']} softEdges={cfg['softEdges']} blurRadii={cfg['blurRadii']} strokeMax={cfg['strokeMax']}")
print(f"strokePermille={th['strokePermille']} detailPermille={th['detailPermille']} bandSteps={band_steps}")
print(f"blocks={len(cfg['blocks'])} violations={len(violations)} counts={counts}")
for v in violations:
print(f" {v['id']:16s} {v['code']}")
print(f"digest={summary['digest']}")
print("wrote out/st12_rounded.json out/st12_rounded.csv out/gen/st12_rounded_sample.png")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st12_rounded.py:
radii=[0, 4, 8, 12] softEdges=[2, 4, 6] blurRadii=[4, 8, 12] strokeMax=2
strokePermille=150 detailPermille=260 bandSteps=[1, 2]
blocks=6 violations=7 counts={'E_RADIUS_LADDER': 2, 'E_RADIUS_CAP': 1, 'E_SOFT_EDGE_LADDER': 1, 'E_BAND_STEP': 1, 'E_BLUR_LADDER': 1, 'E_STROKE_HARD': 1}
icon_badge E_RADIUS_LADDER
icon_badge E_RADIUS_CAP
card_quest E_RADIUS_LADDER
card_quest E_SOFT_EDGE_LADDER
card_quest E_BAND_STEP
panel_dialog E_BLUR_LADDER
panel_dialog E_STROKE_HARD
digest=7af0749ce0829e18
wrote out/st12_rounded.json out/st12_rounded.csv out/gen/st12_rounded_sample.png
读出来:cozy-clean 的风格参数是 strokePermille=150、detailPermille=260(低描边、低细节,与「干净」一致);六块资产里三块合规、三块各踩雷。7 条违例是故意留的,它们证明这道闸门真的会拦东西:
| 资产 | 违例 | 含义 |
|---|---|---|
icon_badge | E_RADIUS_LADDER + E_RADIUS_CAP | 圆角 16 不在阶梯 [0,4,8,12] 内,且超过 24 短边的一半 12 |
card_quest | E_RADIUS_LADDER + E_SOFT_EDGE_LADDER + E_BAND_STEP | 圆角 10 不在阶梯;柔边 5 不在阶梯;5 不能被 2 整除 |
panel_dialog | E_BLUR_LADDER + E_STROKE_HARD | 模糊 6 不在阶梯 [4,8,12];描边 4 超过上限 2 |
E_BAND_STEP 是本节最容易被忽略的一条:它不报「柔边不是整数」,而是报「柔边宽不能被带数整除」——整数在分成若干带之后不一定还是整数。合规块里 softEdge=4, bands=2 与 softEdge=6, bands=3 都给出整数步长,bandSteps=[1, 2] 正是这些合法步长的集合。
P 层:同一套判据的 TS 侧
同一套判据,TypeScript 侧独立实现一遍 core/src/st12/rounded.ts。它只读同一份配置,不 import Python 的任何东西:
// file: core/src/st12/rounded.ts
// tier: P
// verify: node --test 'core/test/st12_*.test.ts'
// 圆角与柔和过渡规范的 TS 侧校验器(第 12 章 12.2)。与 sim/st12_rounded.py 共用
// config/st12_rounded.json 与 config/sp16_themes.json;判据全是整数(圆角、柔边宽、过渡带数、
// 模糊半径、描边宽、千分比),故两侧违例集合与摘要逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。
export type Theme = { id: string; tokens: Record<string, number[]>; strokePermille: number; detailPermille: number };
export type Block = { id: string; kind: string; w: number; h: number; radius: number;
softEdge: number; bands: number; blur: number; stroke: number };
export type RoundedCfg = { unit: number; radii: number[]; softEdges: number[]; blurRadii: number[];
strokeMax: number; blocks: Block[] };
export type Violation = { id: string; code: string };
export type RoundedReport = { unit: number; radii: number[]; softEdges: number[]; blurRadii: number[];
strokeMax: number; strokePermille: number; detailPermille: number; blockCount: number;
bandSteps: number[]; violationCount: number; counts: Record<string, number>;
violations: Violation[]; canonical: string; digest: string };
export function auditBlock(b: Block, cfg: RoundedCfg): string[] {
const errs: string[] = [];
if (!cfg.radii.includes(b.radius)) errs.push("E_RADIUS_LADDER");
if (b.radius > Math.floor(Math.min(b.w, b.h) / 2)) errs.push("E_RADIUS_CAP");
if (!cfg.softEdges.includes(b.softEdge)) errs.push("E_SOFT_EDGE_LADDER");
if (b.softEdge % b.bands !== 0) errs.push("E_BAND_STEP");
if (!cfg.blurRadii.includes(b.blur)) errs.push("E_BLUR_LADDER");
if (b.stroke > cfg.strokeMax) errs.push("E_STROKE_HARD");
return errs;
}
export function auditRounded(cfg: RoundedCfg, themes: { themes: Theme[] }): RoundedReport {
const th = themes.themes.find((t) => t.id === "cozy-clean");
if (!th) throw new Error("themes 缺 cozy-clean 实例");
const violations: Violation[] = [];
for (const b of cfg.blocks) for (const c of auditBlock(b, cfg)) violations.push({ id: b.id, code: c });
const counts: Record<string, number> = {};
for (const v of violations) counts[v.code] = (counts[v.code] ?? 0) + 1;
const bandSteps = [...new Set(cfg.blocks.filter((b) => b.softEdge % b.bands === 0)
.map((b) => Math.floor(b.softEdge / b.bands)))].sort((a, b) => a - b);
const report: RoundedReport = {
unit: cfg.unit, radii: cfg.radii, softEdges: cfg.softEdges, blurRadii: cfg.blurRadii,
strokeMax: cfg.strokeMax, strokePermille: th.strokePermille, detailPermille: th.detailPermille,
blockCount: cfg.blocks.length, bandSteps, violationCount: violations.length, counts, violations,
canonical: "", digest: "",
};
report.canonical = canonical(report);
return report;
}
/** canonical 摘要:键序固定,与 Python 侧同序、无空白,供 sha256 对拍。 */
export function canonical(r: RoundedReport): string {
return JSON.stringify({ blockCount: r.blockCount, violationCount: r.violationCount,
radiusLadder: r.radii, softEdges: r.softEdges, strokePermille: r.strokePermille });
}
node --test 'core/test/st12_*.test.ts'(本节 4 条,三节合计 12 条)真实输出:
rounded digest = 7af0749ce0829e18
✔ 正例:圆角/柔边/模糊取阶梯值、过渡带整除、描边不超上限、风格参数正确 (0.828375ms)
✔ 跨语言:违例集合、计数与摘要逐位一致(整数类) (0.795875ms)
✔ 负例:圆角越界/不在阶梯、柔边不在阶梯、过渡带不整除、模糊不在阶梯、粗描边各自可触发 (0.134833ms)
✔ 地板除锁死:过渡带步长必须整数,短边取半用整数地板除 (0.078542ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0
两条跨语言断言把 TS 复算的违例集合、计数与 canonical 摘要逐位对上 Python 落盘的 out/st12_rounded.json,sha256 前 16 位都是 7af0749ce0829e18——圆角、柔边、带数、模糊、描边全是整数类,逐位一致。第三条是负例:五类违例各自可被单独触发;第四条验证 Math.floor 与 Python // 在「短边取半」上逐位一致(25 短边的块圆角 12 放行、13 被拦)。
自检表
| 检查项 | 合格标准 |
|---|---|
| 圆角受约束 | 圆角在阶梯内、不超块短边 1/2 |
| 柔边取阶梯 | 柔边宽是 unit 的倍数,取阶梯值 |
| 过渡带整除 | softEdge % bands == 0,每带等宽整数像素 |
| 描边不硬 | 柔和风格 strokeWidth ≤ 2,超则判 E_STROKE_HARD |
| 账可复算 | 违例集合与摘要跨语言逐位一致 |
| 边界清晰 | 真实抗锯齿与模糊观感标 X,不写成「实测」 |
颜色与形状都定好之后,真正的敌人是时间:同一份规范铺到几十个场景,总会有几个场景的 token 悄悄偏了一两个通道。下一节把「一致性维护」也变成一笔可断言的整数账。
阅读导航:上一节:12.1 高明度低饱和调色板 · 下一节:12.3 一致性维护 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。