WebGL 變形

Gregg Tavares
Gregg Tavares

WebGL 2D 轉譯

在進入 3D 之前,我們先繼續討論 2D。請稍候。這篇文章對某些人來說可能顯而易見,但我會在幾篇文章中逐步說明。

本文是 WebGL 基礎知識系列文章的續篇。如果您尚未閱讀,建議您至少先閱讀第一章,然後再回到這裡。「平移」是數學術語,基本上就是「移動」的意思。將英文句子翻譯成日文也算是一種轉換,但我們這裡討論的是幾何圖形的轉換。使用第一篇文章中最後的程式碼範例,只要變更傳遞至 setRectangle 的值,就能輕鬆平移矩形,對吧?以下是根據先前範例製作的範例。

  // First lets make some variables 
  // to hold the translation of the rectangle
  var translation = [0, 0];
  // then let's make a function to
  // re-draw everything. We can call this
  // function after we update the translation.
  // Draw the scene.
  function drawScene() {
     // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);
    // Setup a rectangle
    setRectangle(gl, translation[0], translation[1], width, height);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 6);
  }

到目前為止都很順利。但現在假設我們想對更複雜的形狀執行相同操作。假設我們要繪製由 6 個三角形組成的「F」,如下所示。

F 字母

根據目前的程式碼,我們必須將 setRectangle 變更為類似以下內容。

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl, x, y) {
  var width = 100;
  var height = 150;
  var thickness = 30;
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
          // left column
          x, y,
          x + thickness, y,
          x, y + height,
          x, y + height,
          x + thickness, y,
          x + thickness, y + height,

          // top rung
          x + thickness, y,
          x + width, y,
          x + thickness, y + thickness,
          x + thickness, y + thickness,
          x + width, y,
          x + width, y + thickness,

          // middle rung
          x + thickness, y + thickness * 2,
          x + width * 2 / 3, y + thickness * 2,
          x + thickness, y + thickness * 3,
          x + thickness, y + thickness * 3,
          x + width * 2 / 3, y + thickness * 2,
          x + width * 2 / 3, y + thickness * 3]),
      gl.STATIC_DRAW);
}

您應該可以發現,這種做法無法有效擴充。如果想繪製非常複雜的幾何圖形,可能需要數百或數千行程式碼,這會非常複雜。此外,每次繪製時,JavaScript 都必須更新所有點。其實有更簡單的方法。只要上傳幾何圖形,並在著色器中進行平移即可。這是新的著色器

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;

void main() {
   // Add in the translation.
   vec2 position = a_position + u_translation;

   // convert the rectangle from pixels to 0.0 to 1.0
   vec2 zeroToOne = position / u_resolution;
   ...

並稍微重組程式碼。首先,我們只需要設定幾何圖形一次。

// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
  gl.bufferData(
      gl.ARRAY_BUFFER,
      new Float32Array([
          // left column
          0, 0,
          30, 0,
          0, 150,
          0, 150,
          30, 0,
          30, 150,

          // top rung
          30, 0,
          100, 0,
          30, 30,
          30, 30,
          100, 0,
          100, 30,

          // middle rung
          30, 60,
          67, 60,
          30, 90,
          30, 90,
          67, 60,
          67, 90]),
      gl.STATIC_DRAW);
}

接著,只要在繪製前,使用所需的平移值更新 u_translation 即可。

  ...
  var translationLocation = gl.getUniformLocation(
             program, "u_translation");
  ...
  // Set Geometry.
  setGeometry(gl);
  ..
  // Draw scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

請注意,系統只會呼叫 setGeometry 一次。它不再位於 drawScene 內。

現在,WebGL 幾乎包辦了所有繪圖工作。我們只是設定平移並要求繪製。即使幾何圖形有數萬個點,主要程式碼仍會保持不變。

WebGL 2D 旋轉

我先承認,我不知道我的解釋是否合理,但管他的,試試看也無妨。

首先,我想介紹「單位圓」的概念。如果你還記得國中數學 (別睡著了!),圓形有半徑。圓形的半徑是指從圓心到圓周的距離。單位圓是半徑為 1.0 的圓。

如果你還記得小學三年級的基礎數學,任何數字乘以 1 都會保持不變。因此 123 * 1 = 123。很簡單吧?半徑為 1.0 的單位圓也是 1 的一種形式。這是一台兩用捲髮器。因此,你可以將某個值乘以這個單位圓,在某種程度上,這就像是乘以 1,但神奇的是,事物會旋轉。 我們將從單位圓上的任何一點取得 X 和 Y 值,並將先前範例中的幾何圖形乘以這些值。以下是著色器的更新內容。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;

void main() {
  // Rotate the position
  vec2 rotatedPosition = vec2(
     a_position.x * u_rotation.y + a_position.y * u_rotation.x,
     a_position.y * u_rotation.y - a_position.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

接著更新 JavaScript,以便傳入這 2 個值。

  ...
  var rotationLocation = gl.getUniformLocation(program, "u_rotation");
  ...
  var rotation = [0, 1];
  ..
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

為什麼有效?我們來算算看。

rotatedX = a_position.x * u_rotation.y + a_position.y * u_rotation.x;
rotatedY = a_position.y * u_rotation.y - a_position.x * u_rotation.x;

假設您有一個矩形,並想旋轉它。在開始旋轉之前,右上角位於 3.0、9.0。我們在單位圓上選取一個點,從 12 點鐘方向順時針旋轉 30 度。

旋轉 30 度

圓圈上的位置是 0.50 和 0.87

3.0 * 0.87 + 9.0 * 0.50 = 7.1
9.0 * 0.87 - 3.0 * 0.50 = 6.3

這正是我們需要的位置

旋轉繪圖

順時針旋轉 60 度也一樣

旋轉 60 度

圓圈上的位置是 0.87 和 0.50

3.0 * 0.50 + 9.0 * 0.87 = 9.3
9.0 * 0.50 - 3.0 * 0.87 = 1.9

可以看到,當我們將該點順時針向右旋轉時,X 值會變大,Y 值會變小。如果超過 90 度,X 會再次變小,Y 則會變大。這個模式會產生旋轉。單位圓上的點還有另一個名稱。分別是正弦和餘弦。因此,只要知道角度,就能像這樣查出正弦和餘弦值。

function printSineAndCosineForAnAngle(angleInDegrees) {
  var angleInRadians = angleInDegrees * Math.PI / 180;
  var s = Math.sin(angleInRadians);
  var c = Math.cos(angleInRadians);
  console.log("s = " + s + " c = " + c);
}

如果將程式碼複製並貼到 JavaScript 控制台,然後輸入 printSineAndCosignForAngle(30),就會看到系統輸出 s = 0.49 c= 0.87 (注意:我將數字四捨五入)。綜合以上步驟,您就能將幾何圖形旋轉至任何角度。只要將旋轉角度設為所需旋轉角度的正弦和餘弦即可。

  ...
  var angleInRadians = angleInDegrees * Math.PI / 180;
  rotation[0] = Math.sin(angleInRadians);
  rotation[1] = Math.cos(angleInRadians);

希望以上說明對您有幫助。接下來是更簡單的例子。擴充

什麼是弧度?

弧度是用於圓形、旋轉和角度的測量單位。就像我們可以用英吋、碼、公尺等單位測量距離一樣,我們也可以用度或弧度測量角度。

你可能知道,公制單位的數學運算比英制單位更簡單。如要將英寸換算成英尺,請除以 12。如要將英寸換算成碼,請除以 36。我不知道你能不能,但我無法在腦中除以 36。公制單位就簡單多了。如要將公釐換算成公分,請除以 10。將公釐除以 1000 即可換算成公尺。我可以在腦中除以 1000。

弧度和角度類似。角度會讓數學計算變得困難。弧度可簡化數學運算。圓形有 360 度,但只有 2π 弧度。因此,完整一圈是 2π 弧度。半圈是 π 弧度。1/4 圈 (即 90 度) 為 π/2 弧度。因此,如要將某個項目旋轉 90 度,只要使用 Math.PI * 0.5 即可。如要旋轉 45 度,請使用 Math.PI * 0.25 等。

如果開始以弧度思考,幾乎所有涉及角度、圓形或旋轉的數學問題都會變得非常簡單。歡迎試用。請使用弧度,而非度數 (UI 顯示除外)。

WebGL 2D 縮放

縮放操作和翻譯一樣簡單。

將位置乘以所需比例。以下是與先前範例不同的地方。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the positon
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y +
        scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y -
        scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;

並加入繪製時設定縮放比例所需的 JavaScript。

  ...
  var scaleLocation = gl.getUniformLocation(program, "u_scale");
  ...
  var scale = [1, 1];
  ...
  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Set the translation.
    gl.uniform2fv(translationLocation, translation);

    // Set the rotation.
    gl.uniform2fv(rotationLocation, rotation);

    // Set the scale.
    gl.uniform2fv(scaleLocation, scale);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

請注意,如果縮放值為負數,幾何圖形會翻轉。希望最後 3 章能幫助你瞭解平移、旋轉和縮放。接下來,我們將介紹矩陣的神奇之處,矩陣能將這 3 種轉換結合為更簡單、通常也更實用的形式。

為什麼是「F」?

我第一次看到有人使用「F」是在材質上。「F」本身並不重要。重點是,無論從哪個方向看,都能辨識出方向。如果我們使用愛心 ♥ 或三角形 △,就無法判斷是否水平翻轉。圓圈 ○ 則更不理想。彩色矩形或許可以為每個角落設定不同顏色,但你必須記住每個角落代表的意義。F 的方向可以立即辨識。

F 方向

只要能看出方向,任何形狀都可以。我從第一次接觸這個概念起,就一直使用「F」。

WebGL 2D 矩陣

在過去 3 個章節中,我們介紹了如何平移、旋轉和縮放幾何圖形。平移、旋轉和縮放都屬於「轉換」類型。每項轉換都需要變更著色器,且 3 項轉換都必須按照順序進行。

舉例來說,以下是縮放比例 2、1、旋轉 30%,以及平移 100、0。

F 旋轉和平移

平移 100,0、旋轉 30% 和縮放 2, 1

F 旋轉和縮放

結果完全不同。更糟的是,如果需要第二個範例,我們就必須編寫不同的著色器,按照新的所需順序套用平移、旋轉和縮放。 有些比我聰明得多的人發現,你可以用矩陣數學完成所有相同的工作。2D 則使用 3x3 矩陣。3x3 矩陣就像有 9 個方格的網格。

1.0 2.0 3.0
4.0 5.0 6.0
7.0 8.0 9.0

計算方式是將矩陣中各欄的位置相乘,然後將結果相加。位置只有 2 個值 (x 和 y),但進行這項運算需要 3 個值,因此第 3 個值會使用 1。 在本例中,結果會是

newX = x * 1.0 + y * 4.0 + 1 * 7.0

newY = x * 2.0 + y * 5.0 + 1 * 8.0

extra = x * 3.0 + y * 6.0 + 1 * 9.0

你可能會想:「這有什麼意義?」假設我們有翻譯。我們將要平移的量稱為 tx 和 ty。我們來建立一個像這樣的矩陣

1.00.00.0
0.01.00.0
txty1.0

現在來看看

newX = x * 1.0 + y * 0.0 + 1 * tx

newY = x * 0.0 + y * 1.0 + 1 * ty

extra = x * 0.0 + y * 0.0 + 1 * 1

根據代數原理,我們可以刪除任何乘以零的位置。乘以 1 實際上不會有任何作用,因此我們簡化一下,看看會發生什麼事

newX = x + tx;
newY = y + ty;

我們不太在乎額外的部分。這與翻譯範例中的翻譯程式碼非常相似。同樣地,我們來旋轉。正如我們在旋轉貼文中指出的,我們只需要旋轉角度的正弦和餘弦。

s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);

我們建立這樣的矩陣

c-s0.0
秒c0.0
0.00.01.0

套用矩陣後,會得到

newX = x * c + y * s + 1 * 0

newY = x * -s + y * c + 1 * 0

extra = x * 0.0 + y * 0.0 + 1 * 1

將所有乘以 0 和 1 的部分塗黑,

newX = x *  c + y * s;
newY = x * -s + y * c;

這與旋轉範例中的結果完全相同。最後是擴充。我們將 2 個縮放比例係數稱為 sx 和 sy,並建立如下矩陣

sx0.00.0
0.0sy0.0
0.00.01.0

套用矩陣後,會得到

newX = x * sx + y * 0 + 1 * 0

newY = x * 0 + y * sy + 1 * 0

extra = x * 0.0 + y * 0.0 + 1 * 1

這項功能

newX = x * sx;
newY = y * sy;

這與我們的縮放範例相同。現在,您可能還在思考。那又怎樣?有什麼意義?這似乎需要花費大量精力,才能完成我們已經在做的事情?這時,神奇之處就來了。原來我們可以將矩陣相乘,一次套用所有轉換。假設我們有一個函式 matrixMultiply,會接收兩個矩陣,將兩者相乘並傳回結果。為了讓內容更清楚,我們來建立函式,建構平移、旋轉和縮放矩陣。

function makeTranslation(tx, ty) {
  return [
    1, 0, 0,
    0, 1, 0,
    tx, ty, 1
  ];
}

function makeRotation(angleInRadians) {
  var c = Math.cos(angleInRadians);
  var s = Math.sin(angleInRadians);
  return [
    c,-s, 0,
    s, c, 0,
    0, 0, 1
  ];
}

function makeScale(sx, sy) {
  return [
    sx, 0, 0,
    0, sy, 0,
    0, 0, 1
  ];
}

現在來變更著色器。舊著色器看起來像這樣

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
uniform vec2 u_scale;

void main() {
  // Scale the positon
  vec2 scaledPosition = a_position * u_scale;

  // Rotate the position
  vec2 rotatedPosition = vec2(
     scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
     scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);

  // Add in the translation.
  vec2 position = rotatedPosition + u_translation;
  ...

我們的新著色器會簡單得多。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform vec2 u_resolution;
uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  vec2 position = (u_matrix * vec3(a_position, 1)).xy;
  ...

以下說明我們如何使用這項功能

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix =
       makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

    // Set the matrix.
    gl.uniformMatrix3fv(matrixLocation, false, matrix);

    // Draw the rectangle.
    gl.drawArrays(gl.TRIANGLES, 0, 18);
  }

不過,您可能會想問,那又怎樣?這似乎沒什麼好處。但現在如果想變更順序,就不必編寫新的著色器。只要修改數學公式即可。

    ...
    // Multiply the matrices.
    var matrix = matrixMultiply(translationMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, scaleMatrix);
    ...

能夠套用這類矩陣,對於階層式動畫 (例如身體上的手臂、圍繞太陽的行星上的月亮,或樹上的樹枝) 尤其重要。以下是階層式動畫的簡單範例,我們繪製「F」5 次,但每次都從前一個「F」的矩陣開始。

  // Draw the scene.
  function drawScene() {
    // Clear the canvas.
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Compute the matrices
    var translationMatrix = makeTranslation(translation[0], translation[1]);
    var rotationMatrix = makeRotation(angleInRadians);
    var scaleMatrix = makeScale(scale[0], scale[1]);

    // Starting Matrix.
    var matrix = makeIdentity();

    for (var i = 0; i < 5; ++i) {
      // Multiply the matrices.
      matrix = matrixMultiply(matrix, scaleMatrix);
      matrix = matrixMultiply(matrix, rotationMatrix);
      matrix = matrixMultiply(matrix, translationMatrix);

      // Set the matrix.
      gl.uniformMatrix3fv(matrixLocation, false, matrix);

      // Draw the geometry.
      gl.drawArrays(gl.TRIANGLES, 0, 18);
    }
  }

為此,我們必須導入 makeIdentity 函式,建立單位矩陣。單位矩陣是有效代表 1.0 的矩陣,因此乘以單位矩陣不會產生任何效果。就像

X * 1 = X

so too

matrixX * identity = matrixX

以下是建立單位矩陣的程式碼。

function makeIdentity() {
  return [
    1, 0, 0,
    0, 1, 0,
    0, 0, 1
  ];
}

再舉一個例子,到目前為止,在每個範例中,「F」都是以左上角為中心旋轉。這是因為我們使用的數學運算一律會繞著原點旋轉,而「F」的左上角位於原點 (0, 0)。 但現在,由於我們可以進行矩陣數學運算,並選擇套用轉換的順序,因此可以在套用其餘轉換之前移動原點。

    // make a matrix that will move the origin of the 'F' to
    // its center.
    var moveOriginMatrix = makeTranslation(-50, -75);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(moveOriginMatrix, scaleMatrix);
    matrix = matrixMultiply(matrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);

使用這項技術,您就能從任何點旋轉或縮放。現在您知道 Photoshop 或 Flash 如何讓您移動旋轉點。我們來做更瘋狂的事。如果回顧第一篇 WebGL 基本概念文章,您可能會記得著色器中有程式碼,可將像素轉換為裁剪空間,如下所示。

  ...
  // convert the rectangle from pixels to 0.0 to 1.0
  vec2 zeroToOne = position / u_resolution;

  // convert from 0->1 to 0->2
  vec2 zeroToTwo = zeroToOne * 2.0;

  // convert from 0->2 to -1->+1 (clipspace)
  vec2 clipSpace = zeroToTwo - 1.0;

  gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);

如果依序查看每個步驟,第一個步驟「將像素轉換為 0.0 到 1.0」實際上是縮放作業。第二個也是擴充作業。接著是平移,最後將 Y 縮放 -1。實際上,我們可以在傳遞至著色器的矩陣中完成所有操作。我們可以建立 2 個縮放矩陣,一個縮放 1.0/解析度,另一個縮放 2.0,第 3 個平移 -1.0、-1.0,第 4 個將 Y 縮放 -1,然後將它們全部相乘,但由於數學很簡單,我們將建立一個函式,直接為給定的解析度建立「投影」矩陣。

function make2DProjection(width, height) {
  // Note: This matrix flips the Y axis so that 0 is at the top.
  return [
    2 / width, 0, 0,
    0, -2 / height, 0,
    -1, 1, 1
  ];
}

現在,我們可以進一步簡化著色器。以下是完整的全新頂點著色器。

<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;

uniform mat3 u_matrix;

void main() {
  // Multiply the position by the matrix.
  gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>

在 JavaScript 中,我們需要將投影矩陣

  // Draw the scene.
  function drawScene() {
    ...
    // Compute the matrices
    var projectionMatrix =
       make2DProjection(canvas.width, canvas.height);
    ...

    // Multiply the matrices.
    var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
    matrix = matrixMultiply(matrix, translationMatrix);
    matrix = matrixMultiply(matrix, projectionMatrix);
    ...
  }

我們也移除了設定解析度的程式碼。最後這個步驟,我們從相當複雜的 6 到 7 個步驟著色器,變成只有 1 個步驟的簡單著色器,這一切都歸功於矩陣數學的魔力。

希望這篇文章能幫助您瞭解矩陣數學。接下來,我會介紹 3D。3D 矩陣數學遵循相同的原則和用法。我先從 2D 開始,希望簡單易懂。