一个基本的 2D WebGL 动画示例
在这个 WebGL 示例中,我们创建了一个 canvas,并在其中使用 WebGL 渲染一个旋转的正方形。我们用来表示场景的坐标系与 canvas 的坐标系相同。也就是说,(0, 0) 位于左上角,右下角位于 (600, 460)。
旋转正方形示例
让我们一步步来完成旋转正方形的示例。
顶点着色器
首先,我们来看顶点着色器。它的任务,一如既往,是将我们用于场景的坐标转换为裁剪空间坐标(即,(0, 0) 位于上下文中心,每个轴从 -1.0 到 1.0 的系统,无论上下文的实际大小如何)。
<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 代码中找到的 uniform uRotationVector 来计算的。
然后,通过将旋转后的位置乘以 JavaScript 代码在 uScalingFactor 中提供的缩放向量来计算最终位置。由于我们是在 2D 中绘制,z 和 w 的值分别固定为 0.0 和 1.0。
标准 WebGL 全局变量 gl_Position 然后被设置为转换和旋转后的顶点位置。
片段着色器
接下来是片段着色器。它的作用是返回正在渲染的形状中每个像素的颜色。由于我们绘制的是一个没有纹理、没有光照的实心对象,这非常简单。
<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 设置为 uniform 变量 uGlobalColor 的值,它由 JavaScript 代码设置为用于绘制正方形的颜色。
HTML
HTML 仅包含我们将获取 WebGL 上下文的 <canvas> 元素。
<canvas id="gl-canvas" width="600" height="460">
Oh no! Your browser doesn't support canvas!
</canvas>
全局变量和初始化
首先是全局变量。我们在这里不讨论它们;相反,我们将在代码中使用它们时进行讨论。
const glCanvas = document.getElementById("gl-canvas");
const gl = glCanvas.getContext("webgl");
const shaderSet = [
{
type: gl.VERTEX_SHADER,
id: "vertex-shader",
},
{
type: gl.FRAGMENT_SHADER,
id: "fragment-shader",
},
];
const shaderProgram = buildShaderProgram(shaderSet);
// Aspect ratio and coordinate system details
const aspectRatio = glCanvas.width / glCanvas.height;
const currentRotation = [0, 1];
const currentScale = [1.0, aspectRatio];
// Vertex information
const 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,
]);
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, vertexArray, gl.STATIC_DRAW);
const vertexNumComponents = 2;
const vertexCount = vertexArray.length / vertexNumComponents;
// Rendering data shared with the scalers.
let uScalingFactor;
let uGlobalColor;
let uRotationVector;
let aVertexPosition;
// Animation timing
let previousTime = 0.0;
const degreesPerSecond = 90.0;
let currentAngle = 0.0;
animateScene();
在获取 WebGL 上下文 gl 后,我们需要开始构建着色器程序。这里,我们使用了一种可以轻松添加多个着色器的代码。数组 shaderSet 包含一个对象列表,每个对象描述一个要编译到程序中的着色器函数。每个函数都有一个类型(gl.VERTEX_SHADER 或 gl.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() 函数接受一个对象数组作为输入,这些对象描述了一组要编译并链接到着色器程序中的着色器函数,并在着色器程序构建和链接完成后返回该程序。
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() 调用以编译单个着色器。
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() 函数来渲染每个动画帧。
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 着色程序。然后我们使用(通过 getUniformLocation())获取用于在 JavaScript 代码和着色器之间共享信息的每个 uniform 的位置。
名为 uScalingFactor 的 uniform 被设置为之前计算的 currentScale 值;正如你可能记得的,这个值用于根据上下文的纵横比调整坐标系。这是使用 uniform2fv() 完成的(因为这是一个 2 值浮点向量)。
uRotationVector 使用 uniform2fv() 设置为当前旋转向量 (currentRotation)。
uGlobalColor 使用 uniform4fv() 设置为我们希望用于绘制正方形的颜色。这是一个 4 分量浮点向量(分别代表红色、绿色、蓝色和 alpha)。
现在所有这些都完成了,我们可以设置顶点缓冲区并绘制我们的形状。首先,用于绘制形状三角形的顶点缓冲区通过调用 bindBuffer() 来设置。然后通过调用 getAttribLocation() 从着色器程序中获取顶点位置属性的索引。
现在顶点位置属性的索引在 aVertexPosition 中可用,我们调用 enableVertexAttribArray() 来启用位置属性,以便着色器程序(特别是顶点着色器)可以使用它。
然后通过调用 vertexAttribPointer() 将顶点缓冲区绑定到 aVertexPosition 属性。这一步并不明显,因为这个绑定几乎是一个副作用。但结果是,访问 aVertexPosition 现在会从顶点缓冲区获取数据。
在我们的形状的顶点缓冲区和用于逐个将顶点传递到顶点着色器的 aVertexPosition 属性之间建立关联后,我们就可以通过调用 drawArrays() 来绘制形状了。
此时,帧已绘制。剩下要做的就是安排绘制下一帧。这在这里是通过调用 requestAnimationFrame() 来完成的,它请求在下一次浏览器准备好更新屏幕时执行一个回调函数。
我们的 requestAnimationFrame() 回调接收一个参数 currentTime,它指定了帧开始绘制的时间。我们使用它、上次帧绘制保存的时间 previousTime 以及正方形应该旋转的每秒度数 (degreesPerSecond) 来计算 currentAngle 的新值。然后更新 previousTime 的值,并调用 animateScene() 来绘制下一帧(进而安排下一帧的绘制,周而复始)。
结果
这是一个相当简单的示例,因为它只绘制了一个简单的对象,但这里使用的概念可以扩展到更复杂的动画。