从一次主题色失真说起
假设你在为一个电商设计系统定义品牌主色,希望它在最新的 OLED 屏幕上呈现一种浓郁、鲜艳的绿色。你从设计稿中取到一个 color(display-p3 0 1 0),这是 Display P3 色域中最饱和的绿色。把它写进 CSS 后,你在自己的 MacBook 上预览,颜色饱满通透。可当测试同事在一台普通的 sRGB 显示器上打开页面时,同一个元素却变成了另一种绿色——更暗、更不鲜艳,甚至有些发灰。
这不是浏览器渲染错误,也不是色值写错。问题出在:你定义的颜色超出了目标显示器的色域,浏览器必须把它“压缩”回 sRGB 能表达的范围内,这个过程叫色域映射(gamut mapping)。映射不是简单的等比缩放,它会改变颜色的感知属性,于是你看到的颜色和设计意图产生了偏差。
要理解这个偏差从哪来,需要先弄清楚三件事:color() 函数如何描述颜色、OKLCH 为什么适合做设计系统的调色工具,以及浏览器在色域映射时究竟做了什么。
从 HEX 到 color():色域与色彩空间的边界
网页开发用了二十多年的 #RRGGBB 十六进制写法,本质上是 sRGB 色彩空间里三个通道的编码。sRGB 是 1996 年由 HP 和 Microsoft 制定的标准,它基于当时主流 CRT 显示器的荧光粉特性,只能覆盖人眼可见颜色(CIE 1931 色度图)的大约 35%。换句话说,你写的每一个 HEX 颜色,都只是这 35% 里的一个点。
现代显示设备,尤其是苹果的 Liquid Retina 屏幕和许多 OLED 面板,已经能显示比 sRGB 更广的颜色范围,这个范围通常被称为 Display P3。P3 色域比 sRGB 大约大 25%,能表达更饱和的红、绿和蓝。问题在于,CSS 里没有一个语法能直接写出“P3 色域中的某个颜色”——rgb()、hsl() 和 #hex 都只能表达 sRGB 颜色。
CSS Color Module Level 4 引入的 color() 函数解决了这个限制。它的语法允许你指定一个预定义的色彩空间,然后给出该空间下的分量值:
/* 在 Display P3 中定义一个纯绿色 */
color: color(display-p3 0 1 0);
color() 支持多种预定义色彩空间,包括 srgb、srgb-linear、display-p3、a98-rgb、prophoto-rgb、rec2020,以及设备无关的 xyz-d50 和 xyz-d65。其中 display-p3 是当前最常用的广色域目标,因为它正好对应现代显示器的硬件能力。
color() 只是提供了“指定广色域颜色”的语法,它本身并不保证你定义的颜色在所有屏幕上都能原样显示。当目标设备的色域小于颜色所在空间时,浏览器必须进行色域映射。
OKLCH:为感知一致性而生的色彩空间
color(display-p3 0 1 0) 虽然能表达广色域,但它仍然基于 RGB 分量,人类很难从 0 1 0 这三个数字想象出具体颜色。更重要的是,RGB 分量在感知上不均匀——改变相同的数值,在不同色相上产生的视觉变化差异很大。这对设计系统来说是个麻烦:设计师想通过调整明度或饱和度来生成一组和谐的主题色,但用 RGB 或 HSL 操作,结果往往明暗不一。
CSS Color 4 引入了 oklch() 函数,它基于 OKLCH 色彩空间。OKLCH 由 Björn Ottosson 在 2020 年提出,是一种感知均匀的色彩空间。它的三个分量是:
L:感知明度,范围 0~1,数值与人类感知的亮度变化一致。C:色度(chroma),表示颜色的饱和程度,从灰色到最鲜艳。H:色相角,0~360 度。
OKLCH 的关键优势在于“感知均匀”:当你把 L 从 0.5 调整到 0.6,无论 H 是什么,人眼感受到的明度变化幅度基本相同。这对生成色板非常有用。例如,你可以定义一个品牌色,然后通过固定 L 和 H、只改变 C 来生成一系列饱和度不同的颜色,它们会保持一致的明度,不会出现某个颜色突然变暗或变亮的情况。
OKLCH 并不局限于某个色域,它可以表达任意可见颜色。因此,它既能描述 sRGB 内的颜色,也能描述 P3 甚至更广色域的颜色。当你在 OKLCH 中指定一个高色度值,比如 oklch(0.7 0.3 150),这个颜色可能超出 sRGB 的范围,但仍在 P3 内。
色域映射:浏览器如何把“超纲”颜色塞回 sRGB
当浏览器遇到一个超出目标设备色域的颜色时,它不会直接丢弃或报错,而是会执行色域映射算法,把这个颜色转换成一个在目标色域内、且感知上尽可能接近的颜色。CSS Color 4 规范描述了多种映射策略,但浏览器实际使用的通常是基于二分查找的“局部最小色差”算法。
映射过程大致如下:
- 把源颜色从它的原始色彩空间转换到 OKLCH,因为 OKLCH 的感知均匀性让色差计算更有意义。
- 检查颜色是否在目标色域(比如 sRGB)内。如果不在,就逐步降低色度
C,同时保持明度L和色相H不变。 - 每降低一次色度,就把颜色转换到目标 RGB 空间,检查是否落在合法范围内。
- 当颜色进入色域时,停止迭代,输出这个颜色。
这种“色度压缩”策略比简单的“裁剪”更能保留颜色的感知特征。裁剪(clipping)是直接把超出范围的通道值截断到边界,例如把 rgb(120% 0 0) 变成 rgb(100% 0 0),这会让颜色在色相上发生偏移,因为三个通道被不等比例地截断。而色度压缩是在 OKLCH 中保持色相不变,只降低饱和度,这样颜色的“气质”更接近原色。
不过,色度压缩并非完美。当色度被大幅降低时,颜色会变得灰暗,可能看起来和原色差别很大。而且,如果原颜色的色度极高,即使降低到零(灰色),也可能无法在目标色域内找到完全匹配的颜色,这时算法会采用局部裁剪或其他策略。
下面用流程图展示一个 color(display-p3 0 1 0) 在 sRGB 屏幕上的命运:
flowchart TD
A[color(display-p3 0 1 0)] --> B[转换到 OKLCH]
B --> C{在 sRGB 色域内?}
C -- 是 --> D[直接使用]
C -- 否 --> E[降低色度 C]
E --> F[转换回 sRGB]
F --> G{在 sRGB 色域内?}
G -- 否 --> E
G -- 是 --> H[输出映射后的颜色]
这个流程的关键转折点在第 3 步的判断:如果颜色本来就在 sRGB 内,浏览器会原样输出,不会做任何映射。只有当颜色超出时,才会进入降低色度的循环。
为什么映射会导致失真:感知差异与色域边界
色域映射的目标是让映射后的颜色在感知上尽量接近原色,但“接近”是相对的。当源色域和目标色域差异很大时,映射后的颜色必然与原色存在可感知的差异。
以 color(display-p3 0 1 0) 为例,这个颜色在 P3 中是最饱和的绿色,但它的色度远高于 sRGB 能表达的最大绿色。映射算法会把它的色度降低到 sRGB 边界附近,同时保持明度和色相。结果是一个更不饱和的绿色——它可能看起来像 #00e400 或类似颜色,但绝不会像原色那样鲜艳。
这种差异可以用色差公式(如 ΔE2000)量化,但设计师更关心的是视觉上的“失真”。失真的程度取决于两个因素:
- 原颜色的色度超出目标色域多少:超出越多,映射后色度降低越多,失真越明显。
- 色相本身:某些色相区域(如绿色和蓝色)的 sRGB 边界离 P3 边界较远,因此这些颜色的失真更严重。
值得注意的是,色域映射不是“等比缩放”或“整体变暗”,它是在感知空间里进行的非线性操作。因此,两个不同的广色域颜色映射到 sRGB 后,它们之间的相对关系可能改变——原本色度差异很大的两个颜色,映射后可能变得几乎一样。这对依赖颜色区分信息的设计(如数据可视化)是个隐患。
设计系统实践:用 OKLCH 定义主题色,用 @media 提供降级
理解了色域映射的机制,就能在设计系统中采取合理的策略。核心原则是:在广色域设备上使用广色域颜色,在 sRGB 设备上提供经过精心设计的降级颜色,而不是依赖浏览器的自动映射。
第一步,用 OKLCH 定义主题色。OKLCH 的感知均匀性让调色变得可预测,而且它天然支持广色域。例如,你可以定义品牌绿为:
:root {
--brand-green: oklch(0.7 0.2 150);
}
这个颜色在 OKLCH 空间中指定,浏览器会把它转换到显示设备的色域。在 P3 屏幕上,它能显示得比 sRGB 更鲜艳;在 sRGB 屏幕上,浏览器会进行色域映射。
第二步,使用 @media (color-gamut) 为不同色域的设备提供精确的颜色值。color-gamut 媒体特性可以检测输出设备支持的色域范围,可能的值有 srgb、p3 和 rec2020。例如:
:root {
--brand-green: oklch(0.7 0.2 150);
}
/* 为不支持 P3 的设备提供 sRGB 内的颜色 */
@media (color-gamut: srgb) {
:root {
--brand-green: oklch(0.7 0.18 150); /* 手动降低色度,避免浏览器映射 */
}
}
这里的关键是:当设备只支持 sRGB 时,我们主动提供一个色度稍低、但仍在 sRGB 范围内的颜色,这样浏览器就不会执行色域映射,颜色表现是可控的。你可以通过设计工具或计算,找到原色在 sRGB 中的最佳近似,而不是让浏览器“自动决定”。
第三步,理解 color-gamut 的语义。@media (color-gamut: p3) 匹配的是“支持 P3 或更广色域”的设备,因此如果你只想针对真正的广色域设备提供增强颜色,可以这样写:
/* 默认 sRGB 颜色 */
:root {
--brand-green: oklch(0.7 0.18 150);
}
/* 支持 P3 时使用更鲜艳的版本 */
@media (color-gamut: p3) {
:root {
--brand-green: oklch(0.7 0.22 150);
}
}
注意,color-gamut 检测的是“用户代理和输出设备”的联合能力,它不保证颜色一定被精确显示,但至少能让你针对不同设备给出不同的色值。
对比 HEX/RGB 的局限与替代方案
传统上,设计系统用 HEX 或 rgb() 定义颜色,这些格式只能表达 sRGB 色域内的颜色。如果你用了广色域屏幕,会发现某些设计稿中的颜色无法用 HEX 精确还原,因为设计工具可能已经使用了 P3 色域。
下表对比了 HEX、rgb()、color(display-p3) 和 oklch() 在几个关键维度上的差异:
| 维度 | HEX / rgb() | color(display-p3) | oklch() |
|---|---|---|---|
| 可表达色域 | 仅 sRGB | 可指定 P3 等广色域 | 任意可见颜色 |
| 感知均匀性 | 差 | 差 | 好 |
| 可读性 | 差 | 差 | 好 |
| 浏览器支持 | 所有 | 现代浏览器 | 现代浏览器 |
| 色域映射 | 不适用 | 浏览器自动映射 | 浏览器自动映射 |
从表中可以看出,oklch() 在感知均匀性和可读性上明显优于其他格式,而 color(display-p3) 的主要价值在于精确指定广色域颜色。实际项目中,你可以组合使用:用 oklch() 定义设计令牌,用 @media 提供降级,必要时用 color(display-p3) 精确控制某个颜色。
另一种替代方案是使用 color() 配合 display-p3,但它的可读性差,而且同样需要处理色域映射。oklch() 的优势在于,它的分量直接对应感知属性,让你能直观地调整明度、色度和色相,从而更容易预测映射后的效果。
常见失败模式与诊断路径
在实际使用中,有几个常见的坑:
- 过度依赖浏览器映射:如果你只在广色域屏幕上测试,可能会忘记 sRGB 用户看到的是映射后的颜色。解决方法是使用
@media (color-gamut: srgb)提供降级色。 - 色度值过高导致映射后颜色发灰:在 OKLCH 中,色度
C的最大值取决于色相和明度,某些组合甚至超出 P3 色域。如果C设得过高,在 sRGB 上映射后可能变得很灰。建议用工具检查颜色是否在目标色域内。 - 忽略
color-gamut的浏览器兼容性:虽然color-gamut从 2023 年起在主流浏览器中广泛可用,但旧浏览器不支持。对于不支持的环境,默认颜色应保持在 sRGB 内,避免意外映射。
诊断色域问题时,可以打开浏览器的开发者工具,检查计算后的颜色值。如果发现颜色被映射,工具通常会显示转换后的 RGB 值。你还可以用 CSS 的 @supports 检测 color() 和 oklch() 的支持情况,但更重要的是理解映射行为。
结论:在广色域时代重新思考颜色定义
广色域屏幕的普及让 CSS 颜色系统从“只能表达 sRGB”进化到“可以表达更广的颜色”,但这也带来了新的责任:你必须意识到,你定义的颜色可能超出某些设备的显示能力,而浏览器的自动映射并不总是符合设计意图。
通过使用 oklch() 定义感知均匀的颜色,配合 @media (color-gamut) 为不同设备提供精确的降级方案,你可以让设计系统在从 sRGB 到 P3 的设备上都能呈现一致且可控的视觉效果。这并不意味着要放弃 HEX 和 rgb()——它们仍然适用于那些不需要广色域的场景。但当你需要表达“更鲜艳”的颜色时,color() 和 oklch() 提供了必要的工具,而理解色域映射的机制,是避免颜色失真的第一步。