色彩空间:沿什么路径混合颜色
两种颜色之间做插值,得到的不是一个点,而是一条路径。起点与终点固定,中途经过哪些颜色则完全由「在哪个空间里取平均」决定。同一对红与绿,四种空间给出四条形状迥异的路径:sRGB 中段塌成灰,线性光中段偏亮,HSL 会途经黄,OKLCH 则一路保持饱和。
这四条路径没有一条是「错」的,但它们回答的是不同的问题。sRGB 通道平均回答「数值的中点」,线性光回答「光强的中点」,OKLCH 回答「人眼看上去的中点」。动画要的通常是最后一个。
1 · 四条路径并排
2 · gamma 编码与线性光
sRGB 的通道值是 gamma 编码过的:数值 128 对应的实际光强约为最大值的 21%,而不是 50%。直接对编码值取平均,等于在一把非线性的尺子上找中点,落点系统性偏暗。跨色相的过渡最能暴露这一点——红到绿的中段会塌成一片灰。
正确的换算是解码到光强、在光强上平均、再编码回来:
引擎的 mixColor 没有跑完整的 gamma 2.4,而是用通道值的平方近似这条曲线(见插值)。这一页的线性光带走的是完整换算,两者并排看中段亮度略有差异,但方向一致——都比朴素平均亮。
3 · 色相环的方向
HSL 的色相是一个角度,两个角度之间有两条弧可走。0° 到 240°:沿色相递增经黄、绿、青走过 240°,沿色相递减经品红只走 120°。默认取较短的那一段,但「较短」并不总是想要的——从红到红旁边的一点点橙,最短弧是对的;而做一个转满整圈的色相动画,最短弧会让它原地不动。
CSS Color 4 把这几档记作 shorter / longer / increasing / decreasing,本页的方向选择器与前三者同义。
警示 · HSL 沿色相环插值时明度与饱和度也在同时线性变化,途经色未必落在两端连成的「视觉直线」上。黄到紫这类端点,HSL 路径会先经过一段明显发绿的区域——那不是实现的问题,是色相环本身的几何。要避免途经色跑偏,得换到感知均匀的空间。
4 · 感知均匀的坐标
OKLab 的坐标按人眼感知校准:坐标上等距的差对应视觉上等距的差。OKLCH 是它的极坐标形式, 是明度、 是彩度、 是色相角。在这套坐标里沿直线或沿弧走,明度与饱和度都均匀推进,不会在中段塌陷成灰,也不会在某一段突然过饱和。
代价是换算不便宜:sRGB 到 OKLab 要先解码到线性光,再过一个 矩阵,对三个分量各取一次立方根,最后再过一个矩阵。逐帧重算四条路径时这一步是本页最贵的计算——不过对于一次只算一个色值的真实动画,这点开销可以忽略。
// sRGB 编码值 ↔ 线性光强 (gamma 2.4)
const lin = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const gam = (c) => c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055;
// 线性光混合: 解码 → 平均 → 编码回来, 中点不发灰
const atLinear = (a, b, p) =>
a.map(lin).map((x, i) => gam(x + (lin(b[i]) - x) * p));
// 色相环按方向插值 (最短弧 / 顺时针 / 逆时针)
function hueLerp(h1, h2, p, dir) {
let d = h2 - h1;
if (dir === 'short') d = ((d % 360) + 540) % 360 - 180;
else if (dir === 'cw' && d < 0) d += 360;
else if (dir === 'ccw' && d > 0) d -= 360;
return h1 + d * p;
}