到目前为止,我们已经创造了一个有形状、有颜色的三角形。但要让它在画布上移动,我们现在唯一的办法就是......回去修改 JavaScript 里的顶点坐标数组。
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.4
0.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
之前,用它来变换顶点的位置。
ini
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,能让我们以最优的帧率来重复执行绘制函数,从而形成流畅的动画。在每一帧中,我们都会更新变换(比如改变旋转角度),计算新的矩阵,并重新绘制。
下面是集成了所有改动的完整代码。这次,你会看到一个在原地不停旋转的彩色三角形!
html
<!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 必须是 false
gl.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 纹理贴图》!