14.2 品类与视觉可读性

可读性不是调色板的绝对属性,而是调色板与观看条件的匹配。本节把四品类的观看条件(屏幕、单次注视时长、同屏元素数)折成四道整数门槛,对 6 个主题包做 6×4=24 格判定:休闲只过 2 格、中度 3 格、独立 2 格、重度 6 格——同一份调色板,换个品类判定就不同。

站内 《数据可视化中的无障碍与色彩》 讲的是图表口径下的对比度与色盲安全——它回答「一张图怎么让更多人看清」。本节把它换成游戏品类口径:同一份调色板,在「小屏 + 碎片时间 + 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 ms1200 ms2000 ms1500 ms
同屏信息元素12244020
每元素可用像素17280015360051840103680

最后一行是派生量:屏幕宽 × 高 // 同屏元素数(整数地板除)。它把「屏幕多大」和「要挤多少东西」压成一个数。注视越短、每元素可用像素越少,门槛就必须越严——这不是审美偏好,而是把观看条件翻译成阈值参数的必然结果。

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 不通过):

主题包休闲中度重度独立主要卡在哪
pixelFFPF细节 780‰ 超上限
lowpolyFFPF剪影分离 690‰、描边 120‰ 偏低
handdrawnPPPP四道全过
voxelPPPP四道全过
retro-cyberFPPP细节 700‰ 超休闲上限
cozy-cleanFFPF剪影 623‰、描边 150‰ 双双不足
通过数2362合计 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 跨品类复用与移植 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

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