13.2 发光与溢出规范
上一节 13.1 霓虹调色板与色彩分级
定下 retro-cyber 的 8 个角色色与霓虹下限;本节处理「发光」本身:光晕画多大、强度叠到多少会溢出、图集要为光晕留多少 padding。
发光要真画出来,靠的是 bloom 后处理与真渲染,那是 后处理管线
的主场,本书不重推它怎么采样、怎么降采样再模糊。本节只做参数与账:把「发光」拆成 radius(光晕半径,px)与 intensityPermille(强度千分比)两个整数参数,再算三层整数账——层叠加与截断、溢出千分比与最大半径、图集 padding 后的槽位与装箱率。所有数字来自 config/st13_glow.json 与 sp16_themes.json 的 retro-cyber 取值。
发光层:叠加、截断与不可逆丢失
霓虹招牌的光晕不是一层,是多层:外晕铺开、中晕加深、内晕收边、高光提亮。四层参数如下表,强度以千分比表示。
| 层 | radius(px) | intensity‰ |
|---|---|---|
| 外晕 | 6 | 400 |
| 中晕 | 3 | 300 |
| 内晕 | 1 | 300 |
| 高光 | 1 | 250 |
四层强度相加是 1250‰,超过 1000‰(即 1.0,不透明上限)的部分被 min(1000, acc) 截断,丢失 250‰。这不是可以「调回来」的误差——被 clamp 掉的信息在后续任何环节都无法恢复。所以发光层数的第一约束不是好看,而是累计强度不许越 1000‰。
下面这段是 S 层脚本,读 config/st13_glow.json,算光晕层累计 / 截断 / 丢失、溢出千分比与最大半径、图集 padding 槽位,并统计哪些发光 token 亮度达到阈值。
#!/usr/bin/env python3
# file: sim/st13_glow.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/st13_glow.py
"""13.2 发光与溢出规范(S 层,复古 / 赛博)。
读 config/st13_glow.json 与 sp16_themes.json,算三笔整数账:
① 发光层账:多层光晕(radius + intensityPermille)逐层叠加,累计强度按 1000‰ 截断;
超出部分被 clamp 掉,是**不可逆的信息丢失**(lostPermille)。
② 溢出账:光晕外扩环面积 `(cell + 2r)² − cell²` 占 sprite cell 面积 `cell²` 的千分比,
必须 ≤ 500‰。由此反解**允许的最大半径** rMax(逐个试,纯整数比较)。
③ 图集 padding 账:光晕要画到 sprite 外,图集每个 cell 必须四周 padding r,于是
`cellPadded = cell + 2r`,槽位从 16×16=256 掉到 13×13=169,装箱率(千分比)同步变化。
发光要真画出来需要 bloom 后处理与真渲染 —— 那部分外链 graphics-post-processing,
本节只做参数与账。全部整数地板除,跨语言逐位一致。落 out/st13_glow.json。
"""
import hashlib
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def luma255(rgb):
return (2126 * rgb[0] + 7152 * rgb[1] + 722 * rgb[2]) // 10000
def luma_permille(rgb):
return luma255(rgb) * 1000 // 255
def spill_permille(cell, radius):
"""光晕外扩环面积占 cell 面积的千分比,整数地板除。"""
padded = cell + 2 * radius
return (padded * padded - cell * cell) * 1000 // (cell * cell)
def max_radius(cell, ceil_permille):
"""满足溢出上限的最大整数半径(逐个试,纯整数比较,确定性)。"""
r = 0
while spill_permille(cell, r + 1) <= ceil_permille:
r += 1
return r
def glow_slots(w, h, cell, radius):
"""按 padding 后的 cell 计图集槽位:整数地板除。"""
padded = cell + 2 * radius
return (w // padded) * (h // padded)
def pack_permille(icons, cell, w, h):
"""图集装箱千分比:icons × cell² × 1000 // (W × H)。"""
return icons * cell * cell * 1000 // (w * h)
def glow_layers(layers):
"""多层光晕累计强度:求和后按 1000‰ 截断 → (累计, 截断后, 丢失)。"""
acc = sum(layer["intensityPermille"] for layer in layers)
clamped = min(1000, acc)
return acc, clamped, acc - clamped
def main():
cfg = json.load(open(os.path.join(ROOT, "config/st13_glow.json"), encoding="utf-8"))
themes = json.load(open(os.path.join(ROOT, "config/sp16_themes.json"), encoding="utf-8"))
by_id = {t["id"]: t for t in themes["themes"]}
tt, ct = by_id[cfg["theme"]], by_id[cfg["compareTheme"]]
acc, clamped, lost = glow_layers(cfg["layers"])
radius = max(layer["radius"] for layer in cfg["layers"])
diameter = 2 * radius + 1
cell = cfg["spriteCell"]
over = spill_permille(cell, radius)
rmax = max_radius(cell, cfg["overflowCeilPermille"])
a = cfg["atlas"]
slots_raw = (a["width"] // a["cell"]) * (a["height"] // a["cell"])
slots_pad = glow_slots(a["width"], a["height"], a["cell"], radius)
pm_raw = pack_permille(a["icons"], a["cell"], a["width"], a["height"])
pm_pad = pack_permille(a["icons"], a["cell"] + 2 * radius, a["width"], a["height"])
qualify = [n for n in cfg["emissive"] if luma_permille(tt["tokens"][n]) >= cfg["thresholdLumaPermille"]]
c_qualify = [n for n in cfg["emissive"] if luma_permille(ct["tokens"][n]) >= cfg["thresholdLumaPermille"]]
c_radius = cfg["compareGlowRadius"]
canon = json.dumps({
"accumulatedPermille": acc, "clampedPermille": clamped, "lostPermille": lost,
"radius": radius, "diameter": diameter, "overflowPermille": over, "maxRadius": rmax,
"slotsRaw": slots_raw, "slotsPadded": slots_pad,
"permilleRaw": pm_raw, "permillePadded": pm_pad,
"qualify": qualify, "cmpQualify": c_qualify,
}, separators=(",", ":"))
report = {
"project": cfg["project"], "theme": cfg["theme"], "compareTheme": cfg["compareTheme"],
"thresholdLumaPermille": cfg["thresholdLumaPermille"], "overflowCeilPermille": cfg["overflowCeilPermille"],
"layers": cfg["layers"], "accumulatedPermille": acc, "clampedPermille": clamped,
"lostPermille": lost, "radius": radius, "diameter": diameter,
"overflowPermille": over, "maxRadius": rmax,
"atlas": {"slotsRaw": slots_raw, "slotsPadded": slots_pad,
"permilleRaw": pm_raw, "permillePadded": pm_pad},
"qualify": qualify, "qualifyCount": len(qualify),
"compare": {"radius": c_radius, "overflowPermille": spill_permille(cell, c_radius),
"slotsPadded": glow_slots(a["width"], a["height"], a["cell"], c_radius),
"qualify": c_qualify},
"canonical": canon, "digest": hashlib.sha256(canon.encode("utf-8")).hexdigest()[:16],
}
out = os.path.join(ROOT, "out")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, "st13_glow.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"theme={cfg['theme']} layers={len(cfg['layers'])}")
print(f"accumulated={acc} clamped={clamped} lost={lost} radius={radius} diameter={diameter}")
print(f"overflowPermille={over} ceil={cfg['overflowCeilPermille']} maxRadius={rmax}")
print(f"atlas slots {slots_raw} -> {slots_pad} permille {pm_raw} -> {pm_pad}")
print(f"qualify={qualify} ({len(qualify)})")
print(f"compare radius={c_radius} overflow={spill_permille(cell, c_radius)} slotsPadded={glow_slots(a['width'], a['height'], a['cell'], c_radius)}")
print(f"digest={report['digest']}")
print("wrote out/st13_glow.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/st13_glow.py:
theme=retro-cyber layers=4
accumulated=1250 clamped=1000 lost=250 radius=6 diameter=13
overflowPermille=410 ceil=500 maxRadius=7
atlas slots 256 -> 169 permille 144 -> 203
qualify=['ink', 'accent', 'ok', 'warn', 'danger'] (5)
compare radius=0 overflow=0 slotsPadded=256
digest=5b94aa50c1c5b597
wrote out/st13_glow.json
读出来:4 层累计 1250‰、截断后 1000‰、丢失 250‰;发光层里最大半径 6px,光晕直径 13px。发光资格一栏 qualify 有 5 个 token——ink / accent / ok / warn / danger 亮度全部 ≥ 400‰ 阈值,所以它们都要走光晕;muted(627‰)不在 emissive 集合里,不参与。对照主题 cozy-clean 的发光半径是 0,溢出 0‰、槽位仍是 256——治愈系不发光。
溢出与图集 padding
「溢出」指光晕画到 sprite 矩形之外的那一圈。外扩半径 r 后,环面积是 (cell + 2r)² − cell²,占原 cell 面积 cell² 的千分比必须 ≤ 500‰(上限可配)。retro-cyber 的 r = 6、cell = 64,环面积 76² − 64² = 1680,1680 × 1000 // 4096 = 410‰,低于 500‰ 上限。脚本再逐个试反解最大允许半径:r = 7 时是 457‰(仍 ≤ 500‰),r = 8 时是 507‰(越界),故 rMax = 7。
图集侧的成本更实在:光晕要画到 sprite 外,图集每个 cell 必须四周 padding r,cellPadded = 64 + 12 = 76。1024×1024 的图集槽位从 16×16 = 256 掉到 13×13 = 169,37 个图标的装箱率(千分比)从 144‰ 升到 203‰——光晕把图集撑大了四成。这就是「发光」在资产侧的整数代价:半径每加 1,都要重排图集。
下面这段是 P 层 TypeScript 内核 core/src/st13/glow.ts,与 S 脚本逐条对应:溢出千分比、最大半径、padding 槽位、层叠加截断、装箱千分比。
// file: core/src/st13/glow.ts
// tier: P
// verify: node --test 'core/test/st13_*.test.ts'
// 13.2 发光与溢出规范的 TS 内核:光晕层叠加与截断、溢出千分比与最大半径、图集 padding 槽位。
// 全部整数地板除,跨语言逐位一致。只允许可擦除 TS 语法。
export type Layer = { radius: number; intensityPermille: number };
/** 光晕外扩环面积占 cell 面积的千分比,整数地板除。 */
export function spillPermille(cell: number, radius: number): number {
const padded = cell + 2 * radius;
const spill = padded * padded - cell * cell;
return Math.floor((spill * 1000) / (cell * cell));
}
/** 满足溢出上限的最大整数半径(逐个试,纯整数比较,确定性)。 */
export function maxRadius(cell: number, ceilPermille: number): number {
let r = 0;
while (spillPermille(cell, r + 1) <= ceilPermille) r += 1;
return r;
}
/** 按 padding 后的 cell 计图集槽位:整数地板除。 */
export function glowSlots(w: number, h: number, cell: number, radius: number): number {
const padded = cell + 2 * radius;
return Math.floor(w / padded) * Math.floor(h / padded);
}
/** 多层光晕累计强度:求和后按 1000‰ 截断,返回 [累计, 截断后, 丢失]。 */
export function glowLayers(layers: Layer[]): number[] {
const acc = layers.reduce((s, l) => s + l.intensityPermille, 0);
const clamped = Math.min(1000, acc);
return [acc, clamped, acc - clamped];
}
/** 图集装箱千分比:icons × cell² × 1000 // (W × H)。 */
export function packPermille(icons: number, cell: number, w: number, h: number): number {
return Math.floor((icons * cell * cell * 1000) / (w * h));
}
配套测试 core/test/st13_glow.test.ts 读 Python 落盘的 out/st13_glow.json,跑 node --test 'core/test/st13_*.test.ts':
✔ 溢出千分比与最大半径:纯整数,与 Python 逐位一致 (0.487958ms)
✔ 光晕层叠加与截断:与 Python 逐位一致 (0.406209ms)
✔ 图集 padding 槽位与装箱千分比:与 Python 逐位一致 (0.089209ms)
✔ 发光资格:emissive 中亮度达阈值的 token 与 Python 逐位一致 (0.10325ms)
✔ 跨语言:canonical 摘要与 digest 完全一致 (0.353209ms)
✔ 负例:半径超过上限时溢出千分比必然越界 (0.081292ms)
ℹ tests 6
ℹ pass 6
ℹ fail 0
六条全绿,digest 为 5b94aa50c1c5b597。负例守住「半径上限」这条线:rMax 处溢出 457‰ ≤ 500‰,rMax + 1 处 507‰ > 500‰,逐个试出来的最大半径不是拍脑袋。
自检表
| 检查项 | 合格标准 |
|---|---|
| 强度不越界 | 累计 ≤ 1000‰,超出即报丢失量 |
| 溢出有上限 | 环面积千分比 ≤ 500‰,反解 rMax |
| 图集留 padding | cellPadded = cell + 2r,槽位与装箱率同步重算 |
| 边界清晰 | 真实 bloom 实现外链 graphics/,不写成「实测」 |
发光参数与账都定下后,最后一节把主题切换做成可跑的看板:切 retro-cyber ↔ cozy-clean,断言调色板真的变。
阅读导航:上一节:13.1 霓虹调色板与色彩分级 · 下一节:13.3 主题切换与变体管理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。