用于 Web 的矩阵数学
矩阵可用于表示空间中对象的变换,并且在 Web 上构建图像和可视化数据时,用于执行许多关键类型的计算。本文探讨了如何创建矩阵以及如何将它们与 CSS 变换 和 matrix3d 变换类型一起使用。
虽然本文使用 CSS 来简化解释,但矩阵是许多不同技术使用的核心概念,包括 WebGL、WebXR (VR 和 AR) API 以及 GLSL 着色器。
变换矩阵
有许多类型的矩阵,但我们感兴趣的是 3D 变换矩阵。这些矩阵由排列在 4x4 网格中的一组 16 个值组成。在 JavaScript 中,很容易将矩阵表示为数组。
让我们从考虑单位矩阵开始。这是一个特殊的变换矩阵,其功能非常类似于标量乘法中的数字 1;就像 n * 1 = n 一样,任何矩阵乘以单位矩阵得到的矩阵其值都与原始矩阵匹配。
单位矩阵在 JavaScript 中看起来是这样的
// prettier-ignore
const identityMatrix = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
乘以单位矩阵是什么样的?最简单的例子是乘以单个点。由于 3D 点只需要三个值 (x、y 和 z),而变换矩阵是一个 4x4 值矩阵,我们需要为点添加第四个维度。按照惯例,这个维度称为透视,并用字母 w 表示。对于典型位置,将 w 设置为 1 将使计算正常工作。
在为点添加 w 分量后,请注意矩阵和点之间的对齐方式有多么整齐。
[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()。
// point • matrix
function multiplyMatrixAndPoint(matrix, point) {
// Give a simple variable name to each part of the matrix, a column and row number
const c0r0 = matrix[0],
c1r0 = matrix[1],
c2r0 = matrix[2],
c3r0 = matrix[3];
const c0r1 = matrix[4],
c1r1 = matrix[5],
c2r1 = matrix[6],
c3r1 = matrix[7];
const c0r2 = matrix[8],
c1r2 = matrix[9],
c2r2 = matrix[10],
c3r2 = matrix[11];
const c0r3 = matrix[12],
c1r3 = matrix[13],
c2r3 = matrix[14],
c3r3 = matrix[15];
// Now set some simple names for the point
const x = point[0];
const y = point[1];
const z = point[2];
const w = point[3];
// Multiply the point against each part of the 1st column, then add together
const resultX = x * c0r0 + y * c0r1 + z * c0r2 + w * c0r3;
// Multiply the point against each part of the 2nd column, then add together
const resultY = x * c1r0 + y * c1r1 + z * c1r2 + w * c1r3;
// Multiply the point against each part of the 3rd column, then add together
const resultZ = x * c2r0 + y * c2r1 + z * c2r2 + w * c2r3;
// Multiply the point against each part of the 4th column, then add together
const resultW = x * c3r0 + y * c3r1 + z * c3r2 + w * c3r3;
return [resultX, resultY, resultZ, resultW];
}
注意:此页面上的示例使用行向量表示点,并使用右乘来应用变换矩阵。也就是说,上述操作为 point * matrix,其中 point 是一个 4x1 行向量。如果要使用列向量和左乘,则需要相应地调整乘法函数,并转置下面介绍的每个矩阵。
例如,下面介绍的 translationMatrix 最初看起来是这样的
[1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, z, 1]
转置后,它将看起来是这样的
[1, 0, 0, x,
0, 1, 0, y,
0, 0, 1, z,
0, 0, 0, 1]
现在,使用上面的函数,我们可以将一个点乘以矩阵。使用单位矩阵,它应该返回一个与原始点相同的点,因为一个点(或其他任何矩阵)乘以单位矩阵总是等于它本身。
// sets identityResult to [4,3,2,1]
const identityResult = multiplyMatrixAndPoint(identityMatrix, [4, 3, 2, 1]);
返回相同的点没什么用,但还有其他类型的矩阵可以对点执行有用的操作。接下来的几节将演示其中一些矩阵。
乘以两个矩阵
除了将矩阵和点相乘之外,您还可以将两个矩阵相乘。上面的函数可以重用以帮助此过程。
// matrixB • matrixA
function multiplyMatrices(matrixA, matrixB) {
// Slice the second matrix up into rows
const row0 = [matrixB[0], matrixB[1], matrixB[2], matrixB[3]];
const row1 = [matrixB[4], matrixB[5], matrixB[6], matrixB[7]];
const row2 = [matrixB[8], matrixB[9], matrixB[10], matrixB[11]];
const row3 = [matrixB[12], matrixB[13], matrixB[14], matrixB[15]];
// Multiply each row by matrixA
const result0 = multiplyMatrixAndPoint(matrixA, row0);
const result1 = multiplyMatrixAndPoint(matrixA, row1);
const result2 = multiplyMatrixAndPoint(matrixA, row2);
const result3 = multiplyMatrixAndPoint(matrixA, row3);
// Turn the result rows back into a single matrix
// prettier-ignore
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],
];
}
function multiplyArrayOfMatrices(matrices) {
if (matrices.length === 1) {
return matrices[0];
}
return matrices.reduce((result, matrix) => multiplyMatrices(result, matrix));
}
让我们看看这个函数是如何工作的。
// prettier-ignore
const someMatrix = [
4, 0, 0, 0,
0, 3, 0, 0,
0, 0, 5, 0,
4, 8, 4, 1,
];
// prettier-ignore
const identityMatrix = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
// Returns a new array equivalent to someMatrix
const someMatrixResult = multiplyMatrices(identityMatrix, someMatrix);
警告:这些矩阵函数是为了清晰解释而编写的,而不是为了速度或内存管理。这些函数会创建大量新数组,对于实时操作来说,由于垃圾回收,这可能特别昂贵。在实际生产代码中,最好使用优化函数。 glMatrix 是一个专注于速度和性能的库的示例。glMatrix 库的重点是拥有在更新循环之前分配的目标数组。
平移矩阵
平移矩阵基于单位矩阵,并用于 3D 图形中,以在一个或多个三个方向(x、y 和/或 z)上移动点或对象。思考平移的最简单方法就像拿起一个咖啡杯。咖啡杯必须保持直立并保持相同的方向,以免咖啡溅出。它可以从桌子上移到空中,并在空间中四处移动。
您实际上无法仅使用平移矩阵来喝咖啡,因为要喝咖啡,您必须能够倾斜或旋转杯子才能将咖啡倒入您的嘴里。我们稍后将介绍用于此目的的矩阵类型(巧妙地称为旋转矩阵)。
function translate(x, y, z) {
// prettier-ignore
return [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
x, y, z, 1,
];
}
将三个轴上的距离放在平移矩阵的相应位置,然后将其乘以需要穿过 3D 空间的点或矩阵。
使用矩阵操作 DOM
开始使用矩阵的一个非常简单的方法是使用 CSS 的 matrix3d() transform。首先,我们将设置一个简单的 <div> 并包含一些内容。样式未显示,但它被设置为固定的宽度和高度,并在页面上居中。<div> 设置了变换的过渡,以便矩阵可以被动画化,从而很容易地看到正在做什么。
<div class="transformable ghost">
<h2>Move me with a matrix</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
<div id="move-me" class="transformable">
<h2>Move me with a matrix</h2>
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat
non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
</p>
</div>
最后,对于每个示例,我们将生成一个 4x4 矩阵,然后更新 <div> 的样式以应用一个变换,设置为 matrix3d。请注意,即使矩阵由 4 行 4 列组成,它也会折叠成一个包含 16 个值的单行。矩阵始终在 JavaScript 的一维列表中存储。
// Create the matrix3d style property from a matrix array
function matrixArrayToCssMatrix(array) {
return `matrix3d(${array.join(",")})`;
}
const moveMe = document.getElementById("move-me");
function setTransform(matrix) {
moveMe.style.transform = matrixArrayToCssMatrix(matrix);
}
在其中一个示例中,我们使用上面“平移矩阵”部分中的 translate() 函数将 <div> 向下移动 100 像素,向右移动 50 像素。z 值设置为 0,因此它在第三个维度上不移动。
const translationMatrix = translate(50, 100, 0);
setTransform(translationMatrix);
缩放矩阵
缩放矩阵用于使某物在三个维度中的一个或多个维度(宽度、高度和深度)上变大或变小。在典型的(笛卡尔)坐标系中,这会导致对象在相应方向上被拉伸或收缩。
应用于每个宽度、高度和深度的更改量从左上角开始对角线排列,一直向下到右下角。
function scale(x, y, z) {
// prettier-ignore
return [
x, 0, 0, 0,
0, y, 0, 0,
0, 0, z, 0,
0, 0, 0, 1,
];
}
const scaleMatrix = scale(1.5, 0.7, 1);
setTransform(scaleMatrix);
旋转矩阵
旋转矩阵用于旋转点或对象。旋转矩阵看起来比缩放和平移矩阵稍微复杂一些。它们使用三角函数来执行旋转。虽然本节不会将步骤详尽地分解(有关详细信息,请参阅 Wolfram MathWorld 上的这篇文章),但请以这个示例作为说明。
首先,这是在不使用矩阵的情况下围绕原点旋转点的代码。
// Manually rotating a point about the origin without matrices
const point = [10, 2];
// Calculate the distance from the origin
const distance = Math.sqrt(point[0] * point[0] + point[1] * point[1]);
// The equivalent of 60 degrees, in radians
const rotationInRadians = Math.PI / 3;
const transformedPoint = [
Math.cos(rotationInRadians) * distance,
Math.sin(rotationInRadians) * distance,
];
可以将这些类型的步骤编码到矩阵中,并为 x、y 和 z 维度中的每一个都这样做。这里有一组函数,它们返回绕三个轴中的每一个旋转的旋转矩阵。一个很大的注意事项是没有应用透视,所以可能感觉不太 3D。这种扁平化相当于相机非常近地放大远处物体时 — 透视感会消失。
const sin = Math.sin;
const cos = Math.cos;
function rotateX(a) {
// prettier-ignore
return [
1, 0, 0, 0,
0, cos(a), -sin(a), 0,
0, sin(a), cos(a), 0,
0, 0, 0, 1,
];
}
function rotateY(a) {
// prettier-ignore
return [
cos(a), 0, sin(a), 0,
0, 1, 0, 0,
-sin(a), 0, cos(a), 0,
0, 0, 0, 1,
];
}
function rotateZ(a) {
// prettier-ignore
return [
cos(a), -sin(a), 0, 0,
sin(a), cos(a), 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
}
const rotateZMatrix = rotateZ(Math.PI * 0.3);
setTransform(rotateZMatrix);
矩阵组合
矩阵的真正威力来自于矩阵组合。当特定类别的矩阵相乘时,它们会保留变换的历史并且是可逆的。这意味着,如果将平移、旋转和缩放矩阵组合在一起,当矩阵的顺序颠倒并重新应用时,就会返回原始点。
矩阵相乘的顺序很重要。当乘以数字时,a * b = c,并且 b * a = c 都为真。例如,3 * 4 = 12,4 * 3 = 12。在数学中,这些数字将被描述为交换。矩阵不保证顺序切换后相同,因此矩阵是不可交换的。
另一个令人费解的是,WebGL 和 CSS 中的矩阵乘法需要以与操作直观发生的顺序相反的顺序进行。例如,将某物缩小 80%,向下移动 200 像素,然后围绕原点旋转 90 度,在伪代码中看起来像下面这样。
transformation = rotate * translate * scale
组合多个变换
我们将用于组合矩阵的函数是 multiplyArrayOfMatrices(),它是本文开头介绍的实用函数集的一部分。它接受一个矩阵数组并将它们相乘,返回结果。在 WebGL 着色器代码中,这是内置在语言中的,可以使用 * 运算符。
const transformMatrix = multiplyArrayOfMatrices([
rotateZ(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
]);
setTransform(transformMatrix);
最后,一个有趣的步骤可以展示矩阵的工作原理,那就是反转步骤以将矩阵恢复到原始的单位矩阵。
const transformMatrix = multiplyArrayOfMatrices([
scale(1.25, 1.25, 1.25), // Step 6: scale back up
translate(0, -200, 0), // Step 5: move back up
rotateZ(-Math.PI * 0.5), // Step 4: rotate back
rotateZ(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 的用途,图形卡在用矩阵乘以大量空间点方面尤其出色。诸如定位点、计算光照和摆放动画角色等不同操作都依赖于这一基本工具。