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。我们构建了一个如下所示的矩阵,

0.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

同样

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 个矩阵将 Y 乘以 -1,第四个矩阵将 Y 乘以 -1,然后将所有矩阵相乘,但鉴于数学比较简单,我们只需创建一个函数,即可直接生成指定分辨率的“投影”矩阵。

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。在三维矩阵数学中,遵循相同的原则和用法。我从 2D 开始,希望能让它易于理解。