【canvas】用矩阵实现无限画布&等比缩放

1,397 阅读15分钟

🧑‍💻 写在开头

点赞 + 收藏 === 学会 🤣🤣🤣

本篇是图形学专栏的开篇第二篇,目标是将上一篇中通过坐标转换实现的无限画布改为使用矩阵来做计算实现坐标转换,并实现图形的等比缩放,以此来巩固和分享一些图形学的知识。本专栏计划实现一个canvas博客,将平时写的demo项目展示在无限画布中。感兴趣的话大家可以收藏关注一下,这是件长期且有趣的事情。

🥑 你能学到什么?

希望你在阅读本文后不会觉得浪费了时间。如果你跟着学习,你将会掌握:

  • Canvas中矩阵相关api的使用
  • 图形学中的简单矩阵原理
  • 等比缩放原理
  • 如何实现等比缩放

专栏预告

  1. 实现无限画布、鼠标为中心缩放、标尺、移动画布 ✅
  2. 用矩阵实现等比缩放 ✅
  3. 节点树的实现、创建维护、设计绘制节点
  4. 实现编辑操作:等比缩放、自动布局、网格、吸附、对齐至网格等
  5. 更换渲染引擎
  6. 使用 WebGL 绘制 3D 图形
  7. 其他后续内容待定

效果

设置缩放中心,根据缩放中心缩放,代码仓库  分支uniformScale

2024-08-25 16.34.38.gif

一、矩阵原理

1.仿射变换

仿射变换(Affine Transformation)是一种二维或三维空间中的几何变换,它保持直线、平行线和比例关系。换句话说,经过仿射变换后,原本为直线的图形仍然是直线,平行线依然平行,但角度和距离可能会发生变化。

仿射变换包含以下几种基本操作的组合:

  • 平移:将图形在空间中平移一定距离。
  • 旋转:围绕某个点旋转一定角度。
  • 缩放:沿某个方向放大或缩小图形。
  • 斜切:将图形沿某个方向进行拉伸,使得角度发生变化。

2.仿射矩阵

先记住这个仿射矩阵,他的默认值很好理解,正常情况下,缩放肯定是1,没有倾斜和位移,所以是0,至于为什么他们在矩阵中的位置是这样的,暂时先不用理解,文章后面会解释

3.矩阵和矢量的运算

矢量:矢量就是由多个分量组成的对象,比如顶点的坐标(0.0, 0.5, 1.0)

矢量&矩阵的运算:矩阵和矢量的乘法可以写成等式3.4的形式(虽然乘号x 通常被忽略不写。可见,将矩阵 (中间)和矢量 (右边)相乘, 就获得了一个新的矢量。注意矩阵的乘法不符合交换律,也就是说,AxBBxA并不相等。

上式中的这个矩阵具有3行3列,因此又被称为3×3矩阵。矩阵右侧是一个由x、y、z组成的矢量 (为了与矢量相乘,矢量被写成列的形式,其仍然表示点的坐标)。矢量具有3个分量,因此被称为三维矢量。

矩阵与矢量相乘得到的新矢量,其三个分量为x、y、z,其值如等式3.5所示。注意,只有在矩阵的列数与矢量的行数相等时,才可以将两者相乘。

4.旋转

旋转数学表达式

旋转比平移稍微复杂一些,因为描述一个旋转本身就比描述一个平移复杂。为了描述一个旋转,你必须指明:

  • 旋转轴(图形将围绕旋转轴旋转)。2D中一般都是以z轴旋转
  • 旋转方向(方向:顺时针或逆时针)。
  • 旋转角度(图形旋转经过的角度)。

在本节中我们这样来表达旋转操作:绕 Z 轴,逆时针旋转了 β 角度。这种表达方式同样适用于绕 X 轴和 Y 轴的情况。

在旋转中,关于“逆时针”的约定是:如果 β 是正值,观察者在 Z 轴正半轴某处,视线沿着 Z 轴负方向进行观察,那么看到的物体就是逆时针旋转的,如图 3.21 所示。这种情况又可称作正旋转positive rotation)。我们也可以使用右手来确认旋转方向(正如右手坐标系一样):右手握拳,大拇指伸直并使其指向旋转轴的正方向,那么右手其余几个手指就指明了旋转的方向。因此正旋转又可称为右手法则旋转right-hand-rule rotation)。

设点 p(x, y, z) 旋转 β 角度之后变为了点 p'(x', y', z'): 首先旋转是绕 Z 轴进行的,所以 z 坐标不会变,可以直接忽略;然后,x 坐标和 y 坐标的情况有一些复杂。

3.22 计算围绕 z 轴旋转

在图 3.22 中,r 是从原点到点 p 的距离,而 αX 轴旋转到点 p 的角度。用这两个变量计算出点 p 的坐标,如等式 3.2 所示。

等式 3.2

x = r cos α
y = r sin α

类似地,你可以使用 r、α、β 来表示点 p' 的坐标:

x' = r cos (α + β)
y' = r sin (α + β)

利用三角函数两角和公式,可得:

sin(a ± b) = sin a cos b ± cos a sin b
cos(a ± b) = cos a cos b ∓ sin a sin b

带入算式

x' = r (cos α cos β - sin α sin β)
y' = r (sin α cos β + cos α sin β)

最后,将等式 3.2 代入上述式,消除 rα ,可得等式 3.3

等式 3.3

x' = x cos β - y sin β
y' = x sin β + y cos β
z' = z

矩阵是如何代替数学表达式的?

等式 3.6

x' = x cos β - y sin β
y' = x sin β + y cos β
z' = z

与比较关于 x' 的表达式进行比较:

x' = ax + by + cz
x' = x cos β - y sin β

这样的话,如果设 a = cos β,b = -sin β,c = 0,那么这两个等式就完全相同了。再来看一下 y'

y' = dx + ey + fz
y' = x sin β + y cos β

这样的话,设 d = sin β,e = cos β,f = 0,两个等式也就完全相同了。最后的关于 z' 的等式更简单,设 g = 0,h = 0,i = 1 即可。

这样就能得到旋转矩阵

这个矩阵就被称为变换矩阵(transformation matrix),因为它将右侧的矢量 (x, y, z) “变换” 为了左侧的矢量 (x', y', z')。上面这个变换矩阵进行的变换是一次旋转,所以这个矩阵又可以被称为旋转矩阵(rotation matrix)。

5.平移

平移数学表达式

考虑一下,为了平移一个三角形,你需要对它的每一个顶点做怎样的操作?答案是,你需要对顶点坐标的每个分量(xy),加上三角形在对应轴(如 X 轴或 Y 轴)上平移的距离。比如,将点 p(x, y, z) 平移到 p'(x', y', z'),在 X 轴、Y 轴、Z 轴三个方向上平移的距离分别为 Tx、Ty、Tz,其中 Tz 为 0,如图 3.19 所示。 那么在坐标的对应分量上,直接加上这些 T 值,就可以确定 p' 的坐标了,如等式 3.1 所示。 等式 3.1

x' = x + Tx
y' = y + Ty
z' = z + Tz

平移矩阵推导

显然,如果我们使用变换矩阵来表示旋转变换,我们就也应该使用它来表示其他变换,比如平移。比较一下等式 3.5 和等式 3.1(平移的数学表达式),如下所示:

这里第二个等式的右侧有常量项 Tx,第一个等式中没有,这意味着我们无法通过使用一个 3×3 的矩阵来表示平移。为了解决这个问题,我们可以使用一个 4×4 的矩阵,以及具有第 4 个分量(通常被设为 1.0)的矢量。也就是说,我们假设点 p 的坐标为 (x, y, z, 1),平移之后的点 p'的坐标为 (x', y', z', 1),如等式 3.8 所示:

根据最后一个式子 1 = mx + ny + oz + p,很容易求算出系数 m = 0,n = 0,o = 0,p = 1。这些方程都有常数项 d、h、lp,看上去比较适合等式 3.1(因为等式 3.1 中也有常数项)。等式 3.1(平移)如下所示,我们将它与等式 3.9 进行比较:

x' = x + Tx
y' = y + Ty
z' = z + Tz

比较 x',可知 a = 1,b = 0,c = 0,d = Tx;类似地,比较 y',可知 e = 0,f = 1,g = 0,h = Ty;比较 z',可知 i = 0,j = 0,k = 1,l = Tx。这样,你就可以写出表示平移的矩阵,又称为平移矩阵translation matrix),如等式 3.10 所示:

为什么要用4×4的矩阵的使用?

前面我们已经谈过了,平移矩阵无法用3×3的矩阵表示,因为常量的原因。

在“先旋转再平移〞的情形下,我们需要将两个矩阵组合起来然而旋转矩阵(3x3矩阵)与平移矩阵(4x4矩阵)的阶数不同。我不能把两个阶数不一样的矩阵组合起来,所以得使用某种手段,使这两个矩阵的阶数一致。

4×4 的旋转矩阵

将旋转矩阵从一个 3×3 矩阵转变为一个 4×4 矩阵,只需要将方程 3.3 和方程 3.9 比较一下即可。

例如,当你通过比较 x’ = xcosB - ysinBx' = ax + by + cz + d 时, 可 知 a = cosB,b=-sinB,c=0, d=0。以此类推,求得y’z' 等式中的系数,最终得到 4 ×4 的旋转矩阵,如等式3. 11 所示:

缩放

缩放变换矩阵 。 仍然假设最初的点又,经过缩放操作之后变成了p’

假设在三个方向X轴,Y轴,Z轴的缩放因子Sx,Sy,Sz不相关,那么有:

对比可得

复合变换

先平移后旋转

(平移操作)中的坐标方程式。

等式 4.1

“平移”后的坐标 = <平移矩阵> × <原始坐标>

然后对 <平移后的坐标> 进行旋转。

等式 4.2

<“平移后旋转”后的坐标> = <旋转矩阵> × <平移后的坐标>

当然你也可以分步计算这两个等式,但更好的方法是,将等式 4.1 代入到等式 4.2 中,把两个等式组合起来:

<“平移后旋转”后的坐标> = <旋转矩阵> × (<平移矩阵> × <原始坐标>)
<旋转矩阵> × (<平移矩阵> × <原始坐标>)

等于(注意括号的位置)

(<旋转矩阵> × <平移矩阵>) × <原始坐标>

(<旋转矩阵> × <平移矩阵>)即是我们这次平移后旋转的变换矩形

先旋转后平移

我们来回顾一下矩阵的乘法,行*列的结果相加

A * B 的结果并不一定等于B * A 。

显然矩阵存在不相同的情况

二、canvas中矩阵相关的api

在 Canvas 中,矩阵相关的 API 主要用于变换绘图内容(如平移、旋转、缩放、倾斜等)。这些 API 通过修改当前的变换矩阵来影响绘图操作。以下是 Canvas 变换矩阵相关的常用 API 的详解。

1. transform(a, b, c, d, e, f)

transform 方法直接应用一个新的变换矩阵,该矩阵与当前变换矩阵相乘。矩阵的形式如下:一个仿射矩阵

参数解释:

  • ad:控制 x 和 y 方向的缩放。
  • bc:控制倾斜(扭曲)。
  • ef:控制 x 和 y 方向的平移。

使用示例:

ctx.transform(1, 0, 0, 1, 100, 100); // 在 x 方向平移 100,y 方向平移 100

2. setTransform(a, b, c, d, e, f)

setTransform 方法重置当前变换矩阵为给定的矩阵,而不是在现有矩阵基础上进行相乘。它和 transform 的区别在于,它直接替换当前的变换矩阵。

使用示例:

ctx.setTransform(1, 0, 0, 1, 0, 0); // 重置为单位矩阵
ctx.setTransform(2, 0, 0, 2, 50, 50); // 缩放 2 倍并平移

3. resetTransform()

resetTransform 方法将当前的变换矩阵重置为默认状态,即单位矩阵:

这相当于清除之前的所有变换操作。

使用示例:

ctx.resetTransform(); // 清除所有变换

4. scale(x, y)

scale 方法对绘图进行缩放变换。参数 xy 分别表示在 x 和 y 方向的缩放比例。

使用示例:

ctx.scale(2, 2); // 在 x 和 y 方向同时缩放 2 倍

5. rotate(angle)

rotate 方法根据指定的角度进行旋转。旋转是围绕原点(0, 0)进行的,单位是弧度。

使用示例:

ctx.rotate(Math.PI / 4); // 顺时针旋转 45 度(π/4 弧度)

6. translate(x, y)

translate 方法对绘图内容进行平移。参数 xy 分别表示在 x 和 y 方向的平移量。

使用示例:

ctx.translate(50, 100); // 在 x 方向平移 50,y 方向平移 100

7. save()restore()

save() 方法保存当前的绘图状态(包括变换矩阵、剪切区域、样式等)。restore() 方法则恢复到上一次保存的状态。

这两个方法通常与矩阵变换结合使用,以在执行特定变换后恢复到之前的状态。

使用示例:

ctx.save(); // 保存当前状态
ctx.translate(50, 50);
ctx.rotate(Math.PI / 4);
ctx.restore(); // 恢复到保存前的状态

8. 矩阵的综合应用

通过结合 translaterotatescale 等方法,可以实现复杂的图形变换。比如,可以将对象先平移到特定位置,再旋转,然后进行缩放:

ctx.save();
ctx.translate(100, 100); // 平移到 (100, 100)
ctx.rotate(Math.PI / 4); // 旋转 45 度
ctx.scale(2, 2); // 缩放 2 倍
ctx.fillRect(-25, -25, 50, 50); // 在变换后的坐标系中绘制矩形
ctx.restore(); // 恢复初始状态

三、用矩阵实现无限画布

现在我们更改之前实现的无限画布代码,如下: 之前我们处理画布缩放用的是

// 保存当前状态并应用缩放和平移
ctx.translate(offset.x, offset.y);
ctx.scale(scale, scale);

现在改为

ctx.setTransform(
  scale, // a
  0, // b
  0, // c
  scale, // d
  offset.x, // e (平移 x 轴)
  offset.y // f (平移 y 轴)
);

我们更改绘制网格的代码如下

 const drawScene = (
    ctx: CanvasRenderingContext2D,
    canvas: HTMLCanvasElement
  ) => {
    // 清空画布
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    // 保存当前状态并应用缩放和平移
    ctx.save();

    ctx.setTransform(
      scale, // a
      0, // b
      0, // c
      scale, // d
      offset.x, // e (平移 x 轴)
      offset.y // f (平移 y 轴)
    );

    // 绘制网格
    const step = 25; // 网格间隔
    ctx.strokeStyle = "#ddd";
    ctx.lineWidth = 1 / scale;

    // 获取当前视口范围
    const viewportWidth = canvas.width / scale;
    const viewportHeight = canvas.height / scale;

    const startX = Math.floor(-offset.x / scale / step) * step;
    const startY = Math.floor(-offset.y / scale / step) * step;
    const endX = startX + viewportWidth + step;
    const endY = startY + viewportHeight + step;

    // 绘制水平和垂直线
    for (let x = startX; x <= endX; x += step) {
      ctx.beginPath();
      ctx.moveTo(x, startY);
      ctx.lineTo(x, endY);
      ctx.stroke();
    }

    for (let y = startY; y <= endY; y += step) {
      ctx.beginPath();
      ctx.moveTo(startX, y);
      ctx.lineTo(endX, y);
      ctx.stroke();
    }

    // 等比缩放计算
    const uniformScaleMat = uniformScale({ ratio, scaleCenter });

    drawRect(ctx, {
      transform: mat3.mul(
        mat3.create(),
        mat3.fromTranslation(mat3.create(), [100, 100]),
        uniformScaleMat
      ),
    });

    ctx.restore(); // 恢复初始状态以确保其他元素不受影响

    // 绘制标尺
    drawRulers(ctx, canvas);
  };

四、等比缩放原理

等比缩放原理其实就是根据缩放中心,缩放图形,首先需要计算出缩放中心,计算三个矩阵,

  • 一个平移矩阵移动到原点,因为缩放是缩放的坐标轴;
  • 一个缩放矩阵,根据缩放中心缩放图形
  • 一个平移矩阵,将矩阵平移到原点
// 3. 平移矩阵,将图形平移到apivot
const toPivotMat = mat3.fromTranslation(mat3.create(), apivot);
// 4. 缩放矩阵,将图形根据缩放比率缩放
const scaleMat = mat3.fromScaling(mat3.create(), [ratio, ratio]);
// 5. 平移矩阵,将矩阵平移到原点
const fromPivotMat = mat3.invert(mat3.create(), toPivotMat);

// 组合等比缩放矩阵
const newTransMat = mat3.create();
mat3.mul(newTransMat, fromPivotMat, newTransMat); // 平移到原点位置
mat3.mul(newTransMat, scaleMat, newTransMat); // 缩放
mat3.mul(newTransMat, toPivotMat, newTransMat); // 平移到原位置

五、用矩阵实现等比缩放

计算旋转中心

一般就是8个缩放中心,可以用上中下表示,也可以用东南西北表示

用向量表示一条边,然后缩放计算出边的中点

vec2.add(apivot, ap3, ap0);
vec2.scale(apivot, apivot, 0.5);

完整代码

const { maxPos = { x: 0, y: 0, w: 100, h: 100 }, transform = mat3.fromTranslation(mat3.create(), [0, 0]), scaleCenter = 'CC', ratio = 1 } = options;
// 1. 获取最大包围盒的位置信息
// 2. 计算缩放中心
const ap0 = vec2.fromValues(0, 0);
const ap1 = vec2.fromValues(maxPos.w, 0);
const ap2 = vec2.fromValues(maxPos.w, maxPos.h);
const ap3 = vec2.fromValues(0, maxPos.h);

vec2.transformMat3(ap0, ap0, transform);
vec2.transformMat3(ap1, ap1, transform);
vec2.transformMat3(ap2, ap2, transform);
vec2.transformMat3(ap3, ap3, transform);

const apivot = vec2.create();
switch (scaleCenter) {
  case 'LT': vec2.copy(apivot, ap0); break;
  case 'RT': vec2.copy(apivot, ap1); break;
  case 'RB': vec2.copy(apivot, ap2); break;
  case 'LB': vec2.copy(apivot, ap3); break;
  // 计算中点
  case 'TC':
    vec2.add(apivot, ap0, ap1);
    vec2.scale(apivot, apivot, 0.5);
    break;
  case 'RC':
    vec2.add(apivot, ap1, ap2);
    vec2.scale(apivot, apivot, 0.5);
    break;
  case 'BC':
    vec2.add(apivot, ap2, ap3);
    vec2.scale(apivot, apivot, 0.5);
    break;
  case 'LC':
    vec2.add(apivot, ap3, ap0);
    vec2.scale(apivot, apivot, 0.5);
    break;
  case 'CC':
    vec2.add(apivot, ap0, ap2);
    vec2.scale(apivot, apivot, 0.5);
    break;
}

矩阵运算

// 组合等比缩放矩阵
const newTransMat = mat3.create();
mat3.mul(newTransMat, fromPivotMat, newTransMat); // 平移到原点位置
mat3.mul(newTransMat, scaleMat, newTransMat); // 缩放
mat3.mul(newTransMat, toPivotMat, newTransMat); // 平移到原位置
return newTransMat;

完整代码

import { mat3, vec2 } from 'gl-matrix';

export type DirectKey =
  | 'LT' // 左上
  | 'RT' // 右上
  | 'RB' // 右下
  | 'LB' // 左下
  | 'TC' // 上
  | 'BC' // 下
  | 'LC' // 左
  | 'RC' // 右
  | 'CC' // 中

export interface IUniformScale {
  maxPos?: {
    x: number,
    y: number,
    w: number,
    h: number,
  }
  transform?: mat3;
  // 缩放中心
  scaleCenter?: DirectKey;
  // 缩放比率
  ratio?: number;
}

export const uniformScale = (options: IUniformScale = {}) => {
  const { maxPos = { x: 0, y: 0, w: 100, h: 100 }, transform = mat3.fromTranslation(mat3.create(), [0, 0]), scaleCenter = 'CC', ratio = 1 } = options;
  // 1. 获取最大包围盒的位置信息
  // 2. 计算缩放中心
  const ap0 = vec2.fromValues(0, 0);
  const ap1 = vec2.fromValues(maxPos.w, 0);
  const ap2 = vec2.fromValues(maxPos.w, maxPos.h);
  const ap3 = vec2.fromValues(0, maxPos.h);

  vec2.transformMat3(ap0, ap0, transform);
  vec2.transformMat3(ap1, ap1, transform);
  vec2.transformMat3(ap2, ap2, transform);
  vec2.transformMat3(ap3, ap3, transform);

  const apivot = vec2.create();
  switch (scaleCenter) {
    case 'LT': vec2.copy(apivot, ap0); break;
    case 'RT': vec2.copy(apivot, ap1); break;
    case 'RB': vec2.copy(apivot, ap2); break;
    case 'LB': vec2.copy(apivot, ap3); break;
    // 计算中点
    case 'TC':
      vec2.add(apivot, ap0, ap1);
      vec2.scale(apivot, apivot, 0.5);
      break;
    case 'RC':
      vec2.add(apivot, ap1, ap2);
      vec2.scale(apivot, apivot, 0.5);
      break;
    case 'BC':
      vec2.add(apivot, ap2, ap3);
      vec2.scale(apivot, apivot, 0.5);
      break;
    case 'LC':
      vec2.add(apivot, ap3, ap0);
      vec2.scale(apivot, apivot, 0.5);
      break;
    case 'CC':
      vec2.add(apivot, ap0, ap2);
      vec2.scale(apivot, apivot, 0.5);
      break;
  }

  // 3. 平移矩阵,将图形平移到apivot
  const toPivotMat = mat3.fromTranslation(mat3.create(), apivot);
  // 4. 缩放矩阵,将图形根据缩放比率缩放
  const scaleMat = mat3.fromScaling(mat3.create(), [ratio, ratio]);
  // 5. 平移矩阵,将矩阵平移到原点
  const fromPivotMat = mat3.invert(mat3.create(), toPivotMat);

  // 组合等比缩放矩阵
  const newTransMat = mat3.create();
  mat3.mul(newTransMat, fromPivotMat, newTransMat); // 平移到原点位置
  mat3.mul(newTransMat, scaleMat, newTransMat); // 缩放
  mat3.mul(newTransMat, toPivotMat, newTransMat); // 平移到原位置
  return newTransMat;
};