# 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、436(176 - 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)