用於 Web 的矩陣數學

矩陣可用於表示空間中物件的變換,並且在 Web 上構建影像和視覺化資料時,用於執行許多關鍵型別的計算。本文探討了如何建立矩陣以及如何將它們與 CSS 變換matrix3d 變換型別一起使用。

雖然本文使用 CSS 來簡化解釋,但矩陣是許多不同技術使用的核心概念,包括 WebGLWebXR (VR 和 AR) API 以及 GLSL 著色器

變換矩陣

有許多型別的矩陣,但我們感興趣的是 3D 變換矩陣。這些矩陣由排列在 4x4 網格中的一組 16 個值組成。在 JavaScript 中,很容易將矩陣表示為陣列。

讓我們從考慮單位矩陣開始。這是一個特殊的變換矩陣,其功能非常類似於標量乘法中的數字 1;就像 n * 1 = n 一樣,任何矩陣乘以單位矩陣得到的矩陣其值都與原始矩陣匹配。

單位矩陣在 JavaScript 中看起來是這樣的

js
// prettier-ignore
const identityMatrix = [
  1, 0, 0, 0,
  0, 1, 0, 0,
  0, 0, 1, 0,
  0, 0, 0, 1,
];

乘以單位矩陣是什麼樣的?最簡單的例子是乘以單個點。由於 3D 點只需要三個值 (xyz),而變換矩陣是一個 4x4 值矩陣,我們需要為點新增第四個維度。按照慣例,這個維度稱為透視,並用字母 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
  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 最初看起來是這樣的

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]
const identityResult = multiplyMatrixAndPoint(identityMatrix, [4, 3, 2, 1]);

返回相同的點沒什麼用,但還有其他型別的矩陣可以對點執行有用的操作。接下來的幾節將演示其中一些矩陣。

乘以兩個矩陣

除了將矩陣和點相乘之外,您還可以將兩個矩陣相乘。上面的函式可以重用以幫助此過程。

js
// 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));
}

讓我們看看這個函式是如何工作的。

js
// 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 圖形中,以在一個或多個三個方向(xy 和/或 z)上移動點或物件。思考平移的最簡單方法就像拿起一個咖啡杯。咖啡杯必須保持直立並保持相同的方向,以免咖啡濺出。它可以從桌子上移到空中,並在空間中四處移動。

您實際上無法僅使用平移矩陣來喝咖啡,因為要喝咖啡,您必須能夠傾斜或旋轉杯子才能將咖啡倒入您的嘴裡。我們稍後將介紹用於此目的的矩陣型別(巧妙地稱為旋轉矩陣)。

js
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> 設定了變換的過渡,以便矩陣可以被動畫化,從而很容易地看到正在做什麼。

html
<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 的一維列表中儲存。

js
// 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,因此它在第三個維度上不移動。

js
const translationMatrix = translate(50, 100, 0);
setTransform(translationMatrix);

縮放矩陣

縮放矩陣用於使某物在三個維度中的一個或多個維度(寬度、高度和深度)上變大或變小。在典型的(笛卡爾)座標系中,這會導致物件在相應方向上被拉伸或收縮。

應用於每個寬度、高度和深度的更改量從左上角開始對角線排列,一直向下到右下角。

js
function scale(x, y, z) {
  // prettier-ignore
  return [
    x, 0, 0, 0,
    0, y, 0, 0,
    0, 0, z, 0,
    0, 0, 0, 1,
  ];
}
js
const scaleMatrix = scale(1.5, 0.7, 1);
setTransform(scaleMatrix);

旋轉矩陣

旋轉矩陣用於旋轉點或物件。旋轉矩陣看起來比縮放和平移矩陣稍微複雜一些。它們使用三角函式來執行旋轉。雖然本節不會將步驟詳盡地分解(有關詳細資訊,請參閱 Wolfram MathWorld 上的這篇文章),但請以這個示例作為說明。

首先,這是在不使用矩陣的情況下圍繞原點旋轉點的程式碼。

js
// 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,
];

可以將這些型別的步驟編碼到矩陣中,併為 xyz 維度中的每一個都這樣做。這裡有一組函式,它們返回繞三個軸中的每一個旋轉的旋轉矩陣。一個很大的注意事項是沒有應用透視,所以可能感覺不太 3D。這種扁平化相當於相機非常近地放大遠處物體時 — 透視感會消失。

js
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,
  ];
}
js
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 著色器程式碼中,這是內建在語言中的,可以使用 * 運算子。

js
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);

最後,一個有趣的步驟可以展示矩陣的工作原理,那就是反轉步驟以將矩陣恢復到原始的單位矩陣。

js
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 的用途,圖形卡在用矩陣乘以大量空間點方面尤其出色。諸如定位點、計算光照和擺放動畫角色等不同操作都依賴於這一基本工具。