你好,我是月影。
为什么你做了很多可视化项目,解决了一个、两个、三个甚至多个不同类型的图表展现之后,还是不能系统地提升自己的能力,在下次面对新的项目时依然会有各种难以克服的困难?这是因为你陷入了细节里。
什么是细节?简单来说,细节就是各种纯粹的图形学问题。在可视化项目里,我们需要描述很多的图形,而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且,如果我们使用不同的绘图系统,每个绘图系统又可能有独特的方式或者特定的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。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是直角坐标系,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
为了方便处理图形,我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系,所以它们可以很方便地相互转化。其中,HTML、SVG和Canvas都提供了transform的API能够帮助我们很方便地转换坐标系。而WebGL本身不提供tranform的API,但我们可以在shader里做矩阵运算来实现坐标转换,WebGL的问题我们在后续课程会有专门讨论,今天我们先来说说其他三种。那接下来我们就以Canvas为例,来看看用transform API怎样进行坐标转换。
假设,我们要在宽512 * 高256的一个Canvas画布上实现如下的视觉效果。其中,山的高度是100,底边200,两座山的中心位置到中线的距离都是80,太阳的圆心高度是150。
当然,在不转换坐标系的情况下,我们也可以把图形绘制出来,但是要经过顶点换算,下面我们就来说一说这个过程。
首先,因为Canvas坐标系默认的原点是左上角,底边的y坐标是256,而山的高度是100,所以山顶点的y坐标是256 - 100 = 156。而因为太阳的高度是150,所以太阳圆心的y坐标是256 - 150 = 106。
然后,因为x轴中点的坐标是512 / 2 = 256,所以两座山顶点的x坐标分别是256 - 80和256 + 80,也就是176和336。又因为山是等腰三角形,它的底边是200,所以两座山底边的x坐标计算出来,分别是 76、276、236、436(176 - 100 =76、176 + 100=276、336 - 100=236、 336 + 100=436)。
计算出这些坐标之后,我们很容易就可以将这个图画出来了。不过,为了增加一些趣味性,我们用一个Rough.js的库,绘制一个手绘风格的图像(Rough.js库的API和Canvas差不多,绘制出来的图形比较有趣)。绘制的代码如下所示:
const rc = rough.canvas(document.querySelector('canvas'));
const hillOpts = {roughness: 2.8, strokeWidth: 2, fill: 'blue'};
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',
});
最终,我们绘制出的图形效果如下所示:
到这里,我们通过简单的计算就绘制出了这一组图形。但你也能够想到,如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
这里,我们给Canvas的2D上下文设置一下transform变换。我们经常会用到两个变换:translate和scale。
首先,我们通过translate变换将Canvas画布的坐标原点,从左上角(0, 0)点移动至(256, 256)位置,即画布的底边上的中点位置。接着,以移动了原点后新的坐标为参照,通过scale(1, -1)将y轴向下的部分,即y>0的部分沿x轴翻转180度,这样坐标系就变成以画布底边中点为原点,x轴向右,y轴向上的坐标系了。
执行了这个坐标变换,也就是让坐标系原点在中间之后,我们就可以更方便、直观地计算出几个图形元素的坐标了。
两个山顶的坐标就是 (-80, 100) 和 (80, 100),山脚的坐标就是 (-180, 0)、(20, 0)、(-20, 0)、(180, 0),太阳的中心点的坐标就是(0, 150)。那么更改后的代码如下所示。
const rc = rough.canvas(document.querySelector('canvas'));
const ctx = rc.ctx;
ctx.translate(256, 256);
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',
});
好了,现在我们就完成了坐标变换。但是因为这个例子要绘制的图形很少,所以还不太能体现使用坐标系变换的好处。不过,你可以想一下,在可视化的许多应用场景中,我们都要处理成百上千的图形。如果这个时候,我们在原始坐标下通过计算顶点来绘制图形,计算量会非常大,很麻烦。那采用坐标变换的方式就是一个很好的优化思路,它能够简化计算量,这不仅让代码更容易理解,也可以节省CPU运算的时间。
理解直角坐标系的坐标变换之后,我们再来说说直角坐标系里绘制图形的方法。那不管我们用什么绘图系统绘制图形,一般的几何图形都是由点、线段和面构成。其中,点和线段是基础的图元信息,因此,如何描述它们是绘图的关键。
那在直角坐标系下,我们是怎么表示点和线段的呢?我们一般是用向量来表示一个点或者一个线段。
前面的例子因为包含x、y两个坐标轴,所以它们构成了一个绘图的平面。因此,我们可以用二维向量来表示这个平面上的点和线段。二维向量其实就是一个包含了两个数值的数组,一个是x坐标值,一个是y坐标值。
假设,现在这个平面直角坐标系上有一个向量v。向量v有两个含义:一是可以表示该坐标系下位于(x, y)处的一个点;二是可以表示从原点(0,0)到坐标(x,y)的一根线段。
接下来,为了方便你理解,我们先来回顾一下关于向量的数学知识。
首先,向量和标量一样可以进行数学运算。举个例子,现在有两个向量,v1和v2,如果让它们相加,其结果相当于将v1向量的终点(x1, y1),沿着v2向量的方向移动一段距离,这段距离等于v2向量的长度。这样,我们就可以在平面上得到一个新的点(x1 + x2, y1 + y2),一条新的线段[(0, 0), (x1 + x2, y1 + y2)],以及一段折线:[(0, 0), (x1, y1) , (x1 + x2, y1 + y2)]。
其次,一个向量包含有长度和方向信息。它的长度可以用向量的x、y的平方和的平方根来表示,如果用JavaScript来计算,就是:
v.length = function(){return Math.hypot(this.x, this.y)};
它的方向可以用与x轴的夹角来表示,即:
v.dir = function() { return Math.atan2(this.y, this.x);}
在上面的代码里,Math.atan2的取值范围是-π到π,负数表示在x轴下方,正数表示在x轴上方。
最后,根据长度和方向的定义,我们还能推导出一组关系式:
v.x = v.length * Math.cos(v.dir);
v.y = v.length * Math.sin(v.dir);
这个推论意味着一个重要的事实:我们可以很简单地构造出一个绘图向量。也就是说,如果我们希望以点(x0, y0)为起点,沿着某个方向画一段长度为length的线段,我们只需要构造出如下的一个向量就可以了。
这里的α是与x轴的夹角,v是一个单位向量,它的长度为1。然后我们把向量(x0, y0)与这个向量v1相加,得到的就是这条线段的终点。这么讲还是比较抽象,我们看一个例子。
我们用前面学到的向量知识来绘制一棵随机生成的树,想要生成的效果如下:
我们还是用Canvas2D来绘制。首先是坐标变换,原理前面讲过,我就不细说了。这里,我们要做的变换是将坐标原点从左上角移动到左下角,并且让y轴翻转为向上。
ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round';
然后,我们定义一个画树枝的函数 drawBranch。
function drawBranch(context, v0, length, thickness, dir, bias) {
...
}
这个函数有六个参数:
因为v0是树枝的起点坐标,那根据前面向量计算的原理,我们创建一个单位向量(1, 0),它是一个朝向x轴,长度为1的向量。然后我们旋转dir弧度,再乘以树枝长度length。这样,我们就能计算出树枝的终点坐标了。代码如下:
const v = new Vector2D(1, 0).rotate(dir).scale(length);
const v1 = v0.copy().add(v);
向量的旋转是向量的一种常见操作,对于二维空间来说,向量的旋转可以定义成如下方法(这里我们省略了数学推导过程,有兴趣的同学可以去看一下数学原理)。这个方法我们后面还会经常用到,你先记一下,后续我们讲到仿射变换的时候,会有更详细的解释。
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;
}
}
我们可以从一个起始角度开始递归地旋转树枝,每次将树枝分叉成左右两个分枝:
if(thickness > 2) {
const left = dir + 0.2;
drawBranch(context, v1, length * 0.9, thickness * 0.8, left, bias * 0.9);
const right = dir - 0.2;
drawBranch(context, v1, length * 0.9, thickness * 0.8, right, bias * 0.9);
}
这样,我们得到的就是一棵形状规律的树。
接着我们修改代码,加入随机因子,让迭代生成的新树枝有一个随机的偏转角度。
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();
}
这样,我们就实现了绘制一棵随机树的方法。
它的完整代码在GitHub仓库,你可以研究一下。这里面最关键的一步就是前面的向量操作,为了实现向量的rotate、scale、add等方法,我封装了一个简单的库Vector2d.js,你也可以在代码仓库中找到它。
实际上,在我们的可视化项目里,直接使用向量的加法、旋转和乘法来构造线段绘制图形的情形并不多。这是因为,在一般情况下,数据在传给前端的时候就已经计算好了,我们只需要拿到数据点的信息,根据坐标变换进行映射,然后直接用映射后的点来绘制图形即可。
既然这样,为什么我们在这里又要强调向量操作的重要性呢?虽然我们很少直接使用向量构造线段来完成绘图,但是向量运算的意义并不仅仅只是用来算点的位置和构造线段,这只是最初级的用法。我们要记住,可视化呈现依赖于计算机图形学,而向量运算是整个计算机图形学的数学基础。
而且,在向量运算中,除了加法表示移动点和绘制线段外,向量的点乘、叉乘运算也有特殊的意义。课后我会给你出一道有挑战性的思考题 ,让你能更深入地理解向量运算的现实意义,在下一节课里我会给你答案。
这一节课, 我们以Canvas为例学习了坐标变换,以及用向量描述点和线段的原理和方法。
一般来说,采用平面直角坐标系绘图的时候,对坐标进行平移等线性变换,并不会改变坐标系中图形的基本形状和相对位置,因此我们可以利用坐标变换让我们的绘图变得更加容易。Canvas坐标变换经常会用到translate和scale这两个变换,它们的操作和原理都很简单,我们根据实际需求来设置就好了。
在平面直角坐标系中,我们可以定义向量来绘图。向量可以表示绘图空间中的一个点,或者连接原点的一条线段。两个向量相加,结果相当于将被加向量的终点沿着加数向量的方向移动一段距离,移动的距离等于加数向量的长度。利用向量的这个特性,我们就能以某个点为起点,朝任意方向绘制线段,从而绘制各种较复杂的几何图形了。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
[1] 二维旋转矩阵与向量旋转推荐文档
[2] 一个有趣的绘图库:Rough.js
[3] Vector2d.js模块文档
评论