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。因此,你可以将某个值乘以这个单位圆,在某种程度上,这有点像乘以 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,以便传入这两个值。

  ...
  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,以此类推。

如果您开始以弧度思考,几乎所有涉及角度、圆或旋转的数学运算都会变得非常简单。快来试试吧!使用弧度,而不是度数(界面显示除外)。

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 章有助于您了解平移、旋转和缩放。接下来,我们将介绍矩阵的神奇之处,它能将这三种变换组合成一种更简单、通常也更有用的形式。

为什么是“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 个值,因此我们将使用 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;

这正是我们在旋转示例中看到的效果。 最后是规模。我们将两个缩放比例分别称为 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);
    ...

能够应用此类矩阵对于分层动画(例如身体上的手臂、围绕太阳的行星上的卫星或树上的树枝)尤为重要。为了简单演示分层动画,我们来绘制 5 次“F”,但每次都从上一个“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 个用于按 -1 缩放 Y,然后将它们全部相乘,但由于数学运算很简单,我们将直接创建一个函数,为给定分辨率创建“投影”矩阵。

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 开始,希望这样能让大家更容易理解。