2D transform Continued

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 WebGL 的基础开始说起』系列文章的第 10 篇文章。本系列文章从 WebGL 基本运作机制以及使用的原理开始介绍,最後建构出绘制 3D、光影效果之网页。讲完 WebGL 基本机制後,本章节讲述的是 texture 以及 2D transform,如果在阅读本文时觉得有什麽未知的东西被当成已知的,可能可以在前面的文章中找到相关的内容

上篇把原本透过 u_offsetu_resolution 来控制平移以及 clip space 投影换成只用一个矩阵来做转换,我们实做了矩阵的相乘 (multiply)、平移 (translate) 以及缩放 (scale),在常见的 transform 中还剩下旋转 (rotation) 尚未实做,除此之外 lib/matrix.js 也缺乏一些常用的小工具,本篇将加上平移、缩放、旋转之控制项,同时把这些矩阵工具补完

旋转 transform

根据维基百科,可以知道如果原本一个向量为 (x, y),旋转 θ 角度後将变成 (x', y'),那麽公式为:

math-rotation-equation

同时其 tranform 矩阵为:

math-rotation-matrix

只不过我们需要的是 3x3 的矩阵,才能符合运算时的需要,多余的维度跟单位矩阵一样,同时记得行列转换成电脑世界使用的惯例(假设要旋转角度为 rad):

[
  Math.cos(rad),  Math.sin(rad), 0,
  -Math.sin(rad), Math.cos(rad), 0,
  0,              0,             1,
]

最後实做在 lib/matrix.js:

  rotate: rad => {
    const c = Math.cos(rad), s = Math.sin(rad);
    return [
      c, s, 0,
      -s, c, 0,
      0, 0, 1,
    ]
  },

加入平移、缩放、旋转控制

像是速度控制那样,在 HTML 中分别给 X 轴平移、Y 轴平移、缩放、旋转一个 range input

<!-- <form id="controls"> -->
<!-- ... -->
  <div class="py-1">
    <label for="translate-x">TranslateX</label>
    <input
      type="range" id="translate-x" name="translate-x"
      min="-150" max="150" value="0"
    >
  </div>
  <div class="py-1">
    <label for="translate-y">TranslateY</label>
    <input
      type="range" id="translate-y" name="translate-y"
      min="-150" max="150" value="0"
    >
  </div>
  <div class="py-1">
    <label for="scale">Scale</label>
    <input
      type="range" id="scale" name="scale"
      min="0" max="10" value="1" step="0.1"
    >
  </div>
  <div class="py-1">
    <label for="rotation">Rotation</label>
    <input
      type="range" id="rotation" name="rotation"
      min="0" max="360" value="0"
    >
  </div>
<!-- </form> -->

py-1 为模仿 tailwindCSS 的 padding,因为只有这一个 CSS 所以笔者直接实做在 HTML 中:.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }

同时也调整一下 HTML 排版,使得右上角控制 UI 看起来像是这样:

control-ui

接下来在 setup() 中初始化的 app.state 中加入平移、缩放、旋转:

     state: {
       texture: 0,
       offset: [0, 0],
       direction: [Math.cos(directionDeg), Math.sin(directionDeg)], 
+      translate: [0, 0],
+      scale: 1,
+      rotation: 0,
       speed: 0.08,
     },

修改矩阵计算之前,把状态与使用者输入事件串好:

   controlsForm.addEventListener('input', () => {
     const formData = new FormData(controlsForm);
     app.state.texture = parseInt(formData.get('texture'));
     app.state.speed = parseFloat(formData.get('speed'));
     const formData = new FormData(controlsForm);
     app.state.texture = parseInt(formData.get('texture'));
     app.state.speed = parseFloat(formData.get('speed'));
+    app.state.translate[0] = parseFloat(formData.get('translate-x'));                                                                 
+    app.state.translate[1] = parseFloat(formData.get('translate-y'));                                                                 
+    app.state.scale = parseFloat(formData.get('scale'));
+    app.state.rotation = parseFloat(formData.get('rotation')) * Math.PI / 180;                                                        
   });

使用旋转矩阵

render() 内,原本 worldMatrix 只有平移转换 translate(...state.offset);,现在开始也要由多个矩阵相乘:

const worldMatrix = matrix3.multiply(
  matrix3.translate(...state.offset),
  matrix3.rotate(state.rotation),
);

存档试玩看看:

rotation-origin-problem

如果我们想要用图片正中央来旋转而不是左上角呢?在输入顶点位置时,左上角的点为 (0, 0):

top-left-zero

matrix3.rotate() 是基於原点做旋转的,因此调整一下顶点位置,使得原点在正中间:

center-zero

   // a_position
   // ...
   gl.bufferData(
     gl.ARRAY_BUFFER,
     new Float32Array([
-      0, 0, // A
-      150, 0, // B
-      150, 150, // C
+      -75, -75, // A
+      75, -75, // B
+      75, 75, // C
  
-      0, 0, // D
-      150, 150, // E
-      0, 150, // F
+      -75, -75, // D
+      75, 75, // E
+      -75, 75, // F
     ]),
     gl.STATIC_DRAW,
   );

不过就没办法做完美的边缘碰撞测试了,笔者就用原点当成碰撞测试点:

   // function startLoop(app, now = 0) {
   // ...
-  if (state.offset[0] + 150 > gl.canvas.width) {
+  if (state.offset[0] > gl.canvas.width) {
     state.direction[0] *= -1;
-    state.offset[0] = gl.canvas.width - 150;
+    state.offset[0] = gl.canvas.width;
   } else if (state.offset[0] < 0) {
     state.direction[0] *= -1;
     state.offset[0] = 0;
   }
  
-  if (state.offset[1] + 150 > gl.canvas.height) {
+  if (state.offset[1] > gl.canvas.height) {
     state.direction[1] *= -1;
-    state.offset[1] = gl.canvas.height - 150;
+    state.offset[1] = gl.canvas.height;

图片就乖乖的以中心点旋转了:

rotation-origin-center

其实缩放也是从原点出发的,因此这个调整也可以修正待会加入缩放时变成从左上角缩放的问题。笔者学到矩阵 transform 时,似乎就可以感受到 WebGL 的世界为什麽很多东西都是以 -1 ~ +1 作为范围...这样使得原点在正中间,可能在硬体或是 driver 层也更方便使用矩阵做 transform 运算吧

所有 Tranform 我全都要

现在 worldMatrixmatrix3.translate()matrix3.rotate() 相乘而成,要串上使用者控制的 state.translate, state.scale,假设 worldMatrix 要用下面的算式计算而成:

translate(...state.offset) *
  rotate(state.rotation) *
  scale(state.scale, state.scale) *
  translate(...state.translate)

以现成的 matrix3.multiply() 来看会变成这样:

  const worldMatrix = matrix3.multiply(
    matrix3.multiply(
      matrix3.multiply(
        matrix3.translate(...state.offset),
        matrix3.rotate(state.rotation),
      ),
      matrix3.scale(state.scale, state.scale),
    ),
    matrix3.translate(...state.translate),
  );

显然可读性已经大幅下降,换行有波动拳的样子,没换行更惨,之後也会有许多超过两个矩阵依序相乘的状况,因此修改 lib/matrix.jsmatrix3.multiply() 使之可以接收超过两个矩阵,并递回依序做相乘:

  multiply: (a, b, ...rest) => {
    const multiplication = [
      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],
    ];

    if (rest.length === 0) return multiplication;
    return matrix3.multiply(multiplication, ...rest);
  },

...rest 的语法叫做 rest parameters,传超过 2 个参数时再呼叫自己将这回合计算的结果继续与剩下的矩阵做计算

回到主程序,worldMatrix 就可以用清楚的语法写了:

const worldMatrix = matrix3.multiply(
  matrix3.translate(...state.offset),
  matrix3.rotate(state.rotation),
  matrix3.scale(state.scale, state.scale),
  matrix3.translate(...state.translate),
);

所有的控制就完成了,读者也可以自行调整这些矩阵相乘的顺序,玩玩看所谓『转换顺序』的差别

『什麽都不做』转换

在总结 2D transform 之前,给 lib/matrix.js 再补上一个 function:

  identity: () => ([
    1, 0, 0,
    0, 1, 0,
    0, 0, 1,
  ]),

这是一个单位矩阵,如果有时候要除错想要暂时取消一些矩阵的转换效果,但是不想修改程序结构:

const worldMatrix = matrix3.multiply(
  matrix3.translate(...state.offset),
  matrix3.rotate(state.rotation), // 想要暂时取消这行
);

其中一个暂时取消的方式是利用单位矩阵的特性:与其相乘不会改变任何东西,像是这样:

const worldMatrix = matrix3.multiply(
  matrix3.translate(...state.offset),
  matrix3.identity(),
  // matrix3.rotate(state.rotation), // 想要暂时取消这行
);

为了验证,回到主程序修改 worldMatrix 的计算:

const worldMatrix = matrix3.multiply(
  matrix3.identity(),
  matrix3.translate(...state.offset),
  matrix3.rotate(state.rotation),
  matrix3.scale(state.scale, state.scale),
  matrix3.translate(...state.translate),
);

不论 matrix3.identity() 放在哪个位置,都不会改变结果;上述用途只是其中一个举例,之後可能会因为两个物件共用同一个 shader,但是其中一个物件不需要特定转换,那麽也会传入单位矩阵来『什麽都不做』

本篇的完整程序码可以在这边找到:

Texture & 2D Transform 就到这边,笔者学习到此的时候深刻感受到线性代数的威力,输入的矩阵与理论结合扎实地反应在萤幕上,并为接下来 3D transform 打好基础,下个章节将进入 3D,开始尝试渲染现实世界所看到的样子


<<:  AI ninja project [day 10] 基因演算法

>>:  另一个现实世界,网路是如何运作的?

Day28_CSS语法11

border-radius(框线圆角) border-top-left-radius : 左上角显示...

30天学会C语言: Day 26-变数住哪里

变数 & 记忆体 变数的内容储存於记忆体中,记忆体就像是有很多格子的柜子,每格都会有一个编号...

到底是什麽意思?Typescript Partial<Type>

今天在翻旧code的时候,看到了 Partial 的写法,所幸来查查这到底是什麽意思.弄懂了之後看c...

Day25 订单 -- 重新付款1

再做订单的时候,常常会遇到重新付款的需求, 情境通常发生在使用者购物车加入一拖拉库的项目之後, 因为...

15【雷坑】千万别肖想用 APCS 升大学

事实上一现在的情况来看,若是要用 APCS 成绩当作升大学的跳板是完全不建议的,理由如下: 高手独占...