可视化相关的数学知识 - 2

1,008 阅读4分钟

三角剖分

三角剖分(Triangulation)是图形学中一个非常重要的基本操作,就是将多边形分割成若干个三角形的操作。

注意这里为什么讨论的是多边形呢,相信从前面的文章中实现曲线绘制的过程也能得知,事实上我们所绘制的圆形或者其他曲线其本质上都是多边形。

在 WebGL 中,如果我们想要填充多边形那么就需要对*多边形先进行三角剖分,把一个图形分割成若干个三角形,因为 WebGL 只能画点、线、三角形,对于多边形 WebGL 是无法直接处理的。

多边形的分类

我们先来看一下多边形的分类,多边形分为简单多边形复杂多边形,简单多边形又分为凸多边形凹多边形

如果一个多边形的每条边除了相邻的边以外,不和其他边相交,那它就是简单多边形,否则就是复杂多边形。

如果一个多边形的内角都不超过 180°,那么它就是凸多边形,否则就是凹多边形。

多边形.png

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 填充,非零环绕就会填充。效果图如下:

fill1.PNG

evenodd 填充,重叠区为奇数时填充。效果图如下:

fill2.PNG

Canvas2D 实现填充可以是说是非常容易了,但是 WebGL 却不然。

WebGL 实现填充之前需要进行三角剖分,对简单多边形尤其是凸多边形,进行三角剖分相对比较简单,而复杂多边形由于有边的相交和面积重叠区域,所以相对困难许多。总体来说,其算法还是比较复杂的,会涉及到很多图形学的底层数学知识,所以就不在此展开了,参考资料看这里

事实上,Github 上已经有很多成熟的库可以帮助我们进行三角剖分了,比如:EarcutTess2.jscdt2d。下面是一个使用 Earcut 进行三角剖分的一个例子:

codesandbox

<!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>

效果图如下:

triangulation1.jpg

打印出来的数字代表的是顶点的 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);

效果图立马变为下图:

triangulation2.jpg

这就是实际被剖分出来的三角形,一共有 8 个(图中有一条线没有画出来)。

WebGL 绘制 3D 模型的时候也会用三角剖分,而 3D 的三角剖分又被称为网格化(Meshing)。

判断点在多边形内部

SVG 可以通过 DOM 的 API 直接判断鼠标是否在该元素上,但是 Canvas 上就没有那么方便了,首先是 Canvas2D,虽然提供了 isPointInPath 方法来判定,但是其有一定的局限性,因为它只能对当前绘制的图形生效。所以我们需要自己来实现一套通用的代码来做这件事情。

实现原理

通过点与几何图形的数学关系来判断点是否在图形内。

Step 1

已知一个三角形的三条边分别是向量 a、b、c,平面上的一点 u 在这个三角形内,三角形的三个顶点分别连接点 u,形成三个向量分别是 u1、u2、u3,那么 u1 × au2 × bu3 × c 的符号一定相同。因为 u1 到 a、u2 到 b、u3 到 c 的小角旋转方向是相同的(这里都为顺时针),所以 u1 X a、u2 X b、u3 X c 要么同正,要么同负。

可视化-6.png

已知一个三角形的三条边分别是向量 a、b、c,平面上的一点 v 在这个三角形外,三角形的三个顶点分别连接点 v,形成三个向量分别是 v1、v2、v3,那么 v1 × av2 × b、vu3 × c 的符号一定不同。当点 v 在三角形外时,v1 到 a 方向是顺时针,v2 到 b 方向是逆时针,v3 到 c 方向又是顺时针,所以它们叉乘的结果符号并不相同。

可视化-5.png

Step 2

这里虽然可以判断点在三角形的内部或者外部,但却不能判定点恰好在某条边及其延长线上的情况。

假设一个点 u 在三角形的一条边 a 上,那就会需要满足以下 2 个条件:

  1. a × u1 等于0;
  2. a 点乘 u1 的结果除以 a 的长度的平方大于等于 0 小于等于 1。

Step3

对多边形就行三角剖分,判断某一个点,如果不在所有剖分出来的三角形内,那么这个点就不在此多边形内。

代码实现

codesandbox

// 判断是否在三角形内
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;
}

仿射变换

仿射变换简单的说就是“线性变换 + 平移”。几何图形的仿射变换具有以下两个性质:

  1. 仿射变换前是直线段的,仿射变换后依然是直线段;
  2. 对两条直线段 a 和 b 应用同样的仿射变换,变换前后线段长度比例保持不变;

由于仿射变换具有这两个性质,因此对线性空间中的几何图形进行仿射变换,就相当于对它的每个顶点向量进行仿射变换。

平移

平移是最简单的仿射变换,如果我们想让向量 P(x0, y0) 沿着向量 Q(x1, y1) 平移,只要将 P 和 Q 相加就可以了。公式如下:

{x=x0+x1y=y0+y1\begin{cases} x = x_0 + x_1 \\ y = y_0 + y_1 \\ \end{cases}

缩放

缩放也很简单,直接让向量与标量相乘即可。公式如下:

{x=sxx0y=syy0\begin{cases} x = s_x x_0 \\ y = s_y y_0 \\ \end{cases}

其矩阵形式表达为:

[xy]=[sx00sy]×[x0y0]\begin{bmatrix} x \\ y \\ \end{bmatrix} = \begin{bmatrix} s_x & 0 \\ 0 & s_y \\ \end{bmatrix} × \begin{bmatrix} x_0 \\ y_0 \\ \end{bmatrix}

旋转

最后是旋转,其公式为:

{x=xcosαysinαy=xsinα+ycosα\begin{cases} x = x \cos \alpha - y \sin \alpha \\ y = x \sin \alpha + y \cos \alpha \\ \end{cases}

其矩阵形式表达为:

[xy]=[cosαsinαsinαcosα]×[x0y0]\begin{bmatrix} x \\ y \\ \end{bmatrix} = \begin{bmatrix} \cos \alpha & - \sin \alpha \\ \sin \alpha & \cos \alpha \\ \end{bmatrix} × \begin{bmatrix} x_0 \\ y_0 \\ \end{bmatrix}

仿射公式

旋转和缩放都可以写成矩阵与向量相乘的形式,所以这种能写成矩阵与向量相乘形式的变换,就叫做线性变换。线性变换除了可以满足仿射变换的 2 个性质之外,还有 2 个额外的性质:

  1. 线性变换不改变坐标原点(因为如果 x0、y0 等于零,那么 x、y 肯定等于 0);
  2. 线性变换可以叠加,多个线性变换的叠加结果就是将线性变换的矩阵依次相乘,再与原始向量相乘。

根据第二个额外性质,我们可以得出一个通用的线性变换公式,即一个原始向量 P0 经过 M1、M2、... Mn 次线性变换之后得到最终坐标 P。其公式可以表达为:

P=M1×M2×Mn×P0简化P=M×P0(M=M1×M2×Mn)P = M_1 × M_2 × …… M_n × P_0 \\ 简化 \\ P = M × P_0(M = M_1 × M_2 × …… M_n)

最终获得仿射变换公式如下:

P=M×P0+P1P = M × P_0 + P_1

其矩阵形式表达为(实际上是给线性空间增加了一个维度):

[P1]=[MP101]×[P01]\begin{bmatrix} P \\ 1 \\ \end{bmatrix} = \begin{bmatrix} M & P_1 \\ 0 & 1 \\ \end{bmatrix} × \begin{bmatrix} P_0 \\ 1 \\ \end{bmatrix}

这样,我们就将原本 n 维的坐标转换为了 n+1 维的坐标。这种 n+1 维坐标被称为齐次坐标,对应的矩阵就被称为齐次矩阵

齐次坐标和齐次矩阵是可视化中非常常用的数学工具,它能让我们用线性变换来表示仿射变换。

应用实例1:粒子动画

粒子动画能在一定时间内生成许多随机运动的小图形,这类动画通常是通过给人以视觉上的震撼,来达到获取用户关注的效果。在可视化中,粒子动画可以用来表达数据信息本身(比如数量、大小等等),也可以用来修饰界面、吸引用户的关注,它是我们在可视化中经常会用到的一种视觉效果。

在粒子动画的实现过程中,我们通常需要在界面上快速改变一大批图形的大小、形状和位置,所以用图形的仿射变换来实现是一个很好的方法。

先来看一下效果:

particle effect.gif

codesandbox

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 代码为例,实际上相当于做了如下变换:

[1.50001.50001]×[101000150001]×[cosθsinθ0sinθcosθ0001]\begin{bmatrix} 1.5 & 0 & 0 \\ 0 & 1.5 & 0 \\ 0 & 0 & 1 \\ \end{bmatrix} × \begin{bmatrix} 1 & 0 & 100 \\ 0 & 1 & 50\\ 0 & 0 & 1 \\ \end{bmatrix} × \begin{bmatrix} \cos \theta & - \sin \theta & 0 \\ \sin \theta & \cos \theta & 0 \\ 0 & 0 & 1 \\ \end{bmatrix}

关于矩阵的计算我们引入一个库 OGL,它可以帮助我们计算矩阵。

codesandbox

<!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>