This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
<audio id="audio" title="05 | 如何用向量和坐标系描述点和线段?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4d/f9/4d122bf8176b4debc4d6210856537df9.mp3"></audio>
你好,我是月影。
为什么你做了很多可视化项目,解决了一个、两个、三个甚至多个不同类型的图表展现之后,还是不能系统地提升自己的能力,在下次面对新的项目时依然会有各种难以克服的困难?这是因为你陷入了细节里。
什么是细节简单来说细节就是各种纯粹的图形学问题。在可视化项目里我们需要描述很多的图形而描述图形的顶点、边、线、面、体和其他各种信息有很多不同的方法。并且如果我们使用不同的绘图系统每个绘图系统又可能有独特的方式或者特定的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。
尽管这四个坐标系在原点位置、坐标轴方向、坐标范围上有所区别,但都是**直角坐标系**,所以它们都满足直角坐标系的特性:不管原点和轴的方向怎么变,用同样的方法绘制几何图形,它们的形状和相对位置都不变。
<img src="https://static001.geekbang.org/resource/image/5e/89/5e3bc7cd089e2e28c527b57a1df5cb89.jpeg" alt="">
为了方便处理图形我们经常需要对坐标系进行转换。转换坐标系可以说是一个非常基础且重要的操作了。正因为这四个坐标系都是直角坐标系所以它们可以很方便地相互转化。其中HTML、SVG和Canvas都提供了transform的API能够帮助我们很方便地转换坐标系。而WebGL本身不提供tranform的API但我们可以在shader里做矩阵运算来实现坐标转换WebGL的问题我们在后续课程会有专门讨论今天我们先来说说其他三种。那接下来我们就以Canvas为例来看看用transform API怎样进行坐标转换。
## 如何用Canvas实现坐标系转换
假设我们要在宽512 * 高256的一个Canvas画布上实现如下的视觉效果。其中山的高度是100底边200两座山的中心位置到中线的距离都是80太阳的圆心高度是150。
当然,在不转换坐标系的情况下,我们也可以把图形绘制出来,但是要经过顶点换算,下面我们就来说一说这个过程。
<img src="https://static001.geekbang.org/resource/image/a8/09/a8ec91897b2ede72d5c48d4d6b2d5409.jpeg" alt="">
首先因为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
<img src="https://static001.geekbang.org/resource/image/55/29/552676f6f0268d2091b838e268651929.jpeg" alt="">
计算出这些坐标之后,我们很容易就可以将这个图画出来了。不过,为了增加一些趣味性,我们用一个[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',
});
```
最终,我们绘制出的图形效果如下所示:
<img src="https://static001.geekbang.org/resource/image/cd/cb/cddabd7aeca8e5yy0c22c85879f5dccb.jpeg" alt="">
到这里,我们通过简单的计算就绘制出了这一组图形。但你也能够想到,如果每次绘制都要花费时间在坐标换算上,这会非常不方便。所以,为了解决这个问题,我们可以采用坐标系变换来代替坐标换算。
这里我们给Canvas的2D上下文设置一下transform变换。我们经常会用到两个变换translate和scale。
首先我们通过translate变换将Canvas画布的坐标原点从左上角(0, 0)点移动至(256, 256)位置即画布的底边上的中点位置。接着以移动了原点后新的坐标为参照通过scale(1, -1)将y轴向下的部分即y&gt;0的部分沿x轴翻转180度这样坐标系就变成以画布底边中点为原点x轴向右y轴向上的坐标系了。
<img src="https://static001.geekbang.org/resource/image/8a/de/8a1f3ed166942736206124aba16965de.jpeg" alt="" title="坐标系">
执行了这个坐标变换,也就是让坐标系原点在中间之后,我们就可以更方便、直观地计算出几个图形元素的坐标了。
两个山顶的坐标就是 (-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坐标值。
<img src="https://static001.geekbang.org/resource/image/0d/58/0de1596f2df5002c3a8b26723f0f0558.jpeg" alt="">
假设现在这个平面直角坐标系上有一个向量v。向量v有两个含义一是可以表示该坐标系下位于(x, y)处的一个点;二是可以表示从原点(0,0)到坐标(x,y)的一根线段。
接下来,为了方便你理解,我们先来回顾一下关于向量的数学知识。
**首先,向量和标量一样可以进行数学运算。**举个例子现在有两个向量v<sub>1</sub>和v<sub>2</sub>如果让它们相加其结果相当于将v<sub>1</sub>向量的终点x<sub>1</sub>, y<sub>1</sub>沿着v<sub>2</sub>向量的方向移动一段距离这段距离等于v<sub>2</sub>向量的长度。这样我们就可以在平面上得到一个新的点x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>),一条新的线段[(0, 0), (x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>)],以及一段折线:[(0, 0), (x<sub>1</sub>, y<sub>1</sub>) , (x<sub>1</sub> + x<sub>2</sub>, y<sub>1</sub> + y<sub>2</sub>)]。
<img src="https://static001.geekbang.org/resource/image/8e/29/8ebb3963e385ba9fda2dab46d7277e29.jpeg" alt="">
**其次,一个向量包含有长度和方向信息**。它的长度可以用向量的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);
```
这个推论意味着一个重要的事实:我们可以很简单地构造出一个绘图向量。也就是说,如果我们希望以点(x<sub>0</sub>, y<sub>0</sub>)为起点沿着某个方向画一段长度为length的线段我们只需要构造出如下的一个向量就可以了。
<img src="https://static001.geekbang.org/resource/image/7c/a3/7cf68477844ee77a31163008d2bb39a3.jpeg" alt="">
这里的α是与x轴的夹角v是一个单位向量它的长度为1。然后我们把向量(x<sub>0</sub>, y<sub>0</sub>)与这个向量v<sub>1</sub>相加,得到的就是这条线段的终点。这么讲还是比较抽象,我们看一个例子。
## 实战演练:用向量绘制一棵树
我们用前面学到的向量知识来绘制一棵随机生成的树,想要生成的效果如下:
<img src="https://static001.geekbang.org/resource/image/6y/f4/6yydf8017e95529yybb987d97e9yy9f4.jpeg" alt="">
我们还是用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上下文
- v<sub>0</sub>是起始向量
- length是当前树枝的长度
- thickness是当前树枝的粗细
- dir是当前树枝的方向用与x轴的夹角表示单位是弧度。
- bias是一个随机偏向因子用来让树枝的朝向有一定的随机性
因为v<sub>0</sub>是树枝的起点坐标,那根据前面向量计算的原理,我们创建一个单位向量(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 &gt; 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);
}
```
<img src="https://static001.geekbang.org/resource/image/1f/y1/1f95a7d1e6ecf30c7db0ef0afc0f7yy1.jpeg" alt="">
这样,我们得到的就是一棵形状规律的树。
接着我们修改代码,加入随机因子,让迭代生成的新树枝有一个随机的偏转角度。
```
if(thickness &gt; 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);
}
```
这样,我们就可以得到一棵随机的树。
<img src="https://static001.geekbang.org/resource/image/53/7f/5350becdbb756ce4dae1289b7beba37f.jpeg" alt="">
最后,为了美观,我们再随机绘制一些花瓣上去,你也可以尝试绘制其他的图案到这棵树上。
```
if(thickness &lt; 5 &amp;&amp; Math.random() &lt; 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. 我们已经知道如何用向量来定义一个线段你知道如何判断两个线段的位置关系吗假设有两个线段l<sub>1</sub>和l<sub>2</sub>,已知它们的起点和终点分别是[(x<sub>10</sub>, y<sub>10</sub>),(x<sub>11</sub>, y<sub>11</sub>)]、[(x<sub>20</sub>, y<sub>20</sub>),(x<sub>21</sub>, y<sub>21</sub>)],你能判断它们的关系吗(小提示:两个线段之间的关系有**平行、垂直**或既不平行又不垂直)?
1. 已知线段[(x<sub>0</sub>, y<sub>0</sub>)、(x<sub>1</sub>, y<sub>1</sub>)],以及一个点(x<sub>2</sub>, y<sub>2</sub>),怎么求点到线段的距离?
1. 一个平面上放置了一个扫描器方向延y轴方向该坐标系y轴向上扫描器的视角是60度。假设它可以扫描到无限远的地方那对于平面上给定的任意一个点(x,y),我们该如何判断这个点是否处于扫描范围内呢?
<img src="https://static001.geekbang.org/resource/image/89/64/8961491152b0fe953826d59d687a0b64.jpeg" alt="">
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1][绘制随机树的源代码](https://github.com/akira-cn/graphics/tree/master/vector_tree)<br>
[2][坐标变换的源代码](https://github.com/akira-cn/graphics/tree/master/coordinates)
## 推荐阅读
[1] [二维旋转矩阵与向量旋转推荐文档](https://zhuanlan.zhihu.com/p/98007510)<br>
[2] 一个有趣的绘图库:[Rough.js](https://github.com/pshihn/rough)<br>
[3] [Vector2d.js模块文档](https://github.com/akira-cn/graphics/blob/master/common/lib/vector2d.js)

View File

@@ -0,0 +1,123 @@
<audio id="audio" title="06 | 可视化中你必须要掌握的向量乘法知识" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f3/54/f33ea06925ab306654336497a499bb54.mp3"></audio>
你好,我是月影。
上一节课我们学习了Canvas实现坐标系转换的方法以及利用向量描述点和线段来绘制基本图形的方法。接下来我们继续学习和向量有关的绘图知识教你用数学语言描述曲线。
不过,在讨论如何描述曲线之前,还有一些关于向量乘法的前置知识需要你掌握。虽然它们和如何描述曲线并没有直接的关系,但是使用它们可以计算曲线的控制点、判断曲线的方向以及对曲线进行变换。
因此向量的乘法在可视化中也是非常重要并且实用的内容。比如上节课的思考题3给你一个任意点让你判断这个点在不在这个扫描器范围内。这道题可以用我们前面学过的知识解决但用向量乘法可以更轻松解决。接下来就让我们从这一道题开始今天的学习吧
<img src="https://static001.geekbang.org/resource/image/61/55/61240301b570ff20e0fdff2d2910f955.jpg" alt="">
如果利用我们前面学过的知识来解题,你可能是直接使用向量的方向定义来做的,代码如下所示。
```
v.dir = function() {return Math.atan2(this.y, this.x)}
```
没错这道题我们可以使用向量的方向来解。因为这里的dir是由向量与x轴夹角决定的所以判断点是否在扫描器范围内我们只需要计算点坐标对应的向量的dir值是否在扫描器的范围内就可以了。代码如下
```
const isInRange = v0.dir &gt; Math.PI / 3 &amp;&amp; v0.dir &lt; 2 * Math.PI / 3;
```
这是一个很简单、直观的解法但是它不够完美因为这个判断和扫描器的方向有关。什么意思呢从上面的图中你可以看到现在它正对着y轴正方向所以角度在π/3和2π/3之间。但如果将它的方向旋转或者允许它朝向任意的方向我们就必须要修改对应的角度值了。这个时候就会非常麻烦。
因此,我们会使用一个更通用的解法,也就是利用向量的乘法来解。那具体怎么做呢?别着急,我先带你来复习一下我们高中学过的向量乘法的知识,如果你记得不是特别清楚,正好可以借着这个机会来加深印象。
我们知道,向量乘法有两种,一种是点乘,一种是叉乘,它们有着不同的几何和物理含义。下面,我们一一来看。
## 向量的点乘
首先,我们来看向量的点乘。
假设现在有两个N维向量a和ba = [a<sub>1</sub>, a<sub>2</sub>, ...a<sub>n</sub>]b = [b<sub>1</sub>, b<sub>2</sub>, ...b<sub>n</sub>],那向量的点积代码如下:
```
a•b = a1*b1 + a2*b2 + ... + an*bn
```
在N维线性空间中a、b向量点积的几何含义是a向量乘以b向量在a向量上的投影分量。它的物理含义相当于a力作用于物体产生b位移所做的功。点积公式如下图所示
<img src="https://static001.geekbang.org/resource/image/08/36/08ed8e6ded30ae53d8d3900e7f8bee36.jpg" alt="">
好了现在你已经知道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组成的平行四边形的面积**。
<img src="https://static001.geekbang.org/resource/image/2a/92/2ace54dafe5dae80e783a7fd7b38e392.png" alt="">
那叉乘在数学上该怎么计算呢假设现在有两个三维向量a(x<sub>1</sub>, y<sub>1</sub>, z<sub>1</sub>)和b(x<sub>2</sub>, y<sub>2</sub>, z<sub>2</sub>)那么a与b的叉积可以表示为一个如下图的行列式
<img src="https://static001.geekbang.org/resource/image/72/ca/72227ffca461a60a1e79c0f54b6777ca.jpg" alt="">
其中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的叉积方向呢
还记得吗第2节课我们提到过左手系和右手系其中x轴向右、y轴向下的坐标系是右手系。在右手系中求向量a、b叉积的方向时我们可以把右手食指的方向朝向a把右手中指的方向朝向b那么大拇指所指的方向就是a、b叉积的方向这个方向是垂直纸面向外即朝向我们。因此右手系中向量叉乘的方向就是右手拇指的方向那左手系中向量叉乘的方向自然就是左手拇指的方向了。
<img src="https://static001.geekbang.org/resource/image/e1/89/e1a3da7d12e40b7acfa46ba4293d2b89.jpg" alt="">
在二维空间里由于z的值为0因此我们得到的向量a X b的数值就等于x<sub>1</sub> * y<sub>2</sub> - x<sub>2</sub> * y<sub>1</sub>。那它的物理意义是什么呢?二维空间中向量叉乘的物理意义就是**a和b的力矩**力矩你可以理解为一个物体在力的作用下绕着一个轴转动的趋向。它是一个向量等于力臂L和力F的叉乘。这个概念你记住就好了我们今天不会用到后面用到的时候我会再详细来讲
还记得上一节课的思考题2求点到线段的距离吗在了解了向量叉积的几何意义之后我们通过向量叉积得到平行四边形面积再除以底边长就能得到点到向量所在直线的距离了。是不是很简单
同样向量叉积也可以解决思考题3。那具体该怎么做呢
首先对于任意一点v<sub>0</sub>来说,我们都要先将它**归一化**。简单来说归一化就是让向量v<sub>0</sub>除以它的长度或者说是模。归一化后的向量方向不变长度为1。归一化是向量运算中一个非常重要的操作用处也非常多。比如说在向量乘法里如果a、b都是长度为1的归一化向量那么|a X b| 的结果就是a、b夹角的**正弦值**,而|a • b|的结果就是a、b夹角的**余弦值。**这个特性在图形学里用处非常大,你一定要记住它。
好了再说回来我们把归一化的向量a叉乘扫描器中线上的v(0,1)由于扫描器关于y轴对称所以扫描器边缘与y轴的夹角是正负30度。那么在与单位向量求叉积的时候就会出现2种情况
1. 点在扫描范围内如向量a就一定满足 |a X v| &lt;= ||a||v|sin(30°)| = |sin(30°)| = 0.5
1. 点不在扫描范围内如向量b就一定满足|b X v| &gt; ||b||v|sin(30°)| = |sin(30°)| = 0.5。
<img src="https://static001.geekbang.org/resource/image/9e/4a/9ea951c875b5dc3bc3169dda68fea94a.jpg" alt="">
因此只要任意一点所在的向量与单位向量的叉积结果的绝对值不大于0.5即sin30°就说明这个点在扫描范围内。所以我们可以用如下代码来判断
```
const isInRange = Math.abs(new Vec2(0, 1).cross(v0.normalize())) &lt;= 0.5; // v0.normalize()即将v0归一化
```
好了,关于向量乘法的内容,我们就全部复习完了,相信你已经很好地掌握它们了。不过,我还想再多说几句 ,对于图形学来说,向量运算是基础中的基础,非常重要。所以,我们不仅要熟练掌握,还要学会用向量的思路去解决问题。只有这样,你才能学好图形学,从而成为优秀的可视化工程师。
## 要点总结
这一节课,我们学习了向量的乘法,包括点乘与叉乘。
其中点乘的几何意义是向量a与它在向量b所在的轴的投影向量的乘积物理意义是力向量产生的位移向量所做的功。叉乘的几何意义是向量a和向量b构成的平行四边形的面积物理意义是力产生的力矩。
最后,我们还要记住,把向量归一化以后,我们就可以通过向量的点乘与叉乘快速求出向量夹角的正弦和余弦值。
## 小试牛刀
让我们延续上一节课的第2道思考题。已知平面上有一点P它的坐标是(x<sub>0</sub>, y<sub>0</sub>)还有一条直线直线上有两个点Q(x<sub>1</sub>, y<sub>1</sub>)和R(x<sub>2</sub>, y<sub>2</sub>)。你能求出点P到直线的距离以及点P到线段QR的距离吗这里面P、Q、R这三个值可以是任意的。你可以要试着用一段JavaScript代码把计算的过程写出来最好还能把直线、点和距离在Canvas上给直观地绘制出来。
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!

View File

@@ -0,0 +1,359 @@
<audio id="audio" title="07 | 如何用向量和参数方程描述曲线?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/7f/6d02b64abf66844600991a1178236d7f.mp3"></audio>
你好,我是月影。
曲线是图形系统的基本元素之一,它可以构成几何图形的边,也可以描述点和几何体的运动轨迹,还可以控制像素属性的变化。不论我们用什么图形系统绘图,图形的呈现都离不开曲线。因此,对于可视化而言,掌握如何描述曲线是非常重要的。
今天,我们就来学习两种常见的描述曲线的方法,也就是用向量和参数方程来描述曲线。
## 如何用向量描述曲线?
我们先来说第一种方法,用向量来描述曲线。
我们知道曲线是可以用折线来模拟的。因此我们第5节课中用向量来绘制折线的方法同样可以应用于曲线。具体怎么做呢下面我就详细来说说。
首先我们用向量绘制折线的方法来绘制正多边形我们定义一个函数regularShape代码如下
```
function regularShape(edges = 3, x, y, step) {
const ret = [];
const delta = Math.PI * (1 - (edges - 2) / edges);
let p = new Vector2D(x, y);
const dir = new Vector2D(step, 0);
ret.push(p);
for(let i = 0; i &lt; edges; i++) {
p = p.copy().add(dir.rotate(delta));
ret.push(p);
}
return ret;
}
```
我们在regularShape函数中给定边数edges、起点x, y、一条边的长度step就可以绘制一个正多边形了。绘制的思路和我们上一节课的思路类似也就是通过rotate旋转向量然后通过向量加法来计算顶点位置。
具体来说就是我们定义初始点为new Vector2D(x, y)初始方向为x轴方向new Vector2D(step, 0)。然后循环计算正多边形的顶点位置也就是从初始点开始每次将方向向量旋转delta角度delta角度是根据正多边形内角公式计算出来的。最后我们将当前点和方向向量相加就得到下一个顶点坐标了。
有了这个方法我们就可以计算出要绘制的多边形的每一个顶点坐标然后调用图形系统的API将图形绘制出来了。我在下面给出了绘制三角形、六边形、十一边形和六十边形的参数你可以看一看也可以试着自己动手绘制一下。
```
draw(regularShape(3, 128, 128, 100)); // 绘制三角形
draw(regularShape(6, -64, 128, 50)); // 绘制六边形
draw(regularShape(11, -64, -64, 30)); // 绘制十一边形
draw(regularShape(60, 128, -64, 6)); // 绘制六十边形
```
这些图形用Canvas2D绘制出来的结果如下图所示详细的代码我放在了[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/vector_draw)。
<img src="https://static001.geekbang.org/resource/image/e6/59/e62865e98dd038c6da690961fe722c59.jpeg" alt="">
从上面的例子中可以看出当多边形的边数非常多的时候这个图形就会接近圆。所以只要利用regularShape函数将多边形的边数设置得很大我们就可以绘制出圆形了。不过这个做法虽然能够绘制出圆这样的曲线但它还有一些缺点。
首先regularShape定义边数、起点、一条边的长度这就和我们通常的使用习惯也就是定义边数、中心和半径不符。如果我们按照现在这种定义方式绘图是很难精确对应到图形的位置和大小的。那你可能要说了不是还可以换算吗确实可以但是计算的过程比较繁琐也很容易出错。
其次regularShape可以画圆改进一下也可以画圆弧但是对于椭圆、抛物线、贝塞尔曲线等其他曲线的绘制就无能为力了。
那么,为了更简单地绘制出更多的曲线样式,我们需要用更好的模型来描述。
## 如何用参数方程描述曲线?
接下来我们就来讨论用参数方程描述曲线的方法。通过这个方法我们不仅可以描述常见的圆、椭圆、抛物线、正余弦等曲线还能描述更具有一般性的曲线也就是没有被数学公式预设好的曲线比如贝塞尔曲线或者CatmullRom曲线等等。
说到参数方程,接下来我在每次用它来画图之前,还是会先带你一起回顾相关的数学知识,这样对你后面的学习也会很方便。那我们先从最简单的曲线,也就是圆形开始,来看看它是如何用参数方程绘制的。
### 1. 画圆
首先圆可以用一组参数方程来定义。如下所示的参数方程定义了一个圆心在x<sub>0</sub>,y<sub>0</sub>半径为r的圆。
<img src="https://static001.geekbang.org/resource/image/67/09/679bb841b70f7c7bae35d84c98a86b09.jpeg" alt="">
知道了方程下面我们来说一下计算圆顶点的方法。首先我们实现一个画圆弧的函数arc代码如下所示。我们设置圆心为x<sub>0</sub>、y<sub>0</sub>半径为radius起始角度为startAng结束角度是endAng。然后我们就可以用draw(arc(0, 0, 100))这样的方式在(0,0)点绘制一个半径为100的圆了。
```
const TAU_SEGMENTS = 60;
const TAU = Math.PI * 2;
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 &lt;= 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节课利用Canvas2D画圆的时候我们使用的context.arc方法和我们自己实现的这个函数很像既然已经有了现成的API我们为什么还要自己实现呢关于这一点我就要再啰嗦几句了。不是所有的图形系统都提供了画圆的API比如WebGL中就没有默认的画圆API。因此在没有提供画圆的API的时候我们上面实现的函数就可以派上用场了。
### 2. 画圆锥曲线
除了画圆参数方程还可以描述很多其他的圆锥曲线。比如椭圆的参数方程。它其实和圆的参数方程很接近。其中a、b分别是椭圆的长轴和短轴当a = b = r时这个方程是就圆的方程式。所以圆实际上就是椭圆的特例。
<img src="https://static001.geekbang.org/resource/image/c4/4b/c47446d2df11fba932267665e65d254b.jpeg" alt="">
再比如抛物线的参数方程。其中p是常数为焦点到准线的距离。
<img src="https://static001.geekbang.org/resource/image/73/b2/737ab592aa82bdb7e145aebdc7e104b2.jpeg" alt="">
我们修改上面的arc方法中的对应参数就能同样实现椭圆和抛物线的绘制了。修改的操作非常简单我就在下面直接给出这两个函数的代码了。
首先是椭圆,它的函数代码如下所示。
```
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 &lt;= 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));
```
其次是抛物线,它的函数代码如下所示。
```
const LINE_SEGMENTS = 60;
function parabola(x0, y0, p, min, max) {
const ret = [];
for(let i = 0; i &lt;= 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));
```
### 3. 画其他常见曲线
除了前面说的圆锥曲线,应用参数方程我们还可以绘制许多比较有趣的曲线,这些曲线在实际工作中,常常用来构建各种几何图形。
不过如果我们为每一种曲线都分别对应实现一个函数就会非常笨拙和繁琐。那为了方便我们可以用函数式的编程思想封装一个更简单的JavaScript参数方程绘图模块以此来绘制出不同的曲线。这个绘图模块的使用过程主要分为三步。
第一步我们实现一个叫做parametric的高阶函数它的参数分别是x、y坐标和参数方程。
第二步parametric会返回一个函数这个函数会接受几个参数比如start、end这样表示参数方程中关键参数范围的参数以及seg这样表示采样点个数的参数等等。在下面的代码中当seg默认100时就表示在start、end范围内采样101seg+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 &lt; 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 &lt;= 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,
};
};
}
```
利用绘图模块,我们就可以绘制出各种有趣的曲线了。比如,我们可以很方便地绘制出抛物线,代码如下:
```
// 抛物线参数方程
const para = parametric(
t =&gt; 25 * t,
t =&gt; 25 * t ** 2,
);
// 绘制抛物线
para(-5.5, 5.5).draw(ctx);
```
再比如,我们可以绘制出阿基米德螺旋线,代码如下:
```
const helical = parametric(
(t, l) =&gt; l * t * Math.cos(t),
(t, l) =&gt; l * t * Math.sin(t),
);
helical(0, 50, 500, 5).draw(ctx, {strokeStyle: 'blue'});
```
以及,我们还可以绘制星形线,代码如下:
```
const star = parametric(
(t, l) =&gt; l * Math.cos(t) ** 3,
(t, l) =&gt; l * Math.sin(t) ** 3,
);
star(0, Math.PI * 2, 50, 150).draw(ctx, {strokeStyle: 'red'});
```
同时绘制三条曲线后的效果,如下图所示。详细的代码,我都放到了[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/parametric2)。你可以自己动手试一试,看看怎么把它们组合成更多有趣的图形。
<img src="https://static001.geekbang.org/resource/image/3e/b4/3e20fa773a4bfd78a22160832e2fdbb4.jpg" alt="">
### 4. 画贝塞尔曲线
前面我们说的这些曲线都比较常见,它们都是符合某种固定数学规律的曲线。但生活中还有很多不规则的图形,无法用上面这些规律的曲线去描述。那我们该如何去描述这些不规则图形呢?**贝塞尔曲线**Bezier Curves就是最常见的一种解决方式。它在可视化领域中也是一类非常常用的曲线它通过起点、终点和少量控制点就能定义参数方程来生成复杂的平滑曲线所以它通常被用来构建数据信息之间连接线。
<img src="https://static001.geekbang.org/resource/image/54/29/546a855fc34c45cb7e654ffda4f88f29.png" alt="" title="贝塞尔曲线示意图">
贝塞尔曲线又分为**二阶贝塞尔曲线**Quadratic Bezier Curve和**三阶贝塞尔曲线**Qubic Bezier Curve。顾名思义二阶贝塞尔曲线的参数方程是一元二次多项式那么三阶贝塞尔曲线的参数方程是一元三次多项式。接下来我们就分别说说它们的公式和描述曲线的方法
其中二阶贝塞尔曲线由三个点确定P<sub>0</sub>是起点P<sub>1</sub>是控制点P<sub>2</sub>是终点,示意图如下:
<img src="https://static001.geekbang.org/resource/image/5a/36/5a15bb08a9815723a7745119b6328436.jpeg" alt="">
我们可以用parametric构建并绘制二阶贝塞尔曲线代码如下所示
```
const quadricBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}]) =&gt; (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
(t, [{y: y0}, {y: y1}, {y: y2}]) =&gt; (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 &lt; count; i++) {
// 绘制30条从圆心出发旋转不同角度的二阶贝塞尔曲线
p1.rotate(2 / count * Math.PI);
p2.rotate(2 / count * Math.PI);
quadricBezier(0, 1, 100, [
p0,
p1,
p2,
]).draw(ctx);
}
```
在上面的代码中我们绘制了30个二阶贝塞尔曲线它们的起点都是(0,0)终点均匀分布在半径200的圆上控制点均匀地分布在半径100的圆上。最终实现的效果如下图所示。详细的代码你可以访问[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/bezier)
<img src="https://static001.geekbang.org/resource/image/f7/98/f7228ee8407ea5ee8b2ac2896eef0798.jpeg" alt="" title="二阶贝塞尔曲线效果图">
三阶贝塞尔曲线的参数方程为:
<img src="https://static001.geekbang.org/resource/image/d3/1c/d35dfdfca5abf67f98f35e2ae168771c.jpg" alt="">
可以看到与二阶贝塞尔曲线相比三阶贝塞尔曲线有4个点其中P<sub>0</sub>和P<sub>3</sub>是起点和终点P<sub>1</sub>、P<sub>2</sub>是控制点,所以三阶贝塞尔曲线有两个控制点。
[<img src="https://static001.geekbang.org/resource/image/45/55/45d35cb4e1b446501fcefac07b3dab55.gif" alt="" title="三阶贝塞尔曲线的原理示意图">](http://math001.com/bezier_curve/)
我们同样可以用parametric构建并绘制三阶贝塞尔曲线
```
const cubicBezier = parametric(
(t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) =&gt; (1 - t) ** 3 * x0 + 3 * t * (1 - t) ** 2 * x1 + 3 * (1 - t) * t ** 2 * x2 + t ** 3 * x3,
(t, [{y: y0}, {y: y1}, {y: y2}, {y: y3}]) =&gt; (1 - t) ** 3 * y0 + 3 * t * (1 - t) ** 2 * y1 + 3 * (1 - t) * 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 &lt; 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);
}
```
三阶贝塞尔曲线控制点比二阶贝塞尔曲线多这有什么优势呢因为控制点越多曲线能够模拟出更多不同的形状也能更精确地控制细节。比如说在上面的代码中我们绘制了30个三阶贝塞尔曲线它们的起点都为(0,0)终点均匀分布在半径200的圆上控制点1均匀分布在半径为100的圆上控制点2均匀分布半径150的圆上。它和我们之前实现的二阶贝塞尔曲线相比控制得更细致形成的图案信息更丰富。
<img src="https://static001.geekbang.org/resource/image/6a/21/6a86ffe30937734e3601ba3724ab6721.jpeg" alt="" title="三阶贝塞尔曲线效果图">
总的来说贝塞尔曲线对于可视化甚至整个计算机图形学都有着极其重要的意义。因为它能够针对一组确定的点在其中构造平滑的曲线这也让图形的实现有了更多的可能性。而且贝塞尔曲线还可以用来构建CatmullRom曲线。CatmullRom曲线也是一种常用的曲线它可以平滑折线我们在数据统计图表中经常会用到它。
<img src="https://static001.geekbang.org/resource/image/f9/eb/f9dd8a508e3368141d15b85a330378eb.jpg" alt="" title="使用CatmullRom曲线绘制的折线、曲线和部分平滑折线">
实际上Canvas2D和SVG都提供了直接绘制贝塞尔曲线的API比如在Canvas2D中我们可以通过创建Path2D对象使用Path2D支持的SVGPath指令添加贝塞尔曲线。即使如此我们依然需要掌握贝塞尔曲线的基本原理。因为在WebGL这样的图形系统里我们还是需要自己实现贝塞尔曲线的绘制而且贝塞尔曲线除了绘制曲线之外还有其他的用处比如构建平滑的轨迹动画、属性插值等等。这些内容我们也会在后续课程中会深入讨论。
## 要点总结
这一节课我们讨论了用曲线和参数方程描述曲线的方法。
用向量描述比较简单直接,先确定起始点和起始向量,然后通过旋转和向量加法来控制形状,就可以将曲线一段一段地绘制出来。但是它的缺点也很明显,就是数学上不太直观,需要复杂的换算才能精确确定图形的位置和大小。
使用参数方程能够避免向量绘制的缺点,因此是更常用的绘制方式。使用参数方程绘制曲线时,我们既可以使用有规律的曲线参数方程来绘制这些规则曲线,还可以使用二阶、三阶贝塞尔曲线来在起点和终点之间构造平滑曲线。
## 小试牛刀
1. Canvas2D和SVG中都提供了画圆、椭圆、贝塞尔曲线的指令你可以尝试直接使用这些指令来绘制圆、椭圆和贝塞尔曲线然后比较一下使用这些指令和使用我们课程中讲过的方法有什么不同。
1. 除了圆和椭圆这些常见的参数方程你还能自己创造出一些参数方程吗如果可以你可以使用parametric.js把它们绘制出来。
1. 我在课程中画了两个最基础的贝塞尔曲线。你能试着修改parametric.js的代码调整一下贝塞尔曲线控制点参数画出更有趣的图形吗
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1][绘制圆锥曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/parametric)
[2][绘制其他曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/parametric2)
[3][绘制贝塞尔曲线完整代码.](https://github.com/akira-cn/graphics/tree/master/bezier)
## 推荐阅读
[Parametric.js](https://github.com/akira-cn/graphics/blob/master/common/lib/parametric.js)

View File

@@ -0,0 +1,426 @@
<audio id="audio" title="08 | 如何利用三角剖分和向量操作描述并处理多边形?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b5/df/b54c7a4228f0d72010608534496bd0df.mp3"></audio>
你好,我是月影。
在图形系统中我们最终看到的丰富多彩的图像都是由多边形构成的。换句话说不论是2D图形还是3D图形经过投影变换后在屏幕上输出的都是多边形。因此理解多边形的基本性质了解用数学语言描述并且处理多边形的方法是我们在可视化中必须要掌握的内容。
那今天,我们就来说说,不同的图形系统是如何用数学语言描述并处理多边形。首先,我们来说说图形学中的多边形是什么。
## 图形学中的多边形是什么?
多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形,其中,每条线段的端点就是多边形的顶点,线段就是多边形的边。
多边形又可以分为**简单多边形**和**复杂多边形**。我们该怎么区分它们呢?如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。一般来说,我们在绘图时,要尽量构建简单多边形,因为简单多边形的图形性质比较简单,绘制起来比较方便。
而简单多边形又分为凸多边形和凹多边形我们主要是看简单多边形的内角来区分的。如果一个多边形中的每个内角都不超过180°那它就是凸多边形否则就是凹多边形。
<img src="https://static001.geekbang.org/resource/image/74/4a/74c812ef3a15f5f20d7a5bbaff30794a.jpg" alt="">
在图形系统中绘制多边形的时候,最常用的功能是填充多边形,也就是用一种颜色将多边形的内部填满。除此之外,在可视化中用户经常要用鼠标与多边形进行交互,这就要涉及多边形的边界判定。所以今天,我们就来重点讨论**多边形的填充和边界判定**。首先,我们来看多边形的填充。
## 不同的图形系统如何填充多边形?
不同的图形系统会用不同的方法来填充多边形。比如说在SVG和Canvas2D中就都内置了填充多边形的API。在SVG中我们可以直接给元素设置fill属性来填充那在Canvas2D中我们可以在绘图指令结束时调用fill()方法进行填充。而在WebGL中我们是用三角形图元来快速填充的。由于SVG和Canvas2D中的填充方法类似因此今天我们就主要说说Canvas2D和WebGL是怎么填充多边形的。
### 1. Canvas2D如何填充多边形
我们先来说说Canvas2D填充多边形的具体方法可以总结为五步。
第一步构建多边形的顶点。这里我们直接构造5个顶点代码如下
```
const points = [new Vector2D(0, 100)];
for(let i = 1; i &lt;= 4; i++) {
const p = points[0].copy().rotate(i * Math.PI * 0.4);
points.push(p);
}
```
第二步绘制多边形。我们要用这5个顶点分别绘制正五边形和正五角星。显然前者是简单多边形后者是复杂多边形。那在Canvas中只需将顶点构造出来我们就可以通过API绘制出多边形了。具体绘制代码如下
```
const polygon = [
...points,
];
// 绘制正五边形
ctx.save();
ctx.translate(-128, 0);
draw(ctx, polygon);
ctx.restore();
const stars = [
points[0],
points[2],
points[4],
points[1],
points[3],
];
// 绘制正五角星
ctx.save();
ctx.translate(128, 0);
draw(ctx, stars);
ctx.restore();
```
如上面代码所示我们用计算出的5个顶点创建polygon数组和stars数组。其中polygon数组是正五边形的顶点数组。stars数组是我们把正五边形的顶点顺序交换之后构成的五角星的顶点数组。
接着我们将这些点传给draw函数在draw函数中完成具体的绘制。在draw函数中绘制过程的时候我们是调用context.fill来完成填充的。
这里我要补充一点不管是简单多边形还是复杂多边形Canvas2D的fill都能正常填充。并且Canvas2D的fill还支持两种填充规则。其中默认的规则是“nonzero”也就是说 不管有没有相交的边只要是由边围起来的区域都一律填充。在下面的代码中我们就是用“nonzero”规则来填充的。
```
function draw(context, points, {
fillStyle = 'black',
close = false,
rule = 'nonzero',
} = {}) {
context.beginPath();
context.moveTo(...points[0]);
for(let i = 1; i &lt; points.length; i++) {
context.lineTo(...points[i]);
}
if(close) context.closePath();
context.fillStyle = fillStyle;
context.fill(rule);
}
```
我们最终绘制出的效果如下图所示:
<img src="https://static001.geekbang.org/resource/image/37/96/371e3b8d3f484b13aa13f6e8ce60ec96.jpeg" alt="" title="简单多边形a和b">
除了“nonzero”还有一种规则叫做“evenodd”它是根据重叠区域是奇数还是偶数来判断是否填充的。那当我们增加了draw方法的参数将五角星的填充规则改成“evenodd”之后简单多边形没有变化而复杂多边形由于绘制区域存在重叠就出导致图形中心有了空洞的特殊效果。
```
draw(ctx, stars, {rule: 'evenodd'});
```
<img src="https://static001.geekbang.org/resource/image/81/44/81e56244233ebec7e0cc50a661d2cf44.jpeg" alt="" title="使用evenodd之后得到的填充图形a和b">
总之Canvas2D的fill非常实用它可以自动填充多边形内部的区域并且对于任何多边形都能判定和填充你可以自己去尝试一下。
### 2. WebGL如何填充多边形
在WebGL中虽然没有提供自动填充多边形的方法但是我们可以用三角形这种基本图元来快速地填充多边形。因此在WebGL中填充多边形的第一步就是将多边形分割成多个三角形。
这种将多边形分割成若干个三角形的操作,在图形学中叫做**三角剖分**Triangulation
[<img src="https://static001.geekbang.org/resource/image/61/dd/619872b8789bfaeb5fc2c1f0381d52dd.jpeg" alt="" title="同一个多边形的两种三角剖分方法">](http://wikipedia.org)
三角剖分是图形学和代数拓扑学中一个非常重要的基本操作,也有很多不同的实现算法。对简单多边形尤其是凸多边形的三角剖分比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。
那因为这些算法讲解起来比较复杂,还会涉及很多图形学的底层数学知识,你可能很难理解,所以我就不详细说三角剖分的具体算法了。如果你有兴趣学习,可以自己花一点时间去看一些[参考资料](http://www.ae.metu.edu.tr/tuncer/ae546/prj/delaunay/)。
这里我们就直接利用GitHub上的一些成熟的库常用的如[Earcut](https://github.com/mapbox/earcut)、[Tess2.js](https://github.com/memononen/tess2.js)以及[cdt2d](https://github.com/mikolalysenko/cdt2d)来对多边形进行三角剖分就可以了。具体怎么做呢接下来我们就以最简单的Earcut库为例来说一说WebGL填充多边形的过程。
<img src="https://static001.geekbang.org/resource/image/28/9c/28716416aa8c00743843ae208089c99c.jpeg" alt="" title="简单多边形c">
假设,我们要填充一个如上图所示的不规则多边形,它的顶点数据如下:
```
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],
];
```
首先我们要对它进行三角剖分。使用Earcut库的操作很简单我们直接调用它的API就可以完成对多边形的三角剖分具体代码如下
```
import {earcut} from '../common/lib/earcut.js';
const points = vertices.flat();
const triangles = earcut(points);
```
因为Earcut库只接受扁平化的定点数据所以我们先用了数组的flat方法将顶点扁平化然后将它传给Earcut进行三角剖分。这样返回的结果是一个数组这个数组的值是顶点数据的index结果如下
```
[1, 0, 9, 9, 8, 7, 7, 6, 5, 4, 3, 2, 2, 1, 9, 9, 7, 5, 4, 2, 9, 9, 5, 4]
```
这里的值比如1表示vertices中下标为1的顶点即点(-0.4, 0.3)每三个值可以构成一个三角形所以1、0、9表示由(-0.4, 0.3)、(-0.7, 0.5)和(-0.45, 0.0) 构成的三角形。
然后我们将顶点和index下标数据都输入到缓冲区通过gl.drawElements方法就可以把图形显示出来。具体的代码如下
```
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.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);
```
<img src="https://static001.geekbang.org/resource/image/61/26/614e29911f76757a56159ca2d080a526.jpeg" alt="">
你会发现通过上面的步骤整个多边形都被WebGL渲染并填充为了红色。这么一看好像三角剖分并没有什么作用。但实际上WebGL是对这个多边形三角剖分后的每个三角形分别进行填充的。为了让你看得更清楚我们用描边代替填充具体操作就是修改一下gl.drawElements的渲染模式将gl.TRIANGLES改成gl.LINE_STRIP。这样我们就可以清晰地看出经过Earcut处理的这个多边形被分割成了8个三角形。
<img src="https://static001.geekbang.org/resource/image/54/72/549f33d96886fa2bf04f92d30f9ea972.jpeg" alt="">
到这里我们就讲完了2D图形的三角剖分。那针对3D模型WebGL在绘制的时候也需要使用三角剖分而3D的三角剖分又被称为**网格化**Meshing
不过因为3D模型比2D模型更加复杂顶点的数量更多所以针对复杂的3D模型我们一般不在运行的时候进行三角剖分而是通过设计工具把图形的三角剖分结果直接导出进行使用。也就是说在3D渲染的时候我们一般使用的模型数据都是已经经过三角剖分以后的顶点数据。
那如果必须要在可视化项目中实时创建一些几何体的时候,我们该怎么办呢?这部分内容,我们会在视觉篇详细来讲,不过在那之前呢,你也可以自己先想想。
总的来说无论是绘制2D还是3D图形WebGL都需要先把它们进行三角剖分然后才能绘制。因此三角剖分是WebGL绘图的基础。
## 如何判断点在多边形内部?
接下来,我们通过一个简单的例子来说说多边形的交互。这个例子要实现的效果其实就是,当用户的鼠标移动到某一个图形上时,我们要让这个图形变色。在这个例子中,我们要解决的核心问题是:判定鼠标所在位置是否在多边形的内部。
那么问题来了,不同的图形系统都是如何判断点在多边形内部的呢?
在SVG这样的图形系统里由于多边形本身就是一个元素节点因此我们直接通过DOM API就可以判定鼠标是否在该元素上。而对于Canvas2D我们不能直接通过DOM API判定而是要通过Canvas2D提供的isPointInPath方法来判定。所以下面我们就以多边形c为例来详细说说这个过程。
### 1. Canvas2D如何判断点在多边形内部
首先我们先改用Canvas2D来绘制并填充这个多边形。
然后我们在canvas上添加mousemove事件在事件中计算鼠标相对于canvas的位置再将这个位置传给isPointInPath方法isPointInPath方法就会自动判断这个位置是否位于图形内部。代码如下
```
const {left, top} = canvas.getBoundingClientRect();
canvas.addEventListener('mousemove', (evt) =&gt; {
const {x, y} = evt;
// 坐标转换
const offsetX = x - left;
const offsetY = y - top;
ctx.clearRect(-256, -256, 512, 512);
if(ctx.isPointInPath(offsetX, offsetY)) {
draw(ctx, poitions, 'transparent', 'green');
} else {
draw(ctx, poitions, 'transparent', 'red');
}
});
```
最后,上面代码运行效果如下图:
<img src="https://static001.geekbang.org/resource/image/4f/97/4f370d3187e964efc733294a3ed2de97.gif" alt="">
这个运行结果是没有问题的但isPointInPath这个方法实际上并不好用。因为isPointInPath方法只能对当前绘制的图形生效。这是什么意思呢我来举个例子。
假设我们要在Canvas中绘制多边形c和小三角形。那我们先绘制多边形c再绘制小三角形。绘制代码如下
```
draw(ctx, poitions, 'transparent', 'red');
draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');
const {left, top} = canvas.getBoundingClientRect();
canvas.addEventListener('mousemove', (evt) =&gt; {
const {x, y} = evt;
// 坐标转换
const offsetX = x - left;
const offsetY = y - top;
ctx.clearRect(-256, -256, 512, 512);
// 判断 offsetX、offsetY 的坐标是否在多边形内部
if(ctx.isPointInPath(offsetX, offsetY)) {
draw(ctx, poitions, 'transparent', 'green');
draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'orange');
} else {
draw(ctx, poitions, 'transparent', 'red');
draw(ctx, [[100, 100], [100, 200], [150, 200]], 'transparent', 'blue');
}
});
```
这里我们还通过isPointInPath方法判断点的位置这样得到的结果如下图
<img src="https://static001.geekbang.org/resource/image/5e/3e/5e566597db9519bcb0fdc09ce6390e3e.gif" alt="">
你会看到当我们将鼠标移动到中间大图时它的颜色并没有发生变化只有移动到右上角的小三角形时这两个图形才会同时变色。这就是因为isPointInPath仅能判断鼠标是否在最后一次绘制的小三角形内所以大多边形就没有被识别出来。
要解决这个问题一个最简单的办法就是我们自己实现一个isPointInPath方法。然后在这个方法里重新创建一个Canvas对象并且再绘制一遍多边形c和小三角形。这个方法的核心其实就是在绘制的过程中获取每个图形的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;
// 绘制多边形c然后判断点是否在图形内部
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;
}
```
但是这个方法并不通用。因为一旦我们修改了绘图过程也就是增加或者减少了绘制的图形isPointInPath方法也要跟着改变。当然我们也有办法进行优化比如将每一个几何图形的绘制封装起来针对每个图形提供单独的isPointInPath判断但是这样也很麻烦而且有很多无谓的Canvas绘图操作性能会很差。
### 2. 实现通用的isPointInPath方法
那一个更好的办法是我们不使用Canvas的isPointInPath方法而是直接通过点与几何图形的数学关系来判断点是否在图形内。但是直接判断一个点是不是在一个几何图形内还是比较困难的因为这个几何图形可能是简单多边形也可能是复杂多边形。
这个时候,我们完全可以把视线放在最简单的多边形,也就是三角形上。因为对于三角形来说,我们有一个非常简单的方法可以判断点是否在其中。
这个方法就是已知一个三角形的三条边分别是向量a、b、c平面上一点u连接三角形三个顶点的向量分别为u<sub>1</sub>、u<sub>2</sub>、u<sub>3</sub>那么u点在三角形内部的充分必要条件是u<sub>1</sub> X a、u<sub>2</sub> X b、u<sub>3</sub> X c 的符号相同。
你也可以看我下面给出的示意图当点u在三角形a、b、c内时因为u<sub>1</sub>到a、u<sub>2</sub>到b、u<sub>3</sub>到c的小角旋转方向是相同的这里都为顺时针所以u<sub>1</sub> X a、u<sub>2</sub> X b、u<sub>3</sub> X c要么同正要么同负。当点v在三角形外时v<sub>1</sub>到a方向是顺时针v<sub>2</sub>到b方向是逆时针v<sub>3</sub>到c方向又是顺时针所以它们叉乘的结果符号并不相同。
<img src="https://static001.geekbang.org/resource/image/34/c3/3402b08454dbc39f9543cb4c597419c3.jpg" alt="">
根据这个原理,我们就可以写一个简单的判定函数了,代码如下:
```
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));
const s2 = Math.sign(b.cross(u2));
const s3 = Math.sign(c.cross(u3));
return s1 === s2 &amp;&amp; s2 === s3;
}
```
你以为到这里就结束了吗还没有。上面的代码还有个Bug它虽然可以判定点在三角形内部但却不能判定点恰好在三角形某条边上的情况。这又该如何优化呢
在学习了向量乘法之后我们知道。如果一个点u在三角形的一条边a上那就会需要满足以下2个条件
1. a.cross(u1) === 0
1. 0 &lt;= a.dot(u1) / a.length ** 2 &lt;= 1
第一个条件很容易理解我就不细说了我们重点来看第二个条件。下面我就分别讨论一下点u和a在一条直线上和不在一条直线上这两种情况。
<img src="https://static001.geekbang.org/resource/image/ca/8e/ca37834a201b3d704fe40ef3955b608e.jpg" alt="" title="左图是点u和a不在一条直线上右图是点u和a在一条直线上
">
当向量u<sub>1</sub>与a不在一条直线上时u<sub>1</sub>与a的叉乘结果不为0而u<sub>1</sub>与a的点乘的值除以a的长度相当于u<sub>1</sub>在a上的投影。
当向量u<sub>1</sub>与a在一条直线上时u<sub>1</sub>与a的叉乘结果为0u<sub>1</sub>与a的点乘结果除以a的长度的平方正好是u<sub>1</sub>与a的比值。
u<sub>1</sub>与a的比值也有三种情况当u<sub>1</sub>在a上时u<sub>1</sub>和a比值是介于0到1之间的当u<sub>1</sub>在a的左边时这个比值是小于0的当u1在a的右边时这个比值是大于1的。
因此只有当u1和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 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
const s2 = Math.sign(b.cross(u2));
p = b.dot(u2) / b.length ** 2;
if(s2 === 0 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
const s3 = Math.sign(c.cross(u3));
p = c.dot(u3) / c.length ** 2;
if(s3 === 0 &amp;&amp; p &gt;= 0 &amp;&amp; p &lt;= 1) return true;
return s1 === s2 &amp;&amp; s2 === s3;
}
```
这样我们就判断了一个点是否在某个三角形内部。那如果要判断一个点是否在任意多边形的内部,我们只需要在判断之前将它进行三角剖分就可以了。代码如下:
```
function isPointInPath({vertices, cells}, point) {
let ret = false;
for(let i = 0; i &lt; 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;
}
```
## 要点总结
本节课,我们学习了使用三角剖分来填充多边形以及判断点是否在多边形内部。
不同的图形系统有着不同的处理方法Canvas2D的处理很简单它可以使用原生的fill来填充任意多边形使用isPointInPath来判断点是否在多边形内部。但是三角剖分是更加通用的方式WebGL就是使用三角剖分来处理多边形的所以我们要牢记它的操作。
首先在使用三角剖分填充多边形时我们直接调用一些成熟库的API就可以完成这并不难。而当我们要实现图形和用户的交互时也就是要判断一个点是否在多边形内部时也需要先对多边形进行三角剖分然后判断该点是否在其中一个三角形内部。
## 小试牛刀
<li>
在课程中我们使用了Earcut对多边形进行三角剖分。但是tess2.js是一个比Earcut更强大的三角剖分库使用tess2.js可以像原生的Canvas2D的fill方法那样实现evenodd的填充规则。你能试着把代码中的earcut换成tess2.js从而实现evenodd填充规则吗动手之前你可以先去读一下tess2.js的项目文档。
</li>
<li>
今天我们用三角剖分实现了不规则多边形。那你能试着利用三角剖分的原理通过WebGL画出椭圆图案、菱形的星星图案以及正五角星吗
</li>
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[使用三角剖分填充多边形、判断点在多边形内部的完整代码](https://github.com/akira-cn/graphics/tree/master/triangluations)
## 推荐阅读
[tess2.js官方文档](https://github.com/memononen/tess2.js)

View File

@@ -0,0 +1,425 @@
<audio id="audio" title="09 | 如何用仿射变换对几何图形进行坐标变换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e4/55/e40ab19a83e1783031be21ee91e9cc55.mp3"></audio>
你好,我是月影。
前面两节课,我们学习了用向量表示的顶点,来描述曲线和多边形的方法。但是在实际绘制的时候,我们经常需要在画布上绘制许多轮廓相同的图形,难道这也需要我们重复地去计算每个图形的顶点吗?当然不需要。我们只需要创建一个基本的几何轮廓,然后通过**仿射变换**来改变几何图形的位置、形状、大小和角度。
仿射变换是拓扑学和图形学中一个非常重要的基础概念。利用它,我们才能在可视化应用中快速绘制出形态、位置、大小各异的众多几何图形。所以,这一节课,我们就来说一说仿射变换的数学基础和基本操作,它几乎会被应用到我们后面讲到的所有视觉呈现的案例中,所以你一定要掌握。
## 什么是仿射变换?
仿射变换简单来说就是“线性变换+平移”。实际上在平常的Web开发中我们也经常会用到仿射变换比如对元素设置CSS的transform属性就是对元素应用仿射变换。
再说回到几何图形针对它的仿射变换具有以下2个性质
1. 仿射变换前是直线段的,仿射变换后依然是直线段
1. 对两条直线段a和b应用同样的仿射变换变换前后线段长度比例保持不变
由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。
那具体怎么操作呢?下面,我们就来详细说说。
## 向量的平移、旋转与缩放
常见的仿射变换形式包括**平移、旋转、缩放**以及它们的组合。其中平移变换是最简单的仿射变换。如果我们想让向量P(x<sub>0</sub>, y<sub>0</sub>)沿着向量Q(x<sub>1</sub>, y<sub>1</sub>)平移只要将P和Q相加就可以了。
<img src="https://static001.geekbang.org/resource/image/3b/b5/3b1afc9f056d4840cb111252bcc179b5.jpeg" alt="" title="平移后的向量p的坐标">
**接着是旋转变换**。实际上旋转变换我们在第5课接触过当时我们把向量的旋转定义成了如下的函数
```
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;
}
}
```
但是,我们并没有讨论这个函数是怎么来的,那在这里我们通过三角函数来简单推导一下。
<img src="https://static001.geekbang.org/resource/image/91/18/914yy44e969c9f75f5413295eef29718.jpg" alt="">
假设向量P的长度为r角度是现在我们要将它逆时针旋转⍬角此时新的向量P的参数方程为
<img src="https://static001.geekbang.org/resource/image/73/1b/7383bf5a2529bc6b1687617769b6da1b.jpeg" alt="">
然后因为rcos、rsin是向量P原始的坐标x<sub>0</sub>、y<sub>0</sub>,所以,我们可以把坐标代入到上面的公式中,就会得到如下的公式:
<img src="https://static001.geekbang.org/resource/image/88/f4/88aea77872789dfb0322db466315f5f4.jpeg" alt="">
最后,我们再将它写成矩阵形式,就会得到一个旋转矩阵。至于为什么要写成矩阵形式,我后面会讲,这里你先记住这个旋转矩阵的公式就可以了。
<img src="https://static001.geekbang.org/resource/image/e5/a2/e52cae6173e2b4056e9aa752a93076a2.jpeg" alt="">
**然后是缩放变换**。缩放变换也很简单,我们可以直接让向量与标量(标量只有大小、没有方向)相乘。
<img src="https://static001.geekbang.org/resource/image/46/72/46d1bb8b507b1f1c9bc14dd6715a4372.jpeg" alt="">
对于得到的这个公式,我们也可以把它写成矩阵形式。结果如下:
<img src="https://static001.geekbang.org/resource/image/2b/a4/2b15e082213c56756686771526afbda4.jpg" alt="">
现在,我们就得到了三个基本的仿射变换公式,其中旋转和缩放都可以写成矩阵与向量相乘的形式。这种能写成矩阵与向量相乘形式的变换,就叫做**线性变换**。线性变换除了可以满足仿射变换的2个性质之外还有2个额外的性质
1. 线性变换不改变坐标原点因为如果x<sub>0</sub>、y<sub>0</sub>等于零那么x、y肯定等于0
1. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。
那根据线性变换的第2条性质我们就能总结出一个通用的线性变换公式即一个原始向量P<sub>0</sub>经过M<sub>1</sub>、M<sub>2</sub>、…M<sub>n </sub>次的线性变换之后得到最终的坐标P。线性变化的叠加是一个非常重要的性质它是我们对图形进行变换的基础所以你一定要牢记线性变化的叠加性质。
<img src="https://static001.geekbang.org/resource/image/de/c7/deca8b0bce015f249a48a5c6e7dcdfc7.jpeg" alt="">
好了,常见的仿射变换形式我们说完了。总的来说,向量的基本仿射变换分为平移、旋转与缩放,其中旋转与缩放属于线性变换,而平移不属于线性变换。基于此,我们可以得到仿射变换的一般表达式,如下图所示:
<img src="https://static001.geekbang.org/resource/image/c2/57/c275c765a311e4faa2845435f9d54e57.jpg" alt="">
## 仿射变换的公式优化
上面这个公式我们还可以改写成矩阵的形式,在改写的公式里,我们实际上是给线性空间增加了一个维度。换句话说,我们用高维度的线性变换表示了低维度的仿射变换!
<img src="https://static001.geekbang.org/resource/image/53/27/53e134cae1bfced9e5a1bd60df0aed27.jpeg" alt="">
这样我们就将原本n维的坐标转换为了n+1维的坐标。这种n+1维坐标被称为**齐次坐标**,对应的矩阵就被称为**齐次矩阵**。
齐次坐标和齐次矩阵是可视化中非常常用的数学工具,它能让我们用线性变换来表示仿射变换。这样一来,我们就能利用线性变换的叠加性质,来非常方便地进行各种复杂的仿射变换了。落实到共识上,就是把这些变换的矩阵相乘得到一个新的矩阵,再把它乘以原向量。我们在绘制几何图形的时候会经常用到它,所以你要记住这个公式。
## 仿射变换的应用:实现粒子动画
好了,现在你已经知道了仿射变换的数学基础。那它该怎么应用呢?一个很常见的应用,就是利用它来实现粒子动画。
你可能还不熟悉粒子动画,我们先来快速认识一下它。它能在一定时间内生成许多随机运动的小图形,这类动画通常是通过给人以视觉上的震撼,来达到获取用户关注的效果。在可视化中,粒子动画可以用来表达数据信息本身(比如数量、大小等等),也可以用来修饰界面、吸引用户的关注,它是我们在可视化中经常会用到的一种视觉效果。
在粒子动画的实现过程中,我们通常需要在界面上快速改变一大批图形的大小、形状和位置,所以用图形的仿射变换来实现是一个很好的方法。
为了方便你理解,我们今天只讲一个简单的粒子动画。这个粒子动画的运行效果,是从一个点开始发射出许多颜色、大小、角度各异的三角形,并且通过不断变化它们的位置,产生一种撒花般的视觉效果。
### 1. 创建三角形
因为这个粒子动画中主要用到了三角形,所以我们第一步就要创建三角形。**创建三角形一共可以分为两步,第一步,****我们定义三角形的顶点并将数据送到缓冲区**。这一步你直接看下面创建WebGLProgram的步骤就能理解。如果你还不是很熟悉我建议你复习一下第4节课的内容。
```
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);
```
**第二步,我们实现一个创建随机三角形属性的函数**。具体来说就是利用randomTriangles随机创建一个三角形的信息其中的参数包括颜色u_color、初始旋转角度u_rotation、初始大小u_scale、初始时间u_time、动画持续时间u_diration、运动方向u_dir和创建时间startTime。除了startTime之外的数据我们都需要传给shader去处理。
```
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; // 持续3秒钟
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};
}
```
### 2. 设置uniform变量
通过前面的代码我们已经将三角形顶点信息传入缓冲区。我们知道在WebGL的shader中顶点相关的变量可以用attribute声明。但是我们现在要把u_color、u_rotation等一系列变量也传到shader中这些变量与三角形具体顶点无关它们是一些固定的值。这时候我们就要用到shader的另一种变量声明也就是uniform来声明。
那它们有什么区别呢首先attribute变量是对应于顶点的。也就是说几何图形有几个顶点就要提供几份attribute数据。并且attribute变量只能在顶点着色器中使用如果要在片元着色器中使用需要我们通过varying变量将它传给片元着色器才行。这样一来片元着色器中获取的实际值就是经过顶点线性插值的。
而uniform声明的变量不同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传入一个4x4的矩阵对应的uniform变量类型为mat4
今天关于WebGL的uniform的设置我们只需要知道这个最常用的方法就可以了更详细的设置信息你可以参考[MDN官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform)。
接下来我们实现这个函数将随机三角形信息传给shader里的uniform变量。代码如下
```
function setUniforms(gl, {u_color, u_rotation, u_scale, u_time, u_duration, u_dir}) {
// gl.getUniformLocation 拿到uniform变量的指针
let loc = gl.getUniformLocation(program, 'u_color');
// 将数据传给 unfirom 变量的地址
gl.uniform4fv(loc, u_color);
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);
}
```
### 3. 用requestAnimationFrame实现动画
然后我们使用requestAnimationFrame实现动画。具体的方法就是我们在update方法中每次新建数个随机三角形然后依次修改所有三角形的u_time属性通过setUniforms方法将修改的属性更新到shader变量中。这样我们就可以在shader中读取变量的值进行处理了。代码如下
```
let triangles = [];
function update() {
for(let i = 0; i &lt; 5 * Math.random(); i++) {
triangles.push(randomTriangles());
}
gl.clear(gl.COLOR_BUFFER_BIT);
// 对每个三角形重新设置u_time
triangles.forEach((triangle) =&gt; {
triangle.u_time = (performance.now() - triangle.startTime) / 1000;
setUniforms(gl, triangle);
gl.drawArrays(gl.TRIANGLES, 0, position.length / 2);
});
// 移除已经结束动画的三角形
triangles = triangles.filter((triangle) =&gt; {
return triangle.u_time &lt;= triangle.u_duration;
});
requestAnimationFrame(update);
}
requestAnimationFrame(update);
```
我们再回过头来看最终要实现的效果。你会发现,所有的三角形,都是由小变大朝着特定的方向旋转。那想要实现这个效果,我们就需要用到前面讲过的仿射变换,在顶点着色器中进行矩阵运算。
在这一步中顶点着色器中的glsl代码最关键我们先来看一下这个代码是怎么写的。
```
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是当前动画进度它的值是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表示它的最大移动距离为 2p * p也是一个缓动函数作用是让位移的变化量随着时间增加而增大。
定义完这些参数以后我们得到三个齐次矩阵translateMatrix是偏移矩阵rotateMatrix是旋转矩阵scaleMatrix是缩放矩阵。我们将pos的值设置为这三个矩阵与position的乘积这样就完成对顶点的线性变换呈现出来的效果也就是三角形会向着特定的方向旋转、移动和缩放。
### 4. 在片元着色器中着色
最后我们在片元着色器中对这些三角形着色。我们将p也就是动画进度从顶点着色器通过变量varying vP传给片元着色器然后在片元着色器中让alpha值随着vP值变化这样就能同时实现粒子的淡出效果了。
片元着色器中的代码如下:
```
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;
}
```
到这里,我们就用仿射变换实现了一个有趣的粒子动画。
## CSS的仿射变换
既然我们讲了仿射变换这里还是要再提一下CSS中我们常用的属性transform。
```
div.block {
transform: rotate(30deg) translate(100px,50px) scale(1.5);
}
```
CSS中的transform是一个很强大的属性它的作用其实也是对元素进行仿射变换。
它不仅支持translate、rotate、scale等值还支持matrix。CSS的matrix是一个简写的齐次矩阵因为它省略了3阶齐次矩阵第三行的0, 0, 1值所以它 只有6个值。
transform在CSS中变换元素的方法我们作为前端工程师都比较熟悉了。但你知道怎么优化它来提高性能吗下面我就重点来说说这一点。
结合上面介绍的齐次矩阵变换的原理我们可以对CSS的transform属性进行压缩。举个例子我们可以这么定义CSS transform代码如下
```
div.block {
transform: rotate(30deg) translate(100px,50px) scale(1.5);
}
```
也就是我们先旋转30度然后平移100px、50px最后再放大1.5倍。实际上相当于我们做了如下变换:
<img src="https://static001.geekbang.org/resource/image/5e/98/5e18daef0ff059498804419b704c6a98.jpeg" alt="">
这里我就不再自己写矩阵乘法的库了我们用一个向量矩阵运算的数学库math它几乎包含了所有图形学需要用到的数学方法我们在后面课程中也会经常用到它你可以参考[GitHub仓库](https://github.com/akira-cn/graphics/tree/master/common/lib/math)先了解一下它。
我们简单算一下三个矩阵相乘,代码如下:
```
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) =&gt; {
return multiply([], b, a);
});
console.log(res);
/*
[1.299038105676658, -0.7499999999999999, 61.60254037844388,
0.7499999999999999, 1.299038105676658, 93.30127018922192,
0, 0, 1]
*/
```
所以呢我们最终就可以将上面的transform用一个矩阵表示
```
div.block {
transform: matrix(1.3,0.75,-0.75,1.3,61.6,93.3)
}
```
这样的transform效果和之前rotate、translate和scale分开写的效果是一样的但是字符数更少所以能减小CSS文件的大小。
那在我们介绍完仿射变换之后你是不是对CSS transform的理解也更深了呢没错不光是transform在我们之后的学习中你也可以多想想还有哪些内容在CSS中也有相似的作用是不是也能利用在可视化中学到的知识来优化性能。
## 要点总结
这一节课我们介绍了用向量和矩阵运算来改变几何图形的形状、大小和位置。其中向量的平移、旋转和缩放都属于仿射变换而仿射变换具有2个性质
1. 变换前是直线段的,变换后依然是直线段
1. 对两条直线段a和b应用同样的仿射变换变换前后线段长度比例保持不变
那仿射变换中的旋转和缩放又属于线性变换而线性变换在仿射变换性质的基础上还有2个额外的性质
1. 线性变换不改变坐标原点因为如果x<sub>0</sub>、y<sub>0</sub>等于零那么x、y肯定等于0
1. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与向量相乘
通过齐次坐标和齐次矩阵,我们可以将平移这样的非线性仿射变换用更高维度的线性变换来表示。这么做的目的是让我们能够将仿射变换的组合简化为矩阵乘法运算。
到这里,数学基础篇的内容我们就学完了。在这一篇的开头,我们说了要总结出一个通用的基础数学绘图体系,这样才不至于陷入细节里。所以啊,我总结了一个简单的知识脑图,把我们在数学篇里讲过的数学知识汇总到了一起,它肯定不会是一个非常完整的数学绘图体系,但是对我们之后的学习来说,已经足够用了。
<img src="https://static001.geekbang.org/resource/image/bf/7c/bfdd8c7f5f15e5b703128cdaf419f07c.jpg" alt="">
最后呢,我还想再啰嗦几句。图形学作为可视化的基础,是一门很深的学问。它牵涉的数学内容非常多,包括线性代数、几何、微积分和概率统计等等。那这门课里我们所介绍的数学知识,其实还都只是一些入门知识。
那如果你对图形学本身很感兴趣想要深入学习它在其他领域比如游戏、视频、AR/VR等领域的应用这里我推荐你一些深入学习的资料。
1. [3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&amp;list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab) 讲得深入浅出,是非常棒的入门教程。
1. [《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)这本书是图形学入门的经典教材。
## 小试牛刀
<li>
在实现粒子动画的时候我们让translateMatrix * rotateMatrix * scaleMatrix这三个矩阵按这样的顺序相乘。那如果我们颠倒它们的相乘次序把roateMatrix放到translateMatrix前面或者把scaleMatrix放到translateMatrix前面会产生什么样的结果呢为什么呢你可以思考一下然后从GitHub上fork代码动手试一试。
</li>
<li>
<p>我们知道CSS的transform除了translate、rotate和scale变换以外还有skew变换。skew变换是一种沿着轴向的扭曲变换它也属于一种线性变换它的变换矩阵是<br>
<img src="https://static001.geekbang.org/resource/image/b2/44/b265fbd6719e6785c9d0da9364a91f44.jpeg" alt=""></p>
</li>
你可以使用这个矩阵,给我们的粒子动画加上随机的扭曲效果吗?
1. 因为齐次坐标和齐次矩阵的概念可以从二维一直推广到N维而且CSS的transform还支持3D变换。那你可以用齐次矩阵的原理对CSS属性的3D变换应用matrix3d实现出有趣的3D变换效果吗💡小提示要支持3维的齐次坐标需要4维齐次矩阵
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
## 源码
[1]粒子动画的[完整代码](https://github.com/akira-cn/graphics/tree/master/webgl_particles)
[2]矩阵运算数学库的[完整代码](https://github.com/akira-cn/graphics/tree/master/common/lib/math)
## 推荐阅读
[1]WebGL的uniform变量设置[官方文档](https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/uniform)
[2][3Blue1Brown的数学和图形学基础课程](https://www.youtube.com/watch?v=fNk_zzaMoSs&amp;list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab)
[3]图形学入门经典教材[《Fundamentals of Computer Graphics》](https://book.douban.com/subject/26868819/)