You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

302 lines
18 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 05 | 如何用向量和坐标系描述点和线段?
你好,我是月影。
为什么你做了很多可视化项目,解决了一个、两个、三个甚至多个不同类型的图表展现之后,还是不能系统地提升自己的能力,在下次面对新的项目时依然会有各种难以克服的困难?这是因为你陷入了细节里。
什么是细节简单来说细节就是各种纯粹的图形学问题。在可视化项目里我们需要描述很多的图形而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且如果我们使用不同的绘图系统每个绘图系统又可能有独特的方式或者特定的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。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是**直角坐标系**,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
![](https://static001.geekbang.org/resource/image/5e/89/5e3bc7cd089e2e28c527b57a1df5cb89.jpeg)
为了方便处理图形我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系所以它们可以很方便地相互转化。其中HTML、SVG和Canvas都提供了transform的API能够帮助我们很方便地转换坐标系。而WebGL本身不提供tranform的API但我们可以在shader里做矩阵运算来实现坐标转换WebGL的问题我们在后续课程会有专门讨论今天我们先来说说其他三种。那接下来我们就以Canvas为例来看看用transform API怎样进行坐标转换。
## 如何用Canvas实现坐标系转换
假设我们要在宽512 \* 高256的一个Canvas画布上实现如下的视觉效果。其中山的高度是100底边200两座山的中心位置到中线的距离都是80太阳的圆心高度是150。
当然,在不转换坐标系的情况下,我们也可以把图形绘制出来,但是要经过顶点换算,下面我们就来说一说这个过程。
![](https://static001.geekbang.org/resource/image/a8/09/a8ec91897b2ede72d5c48d4d6b2d5409.jpeg)
首先因为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、436176 - 100 =76、176 + 100=276、336 - 100=236、 336 + 100=436
![](https://static001.geekbang.org/resource/image/55/29/552676f6f0268d2091b838e268651929.jpeg)
计算出这些坐标之后,我们很容易就可以将这个图画出来了。不过,为了增加一些趣味性,我们用一个[Rough.js](https://github.com/pshihn/rough)的库绘制一个手绘风格的图像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',
});
```
最终,我们绘制出的图形效果如下所示:
![](https://static001.geekbang.org/resource/image/cd/cb/cddabd7aeca8e5yy0c22c85879f5dccb.jpeg)
到这里,我们通过简单的计算就绘制出了这一组图形。但你也能够想到,如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
这里我们给Canvas的2D上下文设置一下transform变换。我们经常会用到两个变换translate和scale。
首先我们通过translate变换将Canvas画布的坐标原点从左上角(0, 0)点移动至(256, 256)位置即画布的底边上的中点位置。接着以移动了原点后新的坐标为参照通过scale(1, -1)将y轴向下的部分即y>0的部分沿x轴翻转180度这样坐标系就变成以画布底边中点为原点x轴向右y轴向上的坐标系了。
![](https://static001.geekbang.org/resource/image/8a/de/8a1f3ed166942736206124aba16965de.jpeg "坐标系")
执行了这个坐标变换,也就是让坐标系原点在中间之后,我们就可以更方便、直观地计算出几个图形元素的坐标了。
两个山顶的坐标就是 (-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坐标值。
![](https://static001.geekbang.org/resource/image/0d/58/0de1596f2df5002c3a8b26723f0f0558.jpeg)
假设现在这个平面直角坐标系上有一个向量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)\]。
![](https://static001.geekbang.org/resource/image/8e/29/8ebb3963e385ba9fda2dab46d7277e29.jpeg)
**其次,一个向量包含有长度和方向信息**。它的长度可以用向量的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的线段我们只需要构造出如下的一个向量就可以了。
![](https://static001.geekbang.org/resource/image/7c/a3/7cf68477844ee77a31163008d2bb39a3.jpeg)
这里的α是与x轴的夹角v是一个单位向量它的长度为1。然后我们把向量(x0, y0)与这个向量v1相加得到的就是这条线段的终点。这么讲还是比较抽象我们看一个例子。
## 实战演练:用向量绘制一棵树
我们用前面学到的向量知识来绘制一棵随机生成的树,想要生成的效果如下:
![](https://static001.geekbang.org/resource/image/6y/f4/6yydf8017e95529yybb987d97e9yy9f4.jpeg)
我们还是用Canvas2D来绘制。首先是坐标变换原理前面讲过我就不细说了。这里我们要做的变换是将坐标原点从左上角移动到左下角并且让y轴翻转为向上。
```
ctx.translate(0, canvas.height);
ctx.scale(1, -1);
ctx.lineCap = 'round';
```
然后,我们定义一个画树枝的函数 drawBranch。
```
function drawBranch(context, v0, length, thickness, dir, bias) {
...
}
```
这个函数有六个参数:
* context是我们的Canvas2D上下文
* v0是起始向量
* length是当前树枝的长度
* thickness是当前树枝的粗细
* dir是当前树枝的方向用与x轴的夹角表示单位是弧度。
* bias是一个随机偏向因子用来让树枝的朝向有一定的随机性
因为v0是树枝的起点坐标那根据前面向量计算的原理我们创建一个单位向量(1, 0)它是一个朝向x轴长度为1的向量。然后我们旋转dir弧度再乘以树枝长度length。这样我们就能计算出树枝的终点坐标了。代码如下
```
const v = new Vector2D(1, 0).rotate(dir).scale(length);
const v1 = v0.copy().add(v);
```
向量的旋转是向量的一种常见操作,对于二维空间来说,向量的旋转可以定义成如下方法(这里我们省略了数学推导过程,有兴趣的同学可以去看一下[数学原理](https://zhuanlan.zhihu.com/p/98007510))。这个方法我们后面还会经常用到,你先记一下,后续我们讲到仿射变换的时候,会有更详细的解释。
```
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);
}
```
![](https://static001.geekbang.org/resource/image/1f/y1/1f95a7d1e6ecf30c7db0ef0afc0f7yy1.jpeg)
这样,我们得到的就是一棵形状规律的树。
接着我们修改代码,加入随机因子,让迭代生成的新树枝有一个随机的偏转角度。
```
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);
}
```
这样,我们就可以得到一棵随机的树。
![](https://static001.geekbang.org/resource/image/53/7f/5350becdbb756ce4dae1289b7beba37f.jpeg)
最后,为了美观,我们再随机绘制一些花瓣上去,你也可以尝试绘制其他的图案到这棵树上。
```
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仓库](https://github.com/akira-cn/graphics/tree/master/vector_tree)你可以研究一下。这里面最关键的一步就是前面的向量操作为了实现向量的rotate、scale、add等方法我封装了一个简单的库Vector2d.js你也可以在[代码仓库](https://github.com/akira-cn/graphics/blob/master/common/lib/vector2d.js)中找到它。
## 向量运算的意义
实际上,在我们的可视化项目里,直接使用向量的加法、旋转和乘法来构造线段绘制图形的情形并不多。这是因为,在一般情况下,数据在传给前端的时候就已经计算好了,我们只需要拿到数据点的信息,根据坐标变换进行映射,然后直接用映射后的点来绘制图形即可。
既然这样,为什么我们在这里又要强调向量操作的重要性呢?虽然我们很少直接使用向量构造线段来完成绘图,但是向量运算的意义并不仅仅只是用来算点的位置和构造线段,这只是最初级的用法。我们要记住,**可视化呈现依赖于计算机图形学,而向量运算是整个计算机图形学的数学基础。**
而且,在向量运算中,除了加法表示移动点和绘制线段外,向量的点乘、叉乘运算也有特殊的意义。课后我会给你出一道有挑战性的思考题 ,让你能更深入地理解向量运算的现实意义,在下一节课里我会给你答案。
## 要点总结
这一节课, 我们以Canvas为例学习了坐标变换以及用向量描述点和线段的原理和方法。
一般来说采用平面直角坐标系绘图的时候对坐标进行平移等线性变换并不会改变坐标系中图形的基本形状和相对位置因此我们可以利用坐标变换让我们的绘图变得更加容易。Canvas坐标变换经常会用到translate和scale这两个变换它们的操作和原理都很简单我们根据实际需求来设置就好了。
在平面直角坐标系中,我们可以定义向量来绘图。向量可以表示绘图空间中的一个点,或者连接原点的一条线段。两个向量相加,结果相当于将被加向量的终点沿着加数向量的方向移动一段距离,移动的距离等于加数向量的长度。利用向量的这个特性,我们就能以某个点为起点,朝任意方向绘制线段,从而绘制各种较复杂的几何图形了。
## 小试牛刀
1. 我们已经知道如何用向量来定义一个线段你知道如何判断两个线段的位置关系吗假设有两个线段l1和l2已知它们的起点和终点分别是\[(x10, y10),(x11, y11)\]、\[(x20, y20),(x21, y21)\],你能判断它们的关系吗(小提示:两个线段之间的关系有**平行、垂直**或既不平行又不垂直)?
2. 已知线段\[(x0, y0)、(x1, y1)\],以及一个点(x2, y2),怎么求点到线段的距离?
3. 一个平面上放置了一个扫描器方向延y轴方向该坐标系y轴向上扫描器的视角是60度。假设它可以扫描到无限远的地方那对于平面上给定的任意一个点(x,y),我们该如何判断这个点是否处于扫描范围内呢?
![](https://static001.geekbang.org/resource/image/89/64/8961491152b0fe953826d59d687a0b64.jpeg)
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
* * *
## 源码
\[1\][绘制随机树的源代码](https://github.com/akira-cn/graphics/tree/master/vector_tree)
\[2\][坐标变换的源代码](https://github.com/akira-cn/graphics/tree/master/coordinates)
## 推荐阅读
\[1\] [二维旋转矩阵与向量旋转推荐文档](https://zhuanlan.zhihu.com/p/98007510)
\[2\] 一个有趣的绘图库:[Rough.js](https://github.com/pshihn/rough)
\[3\] [Vector2d.js模块文档](https://github.com/akira-cn/graphics/blob/master/common/lib/vector2d.js)