在可视化项目里,我们需要描述很多的图形,而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且,如果我们使用不同的绘图系统,每个绘图系统又可能有独特的方式或者特定的 API,去解决某个或某类具体的问题。
正因为有了太多可以选择的工具,我们也就很难找到最恰当的那一个。而且如果我们手中只有解决具体问题的工具,没有统一的方法论,那我们也无法一劳永逸地解决问题的根本。
因此,我们要建立一套与各个图形系统无关联的、简单的基于向量和矩阵运算的数学体系,用它来描述所有的几何图形信息,并且用这个体系来解决我们的可视化图形呈现的问题。
# 如何用向量和坐标系描述点和线段
# 坐标系与坐标映射
浏览器的四个图形系统通用的坐标系分别如下:
HTML 采用的是窗口坐标系,以参考对象(参考对象通常是最接近图形元素的 position 非 static 的元素)的元素盒子左上角为坐标原点,x 轴向右,y 轴向下,坐标值对应像素值。
SVG 采用的是视区盒子(viewBox)坐标系。这个坐标系在默认情况下,是以 svg 根元素左上角为坐标原点,x 轴向右,y 轴向下,svg 根元素右下角坐标为它的像素宽高值。如果我们设置了 viewBox 属性,那么 svg 根元素左上角为 viewBox 的前两个值,右下角为 viewBox 的后两个值。
Canvas 采用的坐标系默认以画布左上角为坐标原点,右下角坐标值为 Canvas 的画布宽高值。
WebGL 的坐标系比较特殊,是一个三维坐标系。它默认以画布正中间为坐标原点,x 轴朝右,y 轴朝上,z 轴朝外,x 轴、y 轴在画布中范围是 -1 到 1。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是直角坐标系,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
为了方便处理图形,我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系,所以它们可以很方便地相互转换。
# 如何用 Canvas 实现坐标系转换
在不转换坐标系的情况下,我们也可以把图形绘制出来,但是要经过顶点换算,如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
Canvas 实现坐标系变换的方法就是给 Canvas 的 2D 上下文设置一下 transform 变换。这里经常会用到两个变换是:translate 和 scale。
/* globals rough */
const rc = rough.canvas(document.querySelector('canvas'));
const ctx = rc.ctx;
// 通过 translate 变换将 Canvas 画布的坐标原点,从左上角 (0, 0) 点移动至 (256, 256) 位置,即画布的底边上的中点位置
ctx.translate(256, 256);
// 以移动了原点后新的坐标为参照,通过 scale(1, -1) 将 y 轴向下的部分,即 y > 0 的部分沿 x 轴翻转 180 度,
// 这样坐标系就变成以画布底边中点为原点,x 轴向右,y 轴向上的坐标系了
ctx.scale(1, -1);
const hillOpts = {roughness: 2.8, strokeWidth: 2, fill: 'blue'};
// 执行坐标变换后,我们就可以更方便、直观地计算出几个图形元素的坐标了
rc.path('M-180 0L-80 100L20 0', hillOpts);
rc.path('M-20 0L80 100L180 0', hillOpts);
rc.circle(0, 150, 105, {
stroke: 'red',
strokeWidth: 4,
fill: 'rgba(255,255, 0, 0.4)',
fillStyle: 'solid',
});
// console.log(rc);
// rc.path('M76 256L176 156L276 256', hillOpts);
// rc.path('M236 256L336 156L436 256', hillOpts);
// rc.circle(256, 106, 105, {
// stroke: 'red',
// strokeWidth: 4,
// fill: 'rgba(255,255,0,0.4)',
// fillStyle: 'solid',
// });
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
上面这段代码的最终绘制效果可以看这里 (opens new window)。
在可视化的许多应用场景中,我们都要处理成百上千的图形。如果这个时候,我们在原始坐标下通过计算顶点来绘制图形,计算量会非常大,很麻烦。那采用坐标变换的方式就是一个很好的优化思路,它能够简化计算量,这不仅让代码更容易理解,也可以节省 CPU 运算的时间。
理解直角坐标系的坐标变换之后,下面再来看看直角坐标系里绘制图形的方法。不管我们用什么绘图系统绘制图形,一般的几何图形都是由点、线段和面构成。其中,点和线段是基础的图元信息,因此,如何描述它们是绘图的关键。
# 如何用向量来描述点和线段
在直角坐标系下,我们一般是用向量来表示一个点或者一个线段。
x、y 两个坐标轴就构成了一个绘图的平面,我们可以用二维向量来表示这个平面上的点和线段。二维向量其实就是一个包含了两个数值的数组,一个是 x 坐标值,一个是 y 坐标值。
假设,现在这个平面直角坐标系上有一个向量 v。向量 v 有两个含义:一是可以表示该坐标系下位于 (x, y) 处的一个点;二是可以表示从原点 (0, 0) 到坐标 (x, y) 的一根线段。
下面是关于向量的数学知识。
首先,向量和标量一样可以进行数学运算。比如,现在有两个向量,
其次,一个向量包含有长度和方向信息。它的长度可以用向量的 x、y 的平方和的平方根来表示,如果用 JavaScript 来计算,就是:
v.length = function() { return Math.hypot(this.x, this.y); }
它的方向可以用与 x 轴的夹角来表示,即:
// Math.atan2 的取值范围是 -π 到 π,负数表示在 x 轴下方,正数表示在 x 轴上方
v.dir = function() { return Math.atan2(this.y, this.x); }
2
最后,根据长度和方向的定义,我们还能推导出一组关系式:
v.x = v.length * Math.cos(v.dir);
v.y = v.length * Math.sin(v.dir);
2
这个推论意味着一个重要的事实:我们可以很简单地构造出一个绘图向量。也就是说,如果我们希望以点 (x, y) 为起点,沿着某个方向画一段长度为 length 的线段,我们只需要构造出如下的一个向量就可以了。
这里的 α 是与 x 轴的夹角,v 是一个单位向量,它的长度为 1。然后我们把向量 (x, y) 与这个向量 v 相加,得到的就是这条线段的终点。
# 用向量绘制一棵随机生成的树
最终绘制效果可以看这里 (opens new window)。
import {Vector2D} from '../common/lib/vector2d.js';
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
// 执行坐标变换,将坐标原点从左上角移动到左下角,并且让 y 轴翻转为向上。
ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round';
// 画树枝的函数
// context 是 Canvas2D 上下文
// v0 是起始向量
// length 是当前树枝的长度
// thickness 是当前树枝的粗细
// dir 是当前树枝的方向,用与 x 轴的夹角表示,单位是弧度
// bias 是一个随机偏向因子,用来让树枝的朝向有一定的随机性
function drawBranch(context, v0, length, thickness, dir, bias) {
// 因为 v 是树枝的起点坐标,那根据前面向量计算的原理,
// 我们创建一个单位向量 (1, 0),它是一个朝向 x 轴,长度为 1 的向量。
// 然后我们旋转 dir 弧度,再乘以树枝长度 length。
// 这样,我们就能计算出树枝的终点坐标了
const v = new Vector2D().rotate(dir).scale(length);
const v1 = v0.copy().add(v);
context.lineWidth = thickness;
context.beginPath();
context.moveTo(...v0);
context.lineTo(...v1);
context.stroke();
// 从一个起始角度开始递归地旋转树枝,每次将树枝分叉成左右两个分枝
// 同时加入随机因子,让迭代生成的新树枝有一个随机的偏转角度
// 这样就可以得到一棵随机的树
if(thickness > 2) {
const left = Math.PI / 4 + 0.5 * (dir + 0.2) + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
const right = Math.PI / 4 + 0.5 * (dir - 0.2) + bias * (Math.random() - 0.5);
drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
}
// 为了美观,再随机绘制一些花瓣上去
if(thickness < 5 && Math.random() < 0.3) {
context.save();
context.strokeStyle = '#c72c35';
const th = Math.random() * 6 + 3;
context.lineWidth = th;
context.beginPath();
context.moveTo(...v1);
context.lineTo(v1.x, v1.y - 2);
context.stroke();
context.restore();
}
}
const v0 = new Vector2D(256, 0);
drawBranch(ctx, v0, 50, 10, 1, 3);
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
实际上,在我们的可视化项目里,直接使用向量的加法、旋转和乘法来构造线段绘制图形的情形并不多。这是因为,在一般情况下,数据在传给前端的时候就已经计算好了,我们只需要拿到数据点的信息,根据坐标变换进行映射,然后直接用映射后的点来绘制图形即可。
不过,虽然我们很少直接使用向量构造线段来完成绘图,但是向量运算的意义并不仅仅只是用来算点的位置和构造线段,这只是最初级的用法。我们要记住,可视化呈现依赖于计算机图形学,而向量运算是整个计算机图形学的数学基础。
而且,在向量运算中,除了加法表示移动点和绘制线段外,向量的点乘、叉乘运算也有特殊的意义。
# 向量常见操作方法封装
# 二维旋转矩阵与向量旋转
二维旋转矩阵与向量旋转 (opens new window)
# 向量乘法
向量的乘法在可视化中也是非常重要并且实用的内容,使用它们可以计算曲线的控制点、判断曲线的方向以及对曲线进行变换。
向量乘法有两种,一种是点乘,一种是叉乘,它们有着不同的几何和物理含义。
# 向量的点乘
假设,现在有两个 N 维向量 a 和 b,
a•b = a1*b1 + a2*b2 + ... + an*bn
在 N 维线性空间中,a、b 向量点积的几何含义,是 a 向量乘以 b 向量在 a 向量上的投影分量。它的物理含义相当于 a 力作用于物体,产生 b 位移所做的功。
点积公式如下图所示:
关于向量点积有两种特殊的情况:
- 当 a、b 两个向量平行时,它们的夹角就是 0°,那么 a·b=|a|*|b|,用 JavaScript 代码表示就是:
a.x * b.x + a.y * b.y === a.length * b.length;
- 当 a、b 两个向量垂直时,它们的夹角就是 90°,那么 a·b=0,用 JavaScript 代码表示就是:
a.x * b.x + a.y * b.y === 0;
# 向量的叉乘
叉乘和点乘有两点不同:
向量叉乘运算的结果不是标量,而是一个向量;
两个向量的叉积与两个向量组成的坐标平面垂直。
以二维空间为例,向量 a 和 b 的叉积,就相当于向量 a(蓝色带箭头线段)与向量 b 沿垂直方向的投影(红色带箭头线段)的乘积。那如下图所示,二维向量叉积的几何意义就是向量 a、b 组成的平行四边形的面积。
那叉乘在数学上该怎么计算呢?
假设,现在有两个三维向量
其中 i、j、k 分别是 x、y、z 轴的单位向量。我们把这个行列式展开,就能得到如下公式:
a X b = [y1 * z2 - y2 * z1, - (x1 * z2 - x2 * z1), x1 * y2 - x2 * y1]
这个计算公式得到的值还是一个三维向量,它的方向垂直于 a、b 所在平面。在上面的二维空间中,向量 a、b 的叉积方向就是垂直纸面朝向我们的。
如何确定出 a、b 的叉积方向呢?
可以用左手系或右手系 (opens new window)。
其中 x 轴向右、y 轴向下的坐标系是右手系。在右手系中求向量 a、b 叉积的方向时,我们可以把右手食指的方向朝向 a,把右手中指的方向朝向 b,那么大拇指所指的方向就是 a、b 叉积的方向,这个方向是垂直纸面向外(即朝向我们)。因此,右手系中向量叉乘的方向就是右手拇指的方向,那左手系中向量叉乘的方向自然就是左手拇指的方向了。
在二维空间里,由于 z 的值为 0,得到的向量
# 向量的归一化
简单来说归一化就是,对于任意一点
归一化后的向量方向不变,长度为 1。
归一化是向量运算中一个非常重要的操作,用处也非常多。比如说,在向量乘法里,如果 a、b 都是长度为 1 的归一化向量,那么
# 点乘和叉乘的几何和物理意义
点乘的几何意义是向量 a 与它在向量 b 所在的轴的投影向量的乘积,物理意义是力向量产生的位移向量所做的功。
叉乘的几何意义是向量 a 和向量 b 构成的平行四边形的面积,物理意义是力产生的力矩。
# 判断两根线段之间的关系
假设有两个线段
设线段
如果 |ab| 或 |cd| 为 0 ,说明线段退化成点,无法判断关系;
如果 ab·cd 为 0,说明夹角的余弦值为 0,二者垂直;
如果 |ab × cd| 为 0,说明夹角的正弦值为 0,二者方向一致,可能平行也可能重合;
如果 |ab × cd| 不为0,说明四点不共线,排除掉重合的情况,二者平行;
其他情况即为既不平行也不垂直。
# 求点到线段的距离
已知线段
可以利用向量叉积的几何意义,通过向量叉积得到平行四边形面积,再除以底边长,就能得到点到向量所在直线的距离了。
# 点是否处于扫描范围内
一个平面上放置了一个扫描器,方向延 y 轴方向(该坐标系 y 轴向上),扫描器的视角是 60 度。假设它可以扫描到无限远的地方,那对于平面上给定的任意一个点 (x, y),我们该如何判断这个点是否处于扫描范围内呢?
这道题可以直接使用向量的方向定义来做,因为 dir 是由向量与 x 轴夹角决定的,所以判断点是否在扫描器范围内,我们只需要计算点坐标对应的向量的 dir 值,是否在扫描器的范围内就可以了。代码如下:
v.dir = function() { return Math.atan2(this.y, this.x); }
const isInRange = v0.dir > Math.PI / 3 && v0.dir < 2 * Math.PI / 3;
2
这是一个很简单、直观的解法,但是它不够完美,因为这个判断和扫描器的方向有关。从上面的图中可以看到,现在它正对着 y 轴正方向,所以角度在 π/3 和 2π/3 之间。但如果将它的方向旋转,或者允许它朝向任意的方向,我们就必须要修改对应的角度值了。这个时候就会非常麻烦。
如果用向量乘法来解决会更通用。解决方法如下:
把归一化的向量 a 叉乘扫描器中线上的 v(0, 1),由于扫描器关于 y 轴对称,所以扫描器边缘与 y 轴的夹角是正负 30 度。那么在与单位向量求叉积的时候,就会出现 2 种情况:
点在扫描范围内,如向量 a,就一定满足:
; 点不在扫描范围内,如向量 b,就一定满足:
。
因此,只要任意一点所在的向量与单位向量的叉积结果的绝对值不大于 0.5(即 sin30°),就说明这个点在扫描范围内。代码表示如下:
const isInRange = Math.abs(new Vec2(0, 1).cross(v0.normalize())) <= 0.5;
# 如何用向量和参数方程描述曲线
曲线是图形系统的基本元素之一,它可以构成几何图形的边,也可以描述点和几何体的运动轨迹,还可以控制像素属性的变化。不论我们用什么图形系统绘图,图形的呈现都离不开曲线。
常见的描述曲线的方法有两种,那就是用向量或者参数方程来描述曲线。
# 如何用向量描述曲线
曲线是可以用折线来模拟的,因此,用向量来绘制折线的方法,同样可以应用于曲线。
下面这个函数采用向量绘制折线的方法来绘制正多边形,效果可以看这里 (opens new window)。
// 给定边数 edges、起点 x, y、一条边的长度 step
// 绘制的思路就是通过 rotate 旋转向量,然后通过向量加法来计算顶点位置
function regularShape(edges = 3, x, y, step) {
const ret = [];
const delta = Math.PI * (1 - (edges - 2) / edges);
let p = new Vector2D(x, y); // 定义初始点为 new Vector2D(x, y)
const dir = new Vector2D(step, 0); // 初始方向为 x 轴方向 new Vector2D(step, 0)
ret.push(p);
// 循环计算正多边形的顶点位置,也就是从初始点开始,
// 每次将方向向量旋转 delta 角度,delta 角度是根据正多边形内角公式计算出来的。
// 最后,将当前点和方向向量相加,就得到下一个顶点坐标了
for(let i = 0; i < edges; i++) {
p = p.copy().add(dir.rotate(delta));
ret.push(p);
}
return ret;
}
// 调用方式
draw(regularShape(3, 128, 128, 100)); // 绘制三角形
draw(regularShape(6, -64, 128, 50)); // 绘制六边形
draw(regularShape(11, -64, -64, 30)); // 绘制十一边形
draw(regularShape(60, 128, -64, 6)); // 绘制六十边形
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
通过绘制后的图形可以看出,当多边形的边数非常多的时候,这个图形就会接近圆。所以,只要将多边形的边数设置得很大,我们就可以绘制出圆形了。
总结
用向量描述比较简单直接,先确定起始点和起始向量,然后通过旋转和向量加法来控制形状,就可以将曲线一段一段地绘制出来。
但是它的缺点也很明显,就是数学上不太直观,需要复杂的换算才能精确确定图形的位置和大小。而且,这种方式可以画圆,改进一下也可以画圆弧,但是对于椭圆、抛物线、贝塞尔曲线等其他曲线的绘制就无能为力了。
# 如何用参数方程描述曲线
# 1. 画圆
以下是圆的参数方程,定义了一个圆心在(x, y),半径为 r 的圆。
// 绘制圆
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
// 圆心为 x 、y ,半径为 radius,起始角度为 startAng,结束角度是 endAng
function arc(x0, y0, radius, startAng = 0, endAng = Math.PI * 2) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i <= segments; i++) {
const x = x0 + radius * Math.cos(startAng + ang * i / segments);
const y = y0 + radius * Math.sin(startAng + ang * i / segments);
ret.push([x, y]);
}
return ret;
}
draw(arc(0, 0, 100));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
实际上,Canvas2D 提供了画圆的 API:context.arc 方法。但是,不是所有的图形系统都提供了画圆的 API,比如 WebGL 中就没有默认的画圆 API。因此,在没有提供画圆的 API 的时候,我们自己实现的函数就可以派上用场了。
# 2. 画圆锥曲线
以下是椭圆的参数方程,其中,a、b 分别是椭圆的长轴和短轴,当 a = b = r 时,这个方程就是圆的方程式。所以,圆实际上就是椭圆的特例。
以下是抛物线的参数方程,其中 p 是常数,为焦点到准线的距离。
// 绘制椭圆
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
function ellipse(x0, y0, radiusX, radiusY, startAng = 0, endAng = Math.PI * 2) {
const ang = Math.min(TAU, endAng - startAng);
const ret = ang === TAU ? [] : [[x0, y0]];
const segments = Math.round(TAU_SEGMENTS * ang / TAU);
for (let i = 0; i <= segments; i++) {
const x = x0 + radiusX * Math.cos(startAng + ang * i / segments);
const y = y0 + radiusY * Math.sin(startAng + ang * i / segments);
ret.push([x, y]);
}
return ret;
}
draw(ellipse(0, 0, 100, 50));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 绘制抛物线
const LINE_SEGMENTS = 60;
function parabola(x0, y0, p, min, max) {
const ret = [];
for (let i = 0; i <= LINE_SEGMENTS; i++) {
const s = i / 60;
const t = min * (1 - s) + max * s;
const x = x0 + 2 * p * t ** 2;
const y = y0 + 2 * p * t;
ret.push([x, y]);
}
return ret;
}
draw(parabola(0, 0, 5.5, -10, 10));
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3. 画其他常见曲线:parametric 函数
如果为每一种曲线都分别对应实现一个函数,就会非常笨拙和繁琐。为了方便,我们可以用函数式的编程思想,封装一个更简单的 JavaScript 参数方程绘图模块,以此来绘制出不同的曲线。
这个绘图模块的使用过程主要分为三步。
第一步,我们实现一个叫做 parametric 的高阶函数,它的参数分别是 x、y 坐标和参数方程。
第二步,parametric 会返回一个函数,这个函数会接受几个参数,比如,start、end 这样表示参数方程中关键参数范围的参数,以及 seg 这样表示采样点个数的参数等等。在下面的代码中,当 seg 默认 100 时,就表示在 start、end 范围内采样 101(seg+1)个点,后续其他参数是作为常数传给参数方程的数据。
第三步,我们调用 parametric 返回的函数之后,它会返回一个对象。这个对象有两个属性:一个是 points,也就是它生成的顶点数据;另一个是 draw 方法,我们可以利用这个 draw 方法完成绘图。
// 根据点来绘制图形
function draw(points, context, {
strokeStyle = 'black',
fillStyle = null,
close = false,
} = {}) {
context.strokeStyle = strokeStyle;
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) context.closePath();
if (fillStyle) {
context.fillStyle = fillStyle;
context.fill();
}
context.stroke();
}
export function parametric(xFunc, yFunc) {
return function (start, end, seg = 100, ...args) {
const points = [];
for (let i = 0; i <= seg; i++) {
const p = i / seg;
const t = start * (1 - p) + end * p;
const x = xFunc(t, ...args); // 计算参数方程组的 x
const y = yFunc(t, ...args); // 计算参数方程组的 y
points.push([x, y]);
}
return {
draw: draw.bind(null, points),
points,
};
};
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
利用绘图模块,我们就可以绘制出各种有趣的曲线了。
// 抛物线参数方程
const para = parametric(
t => 25 * t,
t => 25 * t ** 2,
);
// 绘制抛物线
para(-5.5, 5.5).draw(ctx);
2
3
4
5
6
7
// 阿基米德螺旋线参数方程
const helical = parametric(
(t, l) => l * t * Math.cos(t),
(t, l) => l * t * Math.sin(t),
);
// 绘制阿基米德螺旋线
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});
2
3
4
5
6
7
// 星形线参数方程
const star = parametric(
(t, l) => l * Math.cos(t) ** 3,
(t, l) => l * Math.sin(t) ** 3,
);
// 绘制星形线
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});
2
3
4
5
6
7
最终效果可以看这里 (opens new window)。
# 4. 画贝塞尔曲线
贝塞尔曲线(Bezier Curves)可以用来描述很多不规则的图形,它在可视化领域中也是一类非常常用的曲线,它通过起点、终点和少量控制点,就能定义参数方程来生成复杂的平滑曲线,所以它通常被用来构建数据信息之间连接线。
贝塞尔曲线又分为二阶贝塞尔曲线(Quadratic Bezier Curve)和三阶贝塞尔曲线(Qubic Bezier Curve)。顾名思义,二阶贝塞尔曲线的参数方程是一元二次多项式,那么三阶贝塞尔曲线的参数方程是一元三次多项式。
二阶贝塞尔曲线由三个点确定,
可以用 parametric 构建并绘制二阶贝塞尔曲线,最终效果可以看这里 (opens new window)。
const quadricBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}]) => (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
(t, [{y: y0}, {y: y1}, {y: y2}]) => (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2,
);
const p0 = new Vector2D(0, 0);
const p1 = new Vector2D(100, 0);
p1.rotate(0.75);
const p2 = new Vector2D(200, 0);
const count = 30;
for (let i = 0; i < count; i++) {
// 绘制 30 条从圆心出发,旋转不同角度的二阶贝塞尔曲线
p1.rotate(2 / count * Math.PI);
p2.rotate(2 / count * Math.PI);
quadricBezier(0, 1, 100, [
p0,
p1,
p2,
]).draw(ctx);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
三阶贝塞尔曲线的参数方程为:
与二阶贝塞尔曲线相比,三阶贝塞尔曲线有 4 个点,其中
可以用 parametric 构建并绘制三阶贝塞尔曲线,最终效果可以看这里 (opens new window)。
const cubicBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) => (1 - t) ** 3 * x0 + 3 * t * (1 - t) ** 2 * x1 + 3 * t * (1 - t) ** 2 * x2 + t ** 3 ** x3,
(t, [{y: y0}, {y: y1}, {y: y2}, {y: y3}]) => (1 - t) ** 3 * y0 + 3 * t * (1 - t) ** 2 * y1 + 3 * t * (1 - t) ** 2 * y2 + t ** 3 ** y3,
);
const p0 = new Vector2D(0, 0);
const p1 = new Vector2D(100, 0);
p1.rotate(0.75);
const p2 = new Vector2D(150, 0);
p2.rotate(-0.75);
const p3 = new Vector2D(200, 0);
const count = 30;
for (let i = 0; i < count; i++) {
p1.rotate(2 / count * Math.PI);
p2.rotate(2 / count * Math.PI);
p3.rotate(2 / count * Math.PI);
cubicBezier(0, 1, 100, [
p0,
p1,
p2,
p3,
]).draw(ctx);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
三阶贝塞尔曲线控制点比二阶贝塞尔曲线多,控制点越多,曲线就能够模拟出更多不同的形状,也能更精确地控制细节。
总的来说,贝塞尔曲线对于可视化,甚至整个计算机图形学都有着极其重要的意义。因为它能够针对一组确定的点,在其中构造平滑的曲线,这也让图形的实现有了更多的可能性。而且,贝塞尔曲线还可以用来构建 Catmull–Rom 曲线。Catmull–Rom 曲线也是一种常用的曲线,它可以平滑折线,我们在数据统计图表中经常会用到它。
实际上 Canvas2D 和 SVG 都提供了直接绘制贝塞尔曲线的 API,比如在 Canvas2D 中,我们可以通过创建 Path2D 对象,使用 Path2D 支持的 SVGPath 指令添加贝塞尔曲线。即使如此,我们依然需要掌握贝塞尔曲线的基本原理。因为在 WebGL 这样的图形系统里,我们还是需要自己实现贝塞尔曲线的绘制,而且贝塞尔曲线除了绘制曲线之外,还有其他的用处,比如构建平滑的轨迹动画、属性插值等等。
总结
使用参数方程能够避免向量绘制的缺点,因此是更常用的绘制方式。
使用参数方程绘制曲线时,我们既可以使用有规律的曲线参数方程来绘制这些规则曲线,还可以使用二阶、三阶贝塞尔曲线来在起点和终点之间构造平滑曲线。
# 如何利用三角剖分和向量操作描述并处理多边形
在图形系统中,我们最终看到的丰富多彩的图像,都是由多边形构成的。换句话说,不论是 2D 图形还是 3D 图形,经过投影变换后,在屏幕上输出的都是多边形。因此,理解多边形的基本性质,了解用数学语言描述并且处理多边形的方法,在可视化中是十分重要的。
# 图形学中的多边形是什么
多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形,其中,每条线段的端点就是多边形的顶点,线段就是多边形的边。
多边形又可以分为简单多边形和复杂多边形。如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。
一般来说,我们在绘图时,要尽量构建简单多边形,因为简单多边形的图形性质比较简单,绘制起来比较方便。
简单多边形又分为凸多边形和凹多边形,如果一个多边形中的每个内角都不超过 180°,那它就是凸多边形,否则就是凹多边形。
在图形系统中绘制多边形的时候,最常用的功能是填充多边形,也就是用一种颜色将多边形的内部填满。
除此之外,在可视化中用户经常要用鼠标与多边形进行交互,这就要涉及多边形的边界判定。
# 不同的图形系统如何填充多边形
在 SVG 中,可以直接给元素设置 fill 属性来填充。
在 Canvas2D 中,可以在绘图指令结束时调用 fill() 方法进行填充。
在 WebGL 中,使用三角形图元来快速填充。
# 1. Canvas2D 如何填充多边形
// 第一步,构建多边形的顶点。这里直接构造 5 个顶点
const points = [new Vector2D(0, 100)];
for (let i = 1; i <= 4; i++) {
const p = points[0].copy().rotate(i * Math.PI * 0.4);
points.push(p);
}
// 第二步,绘制多边形
// polygon 数组是正五边形的顶点数组
const polygon = [
...points,
];
// 绘制正五边形
ctx.save();
ctx.translate(-128, 0);
draw(ctx, polygon);
ctx.restore();
// stars 数组是把正五边形的顶点顺序交换之后,构成的五角星的顶点数组
const stars = [
points[0],
points[2],
points[4],
points[1],
points[3],
];
// 绘制正五角星
ctx.save();
ctx.translate(128, 0);
draw(ctx, stars);
ctx.restore();
// 第三步,调用 draw 函数完成具体的绘制
function draw(context, points, {
fillStyle = 'black',
close = false,
rule = 'nonzero',
} = {}) {
context.beginPath();
context.moveTo(...points[0]);
for (let i = 1; i < points.length; i++) {
context.lineTo(...points[i]);
}
if (close) context.closePath();
context.fillStyle = fillStyle;
// draw 函数是通过调用 context.fill 来完成填充的
context.fill(rule);
}
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
Canvas2D 的 fill 支持两种填充规则:
默认的规则是 “nonzero”,也就是说 不管有没有相交的边,只要是由边围起来的区域都一律填充;
另一种规则叫做 “evenodd”,它是根据重叠区域是奇数还是偶数来判断是否填充的。
当使用 nonzero 规则时,绘制出来的五边形和五角星如下:
当使用 evenodd 规则时,绘制出来的五边形和五角星如下:
# 2. WebGL 如何填充多边形
在 WebGL 中填充多边形的第一步,就是将多边形分割成多个三角形。
这种将多边形分割成若干个三角形的操作,在图形学中叫做三角剖分(Triangulation)。
三角剖分是图形学和代数拓扑学中一个非常重要的基本操作,也有很多不同的实现算法。对简单多边形尤其是凸多边形的三角剖分比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。
# 三角剖分算法
三角剖分的算法比较复杂,涉及很多图形学的底层知识,有兴趣学习的可以看这篇文章 (opens new window)。
# 常见的三角剖分库
常见的三角剖分库有:Earcut (opens new window)、Tess2.js (opens new window)、cdt2d (opens new window)。
// 下面是使用 Earcut 进行三角剖分绘制不规则多边形的过程
import {earcut} from '../common/lib/earcut.js';
const vertices = [
[-0.7, 0.5],
[-0.4, 0.3],
[-0.25, 0.71],
[-0.1, 0.56],
[-0.1, 0.13],
[0.4, 0.21],
[0, -0.6],
[-0.3, -0.3],
[-0.6, -0.3],
[-0.45, 0.0],
];
const points = vertices.flat();
const triangles = earcut(points);
const position = new Float32Array(points);
const cells = new Uint16Array(triangles);
const pointBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);
const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);
const cellsBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, cellsBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, cells, gl.STATIC_DRAW);
gl.clear(gl.COLOR_BUFFER_BIT);
// 如果这里将 gl.TRIANGLES 改成 gl.LINE_STRIP,就可以清晰的看到,
// 经过 Earcut 处理的多边形被分割成了多个三角形
// gl.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);
gl.drawElements(gl.LINE_STRIP, cells.length, gl.UNSIGNED_SHORT, 0);
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
上面这段代码的最终绘制效果可以看这里 (opens new window)。
# 3D 模型的三角剖分
针对 3D 模型,WebGL 在绘制的时候,也需要使用三角剖分,而 3D 的三角剖分又被称为网格化(Meshing)。
不过,因为 3D 模型比 2D 模型更加复杂,顶点的数量更多,所以针对复杂的 3D 模型,我们一般不在运行的时候进行三角剖分,而是通过设计工具把图形的三角剖分结果直接导出进行使用。也就是说,在 3D 渲染的时候,我们一般使用的模型数据都是已经经过三角剖分以后的顶点数据。
总的来说,无论是绘制 2D 还是 3D 图形,WebGL 都需要先把它们进行三角剖分,然后才能绘制。因此,三角剖分是 WebGL 绘图的基础。
# 如何判断点在多边形内部
在可视化中,经常需要实现一些交互效果,比如当用户的鼠标移动到某一个图形上时,我们要让这个图形变色。而多边形的交互需要解决的一个核心问题就是,判定鼠标所在位置是否在多边形的内部。
不同的图形系统判断点在多边形内部的方法如下:
# 1. SVG 如何判断点在多边形内部
在 SVG 这样的图形系统里,由于多边形本身就是一个元素节点,因此我们直接通过 DOM API 就可以判定鼠标是否在该元素上。
# 2. Canvas2D 如何判断点在多边形内部
对于 Canvas2D,我们不能直接通过 DOM API 判定,而是要通过 Canvas2D 提供的 isPointInPath (opens new window) 方法来判定。但 isPointInPath 这个方法实际上并不好用。因为 isPointInPath 方法只能对当前绘制的图形生效。绘制效果可以看这里 (opens new window)。
假设我们先绘制一个多边形,再绘制一个小三角形,此时还通过 isPointInPath 方法判断点的位置的话,就会发现,当鼠标移动到多边形内部时没有任何交互效果,只有移动到小三角形内部时,才会有交互效果。这是因为,isPointInPath 仅能判断鼠标是否在最后一次绘制的小三角形内,所以多边形就没有被识别出来。
解决这个问题,一个最简单的办法就是,自己实现一个 isPointInPath 方法。然后在这个方法里,重新创建一个 Canvas 对象,并且再绘制一遍多边形和小三角形。这个方法的核心,其实就是在绘制的过程中获取每个图形的 isPointInPath 结果。
function isPointInPath(ctx, x, y) {
// 根据 ctx 重新 clone 一个新的 canvas 对象出来
const cloned = ctx.canvas.cloneNode().getContext('2d');
cloned.translate(0.5 * width, 0.5 * height);
cloned.scale(1, -1);
let ret = false;
// 绘制多边形,然后判断点是否在图形内部
draw(cloned, poitions, 'transparent', 'red');
ret |= cloned.isPointInPath(x, y);
if (!ret) {
// 如果不在,在绘制小三角形,然后判断点是否在图形内部
draw(cloned, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');
ret |= cloned.isPointInPath(x, y);
}
return ret;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
这段代码的绘制效果可以看这里 (opens new window)。
但是,这个方法并不通用。因为一旦我们修改了绘图过程,也就是增加或者减少了绘制的图形,isPointInPath 方法也要跟着改变。当然,我们也有办法进行优化,比如将每一个几何图形的绘制封装起来,针对每个图形提供单独的 isPointInPath 判断,但是这样也很麻烦,而且有很多无谓的 Canvas 绘图操作,性能会很差。
# 实现通用的 isPointInPath 方法
有一个更好的方法是,我们不使用 Canvas 的 isPointInPath 方法,而是直接通过点与几何图形的数学关系来判断点是否在图形内。
但是,直接判断一个点是不是在一个几何图形内还是比较困难的,因为这个几何图形可能是简单多边形,也可能是复杂多边形。这个时候,我们完全可以把视线放在最简单的多边形,也就是三角形上。因为对于三角形来说,我们有一个非常简单的方法可以判断点是否在其中。这个方法如下:
已知一个三角形的三条边分别是向量 a、b、c,平面上一点 u 连接三角形三个顶点的向量分别为
当点 u 在三角形 a、b、c 内时,因为
当点 v 在三角形外时,
不过,仅根据这个原理写出来的判定函数还不完整,它虽然可以判定点在三角形内部,但却不能判定点恰好在三角形某条边上的情况。
在学习向量乘法之后,可以知道,如果一个点 u 在三角形的一条边 a 上,那就会需要满足以下 2 个条件:
a.cross(u1) === 0 // 1. 叉乘等于 0
0 <= a.dot(u1) / a.length ** 2 <= 1 // 2. 同时点乘除以边长度的平方在 0 - 1 之间
2
下面讨论下第二个条件,即讨论点 u 和 a 在一条直线上和不在一条直线上这两种情况。
当向量
与 a 不在一条直线上时, 与 a 的叉乘结果不为 0,而 与 a 的点乘的值除以 a 的长度,相当于 在 a 上的投影。 当向量
与 a 在一条直线上时, 与 a 的叉乘结果为 0, 与 a 的点乘结果除以 a 的长度的平方,正好是 与 a 的比值。这个比值有三种情况: 当
在 a 上时, 和 a 比值是介于 0 到 1 之间的; 当
在 a 的左边时,这个比值是小于 0 的; 当
在 a 的右边时,这个比值是大于 1 的。
因此,只有当
和 a 的比值在 0 到 1 之间时,才能说明点在三角形的边上。
综合以上原理,就可以写出一个如下的判定函数,用来判定一个点是否在某个三角形内部。
function inTriangle(p1, p2, p3, point) {
const a = p2.copy().sub(p1);
const b = p3.copy().sub(p2);
const c = p1.copy().sub(p3);
const u1 = point.copy().sub(p1);
const u2 = point.copy().sub(p2);
const u3 = point.copy().sub(p3);
const s1 = Math.sign(a.cross(u1));
let p = a.dot(u1) / a.length ** 2;
if (s1 === 0 && p >= 0 && p <= 1) return true;
const s2 = Math.sign(b.cross(u2));
p = b.dot(u1) / b.length ** 2;
if (s2 === 0 && p >= 0 && p <= 1) return true;
const s3 = Math.sign(c.cross(u3));
p = c.dot(u1) / c.length ** 2;
if (s3 === 0 && p >= 0 && p <= 1) return true;
return s1 === s2 && s2 === s3;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
如果要判断一个点是否在任意多边形的内部,我们只需要在判断之前将它进行三角剖分就可以了。
function isPointInPath({ vertices, cells }, point) {
let ret = false;
for (let i = 0; i < cells.length; i += 3) {
const p1 = new Vector2D(...vertices[cells[i]]);
const p2 = new Vector2D(...vertices[cells[i + 1]]);
const p3 = new Vector2D(...vertices[cells[i + 2]]);
if (inTriangle(p1, p2, p3, point)) {
ret = true;
break;
}
}
return ret;
}
2
3
4
5
6
7
8
9
10
11
12
13
# 如何用仿射变换对几何图形进行坐标变换
在实际绘制的时候,我们经常需要在画布上绘制许多轮廓相同的图形,此时我们只需要创建一个基本的几何轮廓,然后通过仿射变换来改变几何图形的位置、形状、大小和角度。
仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。
# 什么是仿射变换
仿射变换简单来说就是 “线性变换 + 平移”。实际上在平常的 Web 开发中,我们也经常会用到仿射变换,比如,对元素设置 CSS 的 transform 属性就是对元素应用仿射变换。
仿射变换具有以下 2 个性质:
仿射变换前是直线段的,仿射变换后依然是直线段
对两条直线段 a 和 b 应用同样的仿射变换,变换前后线段长度比例保持不变
由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。
# 向量的平移、旋转与缩放
常见的仿射变换形式包括平移、旋转、缩放以及它们的组合。
# 1. 平移
平移变换是最简单的仿射变换。如果我们想让向量
# 2. 旋转
在前面,把向量的旋转定义成了如下的函数:
class Vector2D {
// ...
rotate(rad) {
const c = Math.cos(rad),
s = Math.sin(rad);
const [x, y] = this;
this.x = x * c + y * -s;
this.y = x * s + y * c;
return this;
}
}
2
3
4
5
6
7
8
9
10
11
下面通过三角函数推导下这个函数是怎么来的。
假设向量 P 的长度为 r,角度是 ⍺,现在我们要将它顺时针旋转 ⍬ 角,此时新的向量 P’的参数方程为:
然后,因为 rcos⍺、rsin⍺ 是向量 P 原始的坐标
最后,我们再将它写成矩阵形式,就会得到一个旋转矩阵。
# 3. 缩放
缩放变换也很简单,我们可以直接让向量与标量(标量只有大小、没有方向)相乘。
这个公式也可以把它写成矩阵形式。
到此,我们就得到了三个基本的仿射变换公式,其中旋转和缩放都可以写成矩阵与向量相乘的形式。
# 线性变换
这种能写成矩阵与向量相乘形式的变换,就叫做线性变换。线性变换除了可以满足仿射变换的 2 个性质之外,还有 2 个额外的性质:
线性变换不改变坐标原点(因为如果
、 等于零,那么 x、y 肯定等于 0); 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。
根据线性变换的第 2 条性质,我们就能总结出一个通用的线性变换公式,即一个原始向量
到此,常见的仿射变换形式就介绍完了。总的来说,向量的基本仿射变换分为平移、旋转与缩放,其中旋转与缩放属于线性变换,而平移不属于线性变换。基于此,我们可以得到仿射变换的一般表达式:
这里的 M 是线性变换的矩阵,
这个公式我们也可以改写成矩阵的形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换!
这样,我们就将原本 n 维的坐标转换为了 n+1 维的坐标。
# 齐次坐标和齐次矩阵
这种 n+1 维坐标被称为齐次坐标,对应的矩阵就被称为齐次矩阵。
齐次坐标和齐次矩阵是可视化中非常常用的数学工具,它能让我们用线性变换来表示仿射变换。这样一来,我们就能利用线性变换的叠加性质,来非常方便地进行各种复杂的仿射变换了。即把这些变换的矩阵相乘得到一个新的矩阵,再把它乘以原向量。我们在绘制几何图形的时候会经常用到它,要记住这个公式。
通过齐次坐标和齐次矩阵,我们可以将平移这样的非线性仿射变换用更高维度的线性变换来表示。这么做的目的是让我们能够将仿射变换的组合简化为矩阵乘法运算。
# attribute 变量和 uniform 变量的区别
首先,attribute 变量是对应于顶点的。也就是说,几何图形有几个顶点就要提供几份 attribute 数据。并且,attribute 变量只能在顶点着色器中使用,如果要在片元着色器中使用,需要我们通过 varying 变量将它传给片元着色器才行。这样一来,片元着色器中获取的实际值,就是经过顶点线性插值的。
而 uniform (opens new window) 声明的变量不同,uniform 声明的变量和其他语言中的常量一样,我们赋给 unform 变量的值在 shader 执行的过程中不可改变。而且一个变量的值是唯一的,不随顶点变化。uniform 变量既可以在顶点着色器中使用,也可以在片元着色器中使用。
在 WebGL 中,我们可以通过 gl.uniformXXX(loc, u_color); 的方法将数据传给 shader 的 uniform 变量。其中,XXX 是我们随着数据类型不同取得不同的名字。下面是一些比较常用的:
gl.uniform1f传入一个浮点数,对应的 uniform 变量的类型为 float;gl.uniform4f传入四个浮点数,对应的 uniform 变量类型为 float[4];gl.uniform3fv传入一个三维向量,对应的 uniform 变量类型为 vec3;gl.uniformMatrix4fv传入一个 4 x 4 的矩阵,对应的 uniform 变量类型为 mat4。
# 仿射变换的应用:实现粒子动画
仿射变换的一个很常见的应用,就是用它来实现粒子动画,因为粒子动画通常需要在界面上快速改变一大批图形的大小、形状和位置。
下面的代码实现的粒子动画效果是,从一个点开始发射出许多颜色、大小、角度各异的三角形,并且通过不断变化它们的位置,产生一种撒花般的视觉效果。最终效果可以看这里 (opens new window)。
const canvas = document.querySelector('canvas');
const gl = canvas.getContext('webgl');
// 顶点着色器中的 glsl 代码
// p 是当前动画进度,它的值是 u_time / u_duration,取值区间从 0 到 1
// rad 是旋转角度,它的值是初始角度 u_rotation 加上 10π,表示在动画过程中它会绕自身旋转 5 周
// scale 是缩放比例,它的值是初始缩放比例乘以一个系数,这个系数是 p * (2.0 - p),p * (2.0 - p) 是一个缓动函数,它的作用是让 scale 的变化量随着时间推移逐渐减小
// offset 是一个二维向量,它是初始值 u_dir 与 2.0 * p * p 的乘积,因为 u_dir 是个单位向量,这里的 2.0 表示它的最大移动距离为 2,p * p 也是一个缓动函数,作用是让位移的变化量随着时间增加而增大
// translateMatrix 是偏移矩阵,rotateMatrix 是旋转矩阵,scaleMatrix 是缩放矩阵
// 最后将 pos 的值设置为这三个矩阵与 position 的乘积,这样就完成对顶点的线性变换,呈现出来的效果也就是三角形会向着特定的方向旋转、移动和缩放
const vertex = `
attribute vec2 position;
uniform float u_rotation;
uniform float u_time;
uniform float u_duration;
uniform float u_scale;
uniform vec2 u_dir;
varying float vP;
void main() {
float p = min(1.0, u_time / u_duration);
float rad = u_rotation + 3.14 * 10.0 * p;
float scale = u_scale * p * (2.0 - p);
vec2 offset = 2.0 * u_dir * p * p;
mat3 translateMatrix = mat3(
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
offset.x, offset.y, 1.0
);
mat3 rotateMatrix = mat3(
cos(rad), sin(rad), 0.0,
-sin(rad), cos(rad), 0.0,
0.0, 0.0, 1.0
);
mat3 scaleMatrix = mat3(
scale, 0.0, 0.0,
0.0, scale, 0.0,
0.0, 0.0, 1.0
);
gl_PointSize = 1.0;
vec3 pos = translateMatrix * rotateMatrix * scaleMatrix * vec3(position, 1.0);
gl_Position = vec4(pos, 1.0);
vP = p;
}
`;
// 在片元着色器中对这些三角形着色
// 将 p 也就是动画进度,从顶点着色器通过变量 varying vP 传给片元着色器,然后在片元着色器中让 alpha 值随着 vP 值变化,这样就能同时实现粒子的淡出效果了
const fragment = `
precision mediump float;
uniform vec4 u_color;
varying float vP;
void main()
{
gl_FragColor.xyz = u_color.xyz;
gl_FragColor.a = (1.0 - vP) * u_color.a;
}
`;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
gl.useProgram(program);
// 定义三角形的顶点并将数据送到缓冲区
const position = new Float32Array([
-1, -1,
0, 1,
1, -1,
]);
const bufferId = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, bufferId);
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);
const vPosition = gl.getAttribLocation(program, 'position');
gl.vertexAttribPointer(vPosition, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(vPosition);
// 实现一个创建随机三角形属性的函数
function randomTriangles() {
const u_color = [Math.random(), Math.random(), Math.random(), 1.0]; // 随机颜色
const u_rotation = Math.random() * Math.PI; // 初始旋转角度
const u_scale = Math.random() * 0.05 + 0.03; // 初始大小
const u_time = 0; // 初始时间
const u_duration = 3.0; // 动画持续时间
const rad = Math.random() * Math.PI * 2;
const u_dir = [Math.cos(rad), Math.sin(rad)]; // 运动方向
const startTime = performance.now(); // 创建时间
return {u_color, u_rotation, u_scale, u_time, u_duration, u_dir, startTime};
}
// 设置 uniform 变量
// 在 WebGL 的 shader 中,顶点相关的变量可以用 attribute 声明
// 但是,我们现在要把 u_color、u_rotation 等一系列变量也传到 shader 中,
// 这些变量与三角形具体顶点无关,它们是一些固定的值,所以要用 uniform 来声明
// 下面这个函数将随机三角形信息传给 shader 里的 uniform 变量
function setUniforms(gl, {u_color, u_rotation, u_scale, u_time, u_duration, u_dir}) {
let loc = gl.getUniformLocation(program, 'u_color'); // gl.getUniformLocation 拿到 uniform 变量的指针
gl.uniform4fv(loc, u_color); // 将数据传给 unfirom 变量的地址
loc = gl.getUniformLocation(program, 'u_rotation');
gl.uniform1f(loc, u_rotation);
loc = gl.getUniformLocation(program, 'u_scale');
gl.uniform1f(loc, u_scale);
loc = gl.getUniformLocation(program, 'u_time');
gl.uniform1f(loc, u_time);
loc = gl.getUniformLocation(program, 'u_duration');
gl.uniform1f(loc, u_duration);
loc = gl.getUniformLocation(program, 'u_dir');
gl.uniform2fv(loc, u_dir);
}
let triangles = [];
// 在 update 方法中每次新建数个随机三角形,然后依次修改所有三角形的 u_time 属性,
// 通过 setUniforms 方法将修改的属性更新到 shader 变量中
// 这样,我们就可以在 shader 中读取变量的值进行处理了
function update(t) {
for (let i = 0; i < 5 * Math.random(); i++) {
triangles.push(randomTriangles());
}
gl.clear(gl.COLOR_BUFFER_BIT);
// 对每个三角形重新设置 u_time
triangles.forEach((triangle) => {
triangle.u_time = (performance.now() - triangle.startTime) / 1000;
setUniforms(gl, triangle);
gl.drawArrays(gl.TRIANGLES, 0, position.length / 2);
});
// 移除已经结束动画的三角形
triangles = triangles.filter((triangle) => {
return triangle.u_time <= triangle.u_duration;
});
requestAnimationFrame(update); // 使用 requestAnimationFrame 实现动画
}
requestAnimationFrame(update);
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
# CSS 的仿射变换
CSS 中的 transform 是一个很强大的属性,它的作用其实也是对元素进行仿射变换。它不仅支持 translate、rotate、scale 等值,还支持 matrix。CSS 的 matrix 是一个简写的齐次矩阵,因为它省略了 3 阶齐次矩阵第三行的 0, 0, 1 值,所以它 只有 6 个值。
结合齐次矩阵变换的原理,我们可以对 CSS 的 transform 属性进行压缩。
div.block {
transform: rotate(30deg) translate(100px,50px) scale(1.5);
}
2
3
这段 CSS 代码实际上相当于做了如下变换:
这里可以使用一个向量矩阵运算的数学库 math (opens new window),它几乎包含了所有图形学需要用到的数学方法。
下面简单算一下三个矩阵相乘:
import {multiply} from 'common/lib/math/functions/mat3fun.js';
const rad = Math.PI / 6;
const a = [
Math.cos(rad), -Math.sin(rad), 0,
Math.sin(rad), Math.cos(rad), 0,
0, 0, 1
];
const b = [
1, 0, 100,
0, 1, 50,
0, 0, 1
];
const c = [
1.5, 0, 0,
0, 1.5, 0,
0, 0, 1
];
const res = [a, b, c].reduce((a, b) => {
return multiply([], b, a);
});
console.log(res);
/*
[1.299038105676658, -0.7499999999999999, 61.60254037844388,
0.7499999999999999, 1.299038105676658, 93.30127018922192,
0, 0, 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
通过转换之后,最终就可以将上面的 transform 用一个矩阵表示:
div.block {
transform: matrix(1.3, 0.75, -0.75, 1.3, 61.6, 93.3);
}
2
3
这样的 transform 效果和之前 rotate、translate 和 scale 分开写的效果是一样的,但是字符数更少,所以能减小 CSS 文件的大小。
# 仿射变换学习参考资料
# 更多数学学习资料
图形学作为可视化的基础,是一门很深的学问。它牵涉的数学内容非常多,包括线性代数、几何、微积分和概率统计等等。上面这张图里所介绍的只是一些入门知识,以下是一些深入学习的资料。
3Blue1Brown 的数学和图形学基础课程((B 站) (opens new window)、YouTube (opens new window))讲得深入浅出,是非常棒的入门教程。
Fundamentals of Computer Graphics (opens new window) 是图形学入门的经典教材。





























