一个基本的 2D WebGL 动画示例

在这个 WebGL 示例中,我们创建一个画布,并在其中使用 WebGL 渲染一个旋转的正方形。我们用来表示场景的坐标系与画布的坐标系相同。也就是说,(0, 0) 在左上角,右下角在 (600, 460)。

一个旋转正方形示例

让我们按照不同的步骤来获取我们的旋转正方形。

顶点着色器

首先,让我们看一下顶点着色器。它的作用,一如既往,是将我们用于场景的坐标转换为裁剪空间坐标(即,系统通过 (0, 0) 位于上下文中心,并且每个轴从 -1.0 扩展到 1.0,而不管上下文的实际大小)。

html
<script id="vertex-shader" type="x-shader/x-vertex">
  attribute vec2 aVertexPosition;

  uniform vec2 uScalingFactor;
  uniform vec2 uRotationVector;

  void main() {
    vec2 rotatedPosition = vec2(
      aVertexPosition.x * uRotationVector.y +
            aVertexPosition.y * uRotationVector.x,
      aVertexPosition.y * uRotationVector.y -
            aVertexPosition.x * uRotationVector.x
    );

    gl_Position = vec4(rotatedPosition * uScalingFactor, 0.0, 1.0);
  }
</script>

主程序与我们共享属性 aVertexPosition,它是顶点在其使用的任何坐标系中的位置。我们需要转换这些值,以便位置的两个分量都在 -1.0 到 1.0 的范围内。这可以通过乘以一个基于上下文的纵横比的缩放因子来轻松完成。我们很快就会看到计算过程。

我们还在旋转形状,我们可以在此处通过应用变换来做到这一点。我们将首先执行此操作。顶点的旋转位置是通过应用旋转向量计算出来的,旋转向量位于由 JavaScript 代码计算出的统一 uRotationVector 中。

然后,最终位置是通过将旋转位置乘以 JavaScript 代码中 uScalingFactor 提供的缩放向量来计算的。zw 的值分别固定为 0.0 和 1.0,因为我们在 2D 中绘制。

然后,标准 WebGL 全局 gl_Position 设置为变换和旋转顶点的位置。

片段着色器

接下来是片段着色器。它的作用是返回正在渲染的形状中每个像素的颜色。由于我们正在绘制一个没有应用光照的实心、无纹理的对象,因此这非常简单

html
<script id="fragment-shader" type="x-shader/x-fragment">
  #ifdef GL_ES
    precision highp float;
  #endif

  uniform vec4 uGlobalColor;

  void main() {
    gl_FragColor = uGlobalColor;
  }
</script>

这首先指定 float 类型的精度,这是必需的。然后,我们将全局 gl_FragColor 设置为统一 uGlobalColor 的值,该值由 JavaScript 代码设置为用于绘制正方形的颜色。

HTML

HTML 仅由我们将获取 WebGL 上下文的<canvas>组成。

html
<canvas id="glcanvas" width="600" height="460">
  Oh no! Your browser doesn't support canvas!
</canvas>

全局变量和初始化

首先是全局变量。我们不会在这里讨论它们;相反,我们将在代码中使用它们时讨论它们。

js
let gl = null;
let glCanvas = null;

// Aspect ratio and coordinate system
// details

let aspectRatio;
let currentRotation = [0, 1];
let currentScale = [1.0, 1.0];

// Vertex information

let vertexArray;
let vertexBuffer;
let vertexNumComponents;
let vertexCount;

// Rendering data shared with the
// scalers.

let uScalingFactor;
let uGlobalColor;
let uRotationVector;
let aVertexPosition;

// Animation timing

let shaderProgram;
let currentAngle;
let previousTime = 0.0;
let degreesPerSecond = 90.0;

程序的初始化通过名为 startup()load 事件处理程序处理

js
window.addEventListener("load", startup, false);

function startup() {
  glCanvas = document.getElementById("glcanvas");
  gl = glCanvas.getContext("webgl");

  const shaderSet = [
    {
      type: gl.VERTEX_SHADER,
      id: "vertex-shader",
    },
    {
      type: gl.FRAGMENT_SHADER,
      id: "fragment-shader",
    },
  ];

  shaderProgram = buildShaderProgram(shaderSet);

  aspectRatio = glCanvas.width / glCanvas.height;
  currentRotation = [0, 1];
  currentScale = [1.0, aspectRatio];

  vertexArray = new Float32Array([
    -0.5, 0.5, 0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, -0.5, -0.5,
  ]);

  vertexBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);

  vertexNumComponents = 2;
  vertexCount = vertexArray.length / vertexNumComponents;

  currentAngle = 0.0;

  animateScene();
}

获取 WebGL 上下文 gl 后,我们需要首先构建着色器程序。在这里,我们使用旨在让我们轻松地向程序添加多个着色器的代码。数组 shaderSet 包含一个对象列表,每个对象描述一个要编译到程序中的着色器函数。每个函数都有一个类型(gl.VERTEX_SHADERgl.FRAGMENT_SHADER 之一)和一个 ID(包含着色器代码的<script> 元素的 ID)。

着色器集传递到函数 buildShaderProgram() 中,该函数返回已编译和链接的着色器程序。我们接下来将了解它是如何工作的。

构建着色器程序后,我们通过将上下文的宽度除以高度来计算上下文的纵横比。然后,我们将动画的当前旋转向量设置为 [0, 1],并将缩放向量设置为 [1.0, aspectRatio]。缩放向量,正如我们在顶点着色器中看到的,用于缩放坐标以适应 -1.0 到 1.0 的范围。

接下来创建顶点数组,作为Float32Array,每个要绘制的三角形有六个坐标(三个 2D 顶点),总共 12 个值。

如您所见,我们对每个轴使用 -1.0 到 1.0 的坐标系。您可能会问,为什么我们需要进行任何调整?这是因为我们的上下文不是正方形。我们使用一个 600 像素宽、460 像素高的上下文。这两个维度中的每一个都映射到 -1.0 到 1.0 的范围。由于这两个轴的长度不同,如果我们不调整两个轴之一的值,正方形将在一个方向或另一个方向上拉伸。因此,我们需要标准化这些值。

创建顶点数组后,我们通过调用gl.createBuffer()创建一个新的 GL 缓冲区以包含它们。我们通过调用gl.bindBuffer()将标准 WebGL 数组缓冲区引用绑定到该缓冲区,然后使用gl.bufferData()将顶点数据复制到缓冲区中。指定了使用提示 gl.STATIC_DRAW,告诉 WebGL 数据仅设置一次且永不修改,但将重复使用。这使 WebGL 可以考虑任何可以基于该信息应用的优化,从而提高性能。

现在已将顶点数据提供给 WebGL,我们将 vertexNumComponents 设置为每个顶点中的组件数(2,因为它们是 2D 顶点),并将 vertexCount 设置为顶点列表中的顶点数。

然后,当前旋转角度(以度为单位)设置为 0.0,因为我们尚未执行任何旋转,并且旋转速度(每屏幕刷新周期以度为单位,通常为 60 FPS)设置为 6。

最后,调用 animateScene() 以渲染第一帧并安排渲染动画的下一帧。

编译和链接着色器程序

buildShaderProgram() 函数接受一个描述要编译和链接到着色器程序的一组着色器函数的对象数组作为输入,并在构建和链接后返回着色器程序。

js
function buildShaderProgram(shaderInfo) {
  const program = gl.createProgram();

  shaderInfo.forEach((desc) => {
    const shader = compileShader(desc.id, desc.type);

    if (shader) {
      gl.attachShader(program, shader);
    }
  });

  gl.linkProgram(program);

  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.log("Error linking shader program:");
    console.log(gl.getProgramInfoLog(program));
  }

  return program;
}

首先,调用gl.createProgram()创建一个新的空 GLSL 程序。

然后,对于指定着色器列表中的每个着色器,我们调用 compileShader() 函数对其进行编译,并将着色器函数的 ID 和类型传递给它以进行构建。如前所述,这些对象中的每一个都包含着色器代码所在的 <script> 元素的 ID 和它所属的着色器类型。通过将其传递到gl.attachShader(),已编译的着色器附加到着色器程序。

注意:实际上,我们可以更进一步,查看 <script> 元素的 type 属性的值以确定着色器类型。

所有着色器都编译完成后,使用gl.linkProgram()链接程序。

如果在链接程序时发生错误,则错误消息将记录到控制台。

最后,已编译的程序将返回给调用方。

编译单个着色器

下面的 compileShader() 函数由 buildShaderProgram() 调用以编译单个着色器。

js
function compileShader(id, type) {
  const code = document.getElementById(id).firstChild.nodeValue;
  const shader = gl.createShader(type);

  gl.shaderSource(shader, code);
  gl.compileShader(shader);

  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
    console.log(
      `Error compiling ${
        type === gl.VERTEX_SHADER ? "vertex" : "fragment"
      } shader:`,
    );
    console.log(gl.getShaderInfoLog(shader));
  }
  return shader;
}

通过获取包含在指定 ID 的<script>元素中的文本节点的值从 HTML 文档中获取代码。然后,使用gl.createShader()创建一个新的指定类型的着色器。

通过将其传递到gl.shaderSource()将源代码发送到新的着色器,然后使用gl.compileShader()编译着色器

编译错误会被记录到控制台。请注意,这里使用了模板字面量字符串,以便将正确的着色器类型字符串插入到生成的错误消息中。实际的错误详细信息可以通过调用gl.getShaderInfoLog()获取。

最后,编译后的着色器将返回给调用者(即buildShaderProgram()函数)。

绘制和动画场景

animateScene()函数用于渲染每一帧动画。

js
function animateScene() {
  gl.viewport(0, 0, glCanvas.width, glCanvas.height);
  gl.clearColor(0.8, 0.9, 1.0, 1.0);
  gl.clear(gl.COLOR_BUFFER_BIT);

  const radians = (currentAngle * Math.PI) / 180.0;
  currentRotation[0] = Math.sin(radians);
  currentRotation[1] = Math.cos(radians);

  gl.useProgram(shaderProgram);

  uScalingFactor = gl.getUniformLocation(shaderProgram, "uScalingFactor");
  uGlobalColor = gl.getUniformLocation(shaderProgram, "uGlobalColor");
  uRotationVector = gl.getUniformLocation(shaderProgram, "uRotationVector");

  gl.uniform2fv(uScalingFactor, currentScale);
  gl.uniform2fv(uRotationVector, currentRotation);
  gl.uniform4fv(uGlobalColor, [0.1, 0.7, 0.2, 1.0]);

  gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);

  aVertexPosition = gl.getAttribLocation(shaderProgram, "aVertexPosition");

  gl.enableVertexAttribArray(aVertexPosition);
  gl.vertexAttribPointer(
    aVertexPosition,
    vertexNumComponents,
    gl.FLOAT,
    false,
    0,
    0,
  );

  gl.drawArrays(gl.TRIANGLES, 0, vertexCount);

  requestAnimationFrame((currentTime) => {
    const deltaAngle =
      ((currentTime - previousTime) / 1000.0) * degreesPerSecond;

    currentAngle = (currentAngle + deltaAngle) % 360;

    previousTime = currentTime;
    animateScene();
  });
}

绘制动画帧的第一步是将背景清除为所需的颜色。在本例中,我们根据<canvas>的大小设置视口,调用clearColor()设置清除内容时使用的颜色,然后使用clear()清除缓冲区。

接下来,计算当前旋转向量,方法是将当前旋转角度(以度为单位,即currentAngle)转换为弧度,然后将旋转向量的第一个分量设置为该值的正弦,第二个分量设置为余弦currentRotation向量现在是位于角度currentAngle处的单位圆上的点的坐标。

useProgram()被调用以激活我们之前建立的GLSL着色程序。然后我们获取每个用于在JavaScript代码和着色器之间共享信息的uniform的地址(使用getUniformLocation())。

名为uScalingFactor的uniform被设置为之前计算的currentScale值;您可能还记得,此值用于根据上下文的纵横比调整坐标系。这是使用uniform2fv()完成的(因为这是一个包含两个值的浮点数向量)。

uRotationVector被设置为当前旋转向量(currentRotation),也使用uniform2fv()

uGlobalColor使用uniform4fv()设置为我们希望在绘制正方形时使用的颜色。这是一个包含4个分量的浮点数向量(红色、绿色、蓝色和alpha各一个分量)。

现在一切都准备就绪,我们可以设置顶点缓冲区并绘制我们的形状。首先,通过调用bindBuffer()设置将用于绘制形状三角形的顶点缓冲区。然后,通过调用getAttribLocation()从着色器程序中获取顶点位置属性的索引。

现在顶点位置属性的索引在aVertexPosition中可用,我们调用enableVertexAttribArray()启用位置属性,以便它可以被着色器程序(特别是顶点着色器)使用。

然后,通过调用vertexAttribPointer()将顶点缓冲区绑定到aVertexPosition属性。此步骤并不明显,因为此绑定几乎是一个副作用。但结果是,访问aVertexPosition现在可以从顶点缓冲区获取数据。

在形状的顶点缓冲区与用于将顶点逐个传递到顶点着色器的aVertexPosition属性之间建立关联后,我们就可以通过调用drawArrays()来绘制形状了。

此时,帧已绘制完成。剩下的就是安排绘制下一帧。这里通过调用requestAnimationFrame()来完成此操作,它请求在浏览器准备好更新屏幕时执行回调函数。

我们的requestAnimationFrame()回调接收一个参数currentTime作为输入,该参数指定帧绘制开始的时间。我们使用它以及上次绘制帧时保存的时间previousTime,以及正方形每秒应旋转的度数(degreesPerSecond)来计算currentAngle的新值。然后更新previousTime的值,并调用animateScene()绘制下一帧(并依次安排绘制下一帧,无限循环)。

结果

这是一个非常简单的示例,因为它只绘制一个简单的对象,但这里使用的概念可以扩展到更复杂的动画。

另请参阅