iT邦幫忙

2021 iThome 鐵人賽

DAY 9
0
Modern Web

如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起系列 第 9

2D Transform

大家好,我是西瓜,你現在看到的是 2021 iThome 鐵人賽『如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起』系列文章的第 9 篇文章。本系列文章從 WebGL 基本運作機制以及使用的原理開始介紹,最後建構出繪製 3D、光影效果之網頁。講完 WebGL 基本機制後,本章節講述的是 texture 以及 2D transform,如果在閱讀本文時覺得有什麼未知的東西被當成已知的,可能可以在前面的文章中找到相關的內容

在上篇加入了 u_offset 來控制物件的平移,如果我們現在想要進行縮放、旋轉,那麼就得在傳入更多 uniform,而且這些動作會有誰先作用的差別,比方說,先往右旋轉 90 度、接著往右平移 30px,與先往右平移 30px、再往右旋轉 90 度,這兩著會獲得不一樣的結果,如果只是傳平移量、縮放量、旋轉度數,在 vertex shader 內得有一個寫死的的先後作用順序,那麼在應用層面上就會受到這個先後順序的限制,因此在 vertex 運算座標的時候,通常會運用線性代數,傳入一個矩陣,這個矩陣就能包含任意先後順序的平移、縮放、旋轉動作

矩陣運算

為什麼一個矩陣就能夠包含任意先後順序的平移、縮放、旋轉動作?筆者覺得筆者不論怎麼用文字怎麼解釋,都不會比這部 Youtube 影片解釋得好:作為合成的矩陣乘法 by 3Blue1Brown,如果覺得有需要,也可以從這系列影片的第一部影片開始看。總之,所有的平移、縮放、旋轉動作(現在開始應該說轉換 -- transform)都可以用一個矩陣表示,而把這些 transform 矩陣與向量相乘後,可以得到平移、縮放或旋轉後的結果,而且重點來了,我們也可以事先把多個矩陣相乘,這個相乘的結果與向量相乘與個別依序與向量相乘會得到相同的 transform;舉例來說,先旋轉 45 度、往右平移 30px、再旋轉 45 度,這三個 transform 依序作用在一個向量上,與把這三個 transform 代表的矩陣先相乘,再跟向量相乘,會得到一樣的結果,基於這個特性,我們只要傳送這個相乘出來的矩陣即可

但是上面 3Blue1Brown 的影片使用了數學界的慣例,一個二維的向量是這樣表示:

math-vec2

然而,在程式語言(如 Javascript)中也只能這樣寫:[x, y],因此在數學式與程式語言寫法之間要把行列做對調,一個像這樣的 2x2 矩陣在程式語言中的寫法如藍色文字所示:

cs-matrix

對向量進行 transform 的『矩陣乘以向量』右到左算的寫法,假設向量為 [5, 6]:

math-matrix-product

在 shader 或是 Javascript 內一樣是右到左,大致會變成這樣寫:

v = [5, 6]
m = [
  1, 2,
  3, 4,
]
multiply(m, v) => [23, 34]

在模擬電腦中的矩陣運算時,如果覺得在紙筆跟程式語言表示之間每次都要做行列轉換很麻煩,那麼也可以改變一下矩陣計算的方式,下圖中每個運算式的左方小格子是由右方相乘的兩個矩陣的長條形區域相乘而成:

matrix-product-difference

調整 vertex shader 使用矩陣運算

建立 u_matrix 取代 u_offset 以及 u_resolution,然後讓 u_matrixa_position 相乘,相乘的結果即為任意順序的平移、縮放、旋轉做完後的座標位置。沒錯,針對螢幕寬高調整 clip space 的 u_resolution 也可以在矩陣中順便帶著

 attribute vec2 a_position;
 attribute vec2 a_texcoord;
  
-uniform vec2 u_resolution;
-uniform vec2 u_offset;
+uniform mat3 u_matrix;
  
 varying vec2 v_texcoord;
  
 void main() {
-  vec2 position = a_position + u_offset;
-  gl_Position = vec4(
-    position / u_resolution * vec2(2, -2) + vec2(-1, 1),
-    0, 1
-  );
+  vec3 position = u_matrix * vec3(a_position.xy, 1);
+  gl_Position = vec4(position.xy, 0, 1);
   v_texcoord = a_texcoord;
 }
 `;

u_matrix 的資料型別為 mat3,為 3x3 矩陣,而且在 GLSL 中 * 運算子可以接受 mat3vec3 相乘,u_matrix * vec3(a_position.xy, 1) 就可以得到『使用矩陣轉換向量』後的結果,可是說是把矩陣運算直接內建在語法中了;同時也可以注意到 u_matrix 為 3x3 矩陣,我們還在 2D,為何要用 mat3 呢?因為 2x2 矩陣是無法包含『平移 (translate)』的,要做到平移,必須在運算時增加一個維度,並填上 1,使得向量為 [x, y, 1],接著平移矩陣就是單位矩陣在多餘的維度中放上要平移的量,舉個例子,向量為 [4, 5],要平移 [2, 3]:

multiply(
  [
    1, 0, 0,
    0, 1, 0,
    2, 3, 1,
  ],
  [4, 5, 1]
) // =>
// [
//   6, 8 ,1
// ]

數學上寫起來像是這樣:

math-translate

稍微觀察一下中間的計算過程,應該就能知道為什麼這樣可以形成平移,在多餘維度上的數字會與向量的 1 相乘加在原本的座標上

當然,uniform 位置的取得得修改一下:

   const uniforms = {
-    resolution: gl.getUniformLocation(program, 'u_resolution'),
+    matrix: gl.getUniformLocation(program, 'u_matrix'),
     texture: gl.getUniformLocation(program, 'u_texture'),
-    offset: gl.getUniformLocation(program, 'u_offset'),
   };

平移以及投影的矩陣

shader, uniform 部份準備好後,建立 lib/matrix.js,用來產生特定 transform 用的矩陣,同時也實做 Javascript 端的矩陣相乘運算,才能『事先』運算、合成好矩陣給 GPU 使用。除了矩陣相乘 matrix3.mulitply() 之外,也把上面提到的平移矩陣 matrix3.translate() 實做好:

export const matrix3 = {
  multiply: (a, b) => ([
    a[0]*b[0] + a[3]*b[1] + a[6]*b[2], /**/ a[1]*b[0] + a[4]*b[1] + a[7]*b[2], /**/ a[2]*b[0] + a[5]*b[1] + a[8]*b[2],
    a[0]*b[3] + a[3]*b[4] + a[6]*b[5], /**/ a[1]*b[3] + a[4]*b[4] + a[7]*b[5], /**/ a[2]*b[3] + a[5]*b[4] + a[8]*b[5],
    a[0]*b[6] + a[3]*b[7] + a[6]*b[8], /**/ a[1]*b[6] + a[4]*b[7] + a[7]*b[8], /**/ a[2]*b[6] + a[5]*b[7] + a[8]*b[8],
  ]),

  translate: (x, y) => ([
    1, 0, 0,
    0, 1, 0,
    x, y, 1,
  ]),
};

那麼就剩下針對螢幕寬高調整 clip space 的矩陣,這樣的矩陣稱為 projection,也就是把場景中一個寬高區域框起來,『投影』在 clip space -- 畫布上,看著原本 shader 程式碼拆解一下:

position / u_resolution * vec2(2, -2) + vec2(-1, 1)

可以發現,我們要分別對 x 座標縮放 2 / u_resolution.x 倍、對 y 軸縮放 -2 / u_resolution.y 倍,做完縮放後平移 vec2(-1, 1),平移已經知道要怎麼做了,那麼縮放的矩陣要怎麼產生呢?觀察一下這個算式:

math-scale

單位矩陣中對應維度的數字即為縮放倍率,在這邊把 x 座標乘以 2、y 座標乘以 3,其他欄位為零不會影響,因此縮放矩陣的產生 matrix3.scale() 這樣實做:

  scale: (sx, sy) => ([
    sx, 0,  0,
    0,  sy, 0,
    0,  0,  1,
  ]),

最後投影矩陣的產生 matrix3.projection(),為平移與縮放相乘:

  projection: (width, height) => (
    matrix3.multiply(
      matrix3.translate(-1, 1),
      matrix3.scale(2 / width, -2 / height),
    )
  ),

記得矩陣運算與一般運算運算不同,向量放在最右邊,向左運算,因此 matrix3.translate(-1, 1) 雖然放在前面,但是其 transform 是在 matrix3.translate(-1, 1) 之後的

這樣一來 matrix3 就準備好,回到主程式引入:

import { matrix3 } from './lib/matrix.js';

畫龍點睛的時候來了,在 render() function 設定 uniform 的地方產生、運算矩陣:

const viewMatrix = matrix3.projection(gl.canvas.width, gl.canvas.height);
const worldMatrix = matrix3.translate(...state.offset);

gl.uniformMatrix3fv(
  uniforms.matrix,
  false,
  matrix3.multiply(viewMatrix, worldMatrix),
);

筆者先製作名叫 viewMatrix 的矩陣,包含 matrix3.projection() 負責投影到 clip space;以及另一個矩陣稱為 worldMatrix,表示該物件在場景中位置的 transform。最後把 viewMatrixworldMatrix 相乘,得到包含所有 transform 的矩陣,並使用 gl.uniformMatrix3fv() 設定到 uniform 上,其第二個參數表示要不要做轉置 transpose,我們沒有需要因此傳入 false

存檔重整,使用 matrix 做 transform 的版本看起來跟先前 offset 的版本一模一樣:live 版本

2d-transform-penguin

總結一下 transform,首先 u_matrixviewMatrixworldMatrix 相乘viewMatrixmatrix3.projection()worldMatrixmatrix3.translate(offset),最後在 vertex shader 內 u_matrix * a_position,來展開一路從 a_positiongl_Position / clip space 的運算式(忽略維度的調整):

gl_Position = u_matrix * a_position;
=> gl_Position = viewMatrix * worldMatrix * a_position;
=> gl_Position = matrix3.projection() * matrix3.translate(offset) * a_position;

而 linear transform 在電腦中與數學上都是由右到左計算的,因此最後整個計算下來的效果就是:先對 a_position 平移 offset 量 (translate),接著投影螢幕範圍到 clip space (projection)

完整程式碼可以在這邊找到:github.com/pastleo/webgl-ironman/commit/20f165c

雖然看似沒有什麼新功能,且只有兩個 transform,但是建構出來的流程讓我們可以很容易地加入更多 transform,也更適合之後 3D 場景中複雜的物件位置到螢幕上位置的運算,待下篇再繼續加入更多 2D transform


上一篇
互動 & 動畫
下一篇
2D transform Continued
系列文
如何在網頁中繪製 3D 場景?從 WebGL 的基礎開始說起30

尚未有邦友留言

立即登入留言