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

893 阅读6分钟

Canvas2D 坐标系

首先,坐标系的问题需要解决,Canvas2D 画布的坐标原点在屏幕的左上角,右下角则为画布的宽高。但是这样的坐标系会让我们有些别扭,不过我们可以使用一些 API 来对坐标系进行一些处理,变成我们习惯的那种坐标系。

const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.scale(1, -1);

坐标系.png

变化之后的坐标系能够简化计算量,这不仅让代码更容易理解,也可以节省 CPU 运算的时间。

向量

一个点可以直接使用数组 [x, y] 表示。

向量(暂且只讨论二维向量)包含的内容更丰富,不仅有点,还有长度和方向信息,甚至包含一些向量之间的运算,比如向量相加、相乘等。

坐标系1.png

向量长度

向量长度公式如下所示:

v的长度=x2+y2\vec v 的长度 = \sqrt {x^2 + y^2}

JavaScript 实现如下:

// 求 x、y 的平方和的平方根
const length = Math.hypot(x, y);

向量与 x 轴的夹角

夹角范围是 -π 到 π,负数表示在 x 轴下方,正数表示在 x 轴上方,夹角公式如下所示:

vx周的夹角=arctan(yx)\vec v 与 x 周的夹角 = \arctan (\frac {y} {x})

JavaScript 实现如下:

const angle = Math.atan2(y, x);

另外,根据向量长度和夹角还可以顺便推导出一组关系式:

// 向量的 x 坐标
const x = length * Math.cos(angle);
// 向量的 y 坐标
const y = length * Math.sin(angle);

向量的加法

下图是两个向量相加的一个示意图,其意义为将向量 v1 的终点 (x1, y1),沿着向量 v2 的方向移动一段距离,这段距离的长度为 v2 向量的长度。

坐标系2.png

向量加法公式如下:

v1+v2=(x1+x2,y1+y2)\vec {v1} + \vec {v2} = (x1 + x2, y1 + y2)

JavaScript 实现如下:

const x = x1 + x2;
const y = y2 + y2;

向量伸缩

向量伸缩即向量乘上一个标量,公式如下所示:

v=[x,y]nv=[nx,xy]\vec v = [x, y] \\ n\vec v = [nx, xy]

JavaScript 实现如下:

x = n * x;
y = n * y;

向量旋转

可视化3.png

这里可能需要一点矩阵的知识,列出旋转矩阵如下:

[xy][cosαsinαsinαcosα]=[xcosαysinαxsinα+ycosα]\begin{bmatrix} x\\ y\\ \end{bmatrix} \begin{bmatrix} \cos \alpha & -\sin \alpha \\ \sin \alpha & \cos \alpha \\ \end{bmatrix} = \begin{bmatrix} x \cos \alpha - y \sin \alpha \\ x \sin \alpha + y \cos \alpha \\ \end{bmatrix}

向量旋转公式如下:

v旋转α度之后的坐标=(xcosαysinα,xsinα+ycosα)\vec v 旋转 \alpha 度之后的坐标 = (x \cos \alpha - y \sin \alpha, x \sin \alpha + y \cos \alpha)

JavaScript 实现如下:

// rad 为 α 角对应的弧度
x = x * Math.cos(rad) - y * Math.sin(rad);
y = x * Math.sin(rad) + y * Math.cos(rad);

向量点乘

它的物理含义相当于 a 力作用于物体,产生 b 位移所做的功。

向量点乘公式如下:

a=[a1,a2]b=[b1,b2]ab=a1b1+a2b2\vec a = [a1, a2] \\ \vec b = [b1, b2] \\ \vec a \bullet \vec b = a1b1 + a2b2

其数学上含义为向量 b 在 向量 a 上的投影的长度与向量 a 的长度相乘,两个向量之间的夹角为 α,所以下面这个公式也成立:

ab=abcosα\vec a \bullet \vec b = |a||b| \cos \alpha

二维向量点乘 JavaScript 实现如下:

function dot(a, b) {
  return a.x * b.x + b.x * b.y;
}

向量叉乘

二维向量 a 和 b 的叉积为向量 a 与 向量 b 沿垂直方向的投影的乘积,其几何意义 就是向量 a、b 组成的平行四边形的面积,注:二维空间无法得到一个和原向量组成的坐标平面垂直的新向量,所以只能求出乘积的标量。

a×b=absinα| \vec a × \vec b | = |a||b| \sin \alpha

二维空间中向量叉乘的物理意义是 a 和 b 两个力的力矩,力矩可以理解为一个物体在力的作用下,绕着一个轴转动的趋向。它是一个向量,等于力臂 L 和力 F 的叉乘。

a×b=[x1y1]×[x2y2]=[x1y1x2y2]=x1y2x2y1\vec a × \vec b = \begin{bmatrix} x1 & y1 \\ \end{bmatrix} × \begin{bmatrix} x2 & y2 \\ \end{bmatrix} = \begin{bmatrix} x1 & y1 \\ x2 & y2 \\ \end{bmatrix} = x1y2 - x2y1

事实上,向量叉乘的运算结果不是标量而是一个向量,且叉乘得到的新向量与两个原向量组成的坐标平面。其中 i、j、k 分别是 x、y、z 轴的单位向量。

a×b=[x1y1z1]×[x2y2z2]=[ijkx1y1z1x2y2z2]=[y1z2y2z1(x1z2x2z1)x1y2x2y1]\vec a × \vec b = \begin{bmatrix} x1 & y1 & z1 \\ \end{bmatrix} × \begin{bmatrix} x2 & y2 & z2 \\ \end{bmatrix} = \begin{bmatrix} i & j & k \\ x1 & y1 & z1 \\ x2 & y2 & z2 \\ \end{bmatrix} = \begin{bmatrix} y1z2 - y2z1 \\ -(x1z2 - x2z1) \\ x1y2 - x2y1 \\ \end{bmatrix}

确定出 a、b 的叉积方向的方法:x 轴向右、y 轴向下的坐标系是右手系。在右手系中求向量 a、b 叉积的方向时,我们可以把右手食指的方向朝向 a,把右手中指的方向朝向 b,那么大拇指所指的方向就是 a、b 叉积的方向,这个方向是垂直纸面向外(即朝向我们)。因此,右手系中向量叉乘的方向就是右手拇指的方向,那左手系中向量叉乘的方向自然就是左手拇指的方向了。

e1a3da7d12e40b7acfa46ba4293d2b89.webp

二维向量叉乘 JavaScript 实现如下:

function cross(a, b) {
  return a.x * b.y - b.x * a.y;
}

二维向量类实现

现在我们可以根据向量的一些基础运算来实现一个二维向量的 Class,代码如下:

class Vector2D {
  constructor(x = 1, y = 0) {
    this.x = x;
    this.y = y;
  }

  get length() {
    return Math.hypot(this.x, this.y);
  }

  get angle() {
    return Math.atan2(this.y, this.x);
  }

  add(vec) {
    this.x += vec.x;
    this.y += vec.y;
    return this;
  }
  
  sub(vec) {
    this.x -= vec.x;
    this.y -= vec.y;
    return this;
  }

  scale(rate) {
    this.x *= rate;
    this.y *= rate;
    return this;
  }

  cross(vec) {
    return this.x * vec.y - vec.x * this.y;
  }

  dot(vec) {
    return this.x * vec.x + vec.y * this.y;
  }

  rotate(rad) {
    const cos = Math.cos(rad);
    const sin = Math.sin(rad);
    const x = this.x;
    const y = this.y;

    this.x = x * cos - y * sin;
    this.y = x * sin + y * cos;

    return this;
  }
  
  copy() {
    return new Vector2D(this.x, this.y);
  }
}

实例1

我们可以使用上面实现的那个二维向量实现绘制一颗可以随机生成枝干的树:

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>Vector2D</title>
  <script src="Vector2D.js"></script>
</head>

<body>
  <canvas width="512" height="512"></canvas>
  <script>
    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');

    ctx.translate(0, canvas.height);
    ctx.scale(1, -1);
    ctx.lineCap = 'round';

    function drawBranch(context, v0, length, thickness, angle, offset) {
      const v = new Vector2D().rotate(angle).scale(length);
      const v1 = v0.copy().add(v);

      context.lineWidth = thickness;
      context.beginPath();
      context.moveTo(v0.x, v0.y);
      context.lineTo(v1.x, v1.y);
      context.stroke();

      if (thickness > 2) {
        const left = Math.PI / 4 + 0.5 * (angle + 0.2) + offset * (Math.random() - 0.5);
        drawBranch(context, v1, length * 0.9, thickness * 0.8, left, offset * 0.9);
        const right = Math.PI / 4 + 0.5 * (angle - 0.2) + offset * (Math.random() - 0.5);
        drawBranch(context, v1, length * 0.9, thickness * 0.8, right, offset * 0.9);
      }

      // 绘制树上的花
      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.x, v1.y);
        context.lineTo(v1.x, v1.y - 2);
        context.stroke();
        context.restore();
      }
    }

    const v0 = new Vector2D(canvas.width / 2, 0);
    drawBranch(ctx, v0, 50, 10, 1, 3);
  </script>
</body>

</html>

效果图:

demo.PNG

实例2

使用向量绘制一些正多边形:

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>Vector2D</title>
  <script src="Vector2D.js"></script>
  <script src="draw.js"></script>
</head>

<body>
  <canvas width="512" height="512"></canvas>
  <script>
    const canvas = document.querySelector('canvas');
    const ctx = canvas.getContext('2d');

    ctx.translate(canvas.width / 2, canvas.height / 2);
    ctx.scale(1, -1);

    function regularPolygon(edges = 3, center, sideLength) {
      const coordinates = [];
      // 配置向量起点
      let vec = new Vector2D(center.x, center.y);
      coordinates.push(vec);
      // 向量每次旋转的角度
      const angle = Math.PI * (1 - (edges - 2) / edges);
      // 根据配置的长度生成的一个基础向量
      const basicVec = new Vector2D(sideLength, 0);

      for (let i = 0; i < edges; i++) {
        vec = vec.copy().add(basicVec.rotate(angle));
        coordinates.push(vec);
      }
      return coordinates;
    }

    draw(ctx, regularPolygon(3, { x: 128, y: 128 }, 120));
    draw(ctx, regularPolygon(6, { x: -128, y: 128 }, 60));
    draw(ctx, regularPolygon(12, { x: -128, y: -128 }, 40));
    draw(ctx, regularPolygon(60, { x: 128, y: -128 }, 10));
  </script>
</body>

</html>

效果图:

polygon.PNG

曲线

前面使用向量的方式绘制了了正多边形,可见边只要足够多,画出来就可以是个圆的样子,不过这样画圆并不是一种很好的方式,繁琐、不够精准,也不能绘制其它类型的曲线,比如:椭圆、抛物线、贝塞尔曲线等。所以,要画曲线最好还是使用参数方程。

圆心在 (x0, y0),半径为 r 的圆,参数方程如下:

{x=x0+rcos(θ)y=y0+rsin(θ)\begin{cases} x = x_0 + r \cos (\theta) \\ y = y_0 + r \sin (\theta) \\ \end{cases}

codesandbox

// 默认画成60段
const CircleSegments = 60;
// 圆的角度
const CircleAngle = Math.PI * 2;

function arc(centre, radius, startAngle = 0, endAngle = Math.PI * 2) {
  // 实际应绘制的角度
  const angle = Math.min(Math.PI * 2, endAngle - startAngle);
  // 如果是画一个完整圆周,那么第一个点就先不会,因为最后会画上
  const coordinates = angle === CircleAngle ? [] : [centre];
  // 修改实际绘制的段数
  const segments = Math.round(CircleSegments * angle / CircleAngle);

  for (let i = 0; i <= segments; i++) {
    const x = centre.x + radius * Math.cos(startAngle + angle * i / segments);
    const y = centre.y + radius * Math.sin(startAngle + angle * i / segments);
    coordinates.push({ x, y });
  }
  return coordinates;
}

draw(ctx, arc({ x: 0, y: 0 }, 100));

效果图如下:

circle.PNG

注:Canvas2D 是有提供绘制 API 的,不过 WebGL 并没有提供画圆的 API,所以到 WebGL 里面这个函数就有用了。

Canvas2D API 实现:

codesandbox

// 画圆
ctx.beginPath();
ctx.arc(0, 0, 50, 0, 2 * Math.PI);
ctx.stroke();

椭圆

圆其实是椭圆的特例,当其长短轴(a、b)相等时就是圆,公式如下:

{x=x0+acos(θ)y=y0+bsin(θ)\begin{cases} x = x_0 + a \cos (\theta) \\ y = y_0 + b \sin (\theta) \\ \end{cases}

代码只需要稍做修改即可

codesandbox

function ellipse(centre, a, b, startAngle = 0, endAngle = Math.PI * 2) {
  // 实际应绘制的角度
  const angle = Math.min(Math.PI * 2, endAngle - startAngle);
  // 如果是画一个完整圆周,那么第一个点就先不会,因为最后会画上
  const coordinates = angle === CircleAngle ? [] : [centre];
  // 修改实际绘制的段数
  const segments = Math.round(CircleSegments * angle / CircleAngle);

  for (let i = 0; i <= segments; i++) {
    const x = centre.x + a * Math.cos(startAngle + angle * i / segments);
    const y = centre.y + b * Math.sin(startAngle + angle * i / segments);
    coordinates.push({ x, y });
  }
  return coordinates;
}

draw(ctx, ellipse({ x: 0, y: 0 }, 150, 100));

效果图:

ellipse.PNG

抛物线

抛物线是指平面内到一个定点F(焦点)和一条定直线L(准线)距离相等的点的轨迹。p 为常数,为焦点到准线的距离,t 为参数,抛物线的参数方程如下:

{x=x0+2pt2y=y0+2pt{x=x0+2pty=y0+2pt2\begin{cases} x = x_0 + 2pt^2 \\ y = y_0 + 2pt \\ \end{cases} \\ 或 \\ \begin{cases} x = x_0 + 2pt \\ y = y_0 + 2pt^2 \\ \end{cases}

codesandbox

const LineSegments = 60;
function parabola(x0, y0, p, t) {
  const coordinates = [];

  for (let i = 0; i <= LineSegments; i++) {
    const s = i / LineSegments;
    const paramT = t.min + s * (t.max - t.min);

    const x = x0 + 2 * p * paramT ** 2;
    const y = y0 + 2 * p * paramT;
    // const x = x0 + 2 * p * paramT;
    // const y = y0 + 2 * p * paramT ** 2;
    coordinates.push({ x, y });
  }
  return coordinates;
}

draw(ctx, parabola(0, 0, 5, { min: -10, max: 10 }));

效果图:

parabola.PNG

其他常见曲线

上面对每一种曲线都分别写了一个函数,这样做会显得比较繁琐,为了简化这一部分操作,我们可以实现一个高阶函数

codesandbox

// parametric.js
// 根据点来绘制图形
function draw(
  coordinates,
  context,
  { strokeStyle = "black", fillStyle = null, close = false, rule = 'nonzero' } = {}
) {
  context.strokeStyle = strokeStyle;
  context.beginPath();
  context.moveTo(coordinates[0].x, coordinates[0].y);
  for (let i = 1; i < coordinates.length; i++) {
    context.lineTo(coordinates[i].x, coordinates[i].y);
  }
  if (close) context.closePath();
  if (fillStyle) {
    context.fillStyle = fillStyle;
    context.fill(rule);
  }
  context.stroke();
}

function parametric(xFunc, yFunc) {
  return function (start, end, segments = 100, ...args) {
    const coordinates = [];
    for (let i = 0; i <= segments; i++) {
      const s = i / segments;
      const t = start + s * (end - start);

      const x = xFunc(t, ...args);
      const y = yFunc(t, ...args);
      coordinates.push({ x, y });
    }
    return {
      draw: draw.bind(null, coordinates)
    };
  };
}

<!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>conic</title>
    <script src="parametric.js"></script>
  </head>

  <body>
    <canvas width="512" height="512"></canvas>
    <script>
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext("2d");

      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.scale(1, -1);

      // 抛物线
      const parabola = parametric(
        (t, p) => 2 * p * t, // x
        (t, p) => 2 * p * t ** 2 // y
      );

      parabola(-5.5, 5.5, 60, 10).draw(ctx, { strokeStyle: "green" });

      // 螺旋线
      const helical = parametric(
        (t, l) => l * t * Math.cos(t), // x
        (t, l) => l * t * Math.sin(t) // y
      );

      helical(0, 50, 500, 5).draw(ctx, { strokeStyle: "blue" });

      // 星形线
      const star = parametric(
        (t, l) => l * Math.cos(t) ** 3, // x
        (t, l) => l * Math.sin(t) ** 3 // y
      );

      star(0, Math.PI * 2, 80, 120).draw(ctx, { strokeStyle: "red" });
    </script>
  </body>
</html>

效果图:

conic.jpg

贝塞尔曲线

通过起点、终点和少量的控制点,就能够以参数方程来生成复杂的平滑曲线,所以贝塞尔曲线(Bezier Curves)常常用来绘制一些不规则图形。下图就是一个典型的贝塞尔曲线,调整其控制点就可以生成不一样的曲线:

贝塞尔曲线1.PNG

贝塞尔曲线又分为二阶贝塞尔曲线(Quadratic Bezier Curve)和三阶贝塞尔曲线(Qubic Bezier Curve)。

二阶贝塞尔曲线由三个点确定,P0 是起点,P2 是终点,P1 是控制点,t 是参数,如下图所示:

BezierCurve2.gif

二阶贝塞尔曲线参数方程如下所示:

Bt=(1t)2P0+2(1t)tP1+t2P2(0t1)B_t = (1 - t)^2 P_0 + 2(1 - t)t P_1 + t^2 P_2 (0 \leq t \leq 1)

三阶贝塞尔曲线由四个点确定,比二阶贝塞尔曲线多了一个控制点,P0 是起点,P3 是终点,P1、P2 是控制点,t 是参数,如下图所示:

bezier.gif

三阶贝塞尔曲线参数方程如下所示:

Bt=(1t)3P0+3(1t)2tP1+3(1t)2P2+t3P3(0t1)B_t = (1 - t)^3 P_0 + 3(1 - t)^2t P_1 + 3(1-t)^2 P_2 + t^3 P_3 (0 \leq t \leq 1)

代码实现如下:

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>bezier</title>
    <script src="Vector2D.js"></script>
    <script src="parametric.js"></script>
  </head>

  <body>
    <canvas width="512" height="512"></canvas>
    <script>
      const canvas = document.querySelector("canvas");
      const ctx = canvas.getContext("2d");

      ctx.translate(canvas.width / 2, canvas.height / 2);
      ctx.scale(1, -1);

      // 二阶贝塞尔曲线
      const quadricBezier = parametric(
        (t, [{x: x0}, {x: x1}, {x: x2}]) => (1 - t) ** 2 * x0 + 2 * t * (1 - t) * x1 + t ** 2 * x2,
        (t, [{y: y0}, {y: y1}, {y: y2}]) => (1 - t) ** 2 * y0 + 2 * t * (1 - t) * y1 + t ** 2 * y2,
      );

      const p0 = new Vector2D(0, 0);
      const p1 = new Vector2D(100, 200);
      const p2 = new Vector2D(200, -50);
      quadricBezier(0, 1, 100, [p0, p1, p2]).draw(ctx, {strokeStyle: 'red'});

      // 三阶贝塞尔曲线
      const cubicBezier = parametric(
        (t, [{x: x0}, {x: x1}, {x: x2}, {x: x3}]) => (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}]) => (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(-10, 100);
      const P2 = new Vector2D(150, 100);
      const P3 = new Vector2D(200, -50);
      cubicBezier(0, 1, 100, [P0, P1, P2, P3]).draw(ctx, {strokeStyle: 'blue'});
    </script>
  </body>
</html>

效果图:

bezier.PNG

注:Canvas2D 是有提供绘制 API 的,不过 WebGL 并没有提供画圆的 API,所以到 WebGL 里面这个函数就有用了。

Canvas2D API 实现:

codesandbox

// 画三阶贝塞尔曲线
ctx.beginPath();
// 线的颜色, 线的粗细
ctx.strokeStyle = "blue";
ctx.lineWidth = 4;
// 起始点
ctx.moveTo(0, 0);
ctx.bezierCurveTo(-10, 100, 150, 100, 200, -50);

ctx.stroke();