网页矩阵数学

矩阵可用于表示空间中物体的变换,并用于在 Web 上构建图像和可视化数据时执行许多关键类型的计算。本文探讨了如何创建矩阵以及如何将它们与 CSS 变换matrix3d 变换类型一起使用。

虽然本文使用 CSS 来简化解释,但矩阵是许多不同技术使用的核心概念,包括 WebGLWebXR(VR 和 AR)API 以及 GLSL 着色器。本文也可作为 MDN 内容工具包。实时示例使用 实用函数 集合,这些函数在名为 MDN 的全局对象下可用。

变换矩阵

矩阵有很多类型,但我们感兴趣的是 3D 变换矩阵。这些矩阵由一组 16 个值组成,这些值按 4×4 网格排列。在 JavaScript 中,很容易将矩阵表示为数组。

让我们从考虑单位矩阵开始。这是一个特殊的变换矩阵,其功能类似于标量乘法中的数字 1;就像 n * 1 = n 一样,将任何矩阵乘以单位矩阵都会得到一个结果矩阵,其值与原始矩阵匹配。

单位矩阵在 JavaScript 中看起来像这样

js
let identityMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];

乘以单位矩阵是什么样子?最简单的例子是将一个点乘以单位矩阵。由于 3D 点只需要三个值(xyz),而变换矩阵是一个 4×4 值矩阵,我们需要在点中添加第四个维度。按照惯例,这个维度称为透视,用字母 w 表示。对于典型位置,将 w 设置为 1 将使数学运算正常进行。

在将 w 分量添加到点之后,请注意矩阵和点是如何整齐地对齐的

js
[1, 0, 0, 0,
 0, 1, 0, 0,
 0, 0, 1, 0,
 0, 0, 0, 1]

[4, 3, 2, 1] // Point at [x, y, z, w]

w 分量还有一些其他用途,本文不再详细介绍。查看 WebGL 模型视图投影 文章,了解它如何派上用场。

将矩阵和点相乘

在我们的示例代码中,我们定义了一个函数来将矩阵和点相乘——multiplyMatrixAndPoint()

js
// point • matrix
function multiplyMatrixAndPoint(matrix, point) {
  // Give a simple variable name to each part of the matrix, a column and row number
  let c0r0 = matrix[0],
    c1r0 = matrix[1],
    c2r0 = matrix[2],
    c3r0 = matrix[3];
  let c0r1 = matrix[4],
    c1r1 = matrix[5],
    c2r1 = matrix[6],
    c3r1 = matrix[7];
  let c0r2 = matrix[8],
    c1r2 = matrix[9],
    c2r2 = matrix[10],
    c3r2 = matrix[11];
  let c0r3 = matrix[12],
    c1r3 = matrix[13],
    c2r3 = matrix[14],
    c3r3 = matrix[15];

  // Now set some simple names for the point
  let x = point[0];
  let y = point[1];
  let z = point[2];
  let w = point[3];

  // Multiply the point against each part of the 1st column, then add together
  let resultX = x * c0r0 + y * c0r1 + z * c0r2 + w * c0r3;

  // Multiply the point against each part of the 2nd column, then add together
  let resultY = x * c1r0 + y * c1r1 + z * c1r2 + w * c1r3;

  // Multiply the point against each part of the 3rd column, then add together
  let resultZ = x * c2r0 + y * c2r1 + z * c2r2 + w * c2r3;

  // Multiply the point against each part of the 4th column, then add together
  let resultW = x * c3r0 + y * c3r1 + z * c3r2 + w * c3r3;

  return [resultX, resultY, resultZ, resultW];
}

注意:本页上的示例使用行向量来表示点,并使用右乘法来应用变换矩阵。也就是说,上述操作执行 point * matrix,其中 point 是一个 4x1 行向量。如果你想使用列向量和左乘法,你需要相应地调整乘法函数,并对下面介绍的每个矩阵进行转置。

例如,下面介绍的 translationMatrix 原来看起来像这样

js
[1, 0, 0, 0,
 0, 1, 0, 0,
 0, 0, 1, 0,
 x, y, z, 1]

转置后,它看起来像这样

js
[1, 0, 0, x,
 0, 1, 0, y,
 0, 0, 1, z,
 0, 0, 0, 1]

现在,使用上面的函数,我们可以将点乘以矩阵。使用单位矩阵,它应该返回一个与原始点相同的点,因为点(或任何其他矩阵)乘以单位矩阵始终等于它本身

js
// sets identityResult to [4,3,2,1]
let identityResult = multiplyMatrixAndPoint(identityMatrix, [4, 3, 2, 1]);

返回相同的点并没有什么用,但还有其他类型的矩阵可以对点执行有用的操作。接下来的部分将演示其中一些矩阵。

将两个矩阵相乘

除了将矩阵和点相乘之外,你还可以将两个矩阵相乘。上面的函数可以重复使用来帮助完成此过程

js
//matrixB • matrixA
function multiplyMatrices(matrixA, matrixB) {
  // Slice the second matrix up into rows
  let row0 = [matrixB[0], matrixB[1], matrixB[2], matrixB[3]];
  let row1 = [matrixB[4], matrixB[5], matrixB[6], matrixB[7]];
  let row2 = [matrixB[8], matrixB[9], matrixB[10], matrixB[11]];
  let row3 = [matrixB[12], matrixB[13], matrixB[14], matrixB[15]];

  // Multiply each row by matrixA
  let result0 = multiplyMatrixAndPoint(matrixA, row0);
  let result1 = multiplyMatrixAndPoint(matrixA, row1);
  let result2 = multiplyMatrixAndPoint(matrixA, row2);
  let result3 = multiplyMatrixAndPoint(matrixA, row3);

  // Turn the result rows back into a single matrix
  return [
    result0[0],
    result0[1],
    result0[2],
    result0[3],
    result1[0],
    result1[1],
    result1[2],
    result1[3],
    result2[0],
    result2[1],
    result2[2],
    result2[3],
    result3[0],
    result3[1],
    result3[2],
    result3[3],
  ];
}

让我们看看这个函数在实际中的应用

js
let someMatrix = [4, 0, 0, 0, 0, 3, 0, 0, 0, 0, 5, 0, 4, 8, 4, 1];

let identityMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];

// Returns a new array equivalent to someMatrix
let someMatrixResult = multiplyMatrices(identityMatrix, someMatrix);

警告:这些矩阵函数是为了解释清楚而编写的,而不是为了速度或内存管理。这些函数创建了大量的新的数组,由于垃圾回收,这对于实时操作来说可能非常昂贵。在实际的生产代码中,最好使用优化的函数。 glMatrix 是一个专注于速度和性能的库示例。glMatrix 库的重点是在更新循环之前分配目标数组。

平移矩阵

平移矩阵基于单位矩阵,用于 3D 图形中沿一个或多个三个方向(xy 和/或 z)移动点或物体。最简单的理解平移的方法就像拿起一个咖啡杯。咖啡杯必须保持直立并以相同的方式定向,这样就不会洒出咖啡。它可以从桌子上向上移动到空中,并在空中移动。

你实际上无法只用平移矩阵喝咖啡,因为要喝咖啡,你必须能够倾斜或旋转杯子,将咖啡倒入你的嘴里。我们将在后面看看用于执行此操作的矩阵类型(巧妙地称为旋转矩阵)。

js
let x = 50;
let y = 100;
let z = 0;

let translationMatrix = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, y, z, 1];

将三个轴上的距离分别放置在平移矩阵中的相应位置,然后将它乘以需要在 3D 空间中移动的点或矩阵。

使用矩阵操作 DOM

使用矩阵的一个非常简单的办法是使用 CSS matrix3d() transform。首先,我们将设置一个简单的 <div>,其中包含一些内容。样式没有显示,但它被设置为固定宽度和高度,并且在页面上居中。<div> 为变换设置了过渡,因此矩阵以动画方式进行,这使得很容易看到正在执行的操作。

html
<div id="move-me" class="transformable">
  <h2>Move me with a matrix</h2>
  <p>Lorem ipsum dolor sit amet, consectetur adipisicing elit…</p>
</div>

最后,对于每个示例,我们将生成一个 4×4 矩阵,然后更新 <div> 的样式,使其应用变换,并将其设置为 matrix3d。请记住,即使矩阵由 4 行 4 列组成,它也会折叠成一行 16 个值。在 JavaScript 中,矩阵始终存储在一维列表中。

js
// Create the matrix3d style property from a matrix array
function matrixArrayToCssMatrix(array) {
  return `matrix3d(${array.join(",")})`;
}

// Grab the DOM element
let moveMe = document.getElementById("move-me");

// Returns a result like: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 50, 100, 0, 1);"
let matrix3dRule = matrixArrayToCssMatrix(translationMatrix);

// Set the transform
moveMe.style.transform = matrix3dRule;

在 JSFiddle 上查看

An example of matrix translation

缩放矩阵

缩放矩阵使物体在一个或多个三个维度(宽度、高度和深度)上变大或变小。在典型的(笛卡尔)坐标系中,这会导致物体在相应的方向上拉伸或收缩。

要应用于宽度、高度和深度的变化量分别放置在对角线上,从左上角开始,向下延伸到右下角。

js
let w = 1.5; // width  (x)
let h = 0.7; // height (y)
let d = 1; // depth  (z)

let scaleMatrix = [w, 0, 0, 0, 0, h, 0, 0, 0, 0, d, 0, 0, 0, 0, 1];

在 JSFiddle 上查看

An example of matrix scaling

旋转矩阵

旋转矩阵用于旋转点或物体。旋转矩阵看起来比缩放和变换矩阵复杂一些。它们使用三角函数来执行旋转。虽然本节不会将步骤分解成详尽的细节(有关详细信息,请查看 Wolfram MathWorld 上的这篇文章),但请参考以下示例进行说明。

首先,以下代码在不使用矩阵的情况下将点绕原点旋转。

js
// Manually rotating a point about the origin without matrices
let point = [10, 2];

// Calculate the distance from the origin
let distance = Math.sqrt(point[0] * point[0] + point[1] * point[1]);

// The equivalent of 60 degrees, in radians
let rotationInRadians = Math.PI / 3;

let transformedPoint = [
  Math.cos(rotationInRadians) * distance,
  Math.sin(rotationInRadians) * distance,
];

可以将这些类型的步骤编码到矩阵中,并针对 xyz 的每个维度执行。下面是在左手坐标系中绕 Z 轴逆时针旋转的表示

js
let sin = Math.sin;
let cos = Math.cos;

// NOTE: There is no perspective in these transformations, so a rotation
//       at this point will only appear to only shrink the div

let a = Math.PI * 0.3; //Rotation amount in radians

// Rotate around Z axis
let rotateZMatrix = [
  cos(a),
  -sin(a),
  0,
  0,
  sin(a),
  cos(a),
  0,
  0,
  0,
  0,
  1,
  0,
  0,
  0,
  0,
  1,
];

在 JSFiddle 上查看

An example of matrix rotation.

以下是一组函数,它们返回用于绕三个轴旋转的旋转矩阵。一个重要的注意事项是,没有应用透视,因此它可能感觉不太 3D。扁平度等同于相机非常靠近远处的物体进行缩放时——透视感消失了。

js
function rotateAroundXAxis(a) {
  return [1, 0, 0, 0, 0, cos(a), -sin(a), 0, 0, sin(a), cos(a), 0, 0, 0, 0, 1];
}

function rotateAroundYAxis(a) {
  return [cos(a), 0, sin(a), 0, 0, 1, 0, 0, -sin(a), 0, cos(a), 0, 0, 0, 0, 1];
}

function rotateAroundZAxis(a) {
  return [cos(a), -sin(a), 0, 0, sin(a), cos(a), 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
}

在 JSFiddle 上查看

矩阵合成

矩阵的真正强大之处在于矩阵组合。当特定类别的矩阵相乘时,它们会保留变换的历史记录并可逆。这意味着如果将平移、旋转和缩放矩阵组合在一起,当矩阵的顺序反转并重新应用时,原始点将被返回。

矩阵相乘的顺序很重要。当乘以数字时,a * b = c 和 b * a = c 都是真的。例如,3 * 4 = 12,而 4 * 3 = 12。在数学中,这些数字被称为可交换的。如果矩阵的顺序颠倒,矩阵保证相同,因此矩阵是不可交换的

另一个令人费解的事情是,WebGL 和 CSS 中的矩阵乘法需要以与操作直观发生顺序相反的顺序进行。例如,要将某个物体缩小 80%,向下移动 200 像素,然后围绕原点旋转 90 度,在伪代码中看起来像这样。

transformation = rotate * translate * scale

组合多个变换

我们将用来组合矩阵的函数是multiplyArrayOfMatrices(),它是本文开头介绍的实用函数集的一部分。它接受一个矩阵数组并将它们相乘,返回结果。在 WebGL 着色器代码中,这是内置在语言中的,可以使用*运算符。此外,此示例使用scale()translate()函数,它们返回如上定义的矩阵。

js
let transformMatrix = MDN.multiplyArrayOfMatrices([
  rotateAroundZAxis(Math.PI * 0.5), // Step 3: rotate around 90 degrees
  translate(0, 200, 0), // Step 2: move down 200 pixels
  scale(0.8, 0.8, 0.8), // Step 1: scale down
]);

在 JSFiddle 上查看

An example of matrix composition

最后,一个有趣的一步是展示矩阵如何工作,那就是反转步骤,将矩阵恢复到原始的单位矩阵。

js
let transformMatrix = MDN.multiplyArrayOfMatrices([
  scale(1.25, 1.25, 1.25), // Step 6: scale back up
  translate(0, -200, 0), // Step 5: move back up
  rotateAroundZAxis(-Math.PI * 0.5), // Step 4: rotate back
  rotateAroundZAxis(Math.PI * 0.5), // Step 3: rotate around 90 degrees
  translate(0, 200, 0), // Step 2: move down 200 pixels
  scale(0.8, 0.8, 0.8), // Step 1: scale down
]);

为什么矩阵很重要

矩阵很重要,因为它们包含一小部分数字,可以描述空间中各种各样的变换。它们可以在程序中轻松地共享。不同的坐标空间可以用矩阵来描述,一些矩阵乘法会将一组数据从一个坐标空间移动到另一个坐标空间。矩阵有效地记住生成它们的之前所有变换的每个部分。

对于 WebGL 中的使用,显卡在将大量空间点乘以矩阵方面特别出色。定位点、计算光照和摆放动画角色等不同的操作都依赖于这个基本工具。