【零基础学WebGL】矩阵变换

1,526 阅读7分钟

矩阵变量

GLSL ES语言中,通过关键字mat2、mat3、mat4声明矩阵,并提供内置函数mat2()、mat3()、mat4()构造矩阵。

支持三种方式初始化矩阵:

  1. 从第一列开始,逐列指定元素的值。这里与,高数中习惯按照逐行书写矩阵不同。
  2. 只传入一个值,作为对角矩阵的对角线值;
  3. 传入多个向量;

如下代码初始化2x2单位矩阵:

// 按照列优先,逐列指定每个元素
mat2 aMat = mat2(
  1.0, 0,  // 第一列
  0,   1.0 // 第二列
);

// 构造对角矩阵,值是对角线的值
mat bMat = mat2(1.0);

vec2 aVec = vec2(1.0, 0);
vec2 bVec = vec2(0, 1.0);
// 使用向量构建矩阵
mat cMat = mat2(aVec, bVec);

可以向二维数组一样,使用索引下标访问元素。

mat2 aMat = mat2(
  1.0, 0,  // 第一列
  0,   1.0 // 第二列
);

vec2 aVec = mat2[0]; // 访问第一列的向量,[1.0, 0]
vec2 bVec = mat2[0][1]; // 访问第一列第二行,0
mat2[1] = vec2(1.0, 0); // 给第二列重新赋值

矩阵运算

矩阵与数字加减

矩阵与数字的加减,也就是矩阵的每个元素与数字进行加减。示例如下:

mat2 aMat = mat2(
  1.0, 0,  // 第一列
  0,   1.0 // 第二列
);

// 矩阵aMat加1之后, 得到新的矩阵
// [
//   2.0, 1,  // 第一列
//   1,   2.0 // 第二列
// ]
//
mat2 bMat = aMat + 1; 

矩阵与数字相乘

与矩阵与数字加减类似,矩阵与数字相乘,就是矩阵每个元素与数字相乘。示例如下:

mat2 aMat = mat2(
  1.0, 0,  // 第一列
  0,   1.0 // 第二列
);

// 矩阵aMat乘以2之后, 得到新的矩阵
// [
//   2.0, 0,  // 第一列
//   0,   2.0 // 第二列
// ]
//
mat2 bMat = aMat*2; 

矩阵与矩阵相乘

回顾下线性代数关于矩阵相乘的知识。假设有A、B两个矩阵,有如下定义:

  1. 相乘的前提条件是,A矩阵的列数需等于B矩阵的行数;
  2. 矩阵相乘不遵守交换律,也就是说A⋅B ≠ B⋅A;
  3. 矩阵相乘遵守结合律,A.B.C = A.(B.C);
  4. A⋅B 也是一个矩阵,乘积矩阵的行数等于A矩阵,结果矩阵的列数等于B矩阵;
  5. 乘积矩阵的第n行,第m列的结果等于,A矩阵的第n行与B矩阵的第m列对应值的乘积之和。

具象地举例,A矩阵是3行3列,B矩阵是3行3列,乘积矩阵的第2行第2列的计算过程如下:

c5的计算结果 c5 = a4b2 + a5b5 + a6*b8c5 = a4b2 + a5b5 + a6*b8

下面用GLSL表达如上矩阵相乘:

mat2 aMat = mat2(
  a1, a4, a7,
  a2, a5, a8,
  a3, a6, a9
);

mat2 bMat = mat2(
  b1, b4, b7,
  b2, b5, b8,
  b3, b6, b9,
);

// cMat = mat2(
//  c1, c4, c7,
//  c2, c5, c8,
//  c3, c6, c9
// );
mat2 cMat = aMat * bMat;

空间变换

向量可以用来表示坐标、颜色等信息。WebGL可以用过vec2、vec3、vec4关键字声明向量。

vec2 aVec2 = vec2(1.0, 1.0);
vec3 aVec3 = vec3(1.0, 1.0, 1.0);
vec4 aVec4 = vec4(1.0, 1.0, 1.0, 1.0);

矩阵可以与向量相乘,计算过程和矩阵与矩阵相乘是一致的,因为向量可以看成多行单列矩阵。下图演示

3x3的矩阵与具有三个分量的向量相乘,结果也是一个向量。

下面用GLSL表达矩阵与向量相乘:

mat2 aMat = mat2(
  a1, a4, a7,
  a2, a5, a8,
  a3, a6, a9
);

vec3 aVec = vec3(b1, b4, b7);

// cMat = mat2(
//  c1, c4, c7,
//  c2, c5, c8,
//  c3, c6, c9
// );
mat2 cMat = aMat * bMat;

矩阵和向量相乘可以表达几何变换过程。矩阵与向量相乘,除了可以表示平移、缩放、旋转这三种基本几何变换,还可以表示组合变换。

平移

点p坐标是(x,y),经过x方向移动tx,y方向移动ty之后,新的坐标p1是(x + tx,y + ty)。平移矩阵是:

matrix = [
  1,  0,  0, // 第一列
  0,  1,  0, // 第二列
  tx, ty, 1 // 第三列
]

为了满足矩阵相乘的条件,给原坐标p额外添加一个分量,变成(x,y,1)。矩阵相乘的过程如下图:

缩放

点p坐标是(x,y),经过x方向缩放sx,y方向缩放sy之后,新的坐标p1是(x * sx,y * sy)。缩放矩阵是:

matrix = [
  sx, 0,  0, // 第一列
  0,  sy, 0, // 第二列
  0,  0,  1  // 第三列
]

缩放变换过程,可以使用矩阵相乘进行如下表示:

旋转

点p坐标是(x,y),旋转弧度a之后,新的坐标p1是(xcos(a) + ysin(a),x*-sin(a) + y*cos(a))。缩放矩阵是:

matrix = [
  cos(a), -sin(a), 0,
  sin(a), cos(a),  0,
  0,      0,       1
]

旋转变换过程,可以使用矩阵相乘进行如下表示:

组合

实际运用中,往往同时多种几何变换。假设坐标向量是D,平移矩阵是A、缩放矩阵是B、旋转矩阵C,那么坐标D经过平移、缩放、和旋转后的坐标向量如何表示了?

由于矩阵相乘遵循结合律,因此(C.(B.(A.D))) = (C.B.A).D,也就是,可以先把变换矩阵相乘,然后再与坐标向量相乘。这样的好处是,无论经过多少次变换,只需要向webgl传送一个组合矩阵,即使变换规则发生改变,着色器代码也无需变更。

如下代码,声明类Matrix3,提供了二维矩阵变换常用方法。我们先在js端计算组合矩阵combinationMatrix,然后通过uniformMatrix3fv传递给着色器代码。我们可以任意修改组合变换过程,着色器代码不需要做修改。

const vertextSource = `
  attribute vec2 a_positon;
  uniform mat3 u_matrix;

  void main(void) {
    gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
  }
`;

class Matrix3 {
  /**
   * 列优先计算矩阵相乘
   * @param left 左矩阵,3x3
   * @param right 右矩阵,3x3
   */
  static multiply(left: Array<number>, right: Array<number>) {
    const combination = [];
    for (let i = 0; i < 3; i++) { // 列
      const rightColumn = right.slice(i * 3, i * 3 + 3);
      for (let j = 0; j < 3; j++) { // 行
        const leftColumn = [left[j], left[j + 3], left[j + 6]];
        const result = leftColumn.reduce((sum, leftItem, index) => sum + leftItem * rightColumn[index], 0)
        combination.push(result);
      }
    }
    return combination;
  }

  static identity() {
    return [
      1, 0, 0,
      0, 1, 0,
      0, 0, 1,
    ];
  }

  static translation(matrix: Array<number>, tx: number, ty: number) {
    return Matrix3.multiply(
      matrix,
      [
        1, 0, 0,
        0, 1, 0,
        tx, ty, 1,
      ]
   );
  }

  static rotation(matrix: Array<number>, angleInRadians: number) {
    var c = Math.cos(angleInRadians);
    var s = Math.sin(angleInRadians);
    return Matrix3.multiply(
      matrix,
      [
        c,-s, 0,
        s, c, 0,
        0, 0, 1,
      ]
    );
  }

  static scaling(matrix: Array<number>, sx: number, sy: number) {
    return Matrix3.multiply(
      matrix,
      [
        sx, 0, 0,
        0, sy, 0,
        0, 0, 1,
      ]
    );
  }
};

const rotatedMatrix = Matrix3.rotation(Matrix3.identity(), Math.PI / 2);
const scaledMatrix = Matrix3.scaling(rotatedMatrix, 2, 1);
const translatedMatrix =Matrix3.translation(scaledMatrix, 10, 10);
const uMatrix = gl.getUniformLocation(program, 'u_matrix');
gl.uniformMatrix3fv(uMatrix, false, translatedMatrix);

坐标系统变换

最后,我们举个实际的运用示例,感受下矩阵变换的意义。

在之前【零基础学WebGL】绘制图片,我们提到WebGL着色器代码的坐标系统是规范化设备坐标系,原点在画布中心,x轴方向从左到右,y轴方向从下向上,范围都是从-1到1。

但是,人类还是习惯使用屏幕坐标系,原点在画布左上角,x长度是画布宽度,y长度是画布高度 ,单位是px。

比如,我们想在宽300,高200的画布上绘制一个左上角坐标是(10,10),宽度是40的矩形。那么坐标改如何变换了?

首先,我们需求得屏幕系统转换到归一化的设备坐标系的变换矩阵。假设画布宽度width,高度height,变换过程如下:

屏幕坐标系统,经过x轴缩放1/width,y轴缩放1/height,范围变成0-1。用矩阵表示为:

[
  1 / width, 0, 0,
  0, 1 / height, 0,
  0, 0, 1,
];

然后,将坐标系放大2倍,使得xy范围变成0-2。用矩阵表示为:

[
  2, 0, 0,
  0, 2, 0,
  0, 0, 1,
];

然后,将x轴向反方向平移1个单位,y轴向反方向平移1个单位。用矩阵表示为:

[
  1, 0, -1,
  0, 1, -1,
  0, 0, 1,
];

最后,对y轴进行翻转,实际上也是缩放变换。x轴不变,y轴缩放-1。用矩阵表示为:

[
  1, 0, 0,
  0, -1, 0,
  0, 0, 1,
];

我们可以把上述变换过程,使用一个组合矩阵表达。

class Matrix3 {
  ...

  static projection(canvasWidth: number, canvasHeight: number) {
    const flipY = Matrix3.scale(Matrix3.identity(), 1, -1); // 翻转Y轴
    const moveMinusOne = Matrix3.translate(flipY, -1, -1); // XY向反方向移动1个单位
    const scaleDouble = Matrix3.scale(moveMinusOne, 2, 2); // XY放大两倍
    const scaleCanvas = Matrix3.scale(scaleDouble, 1/ canvasWidth, 1/ canvasHeight); // 按照画布比例缩放
   return  scaleCanvas;
  }
};

进一步,我们可以封装一个绘制矩形的方法。

const drawRectangle = (x: number, y: number, width: number, height: number) => {
    const positionBuffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
    const postionData = [
      x, y,
      x + width, y,
      x, y + height,
      x, y + height,
      x + width, y,
      x + width, y + height
    ];
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(postionData), gl.STATIC_DRAW);

    const aPosition = gl.getAttribLocation(program, 'a_position');
    gl.enableVertexAttribArray(aPosition);
    gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, 0, 0);

    const uMatrix = gl.getUniformLocation(program, 'u_matrix');
    const projectionMatrix = Matrix3.projection(gl.canvas.width, gl.canvas.height)
    gl.uniformMatrix3fv(uMatrix, false, projectionMatrix);

    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }