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.

427 lines
21 KiB
Markdown

2 years ago
# 08 | 如何利用三角剖分和向量操作描述并处理多边形?
你好,我是月影。
在图形系统中我们最终看到的丰富多彩的图像都是由多边形构成的。换句话说不论是2D图形还是3D图形经过投影变换后在屏幕上输出的都是多边形。因此理解多边形的基本性质了解用数学语言描述并且处理多边形的方法是我们在可视化中必须要掌握的内容。
那今天,我们就来说说,不同的图形系统是如何用数学语言描述并处理多边形。首先,我们来说说图形学中的多边形是什么。
## 图形学中的多边形是什么?
多边形可以定义为由三条或三条以上的线段首尾连接构成的平面图形,其中,每条线段的端点就是多边形的顶点,线段就是多边形的边。
多边形又可以分为**简单多边形**和**复杂多边形**。我们该怎么区分它们呢?如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。一般来说,我们在绘图时,要尽量构建简单多边形,因为简单多边形的图形性质比较简单,绘制起来比较方便。
而简单多边形又分为凸多边形和凹多边形我们主要是看简单多边形的内角来区分的。如果一个多边形中的每个内角都不超过180°那它就是凸多边形否则就是凹多边形。
![](https://static001.geekbang.org/resource/image/74/4a/74c812ef3a15f5f20d7a5bbaff30794a.jpg)
在图形系统中绘制多边形的时候,最常用的功能是填充多边形,也就是用一种颜色将多边形的内部填满。除此之外,在可视化中用户经常要用鼠标与多边形进行交互,这就要涉及多边形的边界判定。所以今天,我们就来重点讨论**多边形的填充和边界判定**。首先,我们来看多边形的填充。
## 不同的图形系统如何填充多边形?
不同的图形系统会用不同的方法来填充多边形。比如说在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 <= 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 < points.length; i++) {
context.lineTo(...points[i]);
}
if(close) context.closePath();
context.fillStyle = fillStyle;
context.fill(rule);
}
```
我们最终绘制出的效果如下图所示:
![](https://static001.geekbang.org/resource/image/37/96/371e3b8d3f484b13aa13f6e8ce60ec96.jpeg "简单多边形a和b")
除了“nonzero”还有一种规则叫做“evenodd”它是根据重叠区域是奇数还是偶数来判断是否填充的。那当我们增加了draw方法的参数将五角星的填充规则改成“evenodd”之后简单多边形没有变化而复杂多边形由于绘制区域存在重叠就出导致图形中心有了空洞的特殊效果。
```
draw(ctx, stars, {rule: 'evenodd'});
```
![](https://static001.geekbang.org/resource/image/81/44/81e56244233ebec7e0cc50a661d2cf44.jpeg "使用evenodd之后得到的填充图形a和b")
总之Canvas2D的fill非常实用它可以自动填充多边形内部的区域并且对于任何多边形都能判定和填充你可以自己去尝试一下。
### 2\. WebGL如何填充多边形
在WebGL中虽然没有提供自动填充多边形的方法但是我们可以用三角形这种基本图元来快速地填充多边形。因此在WebGL中填充多边形的第一步就是将多边形分割成多个三角形。
这种将多边形分割成若干个三角形的操作,在图形学中叫做**三角剖分**Triangulation
[![](https://static001.geekbang.org/resource/image/61/dd/619872b8789bfaeb5fc2c1f0381d52dd.jpeg "同一个多边形的两种三角剖分方法")](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填充多边形的过程。
![](https://static001.geekbang.org/resource/image/28/9c/28716416aa8c00743843ae208089c99c.jpeg "简单多边形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);
```
![](https://static001.geekbang.org/resource/image/61/26/614e29911f76757a56159ca2d080a526.jpeg)
你会发现通过上面的步骤整个多边形都被WebGL渲染并填充为了红色。这么一看好像三角剖分并没有什么作用。但实际上WebGL是对这个多边形三角剖分后的每个三角形分别进行填充的。为了让你看得更清楚我们用描边代替填充具体操作就是修改一下gl.drawElements的渲染模式将gl.TRIANGLES改成gl.LINE\_STRIP。这样我们就可以清晰地看出经过Earcut处理的这个多边形被分割成了8个三角形。
![](https://static001.geekbang.org/resource/image/54/72/549f33d96886fa2bf04f92d30f9ea972.jpeg)
到这里我们就讲完了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) => {
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');
}
});
```
最后,上面代码运行效果如下图:
![](https://static001.geekbang.org/resource/image/4f/97/4f370d3187e964efc733294a3ed2de97.gif)
这个运行结果是没有问题的但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) => {
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方法判断点的位置这样得到的结果如下图
![](https://static001.geekbang.org/resource/image/5e/3e/5e566597db9519bcb0fdc09ce6390e3e.gif)
你会看到当我们将鼠标移动到中间大图时它的颜色并没有发生变化只有移动到右上角的小三角形时这两个图形才会同时变色。这就是因为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连接三角形三个顶点的向量分别为u1、u2、u3那么u点在三角形内部的充分必要条件是u1 X a、u2 X b、u3 X c 的符号相同。
你也可以看我下面给出的示意图当点u在三角形a、b、c内时因为u1到a、u2到b、u3到c的小角旋转方向是相同的这里都为顺时针所以u1 X a、u2 X b、u3 X c要么同正要么同负。当点v在三角形外时v1到a方向是顺时针v2到b方向是逆时针v3到c方向又是顺时针所以它们叉乘的结果符号并不相同。
![](https://static001.geekbang.org/resource/image/34/c3/3402b08454dbc39f9543cb4c597419c3.jpg)
根据这个原理,我们就可以写一个简单的判定函数了,代码如下:
```
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 && s2 === s3;
}
```
你以为到这里就结束了吗还没有。上面的代码还有个Bug它虽然可以判定点在三角形内部但却不能判定点恰好在三角形某条边上的情况。这又该如何优化呢
在学习了向量乘法之后我们知道。如果一个点u在三角形的一条边a上那就会需要满足以下2个条件
1. a.cross(u1) === 0
2. 0 <= a.dot(u1) / a.length \*\* 2 <= 1
第一个条件很容易理解我就不细说了我们重点来看第二个条件。下面我就分别讨论一下点u和a在一条直线上和不在一条直线上这两种情况。
![](https://static001.geekbang.org/resource/image/ca/8e/ca37834a201b3d704fe40ef3955b608e.jpg "左图是点u和a不在一条直线上右图是点u和a在一条直线上
")
当向量u1与a不在一条直线上时u1与a的叉乘结果不为0而u1与a的点乘的值除以a的长度相当于u1在a上的投影。
当向量u1与a在一条直线上时u1与a的叉乘结果为0u1与a的点乘结果除以a的长度的平方正好是u1与a的比值。
u1与a的比值也有三种情况当u1在a上时u1和a比值是介于0到1之间的当u1在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 && p >= 0 && p <= 1) return true;
const s2 = Math.sign(b.cross(u2));
p = b.dot(u2) / b.length ** 2;
if(s2 === 0 && p >= 0 && p <= 1) return true;
const s3 = Math.sign(c.cross(u3));
p = c.dot(u3) / c.length ** 2;
if(s3 === 0 && p >= 0 && p <= 1) return true;
return s1 === s2 && s2 === s3;
}
```
这样我们就判断了一个点是否在某个三角形内部。那如果要判断一个点是否在任意多边形的内部,我们只需要在判断之前将它进行三角剖分就可以了。代码如下:
```
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;
}
```
## 要点总结
本节课,我们学习了使用三角剖分来填充多边形以及判断点是否在多边形内部。
不同的图形系统有着不同的处理方法Canvas2D的处理很简单它可以使用原生的fill来填充任意多边形使用isPointInPath来判断点是否在多边形内部。但是三角剖分是更加通用的方式WebGL就是使用三角剖分来处理多边形的所以我们要牢记它的操作。
首先在使用三角剖分填充多边形时我们直接调用一些成熟库的API就可以完成这并不难。而当我们要实现图形和用户的交互时也就是要判断一个点是否在多边形内部时也需要先对多边形进行三角剖分然后判断该点是否在其中一个三角形内部。
## 小试牛刀
1. 在课程中我们使用了Earcut对多边形进行三角剖分。但是tess2.js是一个比Earcut更强大的三角剖分库使用tess2.js可以像原生的Canvas2D的fill方法那样实现evenodd的填充规则。你能试着把代码中的earcut换成tess2.js从而实现evenodd填充规则吗动手之前你可以先去读一下tess2.js的项目文档。
2. 今天我们用三角剖分实现了不规则多边形。那你能试着利用三角剖分的原理通过WebGL画出椭圆图案、菱形的星星图案以及正五角星吗
欢迎在留言区和我讨论,分享你的答案和思考,也欢迎你把这节课分享给你的朋友,我们下节课见!
* * *
## 源码
[使用三角剖分填充多边形、判断点在多边形内部的完整代码](https://github.com/akira-cn/graphics/tree/master/triangluations)
## 推荐阅读
[tess2.js官方文档](https://github.com/memononen/tess2.js)