第六章:颜色与色彩空间 (Colors and Color Spaces)

"色彩是光的艺术,而理解色彩空间则是数字艺术家的必修课。" —— 写给初级程序员的话


目录

  1. 计算机如何表示颜色:RGB 模型
  2. RGB 颜色的生成与缩放
  3. HSL 色彩空间:色相、饱和度、明度
  4. 为什么 HSL 对人类更直观
  5. HSL 颜色插值的问题
  6. CIELAB 色彩空间:感知均匀的颜色
  7. Cubehelix 颜色插值:数据可视化的利器
  8. 渐变与动画中的颜色插值
  9. Gamma 校正基础
  10. 常见问题 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 本质是什么

人类感知颜色时,大脑会自动分离三个信息:

  1. 这是什么颜色?(红色?蓝色?)→ 对应 HSL 的 H(色相)
  2. 这颜色鲜艳吗?(鲜红 vs 暗红)→ 对应 HSL 的 S(饱和度)
  3. 这颜色亮吗?(在强光下还是阴影中)→ 对应 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 空间中构造一条螺旋线,使得:

  1. 亮度(Luminance)单调变化——适合表示有序数据
  2. 色相连续变化——增加可分辨的颜色数量
  3. 对色盲友好——即使分不清色相,也能通过亮度区分

为什么叫 "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 的设计目标是:

  1. 亮度单调(保证色盲可读)
  2. 色相变化增加可分辨度
  3. 打印为灰度时仍有层次

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: 问自己三个问题:

  1. 我的颜色需要表示"物理光照"吗?→ 需要线性空间 + Gamma 校正
  2. 我的颜色需要平滑过渡吗?→ 考虑 LAB 或 Cubehelix
  3. 我的颜色需要人类直观调整吗?→ 使用 HSL

Q6: 色盲友好设计有什么建议?

A:

  • 不要仅用颜色传达信息,配合形状/文字标签
  • 使用 Cubehelix 等亮度单调的映射
  • 避免红-绿对比(最常见的色盲类型)
  • 用工具模拟色盲视觉效果(如 Chrome DevTools 的色盲模拟)

Q7: CSS 的 color-mix() 函数用什么空间插值?

A: CSS Color Module Level 5 的 color-mix() 默认在 oklab 空间中插值(比 CIELAB 更优的现代替代),但可以通过 in srgbin hsl 等关键字指定其他空间。


总结

色彩空间 核心特点 最佳用途
RGB 物理光强,加色模型 硬件渲染、光照计算
HSL 人类直觉,维度解耦 UI 设计、主题系统
CIELAB 感知均匀,距离=差异 图像处理、高质量渐变
Cubehelix 亮度单调,色盲友好 数据可视化、热力图

理解这些色彩空间,就像画家理解不同颜料的特性——没有"最好"的颜料,只有"最适合当前创作"的选择。


"掌握色彩空间,不是为了记住公式,而是为了在需要的时候,选择正确的工具来表达正确的颜色。"

下一步:尝试修改本章的示例代码,创建你自己的颜色渐变和主题系统!