卷 IV · 色· CH 13· OKLCH vs HSL 深度 13/28

HSL 骗了你十年

颜色是全书唯一一处「凭感觉」会必然出错的地方——不是因为它难,是因为你手上那把尺子本身是歪的。这一章先把尺子换掉。

L 不是亮度色域是个纺锤灰色不必是灰的

一个能当场证伪的例子

你想做一套「亮度一致」的品牌色。你知道 hex 不好调,于是听了那句很常见的建议——改用 HSL——把 L 全部设成 50%,只改 H。

结果是这样的:

你写的得到相对亮度L*(真感知亮度)白底对比度
hsl(60 100% 50%)#FFFF00 黄0.927897.11.07:1
hsl(120 100% 50%)#00FF00 绿0.715287.71.37:1
hsl(0 100% 50%)#FF0000 红0.212653.24.00:1
hsl(240 100% 50%)#0000FF 蓝0.072232.38.59:1

同一个「50% 亮度」,白底对比度从 1.078.59——差 8 倍。黄色那一行在白底上几乎是隐形的。

HSL 的 L 不是亮度

它是 (max(R,G,B) + min(R,G,B)) / 2——一个纯粹的几何量,和人眼怎么感知完全无关

人眼对绿光的敏感度远高于蓝光:同样的能量,绿色看起来比蓝色亮十倍。所以任何不给三个通道加权的「亮度」,都是假的。

那三个权重是 0.2126 / 0.7152 / 0.0722(R / G / B),来自 CIE 的实验测定,也是第 3 章那台模糊引擎和第 14 章那个对比度公式共用的同一组数。

《Refactoring UI》说对了方向,但那把尺还是歪的

「扔掉 hex,改用 HSL」这条建议来自《Refactoring UI》,它在 2018 年是完全正确的方向——hex 确实没法调,而 HSL 至少让你能独立地动色相、饱和度、明度。

但它停早了一步。HSL 解决了「可调」,没解决「可比」。你能方便地把 L 从 50% 调到 60%,可你没法比较两个不同色相的颜色谁更亮——而「谁更亮」恰恰是对比度、层级、无障碍全部依赖的那个量。

现在有更好的选择了:OKLCH(Björn Ottosson,2020,现已进 CSS Color 4)。它的三个分量是:

L感知亮度,0 到 1,说了算的那个
C彩度,0 是灰,越大越艳
H色相角,0 到 360

切换下面这台引擎的两个模式,看下排那些柱子(每个色相的真实感知亮度):

HSL 模式下,柱子是波浪形的——你明明把 L 全设成了同一个值。OKLCH 模式下,柱子齐平。

色域是个纺锤,不是球

切到 OKLCH 模式还能看到第二件事:色带的鲜艳程度不均匀。黄和青那一段明显发灰。

这不是引擎的 bug,是 sRGB 的事实。看 Demo 下面那张彩度天花板表:

每个色相能塞下多少彩度,是不一样的,而且顶点不在同一个亮度上
  • (265°)在 L=0.45 最艳,能到 0.309
  • 绿(145°)要到 L=0.87 才最艳,0.271
  • (205°)怎么都上不去,顶点只有 0.147——不到蓝的一半。

而且在 L=0.95 这种很亮的地方,红色的彩度上限只剩 0.025——比它顶点时的 0.255 掉了十倍

《Refactoring UI》里有一节叫「Don't let lightness kill your saturation」,说的是:越把颜色调亮或调暗,越要手动提高饱和度,否则会变得灰扑扑。

这个现象它说对了,但结论要修一下:你加不上去。在很亮或很暗的地方,色域本身就没有那么多彩度可用。你在 HSL 里能写 S: 100%,是因为 HSL 的 S 是个相对量——它相对的是「这个亮度下最大能有多少」,所以 100% 的 S 在 L=95% 时其实是很灰的一点点彩度。HSL 用一个百分号把这个事实藏起来了。

所以正确的做法不是「手动补饱和度」

用一套自动贴着色域边界走的色阶。第 15 章那台 HCT 引擎做的就是这件事:你给色相和目标彩度,它在每个亮度上自动取「目标值和天花板中的较小者」。

你根本不用知道天花板在哪儿——但你需要知道天花板存在,否则你会花一下午试图把一个浅黄色调得更黄。

这本书自己就是例子

这本书的六个卷色不是挑的,是这台引擎生成的:同一个 L = 0.52、同一个目标 C = 0.125,只有 H 在动(265 / 325 / 25 / 85 / 145 / 205)。

色相实际彩度色值白底对比度
I 看清265°0.1250#4565B05.61
II 距离325°0.1250#8C4D905.87
III 字25°0.1250#A547435.86
IV 色85°0.1066#8563005.55
V 手145°0.1250#327B385.22
VI 交付205°0.0888#0077815.31

注意红色那两格:黄(85°)和青(205°)在 L=0.52 上塞不下 0.125,被夹到了各自的天花板。这就是上一节说的事,在这本书自己身上发生了一次。

结果是六个卷色的白底对比度落在 5.225.87 这个很窄的区间里——没有哪一卷的标题会比别的卷更抢眼。这是 HSL 做不到的。

灰色不必是灰的

最后一条,也来自《Refactoring UI》,而且实施成本极低、效果极明显:

中性色带一点色相

纯灰(#808080 这种 R=G=B 的)在界面上会显得脏、死,因为现实里几乎不存在纯中性的光。

做法是给整条中性阶一个统一的、很低的彩度:

  • 偏冷(h ≈ 250–270):科技感、克制。适合工具、效率类。
  • 偏暖(h ≈ 60–80):亲和、纸感。适合内容、生活类。

关键是整条阶用同一个色相——第 4 章那个「脏」,一半来自「有的灰偏蓝有的灰偏黄」。

这本书的中性阶就是 OKLCH h = 262C = 0.004,L 从 0.955 排到 0.215:

--canvas     L 0.955   #EFF0F3
--board-alt  L 0.985   #F9FAFD
--rule       L 0.905   #DEE0E2
--ink-4      L 0.665   #929496
--ink-3      L 0.545   #6F7072
--ink-2      L 0.395   #454649
--ink        L 0.215   #18191B

彩度只有 0.004,肉眼几乎看不出偏色——但把它和一条纯灰阶并排放,纯灰那条会立刻显得发死。这就是「说不出哪里不一样,但就是更像样」的一个具体来源。

Android 上没有原生的 OKLCH,但你不需要在运行时算——颜色应该在构建期就定好

// 这些值是用 OKLCH 生成好之后写死进来的,
// 每一行的注释就是它的出处。
object Neutral {
    // OKLCH(L, 0.004, 262) —— 整条阶同一个色相,微冷
    val canvas   = Color(0xFFEFF0F3)   // L 0.955
    val surface  = Color(0xFFFFFFFF)   // L 1.000
    val outline  = Color(0xFFDEE0E2)   // L 0.905
    val textDim  = Color(0xFF6F7072)   // L 0.545
    val text     = Color(0xFF18191B)   // L 0.215
}

// 需要在运行时插值时,用 OKLab 而不是 sRGB ——
// sRGB 里两个颜色的中点在感知上不是中点
val mid = lerp(startColor, endColor, 0.5f)   // ✗ 会经过一个发灰的中间态
// ✓ 先转 OKLab 插值再转回来,或者干脆预生成好几档
本章关掉的自由度本章 2 条 · 累计 36 / 78
  • 「怎么调出一组亮度一致的颜色」——用 OKLCH 固定 L,不用 HSL。HSL 的 L 是几何量,不是亮度。
  • 「灰色用哪个灰」——整条中性阶用同一个色相、同一个极低彩度(C ≈ 0.004),只改 L。不再一个界面一个灰。