你好,我是月影。从这一节课开始,我们进入一个全新的模块,开始学习视觉基础。
在可视化领域中,图形的形状和颜色信息非常重要,它们都可以用来表达数据。我们利用基本的数学方法可以绘制出各种各样的图形,通过仿射变换还能改变图形的形状、大小和位置。但关于图形的颜色,虽然在前面的课程中,我们也使用片元着色器给图形设置了不同的颜色,可这只是颜色的基本用法,Web图形系统对颜色的支持是非常强大的。
所以这一节课,我们就来系统地学习一下,Web图形系统中表示颜色的基本方法。我会讲四种基本的颜色表示法,分别是RGB和RGBA颜色表示法、HSL和HSV颜色表示法、CIE Lab和CIE Lch颜色表示法以及Cubehelix色盘。
不过,因为颜色表示实际上是一门非常复杂的学问,与我们自己的视觉感知以及心理学都有很大的关系,所以这节课我只会重点讲解它们的应用,不会去细说其中复杂的算法实现和规则细节。但我也会在课后给出一些拓展阅读的链接,如果你有兴趣,可以利用它们深入来学。
作为前端工程师,你一定对RGB和RGBA颜色比较熟悉。在Web开发中,我们首选的颜色表示法就是RGB和RGBA。那我们就先来说说它的应用。
我们在CSS样式中看到的形式如#RRGGBB的颜色代码,就是RGB颜色的十六进制表示法,其中RR、GG、BB分别是两位十六进制数字,表示红、绿、蓝三色通道的色阶。色阶可以表示某个通道的强弱。
因为RGB(A)颜色用两位十六进制数来表示每一个通道的色阶,所以每个通道一共有256阶,取值是0到255。RGB的三个通道色阶的组合,理论上一共能表示224 也就是一共16777216种不同的颜色。因此,RGB颜色是将人眼可见的颜色表示为红、绿、蓝三原色不同色阶的混合。我们可以用一个三维立方体,把RGB能表示的所有颜色形象地描述出来。效果如下图:
那RGB能表示人眼所能见到的所有颜色吗?事实上,RGB色值只能表示这其中的一个区域。如下图所示,灰色区域是人眼所能见到的全部颜色,中间的三角形是RGB能表示的所有颜色,你可以明显地看出它们的对比。
尽管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之间的浮点数。
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数值。这样,一组圆就能呈现不同的亮度了。总体上颜色是越左边的越暗,越右边的越亮。得到的效果如下:
但是,这么做有两个缺点:首先,因为这个例子里的RGB颜色是随机产生的,所以行与行之间的颜色差别可能很大,也可能很小,我们无法保证具体的颜色差别大小;其次,因为无法控制随机生成的颜色本身的亮度,所以这样生成的一组圆的颜色有可能都很亮或者都很暗。比如,下图中另一组随机生成的圆,除了第一行外,后面两行的颜色都很暗,区分度太差。
因此,在需要动态构建视觉颜色效果的时候,我们很少直接选用RGB色值,而是使用其他的颜色表示形式。这其中,比较常用的就是HSL和HSV颜色表示形式。
与RGB颜色以色阶表示颜色不同,HSL和HSV用色相(Hue)、饱和度(Saturation)和亮度(Lightness)或明度(Value)来表示颜色。其中,Hue是角度,取值范围是0到360度,饱和度和亮度/明度的值都是从0到100%。
虽然HSL和HSV在表示方法上有一些区别,但它们能达到的效果比较接近。所以就目前来说,我们并不需要深入理解它们之间的区别,只要学会HSL和HSV通用的颜色表示方法就可以了。
HSL和HSV是怎么表示颜色的呢?实际上,我们可以把HSL和HSV颜色理解为,是将RGB颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和RGB色值是一一对应的。
从上图中,你可以发现,它们之间色值的互转算法比较复杂。不过好在,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的规则,亮度越高,颜色越接近白色,只有同时提升饱和度,才能确保圆的颜色不会太浅。
不过,从上面的例子中你也可以看出来,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是为什么呢?这里我先卖个关子,我们先来做一个简单的实验。
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%,最终生成的效果如下:
先看第一排圆你会发现,虽然它们的色相相差都是15,但是相互之间颜色变化并不是均匀的,尤其是中间几个绿色圆的颜色比较接近。接着我们再看第二排圆,虽然这些圆的亮度都是50%,但是蓝色和紫色的圆看起来就是不如偏绿偏黄的圆亮。这都是由于人眼对不同频率的光的敏感度不同造成的。
因此,HSL依然不是最完美的颜色方法,我们还需要建立一套针对人类知觉的标准,这个标准在描述颜色的时候要尽可能地满足以下2个原则:
于是,一个针对人类感觉的颜色描述方式就产生了,它就是CIE Lab。
CIE Lab颜色空间简称Lab,它其实就是一种符合人类感觉的色彩空间,它用L表示亮度,a和b表示颜色对立度。RGB值也可以Lab转换,但是转换规则比较复杂,你可以通过wikipedia.org来进一步了解它的基本原理。
CIE Lab比较特殊的一点是,目前还没有能支持CIE Lab的图形系统,但是css-color level4规范已经给出了Lab颜色值的定义。
lab() = lab( <percentage> <number> <number> [ / <alpha-value> ]? )
而且,一些JavaScript库也已经可以直接处理Lab颜色空间了,如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();
}
代码最终的运行效果如下:
你会发现,在以CIELab方式呈现的色彩变化中,我们设置的数值和人眼感知的一致性比较强。
而CIE Lch和CIE Lab的对应方式类似于RGB和HSL和HSV的对应方式,也是将坐标从立方体的直角坐标系变换为圆柱体的极坐标系,这里就不再多说了。CIE Lch和CIE Lab表示颜色的技术还比较新,所以目前我们也不会接触很多,但是因为它能呈现的色彩更贴近人眼的感知,所以我相信它会发展得很快。作为技术人,这些新技术,我们也要持续关注。
最后,我们再来说一种特殊的颜色表示法,Cubehelix色盘(立方螺旋色盘)。简单来说,它的原理就是在RGB的立方中构建一段螺旋线,让色相随着亮度增加螺旋变换。如下图所示:
我们还是直接来看它的应用。接下来,我会直接用NPM上的cubehelix模块写一个颜色随着长度变化的柱状图,你可以通过它来看看Cubehelix是怎么应用的。效果如下:
它的实现代码也非常简单,我来简单说一下思路。
首先,我们直接使用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色盘绘制出来吗?
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
颜色也是可视化非常重要的内容,所以这节课的知识点比较多,参考资料也很多。如果你有兴趣深入研究,我建议你一定要认真看看我给的这些资料。
[1] CSS Color Module Level 4
[2] RGB color model
[3] HSL和HSV色彩空间
[4] 色彩空间中的 HSL、HSV、HSB的区别
[5] 用JavaScript实现RGB-HSL-HSB相互转换的方法
[6] Lab色彩空间维基百科
[7] Cubehelix颜色表算法
[8] Dave Green’s `cubehelix’ colour scheme
[9] d3-color官方文档
评论