站内 《数据可视化中的无障碍与色彩》 讲的是图表口径下的对比度与色盲安全——它回答「一张图怎么让更多人看清」。本节把它换成游戏品类口径:同一份调色板,在「小屏 + 碎片时间 + 12 个同屏元素」的休闲条件下和在「大屏 + 专注 2 秒 + 40 个元素」的重度条件下,可读性的判定完全不同。对比度用与第一卷 4.3 色盲安全与对比度下限 同源的 WCAG ×10 整数口径,细节密度用 11.2 细节密度与视觉噪声 的千分比,不另造公式。
14.2 品类与视觉可读性
14.2.1 可读性是「调色板 × 观看条件」
「这套配色可读吗」是一个不完整的问题。完整的问法是:在什么条件下看。同样一组前景色与背景色,站在 55 寸电视前看三秒,和在地铁上单手举着手机瞥 0.8 秒,可读性不是一个量级。四个品类恰好对应四种典型的观看条件:
| 观看条件 | 休闲 | 中度 | 重度 | 独立 |
|---|---|---|---|---|
| 屏幕 | 1080×1920 竖屏 | 1440×2560 竖屏 | 1920×1080 横屏 | 1920×1080 横屏 |
| 单次注视时长 | 800 ms | 1200 ms | 2000 ms | 1500 ms |
| 同屏信息元素 | 12 | 24 | 40 | 20 |
| 每元素可用像素 | 172800 | 153600 | 51840 | 103680 |
最后一行是派生量:屏幕宽 × 高 // 同屏元素数(整数地板除)。它把「屏幕多大」和「要挤多少东西」压成一个数。注视越短、每元素可用像素越少,门槛就必须越严——这不是审美偏好,而是把观看条件翻译成阈值参数的必然结果。
14.2.2 D 层:四道门槛与四个品类
下面这份配置把观看条件与门槛写成数据。四道门槛各守一个可读性方向,全部用整数口径:对比度下限 ×10、剪影明度分离下限 ‰、细节密度上限 ‰、描边千分比下限。
{
"project": "stardust-inn",
"chapter": 14,
"section": "14.2",
"specRef": "config/sp16_themes.json",
"note": "品类与视觉可读性。四个品类各给一组「观看条件」(屏幕、单次注视时长、同屏信息元素数),并据此给四道可读性门槛:对比度下限×10(与卷一 4.3 同一套 WCAG 整数口径)、剪影明度分离下限‰、细节密度上限‰、描边千分比下限。四个门槛值本身是本套构造的示例参数(真实观看行为需真实用户与真实设备,命令行不可复现,标 X)。被判定对象是只读真源 config/sp16_themes.json 的 6 个主题包。elementBudgetPx = 屏幕宽 × 高 // 同屏元素数是派生量,整数。",
"caliber": {
"contrastSource": "art-spec-04-3-colorblind-safe-contrast-floor",
"detailSource": "art-spec-11-2-detail-density-visual-noise"
},
"axes": ["contrast10", "silhouettePermille", "detailPermille", "strokePermille"],
"genres": [
{ "id": "casual", "cn": "休闲", "screenW": 1080, "screenH": 1920, "attentionMs": 800, "maxElements": 12,
"contrastFloor10": 45, "silhouetteFloorPermille": 700, "detailCeilPermille": 640, "strokeFloorPermille": 250 },
{ "id": "mid-core", "cn": "中度", "screenW": 1440, "screenH": 2560, "attentionMs": 1200, "maxElements": 24,
"contrastFloor10": 40, "silhouetteFloorPermille": 600, "detailCeilPermille": 760, "strokeFloorPermille": 180 },
{ "id": "hard-core", "cn": "重度", "screenW": 1920, "screenH": 1080, "attentionMs": 2000, "maxElements": 40,
"contrastFloor10": 35, "silhouetteFloorPermille": 450, "detailCeilPermille": 900, "strokeFloorPermille": 100 },
{ "id": "indie", "cn": "独立", "screenW": 1920, "screenH": 1080, "attentionMs": 1500, "maxElements": 20,
"contrastFloor10": 42, "silhouetteFloorPermille": 650, "detailCeilPermille": 700, "strokeFloorPermille": 200 }
]
}
四道门槛的方向不同,混用会算错:
- 对比度下限 ×10(越高越严):
ink/bg这类前景背景对的 WCAG 对比度,低于下限就糊成一片。 - 剪影明度分离下限 ‰:主体与背景的
luma709整数差 ×1000 // 255。对比度是「比值」,剪影分离是「差值」,两者不是一回事——深底白字和深底中灰字对比度可能都够,但剪影分离差一倍。 - 细节密度上限 ‰(越低越严):单位面积上的细节量,休闲品类必须压低,否则小屏上一片噪声。
- 描边千分比下限 ‰(越高越严):描边宽度相对资产的千分比。小屏 + 短注视下,细描边会直接消失,所以休闲的门槛反而最高。
14.2.3 S 层:6 主题 × 4 品类的 24 格判定
下面这段是 S 层脚本,对只读真源 sp16_themes.json 的 6 个主题包各测四个量,再逐格与四道门槛比。
#!/usr/bin/env python3
# file: sim/st14_readability.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st14_readability.py
"""14.2 品类与视觉可读性(S 层,卷三第 14 章)。
读 config/st14_readability.json(四个品类的观看条件与四道可读性门槛)与只读真源
config/sp16_themes.json(6 个主题包的 tokens 与 stroke/detail 千分比),做两件事:
1. **把观看条件折成一个整数**:``elementBudgetPx = screenW * screenH // maxElements``
(同屏每个信息元素平均可用多少像素),并断言「注视越短、元素越多 → 门槛越严」的单调关系;
2. **6 主题 × 4 品类 = 24 格判定**:每个主题测四个量(对比度下限×10、剪影明度分离‰、
细节密度‰、描边千分比),逐格与门槛比,输出通过矩阵与违例清单。
对比度用与卷一 4.3 同源的 WCAG ×10 整数口径(sRGB→linear 常数 0.04045/12.92、2.4/1.055/0.055),
剪影分离用 luma709 的整数差 ×1000 // 255。整数类逐位一致;门槛值是示例参数(X)。
产出 out/st14_readability.json + out/st14_readability.csv。
"""
import csv
import hashlib
import json
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CFG = os.path.join(ROOT, "config/st14_readability.json")
THEMES = os.path.join(ROOT, "config/sp16_themes.json")
SPEC = os.path.join(ROOT, "config/visual_spec.json")
OUT = os.path.join(ROOT, "out")
def lin(c):
s = c / 255
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
def contrast10(a, b):
"""WCAG 对比度 ×10 取整 → 整数类,与 sp00/spec.ts 的 contrast10 同源。"""
f = lambda c: 0.2126 * lin(c[0]) + 0.7152 * lin(c[1]) + 0.0722 * lin(c[2])
hi, lo = sorted((f(a), f(b)), reverse=True)
return int(((hi + 0.05) / (lo + 0.05)) * 10 + 0.5)
def luma709(c):
return (2126 * c[0] + 7152 * c[1] + 722 * c[2]) // 10000
def theme_metrics(tokens, contrast_pairs, theme):
"""一个主题包的四个可读性量(全整数)。"""
min_contrast = min(contrast10(tokens[a], tokens[b]) for a, b in contrast_pairs)
sep = abs(luma709(tokens["ink"]) - luma709(tokens["bg"])) * 1000 // 255
return {
"contrast10": min_contrast,
"silhouettePermille": sep,
"detailPermille": int(theme["detailPermille"]),
"strokePermille": int(theme["strokePermille"]),
}
def judge(m, g):
"""四道门槛逐条判:越界即违例,违例量 = 差值的整数绝对值。"""
return [
{"axis": "contrast10", "value": m["contrast10"], "floor": g["contrastFloor10"],
"pass": m["contrast10"] >= g["contrastFloor10"], "over": g["contrastFloor10"] - m["contrast10"]},
{"axis": "silhouettePermille", "value": m["silhouettePermille"], "floor": g["silhouetteFloorPermille"],
"pass": m["silhouettePermille"] >= g["silhouetteFloorPermille"],
"over": g["silhouetteFloorPermille"] - m["silhouettePermille"]},
{"axis": "detailPermille", "value": m["detailPermille"], "floor": g["detailCeilPermille"],
"pass": m["detailPermille"] <= g["detailCeilPermille"], "over": m["detailPermille"] - g["detailCeilPermille"]},
{"axis": "strokePermille", "value": m["strokePermille"], "floor": g["strokeFloorPermille"],
"pass": m["strokePermille"] >= g["strokeFloorPermille"], "over": g["strokeFloorPermille"] - m["strokePermille"]},
]
def main() -> int:
cfg = json.load(open(CFG, encoding="utf-8"))
themes = json.load(open(THEMES, encoding="utf-8"))
spec = json.load(open(SPEC, encoding="utf-8"))
pairs = spec["contrastPairs"]
genres = []
for g in cfg["genres"]:
genres.append({**g, "elementBudgetPx": g["screenW"] * g["screenH"] // g["maxElements"]})
cells, violations = [], []
for t in themes["themes"]:
m = theme_metrics(t["tokens"], pairs, t)
row = {"theme": t["id"], "metrics": m, "genres": {}}
for g in genres:
checks = judge(m, g)
ok = all(c["pass"] for c in checks)
row["genres"][g["id"]] = {"pass": ok,
"failAxes": [c["axis"] for c in checks if not c["pass"]]}
if not ok:
for c in checks:
if not c["pass"]:
violations.append({"theme": t["id"], "genre": g["id"], **c})
cells.append(row)
lines = [f'{c["theme"]}:' + ",".join(
f'{g}:{"1" if c["genres"][g]["pass"] else "0"}' for g in [x["id"] for x in genres])
for c in cells]
digest = hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()[:16]
report = {
"project": cfg["project"], "section": cfg["section"], "axes": cfg["axes"],
"themeOrder": [t["id"] for t in themes["themes"]],
"genreOrder": [g["id"] for g in genres],
"genres": genres, "cells": cells,
"passCount": sum(1 for c in cells for g in genres if c["genres"][g["id"]]["pass"]),
"cellCount": len(cells) * len(genres), "violations": violations, "digest": digest,
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "st14_readability.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "st14_readability.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["theme", "contrast10", "silhouettePermille", "detailPermille", "strokePermille"]
+ [f'{g["id"]}_pass' for g in genres])
for c in cells:
m = c["metrics"]
w.writerow([c["theme"], m["contrast10"], m["silhouettePermille"], m["detailPermille"],
m["strokePermille"]] + [1 if c["genres"][g["id"]]["pass"] else 0 for g in genres])
print(f'axes={cfg["axes"]} cells={report["cellCount"]} pass={report["passCount"]}')
for g in genres:
print(f' {g["id"]:<10} screen={g["screenW"]}x{g["screenH"]} attnMs={g["attentionMs"]} '
f'elems={g["maxElements"]} elemBudgetPx={g["elementBudgetPx"]}')
for c in cells:
m = c["metrics"]
marks = " ".join(f'{g["id"]}:{"P" if c["genres"][g["id"]]["pass"] else "F"}'
f'({",".join(c["genres"][g["id"]]["failAxes"]) or "-"})' for g in genres)
print(f' {c["theme"]:<12} c10={m["contrast10"]:>3} sep={m["silhouettePermille"]:>3}‰ '
f'det={m["detailPermille"]:>3}‰ strk={m["strokePermille"]:>3}‰ {marks}')
print(f'digest={digest}')
print("wrote out/st14_readability.json out/st14_readability.csv")
return 0
if __name__ == "__main__":
sys.exit(main())
14.2.4 实跑输出
本机实跑 /opt/homebrew/bin/python3 sim/st14_readability.py:
axes=['contrast10', 'silhouettePermille', 'detailPermille', 'strokePermille'] cells=24 pass=14
casual screen=1080x1920 attnMs=800 elems=12 elemBudgetPx=172800
mid-core screen=1440x2560 attnMs=1200 elems=24 elemBudgetPx=153600
hard-core screen=1920x1080 attnMs=2000 elems=40 elemBudgetPx=51840
indie screen=1920x1080 attnMs=1500 elems=20 elemBudgetPx=103680
pixel c10= 45 sep=827‰ det=780‰ strk=620‰ casual:F(detailPermille) mid-core:F(detailPermille) hard-core:P(-) indie:F(detailPermille)
lowpoly c10= 51 sep=690‰ det=210‰ strk=120‰ casual:F(silhouettePermille,strokePermille) mid-core:F(strokePermille) hard-core:P(-) indie:F(strokePermille)
handdrawn c10= 46 sep=725‰ det=520‰ strk=480‰ casual:P(-) mid-core:P(-) hard-core:P(-) indie:P(-)
voxel c10= 48 sep=733‰ det=640‰ strk=300‰ casual:P(-) mid-core:P(-) hard-core:P(-) indie:P(-)
retro-cyber c10= 55 sep=827‰ det=700‰ strk=540‰ casual:F(detailPermille) mid-core:P(-) hard-core:P(-) indie:P(-)
cozy-clean c10= 46 sep=623‰ det=260‰ strk=150‰ casual:F(silhouettePermille,strokePermille) mid-core:F(strokePermille) hard-core:P(-) indie:F(silhouettePermille,strokePermille)
digest=ab1beb843e8cc196
wrote out/st14_readability.json out/st14_readability.csv
14.2.5 读矩阵:同一份调色板,换个品类就变脸
把 24 格摊成一张通过矩阵(P 通过 / F 不通过):
| 主题包 | 休闲 | 中度 | 重度 | 独立 | 主要卡在哪 |
|---|---|---|---|---|---|
pixel | F | F | P | F | 细节 780‰ 超上限 |
lowpoly | F | F | P | F | 剪影分离 690‰、描边 120‰ 偏低 |
handdrawn | P | P | P | P | 四道全过 |
voxel | P | P | P | P | 四道全过 |
retro-cyber | F | P | P | P | 细节 700‰ 超休闲上限 |
cozy-clean | F | F | P | F | 剪影 623‰、描边 150‰ 双双不足 |
| 通过数 | 2 | 3 | 6 | 2 | 合计 14 / 24 |
四条结论:
- 同一份调色板,在重度下 6/6 全过,在休闲下只有 2/6 过关。 变的是门槛,不是调色板。
cozy-clean在重度下四道全过,在休闲下剪影与描边同时不足——因为休闲的门槛把剪影分离抬到700‰、描边抬到250‰,而它只有623‰与150‰。 - 卡点集中在两处:细节上限与描边下限。 六行里
detailPermille违例 3 次、strokePermille违例 3 次、silhouettePermille违例 2 次、contrast10一次都没违例(六主题的对比度最低是45,全部 ≥ 最严门槛45)。这说明「对比度够」是最容易满足的一道,真正区分品类的是细节与描边。 - 对比度全过不等于配色没问题。 六主题的
contrast10分布在45~55这个很窄的区间里——WCAG 对比度只由相对亮度决定、与色相无关(第一卷 4.2 已实测过这个机制)。所以对比度合格 ≠ 配色好看 ≠ 色相有区分度,本节只判「看得清」,不判「好不好看」。 - 独立档比中度更严,不是笔误。 独立档的注视时长(1500 ms)比中度短、每元素可用像素(103680)也比中度少,所以它的剪影与描边门槛定得比中度更接近休闲。独立不是「门槛更低」,而是「条件更差但玩家更专注」——两股力方向相反,净结果是中间偏严。
14.2.6 边界
- 门槛值是示例参数。观看行为、屏幕尺寸、注意力时长都是本套构造的,真实的可读性评估需真实用户与真实设备(眼动仪 / 用户测试),命令行不可复现(X)。本节演示的是「把观看条件折成整数门槛」的算法,不是一份可移植的门槛表。
- 四道门槛是充分条件里的必要条件。四道全过不代表一定好读(还有版式、动效、背景干扰),但任何一道不过一定不好读。用它拦人,不要用它发奖。
- 描边与细节用
sp16_themes.json的既有参数,与卷一 11.2、卷二 7.1 同源;换一套主题参数整张矩阵会变,判据不变。 - 不做「提升 N 倍」的断言。2/6 与 6/6 是同机、同口径下的相对读数,绝对数值不可移植。
14.2.7 自检表
| 检查项 | 合格标准 |
|---|---|
| 口径同源 | 对比度用卷一 4.3 的 WCAG ×10,细节用卷一 11.2 的千分比 |
| 整数类 | 对比度×10、分离‰、细节‰、描边‰、元素像素全整数 |
| 判定可复算 | 6×4=24 格逐格判定,违例轴与违例量都落 JSON |
| 门槛不混用 | 比值(对比度)与差值(剪影)分开,上限与下限分开 |
| 边界诚实 | 观看条件与门槛标 X(示例参数),不写成「用户实测」 |
品类接上预算(14.1)与可读性(14.2)之后,还剩最后一块:同一批资产换品类时,哪些能复用、哪些必须重做。下一节把跨品类移植写成复用层级与改造人天的整数账。
阅读导航:上一节:14.1 品类与美术预算的关系 · 下一节:14.3 跨品类复用与移植 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。