WebGL 控制图形的形变(模型矩阵)

314 阅读10分钟

本文主要介绍 WebGL 中对于图形的形变的控制,包括平移、旋转和缩放。

平移

使用向量

之前在《动态绘制点》中,我们通过不断地使用 gl.vertexAttrib4f() 改变顶点的 x 坐标实现了点的平移效果,但是假如我们要平移的是 《使用缓冲区绘制点线面》中绘制的三角面呢?此时我们可以直接通过矢量运算来实现,控制面的平移,实际上就是控制各个顶点的平移:

<!-- 代码片段 1 -->
<script type="x-shader/x-vertex" id="vsSource">
  attribute vec4 aPosition;
  uniform vec4 uTranslation;
  void main() {
  gl_Position = aPosition + uTranslation;
  }
</script>

因为平移时各个点的平移的大小和方向是相同的,所以 uTranslation 为 uniform 变量,它的类型也是 vec4,想让点在各个方向上平移多少,就直接让 uTranslation 的各个分量等于多少,最后一个齐次坐标传 0,表示此时的 vec4 代表的是一个向量而不是一个顶点。因为在 GLSL 中,可以直接进行矢量与矢量的加减乘除运算,所以可以让 gl_Position 的值直接等于 aPosition + uTranslation 即可得到平移后的点的坐标。

接着就是之前《动态绘制点》中说过的如何使用 js 对 uniform 变量进行获取与赋值了,配合动画,代码如下:

// 代码片段 1.1
// 获取 uTranslation 的内存地址
const uTranslation = gl.getUniformLocation(program, 'uTranslation')

let x = -1
function animation() {
  x += 0.01
  if (x > 1) {
    x = -1
  }
  gl.uniform4f(uTranslation, x, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT)
  // 执行绘制
  gl.drawArrays(gl.TRIANGLES, 0, 3)
  requestAnimationFrame(animation)
}
animation()

通过改变 uTranslation 的分量值从而实现平移的效果:

平移矩阵

在 webgl 中,控制图形的平移除了可以使用上面介绍的方法,更多地其实是使用我们在高中或大学的数学课里学过的矩阵 —— 它是一个按照矩形纵横排列的复数集合,可以看成 m 行 n 列的数据表格。通过矩阵,可以把一个点转换到另一个点,比如现在想让点 a 从 (x1, y1, z) 平移到 a' 的位置 (x2, y2, z):

2024-03-13_135854.png

就可以得到下面的式子,点 a 和点 a' 都使用了列矢量,或者说 4 行 1 列矩阵来表示。注意顺序,因为只有 m 行 s 列的矩阵和 s 行 n 列的矩阵才能相乘,得到的会是一个 m 行 n 列的矩阵:

2024-03-13_140038.png

即 x2 的获取有等式:a*x1 + b*y1 + c*z + d*w = x2,因为表示的是 3 维空间内的一个点,所以 w1,则 a*x1 + b*y1 + c*z + d = x2,而由图易知 x1 + x = x2,两个公式一对比,可得 a1bc0dx。以此类推,可以获取到平移矩阵为:

2024-03-13_140629.png

webgl 中,表示矩阵需要用到类型化数组,而且 uniformMatrix4fv() 传值时是列主序的,我们可以对上述矩阵进行转置,即行列互换,从而获取得到平移矩阵的方法 getTranslateMatrix

function getTranslateMatrix(x = 0, y = 0, z = 0) {
  return new Float32Array([
    1.0, 0.0, 0.0, 0.0,
    0.0, 1.0, 0.0, 0.0,
    0.0, 0.0, 1.0, 0.0,
    x,   y,   z,   1.0
  ])
}

我们对代码片段 1 做些修改,将原本的 vec4 类型的变量 uTranslation 改为代表四阶矩阵的数据类型 mat4uMatrix,然后让uMatrix 直接与 aPosition 相乘得到平移后的新的点位坐标,注意在计算时,矩阵要放在乘号的左边:

// 代码片段 1.2
const vsSource = `
  attribute vec4 aPosition;
  uniform mat4 uMatrix;
  void main() {
    // 点的坐标
    gl_Position = uMatrix * aPosition;
  }
`

然后将原本的代码片段 1.1 中获取 uTranslation 的内存地址改为去获取 uMatrix

// 代码片段 1.3
// 获取 uMatrix 的内存地址
const uMatrix = gl.getUniformLocation(program, 'uMatrix')

并且使用 gl.uniformMatrix4fv() 方法为 uMatrix 指定矩阵值,它的第 1 个参数为要修改的 uniform 变量的位置,也就是代码片段 1.3 中获取的 uMatrix,第 2 个参数指定是否转置矩阵,在 webgl 中恒为 false,第 3 个参数为 Float32Array 型或者是 GLfloat 序列值,也就是矩阵值:

const mat = getTranslateMatrix(x)
gl.uniformMatrix4fv(uMatrix, false, mat)

旋转

修改点的分量

实现图形旋转的原理同平移其实是一样的,也是控制各个顶点的旋转。以绕着 z 轴旋转为例,顶点着色器源码部分如下,声明 uniform 变量 uAngle 为旋转的弧度,类型为浮点数,sin()cos() 为 GLSL 中的函数,参数为弧度:

// 代码片段 2
const VERTEX_SHADER_SOURCE = `
  attribute vec4 aPosition;
  uniform float uAngle;
    void main() {
      gl_Position.x = aPosition.x * cos(uAngle) - aPosition.y * sin(uAngle);
      gl_Position.y = aPosition.x * sin(uAngle) + aPosition.y * cos(uAngle);
      gl_Position.z = aPosition.z;
      gl_Position.w = aPosition.w;
    }
`

我们可以单独给 gl_Position 各个分量赋值,因为 GLSL 中可以直接通过 x、y、z、w 来访问顶点坐标的分量,又因为是绕 z 轴旋转,所以 gl_Position.z 就等于 aPosition.z。可能的难点在于 gl_Position.xgl_Position.y 的值的数学方面的推导,下面我画个图说明:

2024-03-13_141854.png

以一个点为例,当点从 (x1, y1) 逆时针旋转 b 弧度的角到 (x2, y2),那么 x2 的值,可以通过 L * cosC 获取,即 x2 = L * cosC。角 C 为角 a 和角 b 的合角,所以 cosC = cos(a+b),由余弦公式得,cos(a+b) = cosa * cosb - sina * sinb。那么 x2 = L * (cosa * cosb - sina * sinb),而 L * cosa 等于 x1,L * sina 等于 y1,所以 x2 = x1 * cosb - y1 * sinb

带入到代码中,x2 即为要赋值的 gl_Position.x,x1 为旋转前的点的 x 轴坐标aPosition.x,y1 为旋转前的 y 轴坐标 aPosition.y,旋转弧度 b 为 uAngle,所以 gl_Position.x = aPosition.x * cos(uAngle) - aPosition.y * sin(uAngle)。至于 gl_Position.y 推导,使用的是正弦公式 sin(a+b) = sina * cosb + cosa * sinb,就不再赘述了。

弄清楚了 gl_Position.xgl_Position.y 是怎么获取的,接下来的就还是关于 uniform 变量的获取和结合动画的赋值了:

// 代码片段 2.2
const uAngle = gl.getUniformLocation(program, 'uAngle')
let x = 0
function animation() {
  x += 0.01
  gl.uniform1f(uAngle, x)
  // 执行绘制
  gl.drawArrays(gl.TRIANGLES, 0, 3)
  requestAnimationFrame(animation)
}
animation()

需要注意的是在 webgl 中,逆时针的旋转为正向旋转,所以当我们不断增加 x 的值时,得到的是一个逆时针旋转的三角形:

旋转矩阵

矩阵旋转与矩阵平移类似,这里探究对应的 webgl 中矩阵的第一列(旋转矩阵的第一行)是如何推导出来的。矩阵与点 a 的向量相乘得到的还是 a * x1 + b * y1 + c * z + d * w = x2。而在旋转 uAngle 弧度时,将代码片段 2 中的gl_Position.x = aPosition.x * cos(uAngle) - aPosition.y * sin(uAngle) 做些变量的替换得到 x2 = x1 * cos(uAngle) - y1 * sin(uAngle),两个等式一对比,可知 acos(uAngle)b-sin(uAngle)cd 都为 0。 为了方便,我们做个角度与弧度之间的转换,最终得到的获取绕 z 轴逆时针旋转 deg 角度的矩阵的函数 getRotateZMatrix 如下:

// 绕 z 轴旋转矩阵
function getRotateZMatrix(deg) {
  // 角度转成弧度
  const arc = (deg * Math.PI) / 180
  return new Float32Array([
    Math.cos(arc),  Math.sin(arc), 0.0, 0.0,
    -Math.sin(arc), Math.cos(arc), 0.0, 0.0,
    0.0,            0.0,           1.0, 0.0,
    0.0,            0.0,           0.0, 1.0
  ])
}

绕 x 和 y 轴旋转的矩阵推导过程类似,下面直接放获取绕 x 轴与 y 轴逆时针旋转 uAngle 弧度的矩阵的函数 getRotateXMatrixgetRotateYMatrix

// 绕 x 轴旋转矩阵
function getRotateXMatrix(deg) {
  const arc = (deg * Math.PI) / 180
  return new Float32Array([
    1.0, 0.0,            0.0,           0.0,
    0.0, Math.cos(arc),  Math.sin(arc), 0.0,
    0.0, -Math.sin(arc), Math.cos(arc), 0.0,
    0.0, 0.0,            0.0,           1.0
  ])
}

// 绕 y 轴旋转矩阵
function getRotateYMatrix(deg) {
  const arc = (deg * Math.PI) / 180
  return new Float32Array([
    Math.cos(arc), 0.0, -Math.sin(arc), 0.0,
    0.0,           1.0, 0.0,            0.0,
    Math.sin(arc), 0.0, Math.cos(arc),  0.0,
    0.0,           0.0, 0.0,            1.0
  ])
}

缩放

修改点的分量

做完了平移和旋转,缩放的实现就更轻松了,只需将 gl_Position 的 x、y、z 方向上的分量乘以缩放的比例系数,就可以实现缩放。比如只在 x 轴方向进行缩放:

<!-- 代码片段 3 -->
<script type="x-shader/x-vertex" id="vsSource">
  attribute vec4 aPosition;
  float uScale = 0.5;
  void main() {
    gl_Position = vec4(aPosition.x * uScale, aPosition.y, aPosition.z, 1.0);
  }
</script>

如果 x、y、z 方向都要缩放,可以使用 vec3(),传入 aPosition,得到 aPosition.xaPosition.yaPosition.z 组成的数组,再统一乘以 uScale

<!-- 代码片段 3.1 -->
<script type="x-shader/x-vertex" id="vsSource">
  attribute vec4 aPosition;
  float uScale = 2.0;
  void main() {
    gl_Position = vec4(vec3(aPosition) * uScale, 1.0);
  }
</script>

效果如下,如果要做动画也可以像平移与旋转那样将 uScale 设置为 uniform 变量,再去通过 js 获取与赋值等,此处我就不再重复这些步骤了:

缩放矩阵

假设 x 轴方向的缩放比例为 tx,那么推导 webgl 中缩放矩阵第一列所依据的两个等式即为 a * x1 + b * y1 + c * z + d * w = x2tx * x1 = x2,那么 a 就应该等于 tx,b、c 和 d 都应该为 0。获取缩放矩阵的函数如下:

function getScaleMatrix(tx = 1, ty = 1, tz = 1) {
  return new Float32Array([
    tx,  0.0, 0.0, 0.0,
    0.0, ty,  0.0, 0.0,
    0.0, 0.0, tz,  0.0,
    0.0, 0.0, 0.0, 1.0
  ])
}

缩放矩阵其实是个对角矩阵,其转置等于本身。

复合矩阵(模型矩阵)

如果我们想让三角面先缩放再旋转最后平移,可以借助复合矩阵轻松实现,只需要让 gl_Position 的值为 平移矩阵 * 旋转矩阵 * 缩放矩阵 * aPosition 即可。但如若我们分别给平移矩阵、旋转矩阵和缩放矩阵都创建个 uniform 变量再去分别获取赋值,就会比较繁琐,我们可以只设置一个复合矩阵(或者称为模型矩阵,也就是 MVP 矩阵中的 M,Model 矩阵)) uMatrix,然后让它的值为多个矩阵相乘的结果。

在数学中,两个行主序的矩阵 A(m 行 s 列) 和 B(s 行 n 列) 能够相乘,需要满足前一个矩阵的列数等于后一个矩阵的行数这一条件,相乘的结果是一个 m 行 n 列的新矩阵 C,注意矩阵的乘法是不符合交换律的:

1.png

在使用 uniformMatrix4fv() 向顶点着色器传递矩阵值时,值需要是列主序的,即需要传入的是矩阵 C 的转置矩阵 C`:

2.png

而在矩阵的运算性质中,A 矩阵乘上 B 矩阵后得到的新矩阵的转置,会等于 B 的转置矩阵 B` 乘上 A 的转置矩阵 A`,即 (AB)` = B`A`:

3.png

所以如果我们想让三角面先平移再旋转,即 uMatrix 应该等于(旋转矩阵 * 平移矩阵)`,也就等于平移矩阵` * 旋转矩阵`。之前我们定义的 getTranslateMatrix() 等获取形变矩阵的方法,返回的值恰好都均为转置后的矩阵。为了进一步方便今后的代码复用,可以再定义个获取复合矩阵(矩阵相乘)的函数 mixMatrix 如下,传入的参数 M 和 N 分别为转置后的形变矩阵:

function mixMatrix(M, N) {
  const result = new Float32Array(16)
  for (let i = 0; i < 4; i++) {
    result[i] =
      M[0] * N[i] + M[1] * N[i + 4] + M[2] * N[i + 8] + M[3] * N[i + 12]
    result[i + 4] =
      M[4] * N[i] + M[5] * N[i + 4] + M[6] * N[i + 8] + M[7] * N[i + 12]
    result[i + 8] =
      M[8] * N[i] + M[9] * N[i + 4] + M[10] * N[i + 8] + M[11] * N[i + 12]
    result[i + 12] =
      M[12] * N[i] +
      M[13] * N[i + 4] +
      M[14] * N[i + 8] +
      M[15] * N[i + 12]
  }
  return result
}

假设 M 为平移矩阵的转置, N 为旋转矩阵的转置,那么 mixMatrix(M, N) 返回的结果 result 将是 旋转矩阵 * 平移矩阵 的转置矩阵,效果为让各个顶点先平移再旋转。

现在,我们就可以让 uMatrix 等于各个变换矩阵代入 mixMatrix() 后的结果了,比如想让三角面先绕 z 轴逆时针旋转 90°,再向右平移 0.5(webgl 坐标):

const mat = mixMatrix(getRotateZMatrix(90), getTranslateMatrix(0.5))
gl.uniformMatrix4fv(uMatrix, false, mat)

具体代码及效果如下:

使用矩阵库

除了自己定义矩阵,我们还可以使用现成的矩阵库,比如 gl-matrix。为了方便演示,我去 github 下载了 gl-matrix.js 放在了项目的 utils 目录下,使用时直接在页面 <script src="./utils/gl-matrix.js"></script> 引入。引入之后,在全局就有了个 glMatrix 对象,可以打印查看:

1.png

其中带有 mat 的就是些矩阵对象,vec 是一些向量对象,控制图像形变我们使用四阶矩阵对象 mat4。现在,如果要获得一个平移矩阵,只需要写如下代码即可:

const { mat4 } = glMatrix
const translateMatrix = mat4.create()
mat4.fromTranslation(translateMatrix, [1, 0, 0])

mat4.create() 创建的是单位矩阵 translateMatrix,可以看到它里面的元素已经是 Float32Array 类型的了:

2.png

mat4.fromTranslation(translateMatrix, [1, 0, 0]) 是让 translateMatrix 变为平移矩阵(列主序),且平移的方向为 x 轴正方向,大小为 1。

感谢.gif 点赞.png