WebGL 二维平移
平移就是普通意义的“移动”物体。这里有个例子基于前一个例子。首先我们来定义一些变量存储矩形的平移,宽,高和颜色。
var translation = [0, 0];
var width = 100;
var height = 30;
var color = [Math.random(), Math.random(), Math.random(), 1];
然后定义一个方法重绘所有东西,我们可以在更新变换之后调用这个方法。
// 绘制场景
function drawScene() {
webglUtils.resizeCanvasToDisplaySize(gl.canvas);
// 告诉 WebGL 如何从裁剪空间对应到像素
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 使用我们的程序
gl.useProgram(program);
// 启用属性
gl.enableVertexAttribArray(positionLocation);
// 绑定位置缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// 设置矩形参数
setRectangle(gl, translation[0], translation[1], width, height);
// 告诉属性怎么从 positionBuffer 中读取数据 (ARRAY_BUFFER)
var size = 2; // 每次迭代运行提取两个单位数据
var type = gl.FLOAT; // 每个单位的数据类型是 32 位浮点型
var normalize = false; // 不需要归一化数据
var stride = 0; // 0 = 移动单位数量 * 每个单位占用内存(sizeof(type))
var offset = 0; // 从缓冲起始位置开始读取
gl.vertexAttribPointer(positionLocation, size, type, normalize, stride, offset);
// 设置分辨率
gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
// 设置颜色
gl.uniform4fv(colorLocation, color);
// 绘制矩形
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 6;
gl.drawArrays(primitiveType, offset, count);
}
在下方的例子中,我添加了一对滑块,当它们值改变时会更新 translation[0]
和 translation[1]
并且调用 drawScene 方法。拖动滑块来平移矩形。
到目前为止还不错!但是想象一下如果对一个更复杂的图形做类似操作怎么办。
假设我们想绘制一个由六个三角形组成的 ‘F’ ,像这样

接着当前的代码我们需要修改 setRectangle,像这样
// 在缓冲存储构成 'F' 的值
function setGeometry(gl, x, y) {
var width = 100;
var height = 150;
var thickness = 30;
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// 左竖
x,
y,
x + thickness,
y,
x,
y + height,
x,
y + height,
x + thickness,
y,
x + thickness,
y + height,
// 上横
x + thickness,
y,
x + width,
y,
x + thickness,
y + thickness,
x + thickness,
y + thickness,
x + width,
y,
x + width,
y + thickness,
// 中横
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="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
void main() {
// 加上平移量
vec2 position = a_position + u_translation;
// 从像素坐标转换到 0.0 到 1.0
vec2 zeroToOne = position / u_resolution;
// ...
}
</script>
重构一下代码,首先我们只需要设置一次几何体。
// 在缓冲存储构成 'F' 的值
function setGeometry(gl) {
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// 左竖
0,
0,
30,
0,
0,
150,
0,
150,
30,
0,
30,
150,
// 上横
30,
0,
100,
0,
30,
30,
30,
30,
100,
0,
100,
30,
// 中横
30,
60,
67,
60,
30,
90,
30,
90,
67,
60,
67,
90
]),
gl.STATIC_DRAW
);
}
然后我们只需要在绘制前更新 u_translation
为期望的平移量。
// ...
var translationLocation = gl.getUniformLocation(program, 'u_translation');
// ...
// 创建一个存放位置信息的缓冲
var positionBuffer = gl.createBuffer();
// 绑定到 ARRAY_BUFFER (简单的理解为 ARRAY_BUFFER = positionBuffer)
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
// 将几何数据存到缓冲
setGeometry(gl);
// ...
// 绘制场景
function drawScene() {
// ...
// 设置平移
gl.uniform2fv(translationLocation, translation);
// 绘制矩形
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 18;
gl.drawArrays(primitiveType, offset, count);
}
注意到 setGeometry 只调用了一次,它不在 drawScene 内部了。
这里是那个例子,同样的,拖动滑块来更新平移量。
现在当我们绘制时,WebGL 几乎做了所有事情,我们做的仅仅是设置平移然后让它绘制,即使我们的几何体有成千上万个点,主要的代码还是保持不变。
你可以对比上方例子中使用 JavaScript 更新所有点的情况。
WebGL 二维旋转
首先我想向你介绍一个叫做 单位圆
的东西,一个圆有一个半径,圆的半径是圆心到圆边缘的距离,单位圆是半径为 1.0 的圆。

当你拖拽蓝色圆点的时候 X 和 Y 会改变,它们是那一点在圆上的坐标,在最上方时 Y 是 1 并且 X 是 0,在最右边的时候 X 是 1 并且 Y 是 0。
如果你还记得三年级的数学知识,数字和 1 相乘结果不变。例如 123 * 1 = 123
,那么,单位圆半径为 1.0 的圆也是 1 的一种形式,它是旋转的 1。所以你可以把一些东西和单位圆相乘,除了发生一些魔法和旋转之外,某种程度上和乘以 1 相似。
我们将从单位元上任取一点,并将该点的 X 和 Y 与之前例子中的几何体相乘,以下是新的着色器。
<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
void main() {
// 旋转位置
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
);
// 加上平移
vec2 position = rotatedPosition + u_translation;
}
</script>
更新 JavaScript,传递两个值进去。
// ...
var rotationLocation = gl.getUniformLocation(program, 'u_rotation');
// ...
var rotation = [0, 1];
// ...
// 绘制场景
function drawScene() {
// ...
// 设置平移
gl.uniform2fv(translationLocation, translation);
// 设置旋转
gl.uniform2fv(rotationLocation, rotation);
// 绘制几何体
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 18; // 6 个三角形组成 'F', 每个三角形 3 个点
gl.drawArrays(primitiveType, offset, count);
}
这是结果,拖动圆形手柄来旋转或拖动滑块来平移。
为什么会这样?来看看数学公式。
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,让我们在单位圆上以十二点方向为起点顺时针旋转 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 度也一样

圆上该点的位置是 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);
这里有一个设置角度的版本,拖动滑块来旋转或平移。
这并不是旋转常用的方式,请继续阅读
WebGL 二维缩放
缩放和平移一样简单,让我们将位置乘以期望的缩放值,这是前例中的变化部分。
<script id="vertex-shader-2d" 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() {
// 缩放 vec2 scaledPosition = a_position * u_scale;
// 旋转
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x, scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);
// 平移
vec2 position = rotatedPosition + u_translation;
}
</script>
然后需要在 JavaScript 中绘制的地方设置缩放量。
// ...
var scaleLocation = gl.getUniformLocation(program, 'u_scale');
// ...
var scale = [1, 1];
// ...
// 绘制场景
function drawScene() {
// ...
// 设置平移
gl.uniform2fv(translationLocation, translation);
// 设置旋转
gl.uniform2fv(rotationLocation, rotation);
// 设置缩放 gl.uniform2fv(scaleLocation, scale);
// 绘制几何体
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 18; // 6 个三角形组成 'F', 每个三角形 3 个点
gl.drawArrays(primitiveType, offset, count);
}
现在我们有了缩放,拖动滑块试试。
值得一提的是,缩放值为负数的时候会翻转几何体。
接下来我们将复习神奇的矩阵,这三种操作将包含在一个矩阵中,并表现为一种常用形式。
WebGL 二维矩阵
之前的三篇文章讲了如何对二维物体进行平移,旋转和缩放。每种变换都改变了着色器并且这些变换还受先后顺序影响。在前例中我们先缩放,再旋转,最后平移,如果执行顺序不同结果也不同。
例如这是缩放 (2, 1),旋转 30 度,然后平移 (100, 0) 的结果。

这是平移 (100, 0) ,旋转 30 度,然后缩放 (2, 1) 的结果。

结果截然不同,更糟的是,针对第二种情况中的转换顺序,需要写一个新的着色器。
有些比我聪明的人可能已经想到了矩阵,对于二维我们使用 3x3 的矩阵,3x3 的矩阵就像是有 9 个格子的格网。

在计算的时候我们将位置坐标沿着矩阵列的方向依次相乘再将结果加起来。我们的位置信息只有两个值,x 和 y。但是要进行运算需要三个值,所以我们将第三个值赋值为 1。
在这个例子中结果将是

你可能会想“这样做有什么意义?”,好吧,假设我们要进行平移,平移的量为 tx 和 ty,然后定义一个这样的矩阵

然后计算结果

如果你还记得线性代数的知识,我们可以删除和 0 相乘的部分,和 1 相乘相当于没变,所以简化后为

或者更简洁
newX = x + tx;
newY = y + ty;
其他的就不用关心了。这个看起来和平移例子中的代码有些相似。
同样的来实现旋转,在旋转章节提到过,旋转只需要和旋转角对应的正弦和余弦值
s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);
然后我们创建一个这样的矩阵

使用矩阵后得到

即
newX = x * c + y * s;
newY = x * -s + y * c;
正是我们在旋转例子中得到的结果。
最后是缩放,我们将两个缩放因子叫做 sx 和 sy 。
然后创建一个这样的矩阵

使用矩阵后得到

即
newX = x * sx;
newY = y * sy;
和缩放例子相似。
现在你可能还会想“那又怎样,有什么意义?”,好像花了更多精力做之前做过的事情。
现在开始有趣的部分了,相乘后他们可以用一个矩阵代表三个变换,假定有一个方法 m3.multiply 可以将两个矩阵相乘并返回结果。
为了方便讲解,我们先创建平移,旋转和缩放矩阵。
var m3 = {
translation: function(tx, ty) {
return [1, 0, 0, 0, 1, 0, tx, ty, 1];
},
rotation: function(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [c, -s, 0, s, c, 0, 0, 0, 1];
},
scaling: function(sx, sy) {
return [sx, 0, 0, 0, sy, 0, 0, 0, 1];
}
};
现在该修改着色器了,原来的着色器像这样
<script id="vertex-shader-2d" 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() {
// 缩放
vec2 scaledPosition = a_position * u_scale;
// 旋转
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);
// 平移
vec2 position = rotatedPosition + u_translation;
// ...
}
</script>
新着色器就简单多了。
<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
void main() {
// 将位置乘以矩阵
vec2 position = (u_matrix * vec3(a_position, 1)).xy;
// ...
}
</script>
这是使用的方法
// 绘制场景
function drawScene() {
// ...
// 计算矩阵
var translationMatrix = m3.translation(translation[0], translation[1]);
var rotationMatrix = m3.rotation(angleInRadians);
var scaleMatrix = m3.scaling(scale[0], scale[1]);
// 矩阵相乘
var matrix = m3.multiply(translationMatrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
// 设置矩阵
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// 绘制图形
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
这个例子用的新代码,滑块没变,还是对应平移,旋转和缩放,但是他们在着色器中做的事情是相似的。
可能你还会问,那又怎样?看起来没什么特别好的地方。但是,如果现在我们想改变转换顺序的话,就不需要重写一个着色器了,只需要改变一下数学运算。
// ...
// 矩阵相乘var matrix = m3.multiply(scaleMatrix, rotationMatrix);matrix = m3.multiply(matrix, translationMatrix);
// ...
像这样的矩阵相乘对层级变换至关重要,比如身体的手臂部分运动,月球属于绕太阳转动的地球的一部分,或者树上的树枝。写一个简单的层级运动的例子,我们来画 5 个 ‘F’,并且每个 ‘F’ 都以前一个的矩阵为基础。
// 绘制场景
function drawScene() {
// 清空画布
gl.clear(gl.COLOR_BUFFER_BIT);
// 计算矩阵
var translationMatrix = m3.translation(translation[0], translation[1]);
var rotationMatrix = m3.rotation(angleInRadians);
var scaleMatrix = m3.scaling(scale[0], scale[1]);
// 初始矩阵
var matrix = m3.identity();
for (var i = 0; i < 5; ++i) {
// 矩阵相乘
matrix = m3.multiply(matrix, translationMatrix);
matrix = m3.multiply(matrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
// 设置矩阵
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// 绘制图形
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
}
在这个例子中用到了一个新方法 m3.identity
,这个方法创建一个单位矩阵。单位矩阵就像 1.0 一样,和它相乘的矩阵不会变化
var m3 = {
identity: function() {
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
};
这是 5 个 F。
再看一个例子,之前的每个例子中 ‘F’ 都是绕着它的左上角旋转 (当然,改变转换顺序的那个例子除外)。这是因为我们总是绕原点旋转,而 ‘F’ 的原点就是左上角,也就是 (0, 0) 。
现在我们可以使用矩阵运算,并且自定义转换的顺序。所以让我们改变旋转的中心
// 创建一个矩阵,可以将原点移动到 'F' 的中心
var moveOriginMatrix = m3.translation(-50, -75);
// ...
// 矩阵相乘
var matrix = m3.multiply(translationMatrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
matrix = m3.multiply(matrix, moveOriginMatrix);
这是结果,注意到 F 现在绕中心旋转和缩放了。
通过这种方式你可以绕任意点旋转和缩放,所以现在你可能知道为什么 PhotoShop 或 Flash 可以让你移动旋转中心。
还可以做更有趣的事情,如果你回想第一篇文章 WebGL 基础概念,可能会记得在着色器中我们将像素坐标转换到裁剪空间,这是当时的代码
// ...
// 从像素坐标转换到 0.0 到 1.0
vec2 zeroToOne = position / u_resolution;
// 再把 0->1 转换 0->2
vec2 zeroToTwo = zeroToOne * 2.0;
// 把 0->2 转换到 -1->+1 (裁剪空间)
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
逐步观察,首先 从像素坐标转换到 0.0 到 1.0
事实上是一个缩放变换,第二步也是缩放变换,接着是一个平移和一个 Y 为 -1 的缩放。我们可以将这些操作放入一个矩阵传给着色器,创建两个缩放矩阵,一个缩放 1.0/分辨率,另一个缩放 2.0,第三个平移 (-1.0, -1.0), 然后第四个将 Y 缩放 -1。 然后将他们乘在一起,由于运算很简单,所以我们就直接定义一个 projection 方法,根据分辨率直接生成矩阵。
var m3 = {
projection: function(width, height) {
// 注意:这个矩阵翻转了 Y 轴,所以 0 在上方
return [2 / width, 0, 0, 0, -2 / height, 0, -1, 1, 1];
}
};
现在可以简化着色器,这是新的着色器。
<script id="vertex-shader-2d" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform mat3 u_matrix;
void main() {
// 使位置和矩阵相乘
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>
在 JavaScript 中需要乘上投影矩阵
// 绘制场景
function drawScene() {
// ...
// 计算矩阵
var projectionMatrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);
// ...
// 矩阵相乘
var matrix = m3.multiply(projectionMatrix, translationMatrix);
matrix = m3.multiply(matrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
// ...
}
这里还去除了设置分辨率的代码,通过使用矩阵,我们就把着色器中 6-7 步的操作在一步中完成。
在继续之前我们可以先简化一下操作,虽然先创建一些矩阵再将它们相乘很常见,但是按照我们的顺序依次操作矩阵也比较常见,比较高效的做法是创建这样的方法
var m3 = {
// ...
translate: function(m, tx, ty) {
return m3.multiply(m, m3.translation(tx, ty));
},
rotate: function(m, angleInRadians) {
return m3.multiply(m, m3.rotation(angleInRadians));
},
scale: function(m, sx, sy) {
return m3.multiply(m, m3.scaling(sx, sy));
}
// ...
};
这能够让我们将 7 行的矩阵代码转换成 4 行
// 计算矩阵
var matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight); // 返回的是画布在浏览器中实际显示的大小,所以这里图片比例不会变化。相反如果直接用 canvas.width 和 canvas.height,画布将被拉伸导致图片变形
matrix = m3.translate(matrix, translation[0], translation[1]);
matrix = m3.rotate(matrix, angleInRadians);
matrix = m3.scale(matrix, scale[0], scale[1]);
这是结果
最后一件事,我们之前使用了多种矩阵顺序。第一例中使用
translation * rotation * scale; // 平移 * 旋转 * 缩放
第二例中使用
scale * rotation * translation; // 缩放 * 旋转 * 平移
然后观察了它们的区别。
这有两种方式解读矩阵运算,给定这样一个表达式
projectionMat * translationMat * rotationMat * scaleMat * position;
第一种可能是多数人觉得比较自然的方式,从右向左解释
首先将位置乘以缩放矩阵获得缩放后的位置
scaledPosition = scaleMat * position;
然后将缩放后的位置和旋转矩阵相乘得到缩放旋转位置
rotatedScaledPosition = rotationMat * scaledPosition;
然后将缩放旋转位置和平移矩阵相乘得到缩放旋转平移位置
translatedRotatedScaledPosition = translationMat * rotatedScaledPosition;
最后和投影矩阵相乘得到裁剪空间中的坐标
clipspacePosition = projectioMatrix * translatedRotatedScaledPosition;
第二种方式是从左往右解释,在这个例子中每一个矩阵改变的都是画布的坐标空间,画布的起始空间是裁剪空间的范围(-1 到 +1),矩阵从左到右改变着画布所在的空间。
- 没有矩阵(或者单位矩阵)
白色区域是画布,蓝色是画布以外,我们正在裁剪空间中。传递到这里的点需要在裁剪空间内。
matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight);
现在我们在像素空间,X 范围是 0 到 400,Y 范围是 0 到 300,0,0 点在左上角。传递到这里的点需要在像素空间内,你看到的闪烁是 Y 轴上下颠倒的原因。
matrix = m3.translate(matrix, tx, ty);
原点被移动到了 tx, ty (150, 100),所以空间移动了。
matrix = m3.rotate(matrix, rotationInRadians);
空间绕 tx, ty 旋转
matrix = m3.scale(matrix, sx, sy);
之前的旋转空间中心在 tx, ty,x 方向缩放 2,y 方向缩放 1.5
在着色器中执行 gl_Position = matrix * position;
,position 被直接转换到这个空间。
选一个你容易接受的方式去理解吧。