# 10 | 图形系统如何表示颜色? 你好,我是月影。从这一节课开始,我们进入一个全新的模块,开始学习视觉基础。 在可视化领域中,图形的形状和颜色信息非常重要,它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形,通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色,虽然在前面的课程中,我们也使用片元着色器给图形设置了不同的颜色,可这只是颜色的基本用法,Web图形系统对颜色的支持是非常强大的。 所以这一节课,我们就来系统地学习一下,Web图形系统中表示颜色的基本方法。我会讲四种基本的颜色表示法,分别是RGB和RGBA颜色表示法、HSL和HSV颜色表示法、CIE Lab和CIE Lch颜色表示法以及Cubehelix色盘。 不过,因为颜色表示实际上是一门非常复杂的学问,与我们自己的视觉感知以及心理学都有很大的关系,所以这节课我只会重点讲解它们的应用,不会去细说其中复杂的算法实现和规则细节。但我也会在课后给出一些拓展阅读的链接,如果你有兴趣,可以利用它们深入来学。 ## RGB和RGBA颜色 作为前端工程师,你一定对RGB和RGBA颜色比较熟悉。在Web开发中,我们首选的颜色表示法就是RGB和RGBA。那我们就先来说说它的应用。 ### 1\. RGB和RGBA的颜色表示法 我们在CSS样式中看到的形式如#RRGGBB的颜色代码,就是RGB颜色的十六进制表示法,其中RR、GG、BB分别是两位十六进制数字,表示红、绿、蓝三色通道的**色阶**。色阶可以表示某个通道的强弱。 因为RGB(A)颜色用两位十六进制数来表示每一个通道的色阶,所以每个通道一共有256阶,取值是0到255。RGB的三个通道色阶的组合,理论上一共能表示224 也就是一共16777216种不同的颜色。因此,RGB颜色是将人眼可见的颜色表示为**红、绿、蓝**三原色不同色阶的混合。我们可以用一个三维立方体,把RGB能表示的所有颜色形象地描述出来。效果如下图: ![](https://static001.geekbang.org/resource/image/5f/70/5ff37612dff2e7a89c58fcdc91236270.jpg "RGB的所有颜色") 那RGB能表示人眼所能见到的所有颜色吗?事实上,RGB色值只能表示这其中的一个区域。如下图所示,灰色区域是人眼所能见到的全部颜色,中间的三角形是RGB能表示的所有颜色,你可以明显地看出它们的对比。 ![](https://static001.geekbang.org/resource/image/95/6d/950d5507a41978byyd28d32bb81e736d.jpg "人眼看到的颜色vsRGB能表示的颜色") 尽管RGB色值不能表示人眼可见的全部颜色,但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。 在浏览器中,CSS一般有两种表示RGB颜色值的方式:一种是我们前面说的#RRGGBB表示方式,另一种是直接用rgb(red, green, blue)表示颜色,这里的“red、green、blue”是十进制数值。RGB颜色值的表示方式,你应该比较熟悉,我就不多说了。 好,理解了RGB之后,我们就很容易理解RGBA了。它其实就是在RGB的基础上增加了一个Alpha通道,也就是透明度。一些新版本的浏览器,可以用#RRGGBBAA的形式来表示RGBA色值,但是较早期的浏览器,只支持rgba(red, green, blue, alpha)这种形式来表示色值(注意:这里的alpha是一个从0到1的数)。所以,在实际使用的时候,我们要注意这一点。 WebGL的shader默认支持RGBA。因为在WebGL的shader中,我们是使用一个四维向量来表示颜色的,向量的r、g、b、a分量分别表示红色、绿色、蓝色和alpha通道。不过和CSS的颜色表示稍有不同的是,WebGL采用归一化的浮点数值,也就是说,WebGL的颜色分量r、g、b、a的数值都是0到1之间的浮点数。 ### 2\. RGB颜色表示法的局限性 RGB和RGBA的颜色表示法非常简单,但使用起来也有局限性(因为RGB和RGBA本质上其实非常相似,只不过后者比前者多了一个透明度通道。方便起见,我们后面就用RGB来代表RGB和RGBA了)。 因为对一个RGB颜色来说,我们只能大致直观地判断出它偏向于红色、绿色还是蓝色,或者在颜色立方体的大致位置。所以,在对比两个RGB颜色的时候,我们只能通过对比它们在RGB立方体中的相对距离,来判断它们的颜色差异。除此之外,我们几乎就得不到其他任何有用的信息了。 也就是说,**当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明**。因此,RGB颜色对用户其实并不友好。 这么说可能还是比较抽象,我们来看一个简单的例子。这里,我们在画布上显示3组颜色不同的圆,每组各5个,用来表示重要程度不同的信息。现在我们给这些圆以随机的RGB颜色,代码如下: ``` import {Vec3} from '../common/lib/math/vec3.js'; const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); function randomRGB() { return new Vec3( 0.5 * Math.random(), 0.5 * Math.random(), 0.5 * Math.random(), ); } ctx.translate(256, 256); ctx.scale(1, -1); for(let i = 0; i < 3; i++) { const colorVector = randomRGB(); for(let j = 0; j < 5; j++) { const c = colorVector.clone().scale(0.5 + 0.25 * j); ctx.fillStyle = `rgb(${Math.floor(c[0] * 256)}, ${Math.floor(c[1] * 256)}, ${Math.floor(c[2] * 256)})`; ctx.beginPath(); ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2); ctx.fill(); } } ``` 通过执行上面的代码,我们生成随机的三维向量,然后将它转成RGB颜色。为了保证对比,我们在每一组的5个圆中,依次用0.5、0.75、1.0、1.25和1.5的比率乘上我们随机生成的RGB数值。这样,一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗,越右边的越亮。得到的效果如下: ![](https://static001.geekbang.org/resource/image/9f/a8/9f8af87f3af968e8e70d0ee09a8a8da8.jpeg) 但是,这么做有两个缺点:首先,因为这个例子里的RGB颜色是随机产生的,所以行与行之间的颜色差别可能很大,也可能很小,我们无法保证具体的颜色差别大小;其次,因为无法控制随机生成的颜色本身的亮度,所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如,下图中另一组随机生成的圆,除了第一行外,后面两行的颜色都很暗,区分度太差。 ![](https://static001.geekbang.org/resource/image/33/39/334f2162ab2fc98cae325feacaf6d639.jpeg) 因此,在需要**动态构建视觉颜色效果**的时候,我们很少直接选用RGB色值,而是使用其他的颜色表示形式。这其中,比较常用的就是HSL和HSV颜色表示形式。 ## HSL和HSV颜色 与RGB颜色以色阶表示颜色不同,HSL和HSV用色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。其中,Hue是角度,取值范围是0到360度,饱和度和亮度/明度的值都是从0到100%。 虽然HSL和HSV在[表示方法](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4#/media/File:Hsl-hsv_models.svg)上有一些区别,但它们能达到的效果比较接近。所以就目前来说,我们并不需要深入理解它们之间的区别,只要学会HSL和HSV通用的颜色表示方法就可以了。 ### 1\. HSL和HSV的颜色表示方法 HSL和HSV是怎么表示颜色的呢?实际上,我们可以把HSL和HSV颜色理解为,是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和RGB色值是一一对应的。 ![](https://static001.geekbang.org/resource/image/79/22/79400fe9ded6298b3d7d13f91a64ac22.jpg "HSL和HSV的产生原理") 从上图中,你可以发现,它们之间色值的互转算法比较复杂。不过好在,CSS和Canvas2D都可以直接支持HSL颜色,只有WebGL需要做转换。所以,如果你有兴趣深入了解这个转换算法,可以去看一下我课后给出的推荐阅读。那在这里,你只需要记住我下面给出的这一段RGB和HSV的转换代码就可以了,后续课程中我们会用到它。 ``` vec3 rgb2hsv(vec3 c){ vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0); vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g)); vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r)); float d = q.x - min(q.w, q.y); float e = 1.0e-10; return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x); } vec3 hsv2rgb(vec3 c){ vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0), 6.0)-3.0)-1.0, 0.0, 1.0); rgb = rgb * rgb * (3.0 - 2.0 * rgb); return c.z * mix(vec3(1.0), rgb, c.y); } ``` 好,记住了转换代码之后。下面,我们直接用HSL颜色改写前面绘制三排圆的例子。这里,我们只要把代码稍微做一些调整。 ``` function randomColor() { return new Vec3( 0.5 * Math.random(), // 初始色相随机取0~0.5之间的值 0.7, // 初始饱和度0.7 0.45, // 初始亮度0.45 ); } ctx.translate(256, 256); ctx.scale(1, -1); const [h, s, l] = randomColor(); for(let i = 0; i < 3; i++) { const p = (i * 0.25 + h) % 1; for(let j = 0; j < 5; j++) { const d = j - 2; ctx.fillStyle = `hsl(${Math.floor(p * 360)}, ${Math.floor((0.15 * d + s) * 100)}%, ${Math.floor((0.12 * d + l) * 100)}%)`; ctx.beginPath(); ctx.arc((j - 2) * 60, (i - 1) * 60, 20, 0, Math.PI * 2); ctx.fill(); } } ``` 如上面代码所示,我们生成随机的HSL颜色,主要是随机色相H,然后我们将H值的角度拉开,就能保证三组圆彼此之间的颜色差异比较大。 接着,我们增大每一列圆的饱和度和亮度,这样每一行圆的亮度和饱和度就都不同了。但要注意的是,我们要同时增大亮度和饱和度。因为根据HSL的规则,亮度越高,颜色越接近白色,只有同时提升饱和度,才能确保圆的颜色不会太浅。 ![](https://static001.geekbang.org/resource/image/68/a6/68031c6964650bc2f31def0f93fda2a6.jpeg) ### 2\. HSL和HSV的局限性 不过,从上面的例子中你也可以看出来,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是为什么呢?这里我先卖个关子,我们先来做一个简单的实验。 ``` for(let i = 0; i < 20; i++) { 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++) { ctx.fillStyle = `hsl(${Math.floor((i % 2 ? 60 : 210) + 3 * i)}, 50%, 50%)`; ctx.beginPath(); ctx.arc((i - 10) * 20, -60, 10, 0, Math.PI * 2); ctx.fill(); } ``` 如上面代码所示,我们绘制两排不同的圆,让第一排每个圆的色相间隔都是15,再让第二排圆的颜色在色相60和210附近两两交错。然后,我们让这两排圆的饱和度和亮度都是50%,最终生成的效果如下: ![](https://static001.geekbang.org/resource/image/67/61/674ae06ae45050bb2e9840a1c081b661.jpeg) 先看第一排圆你会发现,虽然它们的色相相差都是15,但是相互之间颜色变化并不是均匀的,尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆,虽然这些圆的亮度都是50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。 因此,HSL依然不是最完美的颜色方法,我们还需要建立一套针对人类知觉的标准,这个标准在描述颜色的时候要尽可能地满足以下2个原则: 1. 人眼看到的色差 = 颜色向量间的欧氏距离 2. 相同的亮度,能让人感觉亮度相同 于是,一个针对人类感觉的颜色描述方式就产生了,它就是CIE Lab。 ## CIE Lab和CIE Lch颜色 CIE Lab颜色空间简称Lab,它其实就是一种符合人类感觉的色彩空间,它用L表示亮度,a和b表示颜色对立度。RGB值也可以Lab转换,但是转换规则比较复杂,你可以通过[wikipedia.org](https://en.wikipedia.org/wiki/CIELAB_color_space)来进一步了解它的基本原理。 CIE Lab比较特殊的一点是,目前还没有能支持CIE Lab的图形系统,但是[css-color level4](https://www.w3.org/TR/css-color-4/#funcdef-lab)规范已经给出了Lab颜色值的定义。 ``` lab() = lab( [ / ]? ) ``` 而且,一些JavaScript库也已经可以直接处理Lab颜色空间了,如[d3-color](https://github.com/d3/d3-color)。下面,我们通过一个代码例子来详细讲讲,d3.lab是怎么处理Lab颜色的。如下面代码所示,我们使用d3.lab来定义Lab色彩。这个例子与HSL的例子一样,也是显示两排圆形。这里,我们让第一排相邻圆形之间的lab色值的欧氏空间距离相同,第二排相邻圆形之间的亮度按5阶的方式递增。 ``` /* global d3 */ for(let i = 0; i < 20; i++) { const c = d3.lab(30, i * 15 - 150, i * 15 - 150).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(); } for(let i = 0; i < 20; i++) { const c = d3.lab(i * 5, 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(); } ``` 代码最终的运行效果如下: ![](https://static001.geekbang.org/resource/image/9c/92/9c0898db90d53fe4fd3e6b3c83043d92.jpeg) 你会发现,在以CIELab方式呈现的色彩变化中,我们设置的数值和人眼感知的一致性比较强。 而CIE Lch和CIE Lab的对应方式类似于RGB和HSL和HSV的对应方式,也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系,这里就不再多说了。CIE Lch和CIE Lab表示颜色的技术还比较新,所以目前我们也不会接触很多,但是因为它能呈现的色彩更贴近人眼的感知,所以我相信它会发展得很快。作为技术人,这些新技术,我们也要持续关注。 ## Cubehelix色盘 最后,我们再来说一种特殊的颜色表示法,Cubehelix色盘(立方螺旋色盘)。简单来说,它的原理就是在RGB的立方中构建一段螺旋线,让色相随着亮度增加螺旋变换。如下图所示: ![](https://static001.geekbang.org/resource/image/28/0c/281b8bbdeebyy0f3e62c80278267150c.jpg "Cubehelix色盘的原理") 我们还是直接来看它的应用。接下来,我会直接用NPM上的[cubehelix](https://www.npmjs.com/package/cubehelix)模块写一个颜色随着长度变化的柱状图,你可以通过它来看看Cubehelix是怎么应用的。效果如下: ![](https://static001.geekbang.org/resource/image/4d/ef/4d71b84fc3282e84a98141e444f658ef.gif) 它的实现代码也非常简单,我来简单说一下思路。 首先,我们直接使用cubehelix函数创建一个color映射。cubehelix函数是一个高阶函数,它的返回值是一个色盘映射函数。这个返回函数的参数范围是0到1,当它从小到大依次改变的时候,不仅颜色会依次改变,亮度也会依次增强。然后,我们用正弦函数来模拟数据的周期性变化,通过color§获取当前的颜色值,再把颜色值赋给ctx.fillStyle,颜色就能显示出来了。最后,我们用rect将柱状图画出来,用requestAnimationFrame实现动画就可以了 。 ``` import {cubehelix} from 'cubehelix'; const canvas = document.querySelector('canvas'); const ctx = canvas.getContext('2d'); ctx.translate(0, 256); ctx.scale(1, -1); const color = cubehelix(); // 构造cubehelix色盘颜色映射函数 const T = 2000; function update(t) { const p = 0.5 + 0.5 * Math.sin(t / T); ctx.clearRect(0, -256, 512, 512); const {r, g, b} = color(p); ctx.fillStyle = `rgb(${255 * r},${255 * g},${255 * b})`; ctx.beginPath(); ctx.rect(20, -20, 480 * p, 40); ctx.fill(); window.ctx = ctx; requestAnimationFrame(update); } update(0); ``` 到这里,我们关于颜色表示的讨论就告一段落了。这4种颜色方式的具体应用你应该已经掌握了,那我再来说说在实际工作中,它们的具体使用场景,这样你就能记得更深刻了。 在可视化应用里,一般有两种使用颜色的方式:第一种,整个项目的UI配色全部由UI设计师设计好,提供给可视化工程师使用。那在这种情况下,设计师设计的颜色是多少就是多少,开发者使用任何格式的颜色都行。第二种方式就是根据数据情况由前端动态地生成颜色值。当然不会是整个项目都由开发者完全自由选择,而一般是由设计师定下视觉基调和一些主色,开发者根据主色和数据来生成对应的颜色。 在一般的图表呈现项目中,第一种方式使用较多。而在一些数据比较复杂的项目中,我们经常会使用第二种方式。尤其是当我们希望连续变化的数据能够呈现连续的颜色变换时,设计师就很难用预先指定的有限的颜色来表达了。这时候,我们就需要使用其他的方式,比如,HLS、CIELab或者Cubehelix色盘,我们会把它们结合数据变量来动态生成颜色值。 ## 要点总结 这一节课,我们系统地学习了Web图形系统表示颜色的方法。它们可以分为2大类,分别是RGB、HSL和HSV、CIELab和CIELch等颜色空间的表示方法,以及Cubehelix色盘的表示方法。 首先,RGB用三原色的色阶来表示颜色,是最基础的颜色表示法,但是它对用户不够友好。而HSL和HSV是用色相、饱和度、亮度(明度)来表示颜色,对开发者比较友好,但是它的数值变换与人眼感知并不完全相符。 CIELab和CIELch与Cubehelix色盘,这两种颜色表示法还比较新,在实际工作中使用得不是很多。其中,CIELab和CIELch是与人眼感知相符的色彩空间表示法,已经被纳入css-color level4规范中。虽然还没有被浏览器支持,但是一些如d3-color这样的JavaScript库可以直接处理Lab颜色空间。而如果我们要呈现颜色随数据动态改变的效果,那Cubehelix色盘就是一种非常更合适的选择了。 最后,我还想再啰嗦几句。在可视化中,我们会使用图形的大小、高低、宽窄、颜色和形状这些常见信息来反映数据。一般来说,我们会使用一种叫做二维强化的技巧,来叠加两个维度的信息,从而加强可视化的视觉呈现效果。 比如,柱状图的高低表示了数据的多少,但是如果这个数据非常重要,那么我们在给柱状图设置不同高低的同时,再加上颜色的变化,就能让这个柱状图更具视觉冲击力。这也是我们必须要学会熟练运用颜色的原因。 所以,颜色的使用在可视化呈现中非常重要,在之后的课程中,我们还会继续深入探讨颜色的用法。 ## 小试牛刀 我在课程中给出了hsv和rgb互转的glsl代码。你能尝试用WebGL画两个圆,让它们的角度对应具体的HUE色相值,让其中一个圆的半径对应饱和度S,另一个圆的半径对应明度V,将HSV色盘绘制出来吗? 欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见! * * * ## 源码 [本节课示例代码完整版](https://github.com/akira-cn/graphics/tree/master/color-hints) ## 推荐阅读 颜色也是可视化非常重要的内容,所以这节课的知识点比较多,参考资料也很多。如果你有兴趣深入研究,我建议你一定要认真看看我给的这些资料。 \[1\] [CSS Color Module Level 4](https://www.w3.org/TR/css-color-4) \[2\] [RGB color model](https://www.wikiwand.com/en/RGB_color_model) \[3\] [HSL和HSV色彩空间](https://zh.wikipedia.org/wiki/HSL%E5%92%8CHSV%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4) \[4\] [色彩空间中的 HSL、HSV、HSB的区别](https://www.zhihu.com/question/22077462) \[5\] [用JavaScript实现RGB-HSL-HSB相互转换的方法](http://wsyks.github.io/2017/03/17/JS%E5%AE%9E%E7%8E%B0RGB-HSL-HSB%E7%9B%B8%E4%BA%92%E8%BD%AC%E6%8D%A2/) \[6\] [Lab色彩空间维基百科](https://zh.wikipedia.org/wiki/Lab%E8%89%B2%E5%BD%A9%E7%A9%BA%E9%97%B4) \[7\] [Cubehelix颜色表算法](https://zhuanlan.zhihu.com/p/121055691) \[8\] [Dave Green’s \`cubehelix’ colour scheme](http://www.mrao.cam.ac.uk/~dag/CUBEHELIX/) \[9\] [d3-color官方文档](https://d3js.org.cn/document/d3-color)