第 3 篇:让图形动起来 - WebGL 2D 变换
到目前为止,我们已经创造了一个有形状、有颜色的三角形。但要让它在画布上移动,我们现在唯一的办法就是……回去修改 JavaScript 里的顶点坐标数组。
// 如果想让三角形向右移动一点...
const positionsAndColors = [0.1, 0.5, 1.0, 0.0, 0.0, // 把 x 从 0.0 改成 0.1-0.4, -0.5, 0.0, 1.0, 0.0, // 把 x 从 -0.5 改成 -0.40.6, -0.5, 0.0, 0.0, 1.0 // 把 x 从 0.5 改成 0.6
];
// 然后重新上传整个 buffer 的数据...
gl.bufferData(...);
这太笨拙了!如果要让它旋转呢?天哪,那我们得拿出三角函数来手算每个点的新坐标。这种方式不仅效率低下,而且完全违背了 GPU 的设计初衷。
GPU,这位图形处理专家,最擅长的工作就是——疯狂地进行数学运算,尤其是矩阵运算。而变换 (Transformation),无论是平移、旋转还是缩放,本质上都是数学运算。
魔法咒语:矩阵 (Matrix)
想象一下,你有一个点的坐标 (x, y)
。现在,我给你一个神奇的“咒语”——矩阵,你用这个点的坐标去乘以这个咒语,就能得到一个新的坐标 (x', y')
。
- 如果这个咒语是“平移咒语”,新坐标就是点平移后的位置。
- 如果这个咒语是“旋转咒语”,新坐标就是点围绕原点旋转后的位置。
- 如果这个咒语是“缩放咒语”,新坐标就是点缩放后的位置。
更神奇的是,这些咒语可以叠加!你可以先旋转,再平移,只需要把两个咒语矩阵先乘起来,得到一个更复杂的“复合咒语”,然后用这个终极咒语去处理所有的点,一步到位。
这就是 2D 变换的核心思想:定义一个物体的形状一次(比如一个以原点为中心的三角形),然后通过向 GPU 发送不同的“变换咒语”(矩阵),来控制它在屏幕上的最终状态。
新伙伴登场:uniform
变量
我们如何把这个“咒语矩阵”发送给 GPU 呢?
回顾一下,attribute
是给每个顶点都不同的数据。但变换矩阵对于一个物体的所有顶点来说,都是一样的,是统一的。因此,我们需要 GLSL 变量家族的最后一位成员:uniform
。
uniform
变量就像一个全局指令,从 JavaScript 发出,顶点着色器(有时片元着色器也会用)里的每一次执行都能接收到这份完全相同的数据。
开始施法:代码改造
1. 顶点着色器:接收并使用矩阵
我们的顶点着色器需要一个 uniform
变量来接收 2D 变换矩阵(它是一个 3x3 的矩阵,所以类型是 mat3
)。然后,在计算 gl_Position
之前,用它来变换顶点的位置。
attribute vec2 a_position;
attribute vec4 a_color;uniform mat3 u_transformMatrix; // 新增:接收变换矩阵varying vec4 v_color;void main() {// 核心改动:// 1. 把 a_position (vec2) 扩展成 vec3,因为 mat3 需要和 vec3 相乘。// 我们加一个 1.0,这在图形学中称为齐次坐标,是矩阵运算的要求。// 2. 将位置向量与变换矩阵相乘,得到变换后的新位置。// 3. 取新位置的 x, y 分量,构建最终的 gl_Position。vec2 transformedPosition = (u_transformMatrix * vec3(a_position, 1.0)).xy;gl_Position = vec4(transformedPosition, 0.0, 1.0);v_color = a_color;
}
2. 片元着色器:无需改动!
颜色逻辑和变换无关,所以我们的片元着色器保持原样。
3. JavaScript:动画循环与矩阵计算
JavaScript 的部分将是改动最大的。我们需要:
- 恢复顶点数据:让三角形的坐标变回最原始、最简单的状态,比如以
(0,0)
为中心。所有变换都交给矩阵。 - 添加矩阵数学函数:在现实项目中,你通常会使用一个成熟的库(如
gl-matrix
)。但为了教学,我们手动实现几个简单的函数来创建平移、旋转和缩放矩阵。这样能让你更清楚地看到底层发生了什么。 - 获取
uniform
的位置:就像getAttribLocation
一样,我们需要用getUniformLocation
来找到着色器中uniform
变量的地址。 - 创建动画循环:使用
requestAnimationFrame
,这是一个浏览器提供的 API,能让我们以最优的帧率来重复执行绘制函数,从而形成流畅的动画。在每一帧中,我们都会更新变换(比如改变旋转角度),计算新的矩阵,并重新绘制。
下面是集成了所有改动的完整代码。这次,你会看到一个在原地不停旋转的彩色三角形!
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>WebGL 教程 3:动态变换</title><style>body { background-color: #333; color: #eee; text-align: center; }canvas { background-color: #000; border: 1px solid #555; }</style>
</head>
<body onload="main()"><h1>让图形动起来 - WebGL 2D 变换</h1><canvas id="webgl-canvas" width="500" height="500"></canvas><!-- 顶点着色器代码 (已更新) --><script id="vertex-shader" type="x-shader/x-vertex">attribute vec2 a_position;attribute vec4 a_color;// 新增:接收一个 3x3 的变换矩阵uniform mat3 u_transformMatrix;varying vec4 v_color;void main() {// 将 2D 位置乘以 3x3 矩阵// a_position 是 vec2,需要扩展成 vec3 (x, y, 1) 才能与 mat3 相乘vec2 transformedPosition = (u_transformMatrix * vec3(a_position, 1.0)).xy;gl_Position = vec4(transformedPosition, 0.0, 1.0);v_color = a_color;}</script><!-- 片元着色器代码 (无变化) --><script id="fragment-shader" type="x-shader/x-fragment">precision mediump float;varying vec4 v_color;void main() {gl_FragColor = v_color;}</script><script>// --- 简单的矩阵数学帮助函数 ---// 在实际项目中,你会使用像 gl-matrix 这样的库const matrixUtils = {createIdentity: () => [1, 0, 0,0, 1, 0,0, 0, 1],createTranslation: (tx, ty) => [1, 0, 0,0, 1, 0,tx, ty, 1],createRotation: (angleInRadians) => {const c = Math.cos(angleInRadians);const s = Math.sin(angleInRadians);return [c, -s, 0,s, c, 0,0, 0, 1];},createScale: (sx, sy) => [sx, 0, 0,0, sy, 0,0, 0, 1],// 矩阵乘法 (matA * matB)multiply: (matA, matB) => {const a00 = matA, a01 = matA, a02 = matA;const a10 = matA, a11 = matA, a12 = matA;const a20 = matA, a21 = matA, a22 = matA;const b00 = matB, b01 = matB, b02 = matB;const b10 = matB, b11 = matB, b12 = matB;const b20 = matB, b21 = matB, b22 = matB;return [b00 * a00 + b01 * a10 + b02 * a20,b00 * a01 + b01 * a11 + b02 * a21,b00 * a02 + b01 * a12 + b02 * a22,b10 * a00 + b11 * a10 + b12 * a20,b10 * a01 + b11 * a11 + b12 * a21,b10 * a02 + b11 * a12 + b12 * a22,b20 * a00 + b21 * a10 + b22 * a20,b20 * a01 + b21 * a11 + b22 * a21,b20 * a02 + b21 * a12 + b22 * a22];}};function main() {const canvas = document.getElementById('webgl-canvas');const gl = canvas.getContext('webgl');if (!gl) { alert('WebGL not supported!'); return; }const vertexShaderSource = document.getElementById('vertex-shader').text;const fragmentShaderSource = document.getElementById('fragment-shader').text;const vertexShader = createShader(gl, gl.VERTEX_SHADER, vertexShaderSource);const fragmentShader = createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource);const program = createProgram(gl, vertexShader, fragmentShader);const positionAttributeLocation = gl.getAttribLocation(program, "a_position");const colorAttributeLocation = gl.getAttribLocation(program, "a_color");// 新增: 获取 uniform 变量的位置const matrixUniformLocation = gl.getUniformLocation(program, "u_transformMatrix");const positionBuffer = gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);// 顶点数据恢复到以原点为中心的简单版本const positionsAndColors = [0.0, 0.25, 1.0, 0.0, 0.0,-0.25,-0.25, 0.0, 1.0, 0.0,0.25,-0.25, 0.0, 0.0, 1.0];gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positionsAndColors), gl.STATIC_DRAW);gl.useProgram(program);gl.enableVertexAttribArray(positionAttributeLocation);gl.enableVertexAttribArray(colorAttributeLocation);gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);const FSIZE = (new Float32Array()).BYTES_PER_ELEMENT;const STRIDE = 5 * FSIZE;gl.vertexAttribPointer(positionAttributeLocation, 2, gl.FLOAT, false, STRIDE, 0);gl.vertexAttribPointer(colorAttributeLocation, 3, gl.FLOAT, false, STRIDE, 2 * FSIZE);let currentAngle = 0;// 动画循环function animate() {// 1. 更新状态 (例如:角度)currentAngle += 0.02;// 2. 计算矩阵let matrix = matrixUtils.createIdentity();// 注意乘法顺序:先缩放,再旋转,最后平移// matrix = matrixUtils.multiply(matrix, matrixUtils.createScale(1.0, 1.0));matrix = matrixUtils.multiply(matrix, matrixUtils.createRotation(currentAngle));matrix = matrixUtils.multiply(matrix, matrixUtils.createTranslation(0.5, 0.0));// 3. 绘制gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);gl.clearColor(0.1, 0.1, 0.1, 1.0);gl.clear(gl.COLOR_BUFFER_BIT);// 将计算好的矩阵发送给顶点着色器// gl.uniformMatrix3fv(location, transpose, value)// transpose 必须是 falsegl.uniformMatrix3fv(matrixUniformLocation, false, matrix);gl.drawArrays(gl.TRIANGLES, 0, 3);// 4. 请求下一帧requestAnimationFrame(animate);}// 启动动画!animate();}// --- 辅助函数 (与之前相同) ---function createShader(gl, type, source) { /* ... */ }function createProgram(gl, vertexShader, fragmentShader) { /* ... */ }// For brevity, I'm hiding the unchanged helper functions here.// They are the same as in the previous article.function createShader(gl, type, source) {const shader = gl.createShader(type); gl.shaderSource(shader, source); gl.compileShader(shader); if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader; console.error("Shader compile error:", gl.getShaderInfoLog(shader)); gl.deleteShader(shader);}function createProgram(gl, vertexShader, fragmentShader) {const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program; console.error("Program link error:", gl.getProgramInfoLog(program)); gl.deleteProgram(program); }</script>
</body>
</html>
总结与展望
太棒了!我们成功地让三角形“活”了起来。它先是围绕自己的中心旋转,然后作为一个整体平移到了画布的右半边。
今天我们解锁了 WebGL 中极为强大的能力:
- 理解了变换的意义:将形状定义与位置状态分离,让控制更灵活。
- 认识了矩阵:它是实现平移、旋转、缩放的数学工具,是 GPU 的“母语”。
- 掌握了
uniform
变量:学会了如何向着色器传递对所有顶点都一致的全局数据。 - 创建了动画循环:使用
requestAnimationFrame
让我们的场景动了起来。
现在,我们的 2D 图形已经相当完备了。但 WebGL 的魅力远不止于此。在下一篇中,我们将为我们的图形穿上华丽的“外衣”——纹理贴图。我们将学习如何加载一张图片,并把它精准地“贴”到我们的三角形表面上,让它不再是简单的颜色渐变。
准备好进入更丰富多彩的视觉世界了吗?敬请期待 《第 4 篇:赋予表面生命 - WebGL 纹理贴图》!