三角剖分
三角剖分(Triangulation)是图形学中一个非常重要的基本操作,就是将多边形分割成若干个三角形的操作。
注意这里为什么讨论的是多边形呢,相信从前面的文章中实现曲线绘制的过程也能得知,事实上我们所绘制的圆形或者其他曲线其本质上都是多边形。
在 WebGL 中,如果我们想要填充多边形那么就需要对*多边形先进行三角剖分,把一个图形分割成若干个三角形,因为 WebGL 只能画点、线、三角形,对于多边形 WebGL 是无法直接处理的。
多边形的分类
我们先来看一下多边形的分类,多边形分为简单多边形和复杂多边形,简单多边形又分为凸多边形和凹多边形。
如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。
如果一个多边形的内角都不超过 180°,那么它就是凸多边形,否则就是凹多边形。
WebGL 多边形填充
在 Canvas2D 中想要填充一个多边形,只需要调用 fill 这个 API 即可,下面是一些例子:
const coordinates = [new Vector2D(0, 100)];
for (let i = 1; i <= 4; i++) {
const coordinate = coordinates[0].copy().rotate(i * Math.PI * 0.4);
coordinates.push(coordinate);
}
const polygon = [...coordinates];
// 绘制正五边形
ctx.save();
ctx.translate(-128, 0);
// draw(polygon, ctx, { strokeStyle: 'blue', fillStyle: 'blue' });
draw(polygon, ctx, { strokeStyle: 'blue', fillStyle: 'blue', rule: 'evenodd' });
ctx.restore();
const stars = [
coordinates[0],
coordinates[2],
coordinates[4],
coordinates[1],
coordinates[3],
];
// 绘制正五角星
ctx.save();
ctx.translate(128, 0);
// draw(stars, ctx, { strokeStyle: 'green', fillStyle: 'green' });
draw(stars, ctx, { strokeStyle: 'green', fillStyle: 'green', rule: 'evenodd' });
ctx.restore();
nonzero 填充,非零环绕就会填充。效果图如下:
evenodd 填充,重叠区为奇数时填充。效果图如下:
Canvas2D 实现填充可以是说是非常容易了,但是 WebGL 却不然。
WebGL 实现填充之前需要进行三角剖分,对简单多边形尤其是凸多边形,进行三角剖分相对比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。总体来说,其算法还是比较复杂的,会涉及到很多图形学的底层数学知识,所以就不在此展开了,参考资料看这里。
事实上,Github 上已经有很多成熟的库可以帮助我们进行三角剖分了,比如:Earcut、Tess2.js、cdt2d。下面是一个使用 Earcut 进行三角剖分的一个例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>triangulation</title>
<script src="https://unpkg.com/earcut@2.2.3/dist/earcut.dev.js"></script>
</head>
<body>
<canvas width="512" height="512"></canvas>
<script>
// Step1:WebGL 上下文
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");
// Step2:创建 WebGL 程序(WebGLProgram 对象)
// 使用 GLSL 语言,创建顶点着色器(Vertex Shader)
const vertex = `
attribute vec2 position;
void main() {
gl_PointSize = 1.0;
gl_Position = vec4(position, 1.0, 1.0);
}
`;
// 使用 GLSL 语言,创建片元着色器(Fragment Shader)
const fragment = `
precision mediump float;
void main()
{
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;
// 创建 Shader 对象
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertex);
gl.compileShader(vertexShader);
// 创建 Shader 对象
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragment);
gl.compileShader(fragmentShader);
// 创建 WebGLProgram 对象
const program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
// 启用这个 WebGLProgram 对象,以使 GPU 执行
gl.useProgram(program);
// Step3:将数据存入缓冲区
// 定义多边形的顶点
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]
];
const coordinates = vertices.flat();
const triangles = earcut(coordinates);
console.log(triangles.toString());
const position = new Float32Array(coordinates);
const cells = new Uint16Array(triangles);
// 将定义好的点数据写入 WebGL 的缓冲区
const pointBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
gl.bufferData(gl.ARRAY_BUFFER, position, gl.STATIC_DRAW);
// Step4:将缓冲区数据取到 GPU
// 获取顶点着色器中的 position 变量的地址
const vPosition = gl.getAttribLocation(program, "position");
// 给变量设置顶点坐标长度(二维坐标还是三维坐标)和类型(FLOAT)
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);
// Step5:执行着色器程序完成绘制
// 将当前画布的内容清除
gl.clear(gl.COLOR_BUFFER_BIT);
// 传入绘制模式、定点偏移量、和顶点数
gl.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);
// gl.drawElements(gl.LINE_STRIP, cells.length, gl.UNSIGNED_SHORT, 0);
</script>
</body>
</html>
效果图如下:
打印出来的数字代表的是顶点的 index,比如:1 0 9 代表了,vertices 数组中 index 为 1 0 9 的三个顶点组成的三角形。
我们把代码稍作修改:
gl.drawElements(gl.TRIANGLES, cells.length, gl.UNSIGNED_SHORT, 0);
// 修改为
gl.drawElements(gl.LINE_STRIP, cells.length, gl.UNSIGNED_SHORT, 0);
效果图立马变为下图:
这就是实际被剖分出来的三角形,一共有 8 个(图中有一条线没有画出来)。
WebGL 绘制 3D 模型的时候也会用三角剖分,而 3D 的三角剖分又被称为网格化(Meshing)。
判断点在多边形内部
SVG 可以通过 DOM 的 API 直接判断鼠标是否在该元素上,但是 Canvas 上就没有那么方便了,首先是 Canvas2D,虽然提供了 isPointInPath 方法来判定,但是其有一定的局限性,因为它只能对当前绘制的图形生效。所以我们需要自己来实现一套通用的代码来做这件事情。
实现原理
通过点与几何图形的数学关系来判断点是否在图形内。
Step 1
已知一个三角形的三条边分别是向量 a、b、c,平面上的一点 u 在这个三角形内,三角形的三个顶点分别连接点 u,形成三个向量分别是 u1、u2、u3,那么 u1 × a、 u2 × b、 u3 × c 的符号一定相同。因为 u1 到 a、u2 到 b、u3 到 c 的小角旋转方向是相同的(这里都为顺时针),所以 u1 X a、u2 X b、u3 X c 要么同正,要么同负。
已知一个三角形的三条边分别是向量 a、b、c,平面上的一点 v 在这个三角形外,三角形的三个顶点分别连接点 v,形成三个向量分别是 v1、v2、v3,那么 v1 × a、 v2 × b、vu3 × c 的符号一定不同。当点 v 在三角形外时,v1 到 a 方向是顺时针,v2 到 b 方向是逆时针,v3 到 c 方向又是顺时针,所以它们叉乘的结果符号并不相同。
Step 2
这里虽然可以判断点在三角形的内部或者外部,但却不能判定点恰好在某条边及其延长线上的情况。
假设一个点 u 在三角形的一条边 a 上,那就会需要满足以下 2 个条件:
- a × u1 等于0;
- a 点乘 u1 的结果除以 a 的长度的平方大于等于 0 小于等于 1。
Step3
对多边形就行三角剖分,判断某一个点,如果不在所有剖分出来的三角形内,那么这个点就不在此多边形内。
代码实现
// 判断是否在三角形内
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;
}
仿射变换
仿射变换简单的说就是“线性变换 + 平移”。几何图形的仿射变换具有以下两个性质:
- 仿射变换前是直线段的,仿射变换后依然是直线段;
- 对两条直线段 a 和 b 应用同样的仿射变换,变换前后线段长度比例保持不变;
由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。
平移
平移是最简单的仿射变换,如果我们想让向量 P(x0, y0) 沿着向量 Q(x1, y1) 平移,只要将 P 和 Q 相加就可以了。公式如下:
缩放
缩放也很简单,直接让向量与标量相乘即可。公式如下:
其矩阵形式表达为:
旋转
最后是旋转,其公式为:
其矩阵形式表达为:
仿射公式
旋转和缩放都可以写成矩阵与向量相乘的形式,所以这种能写成矩阵与向量相乘形式的变换,就叫做线性变换。线性变换除了可以满足仿射变换的 2 个性质之外,还有 2 个额外的性质:
- 线性变换不改变坐标原点(因为如果 x0、y0 等于零,那么 x、y 肯定等于 0);
- 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。
根据第二个额外性质,我们可以得出一个通用的线性变换公式,即一个原始向量 P0 经过 M1、M2、... Mn 次线性变换之后得到最终坐标 P。其公式可以表达为:
最终获得仿射变换公式如下:
其矩阵形式表达为(实际上是给线性空间增加了一个维度):
这样,我们就将原本 n 维的坐标转换为了 n+1 维的坐标。这种 n+1 维坐标被称为齐次坐标,对应的矩阵就被称为齐次矩阵。
齐次坐标和齐次矩阵是可视化中非常常用的数学工具,它能让我们用线性变换来表示仿射变换。
应用实例1:粒子动画
粒子动画能在一定时间内生成许多随机运动的小图形,这类动画通常是通过给人以视觉上的震撼,来达到获取用户关注的效果。在可视化中,粒子动画可以用来表达数据信息本身(比如数量、大小等等),也可以用来修饰界面、吸引用户的关注,它是我们在可视化中经常会用到的一种视觉效果。
在粒子动画的实现过程中,我们通常需要在界面上快速改变一大批图形的大小、形状和位置,所以用图形的仿射变换来实现是一个很好的方法。
先来看一下效果:
Step 1 创建三角形
创建三角形,定义三角形的顶点并将数据送到缓冲区:
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);
Step 2 创建随机三角形
实现一个创建随机三角形的函数,返回三角形的颜色、初始旋转角度、初始大小、初始时间、动画持续时间、运动方向和创建时间,除了开始时间之外,我们都需要传给 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};
}
Step 3 定义 uniform 变量
在 WebGL 的 shader 中,我们用 attribute 属性来声明顶点信息,但是如果要声明颜色、旋转角度等其它信息,则需要设置 uniform 变量,它可以在顶点着色器中使用,也可以在片元着色器中使用。
// 顶点着色器
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;
// ...
// 片元着色器
uniform vec4 u_color;
varying float vP;
// ...
Step 4 设置 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
更多 API 可以参考 MDN 官方文档。
下面实现将随机三角形信息传给 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);
}
Step 5 实现动画
使用 requestAnimationFrame 实现动画。在 update 方法中每次新建数个随机三角形,然后依次修改所有三角形的 u_time 属性,通过 setUniforms 方法将修改的属性更新到 shader 变量中。这样,我们就可以在 shader 中读取变量的值进行处理了。
let triangles = [];
function update() {
for(let i = 0; i < 5 * Math.random(); i++) {
triangles.push(randomTriangles());
}
gl.clear(gl.COLOR_BUFFER_BIT);
// 对每个三角形重新设置 u_time
triangles.forEach((triangle) => {
triangle.u_time = (performance.now() - triangle.startTime) / 1000;
setUniforms(gl, triangle);
gl.drawArrays(gl.TRIANGLES, 0, position.length / 2);
});
// 移除已经结束动画的三角形
triangles = triangles.filter((triangle) => {
return triangle.u_time <= triangle.u_duration;
});
requestAnimationFrame(update);
}
requestAnimationFrame(update);
Step 6 利用仿射修改 WebGL 中的三角形
// 顶点着色器
// ...
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;
}
// 片元着色器
// ...
void main()
{
gl_FragColor.xyz = u_color.xyz;
gl_FragColor.a = (1.0 - vP) * u_color.a;
}
简述一下上面代码的关键点:
- p 是当前动画进度,它的值是 u_time / u_duration,取值区间从 0 到 1。rad 是旋转角度,它的值是初始角度 u_rotation 加上 10π,表示在动画过程中它会绕自身旋转 5 周。
- scale 是缩放比例,它的值是初始缩放比例乘以一个系数,这个系数是 p * (2.0 - p)(这是一个欢动函数),它的作用是让 scale 的变化量随着时间推移逐渐减小。
- offset 是一个二维向量,它是初始值 u_dir 与 2.0 * p * p 的乘积,因为 u_dir 是个单位向量,这里的 2.0 表示它的最大移动距离为 2,p * p 也是一个缓动函数,作用是让位移的变化量随着时间增加而增大。
- 定义完这些参数以后,我们得到三个齐次矩阵:translateMatrix 是偏移矩阵,rotateMatrix 是旋转矩阵,scaleMatrix 是缩放矩阵。我们将 pos 的值设置为这三个矩阵与 position 的乘积,这样就完成对顶点的线性变换,呈现出来的效果也就是三角形会向着特定的方向旋转、移动和缩放。
- 在片元着色器中对这些三角形着色,我们将动画进度 p,从顶点着色器通过变量 varying vP 传给片元着色器,然后在片元着色器中让 α 值随着 vP 值变化,这样就能同时实现粒子的淡出效果了。
应用实例2:CSS 的仿射变换
CSS 中仿射使用的是 transform,比如:
/* 先旋转 30 度,然后平移 100px、50px,最后再放大 1.5 倍 */
.block {
transform: rotate(30deg) translate(100px, 50px) scale(1.5);
}
rotate、translate、scale 这些都是基本操作,它还支持 matrix,使用 matrix 可以提升性能,CSS 的 matrix 是一个简写的齐次矩阵,它只有 6 个值,省略了第三行的 0, 0, 1。
以上面这段 CSS 代码为例,实际上相当于做了如下变换:
关于矩阵的计算我们引入一个库 OGL,它可以帮助我们计算矩阵。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cssTransform</title>
<script src="https://cdn.skypack.dev/ogl"></script>
<style>
div {
height: 100px;
width: 100px;
background-color: #000;
}
</style>
</head>
<body>
<div id="block"></div>
<script type="module">
import { Mat3 } from "https://unpkg.com/ogl";
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) => {
const matrix3 = new Mat3();
return matrix3.multiply(b, a);
});
console.log(res);
/*
[1.299038105676658, -0.7499999999999999, 61.60254037844388,
0.7499999999999999, 1.299038105676658, 93.30127018922192,
0, 0, 1]
*/
const el = document.getElementById("block");
el.style.transform = `matrix(${res[0]},${res[3]},${res[1]},${res[4]},${res[2]},${res[5]})`;
</script>
</body>
</html>