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


<<:  ASP.NET MVC 从入门到放弃 (Day4) -C#运算值介绍

>>:  DAY9 - BFS应用

[Day09] 选择困难再度发作之主题挑选

在昨天的文章中,我们先挑选了一个 Ananke 做为例子,今天我们来聊聊怎麽挑选主题,还有我在选择主...

30-29 之 DDD 战术篇 2 - Aggregate ( 未完成 )

什麽是 Aggregate 呢 ? 还记得我们谈过的 Bounded Context 与 Entit...

Day29 - GitLab CI 如何让工作流程流水线跑快一点?之三 让 Runner 执行更快一点

上一篇谈到从 .gitlab-ci.yml 开始建立关卡及工作,而後依序分派到工作伫列,等待 Git...

Visual studio 2019 使用AddressSanitizer检查程序码

AddressSanitizer (ASan) 是一种编译器和执行时间技术,会以 零 误报来公开许多...

Delegate的使用法 Day8

完蛋了今天,今天比昨天打完更严重,睡到晚上3点,被身体热醒,起床量个温度37.3好像又不是很高,但热...