【可视化学习】80-图形系统如何表示颜色
发表于:2024-08-15 |

前言

最近公司比较忙,没咋更新博客,加上游戏(王者荣耀)运气爆棚,开了个荣耀水晶,换了个小兵皮肤,想多体验几把,因此晚上回去也就没更新博客。
荣耀水晶

今天比较空,就和大家来分享一下 web 中的颜色,这个东西大家平时一定用过,但是对其了解并不是那么深刻,本篇文章就和小伙伴来浅浅聊聊颜色这个话题。

RGB 与 RGBA

我们在 CSS 样式中看到的形式如 #RRGGBB 的颜色代码,就是 RGB 颜色的十六进制表示法,其中 RR、GG、BB 分别是两位十六进制数字,表示红、绿、蓝三色通道的色阶。色阶可以表示某个通道的强弱。

十六进制

这里简单展开说明一下十六进制是什么,相信学习计算机的同学都是知道的,我这里也就简单说一下,我们日常了解的是十进制,也就是 0-9,而十六进制是满 16 位进一,用 0-9,A,B,C,D,E,F 来表示,比如 A 就是 10,B 就是 11,依次类推,F 就是 15
十六进制

更多十六进制的知识我就不展开了,大家如果真的不了解可以自己去翻阅别的资料

三原色

我们为什么要选取红绿蓝呢,这是因为这三种颜色是色彩中不能分解的三种基础颜色,我们可以通过这三种颜色混合出我们想要的绝大多数颜色(美术领域的三原色是红黄蓝,如果你在别的地方说三原色是红黄蓝也别急着去杠别人是错的,在其他比如印刷的三原色又是其他的)。我们在上面说过了,RGB 每个都是由俩个 16 进制表示颜色的色阶,也就是#RRGGBB,每俩位代表每个原色在这个颜色中的比例,比如#FF0000,因为代表红色的比例拉到了最大,FF,而其他位置(绿蓝)的比例都是 0,那么这个红色就是最纯色的红色。

RGB 能代表的颜色

首先我们要明确,RGB 每俩位代表一个色阶的占比,那么也就是我们可以用 RGB 表示,1616161616*16 种颜色,理论上一共能表示 2^24 也就是一共 16777216 种,因此,RGB 颜色是将人眼可见的颜色表示为红、绿、蓝三原色不同色阶的混合。我们可以用一个三维立方体,把 RGB 能表示的所有颜色形象地描述出来。效果如下图:
RGB代表颜色

混色

我们来继续了解一下 RGB 的混合会产生怎么样的颜色,以下是最简单的几种混色,前三个是间色(三原色俩俩等比例混合)
(红)+(绿)=(黄) #FFFF00
(蓝)+(绿)=(青) #00FFFF
(红)+(蓝)=(品红)#FF00FF
(绿)+(蓝)+(红)=(白)#FFFFF
(黑) #000000

混色

冷暖色,邻近色,同类色,互补色,类似色

冷暖色

冷暖色指色彩心理上的冷热感觉。红、橙、黄、棕等色往往给人热烈、兴奋、热情、温和的感觉,所以将其称为暖色。绿、蓝、紫等色往往给人镇静、凉爽、开阔,通透的感觉,所以将其称为冷色。色彩的冷暖感觉又被称为冷暖性。色彩的冷暖感觉是相对的,除橙色与蓝色是色彩冷暖的两个极端外,其他许多色彩的冷暖感觉都是相对存在的。比如说紫色和黄色,紫色中的红紫色较暖,而蓝紫色则较冷。

冷暖色

邻近色

邻近色,所谓邻近色,就是在色带上相邻近的颜色,例如绿色和蓝色,红色和黄色就互为邻近色。邻近色之间往往是你中有我,我中有你。比如:朱红与桔黄,朱红以红为主,里面略有少量黄色;桔黄以黄为主,里面有少许红色,虽然它们在色相上有很大差别,但在视觉上却比较接近。在色轮中,凡在 60 度范围之内的颜色都属邻近色的范围。

同类色

指色相性质相同,但色度有深浅之分。如深红与浅红。是色相环中 15° 夹角内的颜色

类似色

在色轮上 90 度角内相邻接的色统称为类似色。类似色由于色相对比不强给人有色感平静、调和的感觉因此在配色中常应用 。

对比色

对比色 在色相环中每一个颜色对面(180 度对角)的颜色,称为”对比色(互补色)”。把对比色放在一起。会给人强烈的排斥感。若混合在一起,会调出浑浊的颜色。如:红与绿,蓝与橙,黄与紫互为对比色。

RGBA 与 RGB 的局限性

RGB 能表示人眼所能见到的所有颜色吗?事实上,RGB 色值只能表示这其中的一个区域。如下图所示,灰色区域是人眼所能见到的全部颜色,中间的三角形是 RGB 能表示的所有颜色,你可以明显地看出它们的对比。
局限性
尽管 RGB 色值不能表示人眼可见的全部颜色,但它可以 表示的颜色也已经足够丰富了。一般的显示器、彩色打印机、扫描仪等都支持它。

好,理解了 RGB 之后,我们就很容易理解 RGBA 了。它其实就是在 RGB 的基础上增加了一个 Alpha 通道,也就是透明度。一些新版本的浏览器,可以用 #RRGGBBAA 的形式来表示 RGBA 色值,但是较早期的浏览器,只支持 rgba(red, green, blue, alpha) 这种形式来表示色值(注意:这里的 alpha 是一个从 0 到 1 的数)。所以,在实际使用的时候,我们要注意这一点。

局限性:当要选择一组颜色给图表使用时,我们并不知道要以什么样的规则来配置颜色,才能让不同数据对应的图形之间的对比尽可能鲜明。

举个例子:在画布上显示 3 组颜色不同的圆,每组各 5 个,用来表示重要程度不同的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RGB(A) 颜色表示法的局限性</title>
<style>
canvas {
border: 1px dashed salmon;
}
</style>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script type="module">
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);

// 转成 RGB 颜色
for (let i = 0; i < 3; i++) {
const colorVector = randomRGB();
for (let j = 0; j < 5; j++) {
/**
* 依次用 0.5、0.75、1.0、1.25 和 1.5 的比率乘上随机生成的 RGB 数值,这样,一组圆就能呈现不同的亮 度了。总体上颜色是越左边的越暗,越右边的越亮
*/
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();
}
}
</script>
</body>
</html>
vec3.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import * as Vec3Func from "./functions/Vec3Func.js";

export class Vec3 extends Array {
constructor(x = 0, y = x, z = x) {
super(x, y, z);
return this;
}

get x() {
return this[0];
}

get y() {
return this[1];
}

get z() {
return this[2];
}

set x(v) {
this[0] = v;
}

set y(v) {
this[1] = v;
}

set z(v) {
this[2] = v;
}

set(x, y = x, z = x) {
if (x.length) return this.copy(x);
Vec3Func.set(this, x, y, z);
return this;
}

copy(v) {
Vec3Func.copy(this, v);
return this;
}

add(va, vb) {
if (vb) Vec3Func.add(this, va, vb);
else Vec3Func.add(this, this, va);
return this;
}

sub(va, vb) {
if (vb) Vec3Func.subtract(this, va, vb);
else Vec3Func.subtract(this, this, va);
return this;
}

multiply(v) {
if (v.length) Vec3Func.multiply(this, this, v);
else Vec3Func.scale(this, this, v);
return this;
}

divide(v) {
if (v.length) Vec3Func.divide(this, this, v);
else Vec3Func.scale(this, this, 1 / v);
return this;
}

inverse(v = this) {
Vec3Func.inverse(this, v);
return this;
}

// Can't use 'length' as Array.prototype uses it
len() {
return Vec3Func.length(this);
}

distance(v) {
if (v) return Vec3Func.distance(this, v);
else return Vec3Func.length(this);
}

squaredLen() {
return Vec3Func.squaredLength(this);
}

squaredDistance(v) {
if (v) return Vec3Func.squaredDistance(this, v);
else return Vec3Func.squaredLength(this);
}

negate(v = this) {
Vec3Func.negate(this, v);
return this;
}

cross(va, vb) {
if (vb) Vec3Func.cross(this, va, vb);
else Vec3Func.cross(this, this, va);
return this;
}

scale(v) {
Vec3Func.scale(this, this, v);
return this;
}

normalize() {
Vec3Func.normalize(this, this);
return this;
}

dot(v) {
return Vec3Func.dot(this, v);
}

equals(v) {
return Vec3Func.exactEquals(this, v);
}

applyMatrix4(mat4) {
Vec3Func.transformMat4(this, this, mat4);
return this;
}

applyQuaternion(q) {
Vec3Func.transformQuat(this, this, q);
return this;
}

angle(v) {
return Vec3Func.angle(this, v);
}

lerp(v, t) {
Vec3Func.lerp(this, this, v, t);
return this;
}

clone() {
return new Vec3(this[0], this[1], this[2]);
}

fromArray(a, o = 0) {
this[0] = a[o];
this[1] = a[o + 1];
this[2] = a[o + 2];
return this;
}

toArray(a = [], o = 0) {
a[o] = this[0];
a[o + 1] = this[1];
a[o + 2] = this[2];
return a;
}

transformDirection(mat4) {
const x = this[0];
const y = this[1];
const z = this[2];

this[0] = mat4[0] * x + mat4[4] * y + mat4[8] * z;
this[1] = mat4[1] * x + mat4[5] * y + mat4[9] * z;
this[2] = mat4[2] * x + mat4[6] * y + mat4[10] * z;

return this.normalize();
}
}
Vec3Func.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
const EPSILON = 0.000001;

/**
* Calculates the length of a vec3
*
* @param {vec3} a vector to calculate length of
* @returns {Number} length of a
*/
export function length(a) {
let x = a[0];
let y = a[1];
let z = a[2];
return Math.sqrt(x * x + y * y + z * z);
}

/**
* Copy the values from one vec3 to another
*
* @param {vec3} out the receiving vector
* @param {vec3} a the source vector
* @returns {vec3} out
*/
export function copy(out, a) {
out[0] = a[0];
out[1] = a[1];
out[2] = a[2];
return out;
}

/**
* Set the components of a vec3 to the given values
*
* @param {vec3} out the receiving vector
* @param {Number} x X component
* @param {Number} y Y component
* @param {Number} z Z component
* @returns {vec3} out
*/
export function set(out, x, y, z) {
out[0] = x;
out[1] = y;
out[2] = z;
return out;
}

/**
* Adds two vec3's
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {vec3} out
*/
export function add(out, a, b) {
out[0] = a[0] + b[0];
out[1] = a[1] + b[1];
out[2] = a[2] + b[2];
return out;
}

/**
* Subtracts vector b from vector a
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {vec3} out
*/
export function subtract(out, a, b) {
out[0] = a[0] - b[0];
out[1] = a[1] - b[1];
out[2] = a[2] - b[2];
return out;
}

/**
* Multiplies two vec3's
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {vec3} out
*/
export function multiply(out, a, b) {
out[0] = a[0] * b[0];
out[1] = a[1] * b[1];
out[2] = a[2] * b[2];
return out;
}

/**
* Divides two vec3's
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {vec3} out
*/
export function divide(out, a, b) {
out[0] = a[0] / b[0];
out[1] = a[1] / b[1];
out[2] = a[2] / b[2];
return out;
}

/**
* Scales a vec3 by a scalar number
*
* @param {vec3} out the receiving vector
* @param {vec3} a the vector to scale
* @param {Number} b amount to scale the vector by
* @returns {vec3} out
*/
export function scale(out, a, b) {
out[0] = a[0] * b;
out[1] = a[1] * b;
out[2] = a[2] * b;
return out;
}

/**
* Calculates the euclidian distance between two vec3's
*
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {Number} distance between a and b
*/
export function distance(a, b) {
let x = b[0] - a[0];
let y = b[1] - a[1];
let z = b[2] - a[2];
return Math.sqrt(x * x + y * y + z * z);
}

/**
* Calculates the squared euclidian distance between two vec3's
*
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {Number} squared distance between a and b
*/
export function squaredDistance(a, b) {
let x = b[0] - a[0];
let y = b[1] - a[1];
let z = b[2] - a[2];
return x * x + y * y + z * z;
}

/**
* Calculates the squared length of a vec3
*
* @param {vec3} a vector to calculate squared length of
* @returns {Number} squared length of a
*/
export function squaredLength(a) {
let x = a[0];
let y = a[1];
let z = a[2];
return x * x + y * y + z * z;
}

/**
* Negates the components of a vec3
*
* @param {vec3} out the receiving vector
* @param {vec3} a vector to negate
* @returns {vec3} out
*/
export function negate(out, a) {
out[0] = -a[0];
out[1] = -a[1];
out[2] = -a[2];
return out;
}

/**
* Returns the inverse of the components of a vec3
*
* @param {vec3} out the receiving vector
* @param {vec3} a vector to invert
* @returns {vec3} out
*/
export function inverse(out, a) {
out[0] = 1.0 / a[0];
out[1] = 1.0 / a[1];
out[2] = 1.0 / a[2];
return out;
}

/**
* Normalize a vec3
*
* @param {vec3} out the receiving vector
* @param {vec3} a vector to normalize
* @returns {vec3} out
*/
export function normalize(out, a) {
let x = a[0];
let y = a[1];
let z = a[2];
let len = x * x + y * y + z * z;
if (len > 0) {
//TODO: evaluate use of glm_invsqrt here?
len = 1 / Math.sqrt(len);
}
out[0] = a[0] * len;
out[1] = a[1] * len;
out[2] = a[2] * len;
return out;
}

/**
* Calculates the dot product of two vec3's
*
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {Number} dot product of a and b
*/
export function dot(a, b) {
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
}

/**
* Computes the cross product of two vec3's
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @returns {vec3} out
*/
export function cross(out, a, b) {
let ax = a[0],
ay = a[1],
az = a[2];
let bx = b[0],
by = b[1],
bz = b[2];

out[0] = ay * bz - az * by;
out[1] = az * bx - ax * bz;
out[2] = ax * by - ay * bx;
return out;
}

/**
* Performs a linear interpolation between two vec3's
*
* @param {vec3} out the receiving vector
* @param {vec3} a the first operand
* @param {vec3} b the second operand
* @param {Number} t interpolation amount between the two inputs
* @returns {vec3} out
*/
export function lerp(out, a, b, t) {
let ax = a[0];
let ay = a[1];
let az = a[2];
out[0] = ax + t * (b[0] - ax);
out[1] = ay + t * (b[1] - ay);
out[2] = az + t * (b[2] - az);
return out;
}

/**
* Transforms the vec3 with a mat4.
* 4th vector component is implicitly '1'
*
* @param {vec3} out the receiving vector
* @param {vec3} a the vector to transform
* @param {mat4} m matrix to transform with
* @returns {vec3} out
*/
export function transformMat4(out, a, m) {
let x = a[0],
y = a[1],
z = a[2];
let w = m[3] * x + m[7] * y + m[11] * z + m[15];
w = w || 1.0;
out[0] = (m[0] * x + m[4] * y + m[8] * z + m[12]) / w;
out[1] = (m[1] * x + m[5] * y + m[9] * z + m[13]) / w;
out[2] = (m[2] * x + m[6] * y + m[10] * z + m[14]) / w;
return out;
}

/**
* Transforms the vec3 with a mat3.
*
* @param {vec3} out the receiving vector
* @param {vec3} a the vector to transform
* @param {mat3} m the 3x3 matrix to transform with
* @returns {vec3} out
*/
export function transformMat3(out, a, m) {
let x = a[0],
y = a[1],
z = a[2];
out[0] = x * m[0] + y * m[3] + z * m[6];
out[1] = x * m[1] + y * m[4] + z * m[7];
out[2] = x * m[2] + y * m[5] + z * m[8];
return out;
}

/**
* Transforms the vec3 with a quat
*
* @param {vec3} out the receiving vector
* @param {vec3} a the vector to transform
* @param {quat} q quaternion to transform with
* @returns {vec3} out
*/
export function transformQuat(out, a, q) {
// benchmarks: https://jsperf.com/quaternion-transform-vec3-implementations-fixed

let x = a[0],
y = a[1],
z = a[2];
let qx = q[0],
qy = q[1],
qz = q[2],
qw = q[3];

let uvx = qy * z - qz * y;
let uvy = qz * x - qx * z;
let uvz = qx * y - qy * x;

let uuvx = qy * uvz - qz * uvy;
let uuvy = qz * uvx - qx * uvz;
let uuvz = qx * uvy - qy * uvx;

let w2 = qw * 2;
uvx *= w2;
uvy *= w2;
uvz *= w2;

uuvx *= 2;
uuvy *= 2;
uuvz *= 2;

out[0] = x + uvx + uuvx;
out[1] = y + uvy + uuvy;
out[2] = z + uvz + uuvz;
return out;
}

/**
* Get the angle between two 3D vectors
* @param {vec3} a The first operand
* @param {vec3} b The second operand
* @returns {Number} The angle in radians
*/
export const angle = (function () {
const tempA = [0, 0, 0];
const tempB = [0, 0, 0];

return function (a, b) {
copy(tempA, a);
copy(tempB, b);

normalize(tempA, tempA);
normalize(tempB, tempB);

let cosine = dot(tempA, tempB);

if (cosine > 1.0) {
return 0;
} else if (cosine < -1.0) {
return Math.PI;
} else {
return Math.acos(cosine);
}
};
})();

/**
* Returns whether or not the vectors have exactly the same elements in the same position (when compared with ===)
*
* @param {vec3} a The first vector.
* @param {vec3} b The second vector.
* @returns {Boolean} True if the vectors are equal, false otherwise.
*/
export function exactEquals(a, b) {
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2];
}

效果图
缺陷:
无法保证具体的颜色差别大小
无法控制随机生成的颜色本身的亮度

比如下面这种:后面一行的颜色很暗,区分度太差
效果图

需要动态构建视觉颜色效果,很少直接选用 RGB(A) 色值,比较常用的就是 HSL 和 HSV 颜色表示形式。

HSL 和 HSV 颜色

各字母含义

各字母的含义:

  • H:色相(Hue),Hue 是角度,取值范围是 0 到 360 度
  • S:饱和度(Saturation),取值范围从 0 到 100%。
  • L:亮度(Lightness),取值范围从 0 到 100%。
  • V:明度(Value),取值范围从 0 到 100%。

HSL 和 HSV 的产生原理

可以把 HSL 和 HSV 颜色理解为,是将 RGB 颜色的立方体从直角坐标系投影到极坐标的圆柱上,所以它的色值和 RGB 色值是一一对应的。
效果图

颜色转换公式

从上图中,你可以发现,它们之间色值的互转算法比较复杂。不过好在,CSS 和 Canvas2D 都可以直接支持 HSL 颜色,只有 WebGL 需要做转换。
转换逻辑大家可以参考:
用 JavaScript 实现 RGB-HSL-HSB 相互转换的方法

这里我就列个公式出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 颜色改写前面绘制三排圆的例子。这里,我们只要把代码稍微做一些调整。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 的规则,亮度越高,颜色越接近白色,只有同时提升饱和度,才能确保圆的颜色不会太浅。

效果图

利用 HSV 实现 HSV 色轮

我们现在大概了解了 HSV,接下来,我们用 HSV 结合 webgl 做个 HSV 色轮的小 demo。

利用极坐标画圆

基础 webgl 的 js 代码

我们先将基础的 webgl 代码写一下,不懂的可以去翻我之前的 webgl 文章

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function loadShader(gl, type, source) {
//根据着色类型,建立着色器对象
const shader = gl.createShader(type);
//将着色器源文件传入着色器对象中
gl.shaderSource(shader, source);
//编译着色器对象
gl.compileShader(shader);
//返回着色器对象
return shader;
}
function initShaders(gl, vsSource, fsSource) {
//创建程序对象
const program = gl.createProgram();
//建立着色对象
const vertexShader = loadShader(gl, gl.VERTEX_SHADER, vsSource);
const fragmentShader = loadShader(gl, gl.FRAGMENT_SHADER, fsSource);
//把顶点着色对象装进程序对象中
gl.attachShader(program, vertexShader);
//把片元着色对象装进程序对象中
gl.attachShader(program, fragmentShader);
//连接webgl上下文对象和程序对象
gl.linkProgram(program);
//启动程序对象
gl.useProgram(program);
//将程序对象挂到上下文对象上
gl.program = program;
return true;
}

const canvas = document.getElementById("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const gl = canvas.getContext("webgl");

const vsSource = document.getElementById("vertexShader").innerText;
const fsSource = document.getElementById("fragmentShader").innerText;
initShaders(gl, vsSource, fsSource);

const vertices = new Float32Array([-1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0, 1.0]);

const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
const a_Position = gl.getAttribLocation(gl.program, "a_Position");
gl.vertexAttribPointer(a_Position, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(a_Position);

gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);

// 画三角扇
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
顶点着色器代码

我们传递了 4 个点位信息,每俩个点作为一组,因此我们用 a_Position 接收一下,然后自定义一个 vUv,使他的值范围在[0,1]之间,因为我们的 a_Position 的范围是在[-1,1]之间,所以做了下面公式的转化

1
2
3
4
5
6
7
8
9
<!-- 顶点着色器 -->
<script id="vertexShader" type="x-shader/x-vertex">
attribute vec2 a_Position;
varying vec2 vUv;
void main() {
gl_Position = vec4(a_Position, 0.0, 1.0);
vUv = a_Position * 0.5 + 0.5;
}
</script>
片元着色器代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 片元着色器 -->
<script id="fragmentShader" type="x-shader/x-fragment">
precision mediump float;
varying vec2 vUv;

vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}
void main() {
vec2 st = vUv - vec2(0.5,0.5);
st = polar(st);
float d = smoothstep(st.x, st.x + 0.01, 0.2);
gl_FragColor.rgb=d*vec3(1.0,0.0,0.0);
gl_FragColor.a = 1.0;
}
</script>
代码解析
极坐标转化

这里我定义了一个 polar,这个是将直角坐标转成了极坐标,转换后的 st.x 实际上是极坐标的 r 分量,而 st.y 就是极坐标的 θ 分量。不懂极坐标的也可以去翻阅我之前的 webgl 的文章。
直角坐标和极坐标转化公式大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 直角坐标影射为极坐标
function toPolar(x, y) {
const r = Math.hypot(x, y);
const θ = Math.atan2(y, x);
return [r, θ];
}

// 极坐标映射为直角坐标
function fromPolar(r, θ) {
const x = r * cos(θ);
const y = r * sin(θ);
return [x, y];
}

极坐标图

计算标准化坐标
1
vec2 st = vUv - vec2(0.5,0.5);

通过从 vUv 中减去 (0.5, 0.5),将标准化的纹理坐标从范围 [0, 1] 转换到范围 [-0.5, 0.5]。这使得中心点位于原点 (0, 0),便于进一步计算。

生成圆形的逻辑

我们知道,对于极坐标下过极点的圆,实际上的 r 值就是一个常量值,对应圆的半径,所以我们取 smoothstep(st.x, st.x + 0.01, 0.2),就能得到一个半径为 0.2 的圆了。这一步,我们用的还是距离场方法(参考上一篇 canvas 学习 4)。只不过,在直角坐标系下,点到圆心的距离 d 需要用 x、y 平方和的开方来计算,而在极坐标下,点的极坐标 r 值正好表示了点到圆心的距离 d,所以计算起来就比直角坐标系简单了很多。

这里的 smoothstep 函数用于创建一个平滑的边界。它的三个参数是:

  • st.x:当前的半径值。
  • st.x + 0.01:允许的最大半径值(增大 0.01)。
  • 0.2:这是临界值,在这个范围内,smoothstep 会输出从 0 到 1 的平滑插值。
    当 st.x 小于 0.2 时,d 的值接近于 1,当 st.x 超过 0.2 时,d 的值接近于 0。因此,这部分代码实际上是在生成一个厚度为 0.01 单位的圆形区域
设置颜色
1
2
gl_FragColor.rgb = d * vec3(1.0, 0.0, 0.0);
gl_FragColor.a = 1.0;

d * vec3(1.0, 0.0, 0.0):如果 d 接近于 1(表示在圆内),颜色为红色 (1.0, 0.0, 0.0);如果 d 接近于 0(表示在圆外),颜色将会变得透明。
gl_FragColor.a = 1.0;:设定不透明度为 1,确保圆是完全不透明的。

效果图

实现角向渐变

我们将像素坐标转变为极坐标之后,st.y 就是与 x 轴的夹角。因为 polar 函数里计算的 atan(y, x) 的取值范围是 -π 到 π,所以我们在 st.y 小于 0 的时候,将它加上 2π,这样就能把取值范围转换到 0 到 2π 了。
然后,我们根据角度换算出对应的比例对颜色进行线性插值。比如,比例在 0%~45% 之间,我们让颜色从红色过渡为绿色,那在 45% 到 100% 之间,我们让颜色从绿色过渡到蓝色。这样,我们最终就会得到如下效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
float d = smoothstep(st.x, st.x + 0.01, 0.2);
// 将角度范围转换到0到2pi之间
if(st.y < 0.0) st.y += 6.28;
// 计算p的值,也就是相对角度,p取值0到1
float p = st.y / 6.28;
if(p < 0.45) {
// p取0到0.45时从红色线性过渡到绿色
gl_FragColor.rgb = d * mix(vec3(1.0, 0, 0), vec3(0, 0.5, 0), p / 0.45);
} else {
// p超过0.45从绿色过渡到蓝色
gl_FragColor.rgb = d * mix(vec3(0, 0.5, 0), vec3(0, 0, 1.0), (p - 0.45) / (1.0 - 0.45));
}
gl_FragColor.a = 1.0;
}

效果图

结合 HSV 实现色轮

将像素坐标转换为极坐标,再除以 2π,就能得到 HSV 的 H 值。然后我们用鼠标位置的 x、y 坐标来决定 S 和 V 的值,完整的片元着色器代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
precision mediump float;

varying vec2 vUv;
uniform vec2 uMouse;

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);
}

vec2 polar(vec2 st) {
return vec2(length(st), atan(st.y, st.x));
}

void main() {
vec2 st = vUv - vec2(0.5);
st = polar(st);
float d = smoothstep(st.x, st.x + 0.01, 0.2);
if(st.y < 0.0) st.y += 6.28;
float p = st.y / 6.28;
gl_FragColor.rgb = d * hsv2rgb(vec3(p, uMouse.x, uMouse.y));
gl_FragColor.a = 1.0;
}

然后 js 加个 mousemove 事件

1
2
3
4
5
6
7
8
9
10
11
12
13
const uMouse = gl.getUniformLocation(gl.program, "uMouse");
gl.uniform2f(uMouse, 0.5, 0.5);

canvas.addEventListener("mousemove", (e) => {
const { x, y, width, height } = e.target.getBoundingClientRect();
const S = (e.x - x) / width;
const V = 1.0 - (e.y - y) / height;
gl.uniform2f(uMouse, S, V);
gl.clearColor(0.0, 0.0, 0.0, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
// 画三角扇
gl.drawArrays(gl.TRIANGLE_FAN, 0, 4);
});

色轮

这样,一个色轮的小案例就实现了

HSL 和 HSV 的局限性

回归正题(忽视色轮小案例的上文),我们继续说HSL 和 HSV,在依次按照色相画圆的时候,即使我们可以均匀地修改每组颜色的亮度和饱和度,但这样修改之后,有的颜色看起来和其他的颜色差距明显,有的颜色还是没那么明显。这是因为人眼对不同频率的光的敏感度不同造成的。

比如我们生成一排圆

1
2
3
4
5
6
7
8
9
10
11
12
13
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 个原则:

  1. 人眼看到的色差 = 颜色向量间的欧氏距离相同的亮度,
  2. 能让人感觉亮度相同
    于是,一个针对人类感觉的颜色描述方式就产生了,它就是 CIE Lab。

CIE Lab 和 CIE Lch 颜色

CIE Lab 颜色空间简称 Lab,它其实就是一种符合人类感觉的色彩空间,它用 L 表示亮度,a 和 b 表示颜色对立度。RGB 值也可以 Lab 转换,但是转换规则比较复杂,你可以通过wikipedia.org来进一步了解它的基本原理。

CIE Lab 比较特殊的一点是,目前还没有能支持 CIE Lab 的图形系统,但是css-color level4规范已经给出了 Lab 颜色值的定义。

而且,一些 JavaScript 库也已经可以直接处理 Lab 颜色空间了,如d3-color。下面,我们通过一个代码例子来详细讲讲,d3.lab 是怎么处理 Lab 颜色的。如下面代码所示,我们使用 d3.lab 来定义 Lab 色彩。这个例子与 HSL 的例子一样,也是显示两排圆形。这里,我们让第一排相邻圆形之间的 lab 色值的欧氏空间距离相同,第二排相邻圆形之间的亮度按 5 阶的方式递增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* 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 方式呈现的色彩变化中,我们设置的数值和人眼感知的一致性比较强

Cubehelix 色盘

最后,我们再来说一种特殊的颜色表示法,Cubehelix 色盘(立方螺旋色盘)。简单来说,它的原理就是在 RGB 的立方中构建一段螺旋线,让色相随着亮度增加螺旋变换。如下图所示
效果图

我们还是直接来看它的应用。接下来,用cubehelix模块写一个颜色随着长度变化的柱状图。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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);

首先,我们直接使用 cubehelix 函数创建一个 color 映射。cubehelix 函数是一个高阶函数,它的返回值是一个色盘映射函数。这个返回函数的参数范围是 0 到 1,当它从小到大依次改变的时候,不仅颜色会依次改变,亮度也会依次增强。然后,我们用正弦函数来模拟数据的周期性变化,通过 color§获取当前的颜色值,再把颜色值赋给 ctx.fillStyle,颜色就能显示出来了。最后,我们用 rect 将柱状图画出来,用 requestAnimationFrame 实现动画就可以了

效果图

结语

本篇文章就到这里了,更多内容敬请期待,债见~

上一篇:
【可视化学习】81-Gitee贡献3D表
下一篇:
【可视化学习】79-从入门到放弃WebGL(十三)