第六章:颜色与色彩空间 (Colors and Color Spaces)
"色彩是光的艺术,而理解色彩空间则是数字艺术家的必修课。" —— 写给初级程序员的话
目录
- 计算机如何表示颜色:RGB 模型
- RGB 颜色的生成与缩放
- HSL 色彩空间:色相、饱和度、明度
- 为什么 HSL 对人类更直观
- HSL 颜色插值的问题
- CIELAB 色彩空间:感知均匀的颜色
- Cubehelix 颜色插值:数据可视化的利器
- 渐变与动画中的颜色插值
- Gamma 校正基础
- 常见问题 Q&A
1. RGB 模型
1.1 生活类比:三原色手电筒
想象你走进一个漆黑的房间,手里有三支手电筒——一支红色、一支绿色、一支蓝色。房间的墙壁就像一块白色幕布,当三支手电筒同时照向同一个点时,你会看到白色光;只打开红色手电筒,墙壁变红;同时打开红色和绿色,墙壁变黄。这就是 RGB 的本质:用三种基础光的亮度混合出任意颜色。
1.2 本质是什么
RGB(Red, Green, Blue)是一种加色模型(Additive Color Model)。它的核心思想是:
- 每个颜色通道用一个数值表示该色光的强度
- 三个通道独立控制,互不影响
- 最终颜色 = 红光 + 绿光 + 蓝光
在计算机中,每个通道通常用 8 位(1 字节)存储,取值范围是 0 ~ 255:
R = 0 表示"没有红光" R = 255 表示"最亮的红光"
G = 0 表示"没有绿光" G = 255 表示"最亮的绿光"
B = 0 表示"没有蓝光" B = 255 表示"最亮的蓝光"
1.3 代码示例
// 文件: akira-graphics/color-hints/app-rgb.js
import {Vec3} from '../common/lib/math/Vec3.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 生成一个随机的 RGB 颜色向量
// 每个分量在 [0, 0.5) 范围内
function randomRGB() {
return new Vec3(
0.5 * Math.random(), // R: 0 ~ 0.5
0.5 * Math.random(), // G: 0 ~ 0.5
0.5 * Math.random(), // B: 0 ~ 0.5
);
}
// 将画布原点移到中心,Y 轴向上
ctx.translate(256, 256);
ctx.scale(1, -1);
// 绘制 3 行 5 列的圆点
// 每一行使用同一个基础颜色,但通过缩放改变亮度
for(let i = 0; i < 3; i++) {
const colorVector = randomRGB(); // 获取一个随机基础色
for(let j = 0; j < 5; j++) {
// scale(0.5 + 0.25 * j) 将颜色向量缩放
// j=0: 0.5倍亮度, j=1: 0.75倍, j=2: 1.0倍, j=3: 1.25倍, j=4: 1.5倍
const c = colorVector.clone().scale(0.5 + 0.25 * j);
// 将 [0,1] 范围的浮点数转换为 [0,255] 的整数
const r = Math.floor(c[0] * 256);
const g = Math.floor(c[1] * 256);
const b = Math.floor(c[2] * 256);
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`;
ctx.beginPath();
ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
ctx.fill();
}
}
1.4 视觉描述
运行上述代码后,你会在画布上看到:
- 3 行圆点:每行代表一个不同的基础颜色
- 每行 5 个圆点:从左到右,颜色越来越亮
- 最左边的圆点最暗(缩放系数 0.5),最右边的最亮(缩放系数 1.5)
- 同一行的圆点色调相同,只是亮度不同
1.5 常见误区
误区一:RGB 值越大颜色越"鲜艳"
错误理解:
rgb(255, 0, 0)比rgb(128, 0, 0)更"红"。正确理解:前者更亮,但两者的"红度"(饱和度)是一样的。RGB 值描述的是光的物理强度,不是人眼感知的"鲜艳程度"。
误区二:RGB 是均匀的色彩空间
RGB 数值上的等差变化,在人眼看来并不均匀。比如从
rgb(0,0,0)到rgb(64,64,64)的亮度变化,和从rgb(192,192,192)到rgb(255,255,255)的亮度变化,人眼感知到的差异是不一样的。
1.6 动手试一试
打开 akira-graphics/color-hints/color-hints-rgb.html,尝试修改 randomRGB() 函数:
// 练习 1:让颜色更鲜艳(增大随机范围)
function randomRGB() {
return new Vec3(
Math.random(), // 改为 0 ~ 1
Math.random(),
Math.random(),
);
}
// 练习 2:只生成红色系颜色
function randomRGB() {
return new Vec3(
0.5 + 0.5 * Math.random(), // R: 0.5 ~ 1.0
0.1 * Math.random(), // G: 0 ~ 0.1 (很少)
0.1 * Math.random(), // B: 0 ~ 0.1 (很少)
);
}
2. RGB 颜色的生成与缩放
2.1 生活类比:调光台灯
想象你有一个可以调节亮度的台灯。灯泡本身的颜色(色温)不变,但你可以通过旋钮让房间变亮或变暗。RGB 的"缩放"操作就像这个调光旋钮——不改变颜色本身,只改变它的亮度。
2.2 本质是什么
RGB 颜色可以看作三维空间中的一个向量:
Color = (R, G, B)
对这个向量进行缩放(乘以标量),就是同时改变三个通道的强度:
scale * Color = (scale * R, scale * G, scale * B)
当 scale > 1 时,颜色变亮;当 0 < scale < 1 时,颜色变暗;当 scale = 0 时,变成纯黑。
2.3 数学推导
假设我们有一个颜色 C = (0.4, 0.2, 0.8),将其缩放 0.5 倍:
Step 1: C' = 0.5 * C
Step 2: C' = (0.5 * 0.4, 0.5 * 0.2, 0.5 * 0.8)
Step 3: C' = (0.2, 0.1, 0.4)
验证:新的 RGB 值是原来的一半,颜色变暗但保持相同的色调比例。
2.4 代码示例
// Vec3.scale() 的实现原理
class Vec3 {
constructor(x, y, z) {
this[0] = x; this[1] = y; this[2] = z;
}
// 向量缩放:每个分量乘以同一个数
scale(s) {
this[0] *= s;
this[1] *= s;
this[2] *= s;
return this;
}
// 克隆一个相同的向量
clone() {
return new Vec3(this[0], this[1], this[2]);
}
}
// 使用示例
const baseColor = new Vec3(0.8, 0.4, 0.2); // 橙红色
const darker = baseColor.clone().scale(0.5); // 变暗: (0.4, 0.2, 0.1)
const brighter = baseColor.clone().scale(1.5); // 变亮: (1.2, 0.6, 0.3)
// 注意:brighter 的 R 分量 > 1,需要截断到 1.0
2.5 常见误区
误区:缩放 RGB 就是改变饱和度
缩放 RGB 改变的是亮度(Brightness/Lightness),不是饱和度(Saturation)。
- 亮度:颜色有多"亮"
- 饱和度:颜色有多"纯"(有多接近纯色,不掺杂灰度)
要改饱和度,需要更复杂的操作(后面会讲 HSL)。
2.6 动手试一试
// 练习:写一个函数,生成从暗到亮的渐变色条
function drawGradient(ctx, baseR, baseG, baseB, steps) {
const width = ctx.canvas.width / steps;
for(let i = 0; i < steps; i++) {
const scale = (i + 1) / steps; // 从 1/steps 到 1.0
const r = Math.min(255, Math.floor(baseR * scale));
const g = Math.min(255, Math.floor(baseG * scale));
const b = Math.min(255, Math.floor(baseB * scale));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(i * width, 0, width, ctx.canvas.height);
}
}
// 使用:以蓝色为基础,绘制 10 级亮度渐变
drawGradient(ctx, 100, 150, 255, 10);
3. HSL 色彩空间
3.1 生活类比:调色盘的三要素
想象你是一位画家,面前有一盘颜料。你要描述一种颜色,会怎么说呢?
- 色相(Hue):"这是红色"、"那是蓝色"——描述的是颜色的"种类"
- 饱和度(Saturation):"这红色很鲜艳"、"那红色发灰"——描述的是颜色的"纯度"
- 明度(Lightness):"这颜色很亮"、"那颜色很暗"——描述的是颜色的"明暗"
HSL 就是按照人类这种直觉方式组织颜色的模型!
3.2 本质是什么
HSL 将颜色分解为三个独立的、符合人类直觉的维度:
| 维度 | 含义 | 取值范围 | 类比 |
|---|---|---|---|
| H (Hue) | 色相 | 0° ~ 360° | 彩虹上的位置 |
| S (Saturation) | 饱和度 | 0% ~ 100% | 颜料的纯度 |
| L (Lightness) | 明度 | 0% ~ 100% | 加入黑/白颜料的量 |
关键洞察:在 HSL 中,改变明度不会影响色相,改变饱和度不会影响色相。这三个维度是解耦的!
3.3 代码示例
// 文件: akira-graphics/color-hints/app-hsl.js
import {Vec3} from '../common/lib/math/Vec3.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 生成一个随机的"基础参数"
// 这里借用 Vec3 存储 (h, s, l) 三个值
function randomRGB() {
return new Vec3(
0.5 * Math.random(), // h: 随机色相 (0 ~ 0.5 圈)
0.7, // s: 固定饱和度 70%
0.45, // l: 固定明度 45%
);
}
ctx.translate(256, 256);
ctx.scale(1, -1);
const [h, s, l] = randomRGB();
// 绘制 3 行 5 列的圆点
for(let i = 0; i < 3; i++) {
// p 是当前行的色相偏移量
// 每行色相不同,展示不同"种类"的颜色
const p = (i * 0.25 + h) % 1;
for(let j = 0; j < 5; j++) {
const d = j - 2; // d = -2, -1, 0, 1, 2
// 饱和度随列变化:中间最高,两边降低
const sat = Math.floor((0.15 * d + s) * 100);
// 明度随列变化:中间最高,两边降低
const light = Math.floor((0.12 * d + l) * 100);
ctx.fillStyle = `hsl(${Math.floor(p * 360)}, ${sat}%, ${light}%)`;
ctx.beginPath();
ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2);
ctx.fill();
}
}
3.4 视觉描述
运行代码后,你会看到:
- 3 行圆点:每行代表一个不同的色相(如一行偏红、一行偏绿、一行偏蓝)
- 每行 5 个圆点:
- 最左(j=0, d=-2):饱和度最低(最灰)、明度最低(最暗)
- 中间(j=2, d=0):饱和度最高(最鲜艳)、明度适中
- 最右(j=4, d=2):饱和度最低、明度最高(最亮)
- 同一列的圆点饱和度/明度模式相同,但色相不同
3.5 常见误区
误区一:HSL 和 HSV 是一样的
HSL 和 HSV(也叫 HSB)很相似,但明度/亮度的计算方式不同:
- HSL 的 L = 0% 是纯黑,L = 100% 是纯白,L = 50% 是"最纯"的颜色
- HSV 的 V = 100% 是"最纯"的颜色,不会变成白色
在 CSS 中,
hsl()函数使用的是 HSL 模型。
误区二:色相 0° 和 360° 是不同的颜色
色相是一个环形(色轮),0° 和 360° 都代表纯红色,它们是同一个颜色。这在后面插值时会产生一个有趣的问题。
3.6 动手试一试
打开 akira-graphics/color-hints/color-hints-hsl.html,尝试:
// 练习 1:生成彩虹色条
for(let i = 0; i < 360; i += 10) {
ctx.fillStyle = `hsl(${i}, 100%, 50%)`;
ctx.fillRect(i, 0, 10, 100);
}
// 练习 2:生成饱和度渐变
const hue = 200; // 蓝色
for(let i = 0; i <= 100; i++) {
ctx.fillStyle = `hsl(${hue}, ${i}%, 50%)`;
ctx.fillRect(i * 5, 0, 5, 100);
}
// 左边是灰色,右边是鲜艳的蓝色
4. 为什么 HSL 对人类更直观
4.1 生活类比:描述衣服颜色
假设你要描述一件 T 恤的颜色,哪种说法更自然?
RGB 方式:"这件 T 恤的红色通道是 200,绿色通道是 100,蓝色通道是 50。"
HSL 方式:"这是一件橙色 T 恤,颜色很鲜艳,亮度适中。"
显然,HSL 的描述更符合人类的语言习惯!
4.2 本质是什么
人类感知颜色时,大脑会自动分离三个信息:
- 这是什么颜色?(红色?蓝色?)→ 对应 HSL 的 H(色相)
- 这颜色鲜艳吗?(鲜红 vs 暗红)→ 对应 HSL 的 S(饱和度)
- 这颜色亮吗?(在强光下还是阴影中)→ 对应 HSL 的 L(明度)
RGB 的问题是:改变 R 值,可能同时影响了"色相"、"饱和度"和"明度"三个感知属性。你无法只改一个而不影响其他的感知。
4.3 对比示例
| 目标操作 | RGB 方式 | HSL 方式 |
|---|---|---|
| 让红色更亮 | 增加 R、G、B?很难直觉判断 | 增加 L: hsl(0, 100%, 70%) |
| 让红色更鲜艳 | 增加 R,减少 G 和 B? | 增加 S: hsl(0, 100%, 50%) |
| 从红变橙 | R=255, G 从 0 变到 128... 需要计算 | 增加 H: hsl(30, 100%, 50%) |
4.4 代码示例
// 场景:设计一个按钮,需要"正常态"、"悬停态"、"按下态"
// === RGB 方式(痛苦)===
const normalRGB = 'rgb(66, 133, 244)'; // 谷歌蓝
const hoverRGB = 'rgb(77, 145, 255)'; // 怎么调的?凭感觉!
const activeRGB = 'rgb(55, 120, 220)'; // 更难把握
// === HSL 方式(优雅)===
const baseHue = 217; // 蓝色
const baseSat = 89; // 很鲜艳
const baseLight = 61; // 中等亮度
const normalHSL = `hsl(${baseHue}, ${baseSat}%, ${baseLight}%)`;
const hoverHSL = `hsl(${baseHue}, ${baseSat}%, ${baseLight + 8}%)`; // 变亮一点
const activeHSL = `hsl(${baseHue}, ${baseSat}%, ${baseLight - 8}%)`; // 变暗一点
// 规律清晰:只改 L,H 和 S 不变!
4.5 动手试一试
// 练习:用 HSL 生成一套主题色
function generateTheme(baseHue) {
return {
primary: `hsl(${baseHue}, 80%, 50%)`,
light: `hsl(${baseHue}, 80%, 70%)`, // 同色相,更亮
dark: `hsl(${baseHue}, 80%, 30%)`, // 同色相,更暗
muted: `hsl(${baseHue}, 40%, 50%)`, // 同色相,更低饱和
accent: `hsl(${(baseHue + 180) % 360}, 80%, 50%)`, // 互补色
};
}
const blueTheme = generateTheme(210);
const redTheme = generateTheme(0);
// 只需改一个数字,整套颜色跟着变!
5. HSL 插值问题
5.1 生活类比:绕远路的导航
想象你要从色轮上的"红色"(0°)走到"紫色"(300°)。有两条路:
- 顺时针:0° → 60° → 120° → 180° → 240° → 300°(走 300°)
- 逆时针:0° → 330° → 300°(只走 60°)
HSL 的默认插值会走较长的那条路(顺时针),导致颜色经过了绿色、青色——这通常不是你想要的!
5.2 本质是什么
HSL 插值有两个核心问题:
问题一:色相环绕(Hue Wrap-around)
色相是环形的(0° = 360°),但简单的线性插值不知道这一点。
从 hsl(10°, 100%, 50%) 插值到 hsl(350°, 100%, 50%)
错误做法(线性插值):
中间点 = (10 + 350) / 2 = 180° → 青色!
正确做法(最短路径):
350° 等价于 -10°
中间点 = (10 + (-10)) / 2 = 0° → 红色!
问题二:非感知均匀
即使解决了色相环绕,HSL 插值在视觉上仍然不均匀。从黄色到蓝色的插值会经过难看的灰绿色——因为人眼对不同颜色的敏感度不同。
5.3 代码示例
// 文件: akira-graphics/color-hints/app-hsl-problem.js
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(256, 256);
ctx.scale(1, -1);
// === 第一行:正常的色相渐变(0° ~ 300°)===
for(let i = 0; i < 20; i++) {
// i=0: 0°, i=19: 285°
ctx.fillStyle = `hsl(${Math.floor(i * 15)}, 50%, 50%)`;
ctx.beginPath();
ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
ctx.fill();
}
// 视觉:从左到右,颜色从红→橙→黄→绿→青→蓝,过渡自然
// === 第二行:有问题的色相插值 ===
for(let i = 0; i < 20; i++) {
// 在 60°(黄)和 210°(青)之间交替
// 然后每次增加 3°
const baseHue = i % 2 ? 60 : 210;
ctx.fillStyle = `hsl(${Math.floor(baseHue + 3 * i)}, 50%, 50%)`;
ctx.beginPath();
ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
ctx.fill();
}
// 视觉:颜色跳跃剧烈,没有平滑过渡
5.4 视觉描述
- 上方一行:20 个圆点从红色平滑过渡到蓝紫色,像一道微型彩虹
- 下方一行:颜色在黄和青之间"跳跃",看起来像闪烁的警示灯,完全没有渐变感
5.5 常见误区
误区:HSL 插值总是比 RGB 插值好
HSL 在"调整单个颜色属性"时更直观,但在"两个颜色之间插值"时,它并不比 RGB 好多少。
- RGB 插值的问题:经过灰色(物理上正确,但视觉上发灰)
- HSL 插值的问题:色相环绕、非感知均匀
对于真正平滑的颜色过渡,需要更好的色彩空间(如 CIELAB 或 Cubehelix)。
5.6 动手试一试
// 练习:实现"最短路径"的色相插值
function lerpHue(h1, h2, t) {
// 确保输入在 [0, 360)
h1 = ((h1 % 360) + 360) % 360;
h2 = ((h2 % 360) + 360) % 360;
let diff = h2 - h1;
// 如果差值 > 180,走逆时针更短
if(diff > 180) diff -= 360;
// 如果差值 < -180,走顺时针更短
if(diff < -180) diff += 360;
return (h1 + diff * t + 360) % 360;
}
// 测试:从 10° 到 350°
console.log(lerpHue(10, 350, 0.5)); // 输出 0(红色),而不是 180(青色)!
6. CIELAB 色彩空间
6.1 生活类比:公平的尺子
想象你有一把"魔法尺子",用它测量"从黑到白"的距离。在 RGB 空间里,这把尺子是不公平的——从黑到深灰的"刻度"很大,但从浅灰到白的"刻度"很小。CIELAB 就是一把公平的尺子:数值差多少,人眼就感知到多大的差异。
6.2 本质是什么
CIELAB(也叫 Lab*)是一个感知均匀(Perceptually Uniform)的色彩空间。它的设计目标是:
空间中任意两个颜色的"欧几里得距离",等于人眼感知的"颜色差异程度"。
| 维度 | 含义 | 取值范围 | 类比 |
|---|---|---|---|
| L* | 明度 | 0(黑)~ 100(白) | 灰度照片的亮度 |
| a* | 红-绿轴 | 负值=绿,正值=红 | 从绿到红的连续谱 |
| b* | 黄-蓝轴 | 负值=蓝,正值=黄 | 从蓝到黄的连续谱 |
关键洞察:Lab* 的设计基于大量人类视觉实验,它的三个轴尽量与人眼的生理感知对齐。
6.3 代码示例
// 文件: akira-graphics/color-hints/app-lab.js
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(256, 256);
ctx.scale(1, -1);
// 需要引入 d3-color 库来处理 CIELAB
/* global d3 */
// === 第一行:固定 L*=30,改变 a* 和 b* ===
for(let i = 0; i < 20; i++) {
// a* 和 b* 从 -150 变化到 +150
const a = i * 15 - 150;
const b = i * 15 - 150;
// d3.lab(L, a, b) 创建 LAB 颜色,.rgb() 转换为 RGB
const c = d3.lab(30, a, b).rgb();
ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
ctx.beginPath();
ctx.arc((i - 10) * 20, 60, 10, 0, Math.PI * 2);
ctx.fill();
}
// 视觉:颜色在色轮上旋转,但明度保持不变!
// === 第二行:改变 L*(明度),固定 a* 和 b* ===
for(let i = 0; i < 20; i++) {
const L = i * 5; // L* 从 0 增加到 100
const c = d3.lab(L, 80, 80).rgb();
ctx.fillStyle = `rgb(${c.r}, ${c.g}, ${c.b})`;
ctx.beginPath();
ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2);
ctx.fill();
}
// 视觉:颜色从黑逐渐变亮,但色调(橙黄色)保持一致!
6.4 视觉描述
- 上方一行:20 个圆点在保持相同明度(L*=30,较暗)的情况下,从绿-蓝区域逐渐过渡到红-黄区域。每个圆点的"亮度感"几乎相同,只有"色调"在变。
- 下方一行:20 个圆点从纯黑逐渐变到明亮的橙黄色。每个圆点的"色调"几乎相同,只有"亮度"在变。
6.5 常见误区
误区:CIELAB 可以直接在 CSS 中使用
目前 CSS Color Module Level 4 已经支持
lab()函数,但浏览器兼容性还不完善。实际项目中,通常先用 CIELAB 做计算,再转换回 RGB 显示。
误区:CIELAB 是"完美"的均匀空间
CIELAB 在大部分情况下足够均匀,但在极端饱和度区域仍有偏差。更精确的空间如 CIECAM02、OKLab 等已经问世,但 CIELAB 仍是工业标准。
6.6 动手试一试
// 练习:用 CIELAB 生成感知均匀的渐变色
function labGradient(startL, startA, startB, endL, endA, endB, steps) {
const colors = [];
for(let i = 0; i < steps; i++) {
const t = i / (steps - 1);
const L = startL + (endL - startL) * t;
const a = startA + (endA - startA) * t;
const b = startB + (endB - startB) * t;
const c = d3.lab(L, a, b).rgb();
colors.push(`rgb(${Math.round(c.r)},${Math.round(c.g)},${Math.round(c.b)})`);
}
return colors;
}
// 从蓝色感知均匀地过渡到黄色
const gradient = labGradient(30, -50, -80, 90, 20, 80, 10);
// 对比:如果用 RGB 或 HSL 插值,中间会出现难看的灰绿色
// 用 LAB 插值,过渡更自然!
7. Cubehelix 颜色插值
7.1 生活类比:DNA 双螺旋
想象一条彩色的 DNA 双螺旋。当你沿着螺旋前进时,颜色不仅沿着色相环旋转,同时亮度也在有规律地变化——就像从深海逐渐浮出水面,同时周围的颜色也在变化。Cubehelix 就是这种"螺旋式"的颜色映射。
7.2 本质是什么
Cubehelix 是由 D.A. Green 设计的一种颜色插值算法,专门为数据可视化而生。它的核心思想是:
在 RGB 空间中构造一条螺旋线,使得:
- 亮度(Luminance)单调变化——适合表示有序数据
- 色相连续变化——增加可分辨的颜色数量
- 对色盲友好——即使分不清色相,也能通过亮度区分
为什么叫 "Cubehelix"?
因为在 RGB 立方体中,这条颜色路径看起来像一条螺旋线(helix)!
7.3 数学原理
Cubehelix 的参数:
| 参数 | 含义 | 默认值 |
|---|---|---|
start |
起始色相(以弧度为单位,除以 2π 得圈数) | 0.5 |
r |
色相旋转圈数 | -1.5 |
hue |
饱和度(色相变化的幅度) | 1.2 |
gamma |
Gamma 校正因子 | 1.0 |
核心公式(简化版):
输入: l ∈ [0, 1] (数据的归一化值)
Step 1: 计算色相角度
φ = 2π * (start/3 + r * l)
Step 2: 计算亮度(带 gamma 校正)
brightness = l^gamma
Step 3: 计算颜色振幅(饱和度包络)
α = hue * brightness * (1 - brightness) / 2
// 注意:在两端(l=0 和 l=1)振幅为 0,中间最大
Step 4: 组合成 RGB
[R] [brightness] [cos(φ)]
[G] = [brightness] + α * M * [sin(φ)]
[B] [brightness]
其中 M 是一个固定的 3x2 变换矩阵
7.4 代码示例
// 文件: akira-graphics/color-hints/app-cubehelix.js
import {cubehelix} from '../common/lib/color/cubehelix/index.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 坐标系:原点在左下角,Y 轴向上
ctx.translate(0, 256);
ctx.scale(1, -1);
// 创建默认配置的 cubehelix 颜色映射
const color = cubehelix();
// 等价于: cubehelix({start: 0.5, r: -1.5, hue: 1.2, gamma: 1.0})
const T = 2000; // 动画周期(毫秒)
function update(t) {
// p 在 [0, 1] 之间正弦变化
const p = 0.5 + 0.5 * Math.sin(t / T);
ctx.clearRect(0, -256, 512, 512);
// 获取当前 p 对应的颜色
const {r, g, b} = color(p);
ctx.fillStyle = `rgb(${255 * r},${255 * g},${255 * b})`;
ctx.beginPath();
// 绘制一个宽度随 p 变化的矩形
ctx.rect(20, -20, 480 * p, 40);
ctx.fill();
requestAnimationFrame(update);
}
update(0);
Cubehelix 核心实现:
// 文件: akira-graphics/common/lib/color/cubehelix/index.js
const defaultHelixConfig = {start: 0.5, r: -1.5, hue: 1.2, gamma: 1.0};
export function cubehelix(overrides = {}) {
const options = Object.assign({}, defaultHelixConfig, overrides);
const {start, r, hue, gamma} = options;
return function (l) {
if((l < 0) || (l > 1)) {
throw new Error(`input must be in the [0, 1] range (was: ${l})`);
}
// 色相角度
const phi = 2 * Math.PI * (start / 3 + r * l);
// 带 gamma 校正的亮度
const exp = l ** gamma;
// 饱和度包络(两端为 0,中间最大)
const alpha = hue * exp * (1 - exp) / 2;
// RGB = 灰度基线 + 色相旋转分量
const rgb = add(
[[exp], [exp], [exp]], // 灰度基线
scalarMul(alpha, multiply(
[[-0.14861, 1.78277], // 变换矩阵
[-0.29227, -0.90649],
[1.97294, 0]],
[[Math.cos(phi)], // 旋转分量
[Math.sin(phi)]]
))
);
return {r: rgb[0], g: rgb[1], b: rgb[2]};
};
}
7.5 视觉描述
运行代码后,你会看到:
- 画布中央有一个彩色矩形条,宽度随时间周期性变化
- 颜色从黑色(p=0)开始,经过蓝紫色、绿色、黄色,最终到达白色(p=1)
- 即使将画面调成黑白,你依然能清楚地看到矩形条的亮度变化——这就是 Cubehelix 的优势
7.6 常见误区
误区:Cubehelix 适合所有场景
Cubehelix 是为有序数据(如温度、海拔、时间序列)设计的。如果数据是分类数据(如"苹果/香蕉/橙子"),用 Cubehelix 反而会造成误导,因为它暗示了顺序关系。
误区:Cubehelix 的颜色"好看"就是好的
评价颜色映射的首要标准是信息传达的准确性,不是"好看"。Cubehelix 的设计目标是:
- 亮度单调(保证色盲可读)
- 色相变化增加可分辨度
- 打印为灰度时仍有层次
7.7 动手试一试
// 练习:自定义 Cubehelix 参数,创建不同的颜色映射
// 1. 彩虹风格(更多色相旋转)
const rainbow = cubehelix({start: 0.5, r: -3, hue: 1.5, gamma: 1.0});
// 2. 暖色风格(减少旋转,集中在红-黄区域)
const warm = cubehelix({start: 0.8, r: -0.5, hue: 1.0, gamma: 1.2});
// 3. 冷色风格(集中在蓝-绿区域)
const cool = cubehelix({start: 0.3, r: -1.0, hue: 0.8, gamma: 0.9});
// 4. 高对比度(增大 hue 参数)
const highContrast = cubehelix({start: 0.5, r: -1.5, hue: 2.0, gamma: 1.0});
// 绘制颜色条对比
function drawColorBar(ctx, colorMap, y, height) {
for(let i = 0; i < 256; i++) {
const p = i / 255;
const {r, g, b} = colorMap(p);
ctx.fillStyle = `rgb(${255*r},${255*g},${255*b})`;
ctx.fillRect(i * 2, y, 2, height);
}
}
8. 颜色插值实践
8.1 生活类比:调色师的渐变板
想象一位油画调色师,要在画布上创造从日出到日落的渐变天空。他不会直接把"橙色颜料"和"紫色颜料"混在一起(那样会得到脏灰色),而是精心选择中间过渡色。数字世界中的颜色插值,就是这位调色师的"自动助手"。
8.2 本质是什么
颜色插值(Color Interpolation)是指在两种或多种颜色之间计算中间颜色的过程。不同的插值策略适用于不同场景:
| 方法 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| RGB 线性插值 | 快速预览、物理光照计算 | 计算简单、速度快 | 经过灰色,视觉不自然 |
| HSL 插值 | UI 主题色调整 | 符合直觉 | 色相环绕问题 |
| LAB 插值 | 高质量渐变、图像处理 | 感知均匀 | 需要库支持,计算复杂 |
| Cubehelix | 数据可视化 | 亮度单调、色盲友好 | 参数需要调优 |
8.3 代码示例
// 通用颜色插值函数
// 1. RGB 线性插值
function lerpRGB(c1, c2, t) {
return {
r: Math.round(c1.r + (c2.r - c1.r) * t),
g: Math.round(c1.g + (c2.g - c1.g) * t),
b: Math.round(c1.b + (c2.b - c1.b) * t),
};
}
// 2. HSL 插值(带最短路径处理)
function lerpHSL(hsl1, hsl2, t) {
let h1 = hsl1.h, h2 = hsl2.h;
let diff = h2 - h1;
if(diff > 180) diff -= 360;
if(diff < -180) diff += 360;
return {
h: (h1 + diff * t + 360) % 360,
s: hsl1.s + (hsl2.s - hsl1.s) * t,
l: hsl1.l + (hsl2.l - hsl1.l) * t,
};
}
// 3. 使用 d3 的 LAB 插值
function lerpLAB(lab1, lab2, t) {
const L = lab1.L + (lab2.L - lab1.L) * t;
const a = lab1.a + (lab2.a - lab1.a) * t;
const b = lab1.b + (lab2.b - lab1.b) * t;
return d3.lab(L, a, b).rgb();
}
// 4. 动画中的颜色插值
function animateColorTransition(element, startColor, endColor, duration) {
const startTime = performance.now();
function frame(now) {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
// 使用 ease-in-out 让过渡更自然
const easedT = t < 0.5
? 2 * t * t
: 1 - Math.pow(-2 * t + 2, 2) / 2;
const c = lerpRGB(startColor, endColor, easedT);
element.style.backgroundColor = `rgb(${c.r},${c.g},${c.b})`;
if(t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
8.4 视觉描述
不同插值方法的视觉对比:
- RGB 插值:从红色到绿色的渐变中间会出现暗黄色/棕色
- HSL 插值:从红色到绿色的渐变会经过黄色、黄绿色(绕色轮走)
- LAB 插值:从红色到绿色的渐变会经过橙色、黄色(感知上最均匀)
- Cubehelix:从黑到白的渐变同时色相也在旋转,适合热力图
8.5 常见误区
误区:插值就是"取平均值"
简单的
(a + b) / 2只是线性插值在 t=0.5 时的特例。实际插值需要考虑:
- 插值空间(RGB?HSL?LAB?)
- 缓动函数(线性?ease-in-out?)
- 颜色数量(两个颜色?多个颜色形成的色带?)
8.6 动手试一试
// 练习:创建一个"颜色插值对比器"
function drawInterpolationComparison(ctx, color1, color2) {
const width = ctx.canvas.width;
const segmentHeight = ctx.canvas.height / 4;
// 1. RGB 插值
for(let i = 0; i < width; i++) {
const t = i / width;
const c = lerpRGB(color1, color2, t);
ctx.fillStyle = `rgb(${c.r},${c.g},${c.b})`;
ctx.fillRect(i, 0, 1, segmentHeight);
}
// 2. HSL 插值
const hsl1 = rgbToHsl(color1);
const hsl2 = rgbToHsl(color2);
for(let i = 0; i < width; i++) {
const t = i / width;
const c = lerpHSL(hsl1, hsl2, t);
ctx.fillStyle = `hsl(${c.h},${c.s}%,${c.l}%)`;
ctx.fillRect(i, segmentHeight, 1, segmentHeight);
}
// 3. LAB 插值(需要 d3)
const lab1 = d3.lab(d3.rgb(color1.r, color1.g, color1.b));
const lab2 = d3.lab(d3.rgb(color2.r, color2.g, color2.b));
for(let i = 0; i < width; i++) {
const t = i / width;
const c = d3.lab(
lab1.l + (lab2.l - lab1.l) * t,
lab1.a + (lab2.a - lab1.a) * t,
lab1.b + (lab2.b - lab1.b) * t
).rgb();
ctx.fillStyle = `rgb(${c.r},${c.g},${c.b})`;
ctx.fillRect(i, segmentHeight * 2, 1, segmentHeight);
}
// 4. Cubehelix
const helix = cubehelix();
for(let i = 0; i < width; i++) {
const t = i / width;
const c = helix(t);
ctx.fillStyle = `rgb(${255*c.r},${255*c.g},${255*c.b})`;
ctx.fillRect(i, segmentHeight * 3, 1, segmentHeight);
}
}
9. Gamma 校正
9.1 生活类比:非线性的水龙头
想象一个奇怪的水龙头:拧开一点点,水流就很大;继续拧,水流增加得却很慢。这就是"非线性"——输入和输出不成正比。显示器的亮度响应就像这个水龙头:你发送的数值和实际显示的亮度不是线性关系。
9.2 本质是什么
人眼对暗部的变化更敏感,对亮部的变化较迟钝。为了利用这一特性,显示器采用了一种非线性编码:
显示亮度 = 输入电压 ^ gamma
通常 gamma ≈ 2.2
这意味着:
- 输入 0.5 时,实际亮度只有
0.5^2.2 ≈ 0.22(22%,不是 50%!) - 输入 0.25 时,实际亮度只有
0.25^2.2 ≈ 0.05(5%)
Gamma 校正就是在计算时"预先补偿"这种非线性:
线性空间值 → 伽马编码 → 存入文件/发送给显示器
文件值 → 伽马解码 → 线性空间值 → 进行光照计算
9.3 为什么重要
如果在"伽马编码后的值"上直接做光照计算(如颜色混合、插值),结果会出错:
错误做法(在伽马空间混合):
混合(0.5, 0.5) = 0.5
但显示出来是 22% 亮度,不是 50%!
正确做法(在线性空间混合):
解码: 0.5^2.2 → 0.22
混合: (0.22 + 0.22) / 2 = 0.22
编码: 0.22^(1/2.2) → 0.5
显示出来是 22% 亮度,正确!
9.4 代码示例
// Gamma 校正工具函数
const GAMMA = 2.2;
// 将线性空间的值编码为伽马空间(存入图片/显示)
function linearToGamma(linear) {
return Math.pow(linear, 1 / GAMMA);
}
// 将伽马空间的值解码为线性空间(进行计算)
function gammaToLinear(gamma) {
return Math.pow(gamma, GAMMA);
}
// 正确的颜色混合
function correctColorMix(c1, c2, t) {
// 1. 解码到线性空间
const l1 = gammaToLinear(c1 / 255);
const l2 = gammaToLinear(c2 / 255);
// 2. 在线性空间插值
const mixed = l1 + (l2 - l1) * t;
// 3. 编码回伽马空间
return Math.round(linearToGamma(mixed) * 255);
}
// 对比:错误 vs 正确
const a = 64, b = 192; // 两个灰度值
// 错误:直接混合(在伽马空间操作)
const wrong = Math.round(a + (b - a) * 0.5); // 128
// 正确:线性空间混合
const correct = correctColorMix(a, b, 0.5); // 约 138
console.log(`错误结果: ${wrong}, 正确结果: ${correct}`);
// 正确结果更大,因为线性空间的"中点"在伽马空间偏亮
9.5 常见误区
误区:Gamma 校正是"让图片更好看"的可选操作
Gamma 校正是物理正确性的要求,不是"可选美化"。在渲染、图像处理、颜色插值中忽略 Gamma 校正,会导致:
- 渐变出现"暗带"
- 半透明混合变暗
- 光照计算结果偏暗
误区:所有图片的 Gamma 都是 2.2
sRGB 标准使用约 2.2 的 Gamma,但分段线性(不是纯幂函数)。专业图像工作流中可能使用线性 EXR、对数编码等其他格式。
9.6 动手试一试
// 练习:观察 Gamma 对渐变的影响
function drawGammaComparison(ctx) {
const width = 256;
// 上方:错误(伽马空间插值)
for(let i = 0; i < width; i++) {
const v = Math.floor(i); // 0 ~ 255
ctx.fillStyle = `rgb(${v},${v},${v})`;
ctx.fillRect(i, 0, 1, 100);
}
// 下方:正确(线性空间插值后伽马编码)
for(let i = 0; i < width; i++) {
const t = i / 255; // 0 ~ 1
const linear = t; // 线性空间值
const gamma = Math.pow(linear, 1 / 2.2); // 伽马编码
const v = Math.floor(gamma * 255);
ctx.fillStyle = `rgb(${v},${v},${v})`;
ctx.fillRect(i, 120, 1, 100);
}
}
// 观察:上方的渐变在暗部变化快、亮部变化慢
// 下方的渐变更"均匀"(感知上)
10. Q&A
Q1: 我应该总是用 CIELAB 吗?它看起来是最好的。
A: 不一定。CIELAB 适合需要"感知均匀"的场景(如高质量渐变、图像处理),但:
- 计算成本高(需要转换矩阵)
- 需要库支持(如 d3-color)
- 对于简单的 UI 开发,HSL 更直观、更轻量
选择指南:
- UI 主题开发 → HSL
- 快速原型 → RGB
- 数据可视化 → Cubehelix
- 图像处理/高质量渲染 → CIELAB
Q2: 为什么 RGB(128, 128, 128) 看起来不是"50% 亮度"?
A: 因为显示器有 Gamma 校正!128/255 ≈ 0.5,但经过 Gamma 2.2 后:
实际亮度 = 0.5^2.2 ≈ 0.22 = 22%
真正的"50% 感知亮度"大约在 RGB(186, 186, 186):
0.5^(1/2.2) ≈ 0.73 → 186/255
Q3: HSL 的 L=50% 为什么对应 RGB 的 "最纯" 颜色?
A: 这是 HSL 的设计约定。当 L=50% 时,至少有一个通道为 255,至少一个通道为 0,此时颜色"不掺杂灰度",饱和度最高。L<50% 时加入黑色,L>50% 时加入白色。
Q4: Cubehelix 的默认参数是什么意思?
A:
start: 0.5:起始色相在色轮的中间位置r: -1.5:从起点逆时针旋转 1.5 圈hue: 1.2:色相变化的幅度(饱和度)gamma: 1.0:亮度线性变化
Q5: 我如何知道自己用的颜色空间对不对?
A: 问自己三个问题:
- 我的颜色需要表示"物理光照"吗?→ 需要线性空间 + Gamma 校正
- 我的颜色需要平滑过渡吗?→ 考虑 LAB 或 Cubehelix
- 我的颜色需要人类直观调整吗?→ 使用 HSL
Q6: 色盲友好设计有什么建议?
A:
- 不要仅用颜色传达信息,配合形状/文字标签
- 使用 Cubehelix 等亮度单调的映射
- 避免红-绿对比(最常见的色盲类型)
- 用工具模拟色盲视觉效果(如 Chrome DevTools 的色盲模拟)
Q7: CSS 的 color-mix() 函数用什么空间插值?
A: CSS Color Module Level 5 的 color-mix() 默认在 oklab 空间中插值(比 CIELAB 更优的现代替代),但可以通过 in srgb、in hsl 等关键字指定其他空间。
总结
| 色彩空间 | 核心特点 | 最佳用途 |
|---|---|---|
| RGB | 物理光强,加色模型 | 硬件渲染、光照计算 |
| HSL | 人类直觉,维度解耦 | UI 设计、主题系统 |
| CIELAB | 感知均匀,距离=差异 | 图像处理、高质量渐变 |
| Cubehelix | 亮度单调,色盲友好 | 数据可视化、热力图 |
理解这些色彩空间,就像画家理解不同颜料的特性——没有"最好"的颜料,只有"最适合当前创作"的选择。
"掌握色彩空间,不是为了记住公式,而是为了在需要的时候,选择正确的工具来表达正确的颜色。"
下一步:尝试修改本章的示例代码,创建你自己的颜色渐变和主题系统!