Material Design 3 色彩系统的三次迭代:一个主题开发者的色彩理论实践
# Material Design 3 色彩系统的三次迭代:一个主题开发者的色彩理论实践
> 本文记录了 Aether 主题中 MD3 动态色彩系统从 2026 年 1 月到 6 月的演进历程。这不是一篇教程,而是一段真实的踩坑与修正的记录。
## 序:丐版 HCT
在开始正文之前,先交代一下这套色彩系统的定位。
Google 的 Material Theme Editor(也就是 Materia)的核心贡献是什么?是用 Bootstrap 的工程体系去承接 M3 的 Dynamic Color 引擎。它把谷歌那套复杂的 HCT 色彩数学公式,翻译成了 SCSS 变量,让整个系统可以随用户心意变幻色彩。
然而我不懂什么 SCSS,但我懂 PHP、JS 和 CSS 自定义属性。
所以我做了个丐版 HCT——同时有 PHP 和 JS 双版本。输入一个颜色,会在内部拆分成 HSL,然后"标准化"饱和度(此处就是那个视觉补偿工作的地方,我预先定义了一些色相与饱和度关系的数组,让它做个线性插值看着就足够顺眼了)和亮度,然后生成从浅到深的 color token CSS 变量,然后我自己做了个转接环 CSS 将这些 token 与实际的那些 Bootstrap 颜色 CSS 变量连接在一起,这样整个界面就是用户选择的颜色了,闭着眼睛选也不会有过度鲜艳或者本色太深太浅的问题。
这个就是我做这么个色彩系统的初衷。而下面要讲的,就是这个丐版 HCT 在色相规则和亮度补偿上走过的弯路。
## 第一章:2026 年 1 月 —— "一开始的做法"
Aether 主题从 A3 版本开始引入 Material Design 3 色彩系统。彼时的核心需求很明确:用户选择一个主色(Primary),系统自动生成完整的色板——包括 Secondary 和 Tertiary。
当时的色相偏移算法是这样的:
```
secondary: ±40° analogous shift
tertiary: 180° complementary
```
翻译成人话就是:Secondary 往色相环的相邻方向偏 40 度,Tertiary 直接转半圈取互补色。
这个逻辑的来源很朴素——翻翻任何一本平面设计入门教材,你都能找到类似的色相环示意图。邻近色(Analogous)和互补色(Complementary)是最经典的两套配色方案,看起来没什么问题。
而且它确实"能用"。红色主色会得到橙色 Secondary 和青色 Tertiary,蓝色主色会得到靛蓝 Secondary 和橙色 Tertiary。色相环上转一圈,每种主色都能得到一组看起来还过得去的三色组合。
于是这套算法就这么跑了几个月,从 A3 一路跑到了 B5。
没有人投诉,但也没有人夸好看。
## 第二章:2026 年 6 月 —— "觉得丑,以及重新认识"
2026 年 6 月,在准备 B6 版本时,我重新审视了这套色彩系统的输出,越看越觉得不对劲。
### 问题一:40 度的阶梯太大了
把色相环想象成一个钟表,40 度大约是 1 小时 20 分钟的刻度。红色(0°)的 Secondary 落在橙色(40°),橙色(30°)的 Secondary 落在黄色(70°)——这个跳跃在色相环上看起来只是一小段弧,但在实际界面上,两种颜色的"气质"已经有了明显的割裂感。
Material Design 3 的设计哲学强调的是"和谐"而非"对比"。Secondary 的角色是辅助 Primary,而不是跟 Primary 抢戏。40 度的偏移让 Secondary 离 Primary 太远了,不够"一家人"。
### 问题二:180 度互补色太刺眼
这是更严重的问题。互补色在色彩理论中确实是一种经典方案,但它的经典用途是"制造视觉张力"——想想红配绿、蓝配橙。这种张力在海报设计里是好事,但在 UI 界面里是灾难。
Tertiary 在 MD3 中的角色是"点缀",用于 FAB、Switch、Chip 等小面积元素。当你的 Primary 是蓝色(213°),Tertiary 是橙色(33°),这两种颜色在色相环上隔了 180 度,视觉上就是"对着干"的。点缀变成了冲突。
### 重新设计
经过对 MD3 规范的重新研读和反复调试,新的色相规则定为:
```
secondary: ±30° analogous shift
tertiary: ±120° triadic split
```
**Secondary 从 ±40° 收紧到 ±30°**,让辅助色更贴近主色,形成真正的邻近关系。红色(0°)的 Secondary 现在是偏橙(30°),蓝色(213°)的 Secondary 是靛蓝(243°)——更像是同一个色系的深浅变化,而非两个色系的拼接。
**Tertiary 从 180° 互补改为 ±120° 三色均分**。三色均分(Triadic)是色相环上等距 120 度的三个点,天然形成平衡的三角关系。相比互补色的"对立",三色均分更像是"各据一方、互不侵犯"。蓝色(213°)的 Tertiary 落在黄绿区(93°),虽然跟蓝色差异明显,但不会像橙色那样形成强烈的视觉对抗。
这个模型我称之为"30°-120° 和谐三角":Primary、Secondary、Tertiary 在色相环上构成一个不等边三角形,30 度的那条边连接 Primary 和 Secondary(亲密),120 度的那条边连接 Primary 和 Tertiary(独立但不冲突)。
## 第三章:修复蓝色系 Secondary 的亮度差异
色相规则改完之后,我以为这事儿就结了。然而一个新的问题浮出水面——而且这个问题比色相偏移更隐蔽。
### 现象
当 Primary 是紫色系(hue 270° 左右)时,Secondary 的 hue 会落在 240°-300° 区间(蓝到紫)。在浅色模式下,这个区间的颜色**看起来比同等 Lightness 值的其他色相更深**。
举个例子:`hsl(240deg 82% 50%)` 和 `hsl(0deg 82% 50%)`,两者的饱和度和亮度数值完全相同,但蓝色的那个在人眼看来明显更暗。这是因为人眼对不同波长的光敏感度不同——我们对蓝紫区域的光感知效率较低,同样的物理亮度,蓝紫色看起来就是更暗。
结果就是:当其他色相的 Secondary 在 Lightness=50 时看起来恰到好处,蓝紫色的 Secondary 在 Lightness=50 时却显得沉闷压抑,跟 Primary 的明快感不搭。
### 第一次尝试:调整输入 hex 的 Lightness(失败)
我的第一反应是:在生成 Secondary 的 hex 值时,把 Lightness 调高不就行了?
```php
$secondaryHex['l'] = adjustLightnessForLightMode($secondaryHex['h'], $secondaryHex['l']);
$secondaryHex = hslToHex($secondaryHex['h'], $secondaryHex['s'], $secondaryHex['l']);
```
我写了一个 `adjustLightnessForLightMode` 函数,用查表+插值的方式在 240°-300° 区间给 Lightness 加上 +3 到 +10 的补偿。
但输出完全没变。`--md3-secondary-50` 的 Lightness 还是 50。
### 第二次尝试:更激进的补偿(还是失败)
我换了个思路,写了一个 `adjustSecondaryForLightMode` 函数,直接把 Lightness 设到 60-65,同时降低饱和度。结果——还是没变。
### 根因
问题出在 `generateMd3ColorPalette` 函数里。这个函数的工作方式是:
```php
foreach ($toneLevels as $tone) {
$palette[$tone] = hslToHex($hsl['h'], $hsl['s'], $tone);
}
```
它**直接用 tone 值作为 Lightness**。`--md3-secondary-50` 的 Lightness 永远是 50,因为 tone 就是 50。输入 hex 的 Lightness 在这里完全被忽略了——它只用来提取色相和饱和度。
所以不管我怎么调 `secondaryHex.l`,只要进了 `generateMd3ColorPalette`,Lightness 就会被 tone 值覆盖。
### 最终方案:Lightness Offset
既然 tone 值是直接作为 Lightness 使用的,那就在 tone 值上加偏移。
```php
function generateMd3ColorPalette($hex, $darkMode = false, $lightnessOffset = 0) {
// ...
foreach ($toneLevels as $tone) {
$targetTone = $darkMode ? $toneMapping[$tone] : $tone;
if (!$darkMode) {
$targetTone = min(100, $targetTone + $lightnessOffset);
}
$palette[$tone] = hslToHex($hsl['h'], $hsl['s'], $targetTone);
}
}
```
新增第三个参数 `lightnessOffset`,在浅色模式下把每个 tone 值加上偏移量。深色模式不受影响——深色模式的 Lightness 控制本来就很好,不需要补偿。
然后在 `generateMd3CssVariables` 中,只在生成 Secondary 浅色调色板时传入偏移:
```php
$secondaryOffset = getSecondaryLightnessOffset(hexToHsl($secondaryHex, true)['h']);
$secondaryPalette = generateMd3ColorPalette($secondaryHex, false, $secondaryOffset);
$secondaryPaletteDark = generateMd3ColorPalette($secondaryHex, true); // 深色不传 offset
```
`getSecondaryLightnessOffset` 的逻辑很简单:hue 在 240°-300° 时返回 +10,否则返回 0。
这样,当 Primary 是紫色(hue 270°)时,Secondary(hue 240°)的浅色调色板中,`--md3-secondary-50` 的实际 Lightness 变成了 60,视觉上终于跟其他色相的 Secondary 协调了。
## 尾声
回顾这段历程,最大的教训是:**色彩理论是指导原则,不是工程公式。** 40°/180° 在色相环上看起来很合理,但落到像素上就不一定了;Lightness=50 在数值上对所有色相一视同仁,但人眼不是色差计。
MD3 的动态色彩系统(Dynamic Color)之所以看起来舒服,不是因为它用了什么高深的数学,而是因为 Google 的设计师在 HCT 色彩空间里做了大量的人眼感知校准。我们在 HSL 空间里做近似,就必须自己补上这些感知补偿。说到底,这套丐版 HCT 的三次迭代,每一次都是在补 HSL 与人眼感知之间的差距——色相偏移的调整是在补"色彩和谐感"的差距,亮度偏移的引入是在补"感知均匀性"的差距。HCT 里的 T 就是 Tone(色调),Google 用它来表示感知均匀的亮度;我们的 `lightnessOffset` 就是这个 T 的乞丐版实现。
三次迭代,从"能用"到"好看"到"对",每一步都是对着屏幕反复调参、对比、推翻重来的结果。色彩这东西,差之毫厘,谬以千里。