Canvas 矩阵数学

612 阅读7分钟

本文所有知识点均来源于《图形渲染实战 2D架构设计与实现》这本书,作者关于矩阵数学知识点的讲解是我目前看到的最全面、最容易理解的。这里只是抛砖引玉,感兴趣请阅读书籍

定义

矩阵(Matrix)是m个行(Row)和n个列(Column)构成的数组。m行n列的矩阵被称为m×n矩阵,当m=n时的矩阵称为方矩阵(Square Matrix)。

在游戏或计算机图形学中,2×2矩阵(二维旋转矩阵),3×3矩阵(可以表示二维仿射变换矩阵或三维旋转矩阵)和4×4矩阵(可以表示三维仿射变换矩阵或投影矩阵)是最常见的。由于4×4矩阵主要用于三维环境中,因此主要关注3×3仿射变换矩阵。

\

矩阵乘法

Canvas2D中二维3×3仿射变换矩阵及其矩阵乘法具有如下表现形式

上述两个3×3仿射变换矩阵相乘的结果如下:

用代码实现:

  /**
   * 矩阵相乘
   * @param left
   * @param right
   * @param result
   * @returns
   */
  public static multiply(
    left: mat2d,
    right: mat2d,
    result: mat2d | null = null
  ): mat2d {
    if (result === null) result = new mat2d();

    let a0: number = left.values[0];
    let a1: number = left.values[1];
    let a2: number = left.values[2];
    let a3: number = left.values[3];
    let a4: number = left.values[4];
    let a5: number = left.values[5];

    let b0: number = right.values[0];
    let b1: number = right.values[1];
    let b2: number = right.values[2];
    let b3: number = right.values[3];
    let b4: number = right.values[4];
    let b5: number = right.values[5];

    result.values[0] = a0 * b0 + a2 * b1;
    result.values[1] = a1 * b0 + a3 * b1;
    result.values[2] = a0 * b2 + a2 * b3;
    result.values[3] = a1 * b2 + a3 * b3;
    result.values[4] = a0 * b4 + a2 * b5 + a4;
    result.values[5] = a1 * b4 + a3 * b5 + a5;

    return result;
  }

单位矩阵

在标量乘法中,任何数乘以1,不会改变该标量的值。同样地,在矩阵中也存在着这样一种矩阵,称为单位矩阵,一般使用大写粗体I来标记单位矩阵。任何矩阵和单位矩阵I相乘,矩阵保持不变,其公式如下:

单位矩阵I除了对角线上的元素为1外,其他元素都为0,如下来看一下单位矩阵的实现代码,具体如下

  /**
   * 单位矩阵
   */
  public identity(): void {
    this.values[0] = 1.0;
    this.values[1] = 0.0;
    this.values[2] = 0.0;
    this.values[3] = 1.0;
    this.values[4] = 0.0;
    this.values[5] = 0.0;
  }

矩阵求逆

  • 方矩阵M的逆矩阵可以被记作M-1 ,也是一个方矩阵。
  • 方矩阵M的逆的逆等于原矩阵M,即:(M-1)-1=M
  • 矩阵乘积的逆等于每个矩阵的逆的相反顺序的乘积,例如,(A×B×C)-1=C-1×B-1 ×A-1,可以扩展到n个矩阵的情况下

由于逆矩阵的算法比较复杂,此处就不再展开

\

用矩阵变换向量

矩阵 M 与列向量 v 相乘的结果仍旧是个列向量,具体过程如下:

平移矩阵及其逆矩阵

下面首先来看一下Canvas2D中的translate平移方法的矩阵实现方式,以及该平移矩阵作用于某个向量上的操作,具体效果如下:

在上述的公式中,T表示平移矩阵,v表示要变换的向量,而v’表示变换后的向量。我们先来实现平移矩阵:

 /**
   * 返回一个平移矩阵
   * @param tx
   * @param ty
   * @param result
   * @returns
   */
  public static makeTranslation(
    tx: number,
    ty: number,
    result: mat2d | null = null
  ): mat2d {
    if (result === null) result = new mat2d();
    result.values[0] = 1;
    result.values[1] = 0;
    result.values[2] = 0;
    result.values[3] = 1;

    result.values[4] = tx;
    result.values[5] = ty;
    return result;
  }

平移矩阵的逆矩阵就是:

\

缩放矩阵及其逆矩阵

下面看一下缩放矩阵,其表达方式如下:

根据上述公式来实现一个缩放矩阵。具体代码如下:

/**
   * 返回一个缩放矩阵
   * @param sx
   * @param sy
   * @param result
   * @returns
   */
  public static makeScale(
    sx: number,
    sy: number,
    result: mat2d | null = null
  ): mat2d {
    if (Math2D.isEquals(sx, 0) || Math2D.isEquals(sy, 0)) {
      alert(" x轴或y轴缩放系数为0 ");
      throw new Error(" x轴或y轴缩放系数为0 ");
    }

    if (result === null) result = new mat2d();
    result.values[0] = sx;
    result.values[1] = 0;
    result.values[2] = 0;
    result.values[3] = sy;
    result.values[4] = 0;
    result.values[5] = 0;
    return result;
  }

可以发现缩放矩阵的逆矩阵就是

旋转矩阵及其逆矩阵

相对于平移矩阵和缩放矩阵,旋转矩阵比较复杂,下面来看一下,在直角平面坐标系下,将某个点P绕原点O旋转a弧度的变换矩阵是如何推导出来的,参考图7.1所示的效果。

根据图7.1来看一下已知条件:

● 以O为原点,有两个坐标系:XY坐标系和X'Y’坐标系,这两个坐标系之间的关系是XY坐标系顺时针旋转a弧度后变成X'Y’坐标系,a弧度是作为已知条件。

● 已知点P在X'Y’坐标系中的坐标为[x' , y' ]。

● 已知,现在问题是:求点P在XY坐标系中的坐标[x, y]是多少?

旋转矩阵求逆,转置(Transpose)矩阵方式求旋转矩阵的逆

如上所示,我们会发现转置矩阵就是行列交换,即等号左侧矩阵的第一行变为等号右侧矩阵的第一列,等号左侧矩阵的第二行变为等号右侧矩阵的第二列

/**
   * 旋转矩阵
   * @param radians 
   * @param result 
   * @returns 
   */
  public static makeRotation(
    radians: number,
    result: mat2d | null = null
  ): mat2d {
    if (result === null) result = new mat2d();
    let s: number = Math.sin(radians),
      c: number = Math.cos(radians);
    result.values[0] = c;
    result.values[1] = s;
    result.values[2] = -s;
    result.values[3] = c;
    result.values[4] = 0;
    result.values[5] = 0;
    return result;
  }
  /**
   * 旋转矩阵的逆
   * @returns 
   */
  public onlyRotationMatrixInvert(): mat2d {
    let s: number = this.values[1];
    this.values[1] = this.values[2];
    this.values[2] = s;
    return this;
  }

两个单位向量构建旋转矩阵

对于单位向量a和b,可以得到如下两个重要的公式:

● sin ( a ) = || a⊗ b|| / (|| a || * || b ||) = || a⊗ b|| = a . x * b . y - b . x * a . y

● cos ( a ) = a.b / (|| a || * || b ||) = a · b = a · x * b · x + a · y * b · y

\

那么就可以从两个单位向量a和b得到一个旋转矩阵:

接下来看一下实现代码,首先在vec2类中增加sinAngle和cosAngle这两个方法。

 /**
   * 求cos(θ)的值 = a 点乘 b / ( ||a|| * ||b|| )
   * @param a
   * @param b
   * @param norm
   * @returns
   */
  public static cosAngle(a: Vec2, b: Vec2, norm: boolean = false): number {
    if (norm === true) {
      a.normalize();
      b.normalize();
    }
    return Vec2.dotProduct(a, b);
  }
  /**
   * 求sin(θ)的值 = a 叉乘 b / ( ||a|| * ||b|| )
   * @param a
   * @param b
   * @param norm
   * @returns
   */
  public static sinAngle(a: Vec2, b: Vec2, norm: boolean = false): number {
    if (norm === true) {
      a.normalize();
      b.normalize();
    }
    return a.x * b.y - b.x * a.y;
  }

\

仿射变换

前面一直提到使用的是仿射变换(Affine Transformation)这个词,现在是时候来了解一下仿射变换的相关内容,如图7.2所示。如图7.2所示是一直使用的二维仿射变换矩阵的一般形式这个矩阵中的每个元素都有特殊的含义:

部分可以对图形进行缩放和旋转等线性变换。

部分可以对图形进行平移变换。

\

矩阵堆栈

在前面章节中已经获得了平移矩阵、缩放矩阵和旋转矩阵,接下来实现一个矩阵堆栈类,用来模拟Canvas2D中的translate、scale和rotate操作,这样就能够清晰地了解Canvas2D中矩阵变换实现的底层细节。

MatrixStack

import mat2d from "./math/mat2d";
import Math2D from "./math/math2d";
import vec2 from "./math/vec2";

export default class MatrixStack {
  private _mats: mat2d[];
  public constructor() {
    this._mats = [];
    this._mats.push(new mat2d());
  }
  // 获取栈顶
  public get matrix(): mat2d {
    if (this._mats.length === 0) {
      alert(" 矩阵堆栈为空 ");
      throw new Error(" 矩阵堆栈为空 ");
    }

    return this._mats[this._mats.length - 1];
  }
  // 将当前matrix push栈
  public pushMatrix(): void {
    let mat: mat2d = mat2d.copy(this.matrix);
    this._mats.push(mat);
  }
  // 获取栈顶
  public popMatrix(): void {
    if (this._mats.length === 0) {
      alert(" 矩阵堆栈为空 ");
      return;
    }
    this._mats.pop();
  }
  // 设置为单位矩阵
  public loadIdentity(): void {
    this.matrix.identity();
  }

  // 覆盖当前matrix
  public loadMatrix(mat: mat2d): void {
    mat2d.copy(mat, this.matrix);
  }
  // 矩阵相乘
  // 更新栈顶元素,累计变换效果
  public multMatrix(mat: mat2d): void {
    mat2d.multiply(this.matrix, mat, this.matrix);
  }

  // 平移
  public translate(x: number = 0, y: number = 0): void {
    let mat: mat2d = mat2d.makeTranslation(x, y);
    this.multMatrix(mat);
  }
  // 旋转
  public rotate(degree: number = 0, isRadian: boolean = false): void {
    if (isRadian === false) {
      degree = Math2D.toRadian(degree);
    }
    let mat: mat2d = mat2d.makeRotation(degree);
    this.multMatrix(mat);
  }
  // 从两个向量构造旋转矩阵
  public rotateFrom(v1: vec2, v2: vec2, norm: boolean = false): void {
    let mat: mat2d = mat2d.makeRotationFromVectors(v1, v2, norm);
    this.multMatrix(mat);
  }
  // 缩放
  public scale(x: number = 1.0, y: number = 1.0): void {
    let mat: mat2d = mat2d.makeScale(x, y);
    this.multMatrix(mat);
  }
  // 求逆
  public invert(): mat2d {
    let ret: mat2d = new mat2d();
    if (mat2d.invert(this.matrix, ret) === false) {
      alert(" 堆栈顶部矩阵为奇异矩阵,无法求逆 ");
      throw new Error(" 堆栈顶部矩阵为奇异矩阵,无法求逆 ");
    }
    return ret;
  }
}

setTransform 与 transform

  • setTransform方法的作用和MatrixStack的loadMatrix一致,是将参数矩阵中各个元素的值直接复制到CanvasRenderingContext2D上下文渲染对像所持有的矩阵堆栈的栈顶矩阵中。
  • transform方法的作用和MatrixStack的multMatrix一致,是将当前栈顶矩阵乘以参数矩阵,因此会累积上一次的变换。

\