WebGL 2D 平移
在进入 3D 之前,我们先继续探讨 2D。请耐心等待。对某些人来说,本文可能显得过于浅显,但我会在接下来的几篇文章中逐步深入。
本文是 WebGL 基础知识系列文章的延续。如果您还没有阅读过,建议您至少阅读第一章,然后再回到这里。 “平移”是一个数学术语,基本上就是指“移动”某个对象。我认为将句子从英语翻译成日语也属于此范畴,但在这里,我们讨论的是移动几何图形。使用第一篇博文中最终得到的示例代码,您只需更改传递给 setRectangle 的值,即可轻松平移矩形,对吧?以下是基于上一个示例的示例。
// First lets make some variables
// to hold the translation of the rectangle
var translation = [0, 0];
// then let's make a function to
// re-draw everything. We can call this
// function after we update the translation.
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Setup a rectangle
setRectangle(gl, translation[0], translation[1], width, height);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
目前为止,一切都好。但现在,假设我们要对一个更复杂的形状执行相同的操作。 假设我们要绘制一个由 6 个三角形组成的“F”,如下所示。
以下是当前代码,我们需要将 setRectangle 更改为类似这样的内容。
// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl, x, y) {
var width = 100;
var height = 150;
var thickness = 30;
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// left column
x, y,
x + thickness, y,
x, y + height,
x, y + height,
x + thickness, y,
x + thickness, y + height,
// top rung
x + thickness, y,
x + width, y,
x + thickness, y + thickness,
x + thickness, y + thickness,
x + width, y,
x + width, y + thickness,
// middle rung
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="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
void main() {
// Add in the translation.
vec2 position = a_position + u_translation;
// convert the rectangle from pixels to 0.0 to 1.0
vec2 zeroToOne = position / u_resolution;
...
并稍微重构一下代码。首先,我们只需设置一次几何图形。
// Fill the buffer with the values that define a letter 'F'.
function setGeometry(gl) {
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
// left column
0, 0,
30, 0,
0, 150,
0, 150,
30, 0,
30, 150,
// top rung
30, 0,
100, 0,
30, 30,
30, 30,
100, 0,
100, 30,
// middle rung
30, 60,
67, 60,
30, 90,
30, 90,
67, 60,
67, 90]),
gl.STATIC_DRAW);
}
然后,我们只需在绘制之前使用所需的平移更新 u_translation 即可。
...
var translationLocation = gl.getUniformLocation(
program, "u_translation");
...
// Set Geometry.
setGeometry(gl);
..
// Draw scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
请注意,setGeometry 只会被调用一次。它不再位于 drawScene 内部。
现在,当我们绘制时,WebGL 几乎会完成所有工作。我们所做的只是设置平移并要求其进行绘制。即使我们的几何图形有数万个点,主要代码也会保持不变。
WebGL 2D 旋转
我先坦白,我不知道自己这样解释是否合理,但管它呢,试试看吧。
首先,我想向大家介绍一下什么是“单位圆”。如果您还记得初中数学(别睡着了!),圆有一个半径。圆的半径是指从圆心到圆边缘的距离。单位圆是半径为 1.0 的圆。
根据小学三年级的数学知识,任何数乘以 1 都保持不变。因此,123 * 1 = 123。很简单,对吧?单位圆(半径为 1.0 的圆)也是 1 的一种形式。这是一个旋转的 1。因此,你可以将某个值乘以这个单位圆,在某种程度上,这有点像乘以 1,但神奇的是,它会发生旋转。 我们将从单位圆上的任意点获取 X 和 Y 值,然后将上一个示例中的几何图形乘以这些值。 以下是着色器的更新。
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform vec2 u_translation;
uniform vec2 u_rotation;
void main() {
// Rotate the position
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);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
我们还更新了 JavaScript,以便传入这两个值。
...
var rotationLocation = gl.getUniformLocation(program, "u_rotation");
...
var rotation = [0, 1];
..
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Set the rotation.
gl.uniform2fv(rotationLocation, rotation);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
为什么有效?我们来算一笔账。
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。我们在单位圆上选择一个点,该点位于 12 点钟方向顺时针旋转 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);
希望以上说明对您有所帮助。接下来是一个更简单的。灵活扩缩。
什么是弧度?
弧度是用于测量圆、旋转和角度的单位。就像我们可以用英寸、码、米等单位来测量距离一样,我们也可以用度或弧度来测量角度。
您可能知道,使用公制单位进行数学计算比使用英制单位更容易。要将英寸转换为英尺,我们需要除以 12。要将英寸转换为码,我们需要除以 36。我不知道你们怎么样,但我无法在脑子里除以 36。使用公制单位就简单多了。要将毫米转换为厘米,我们需要除以 10。要将毫米转换为米,我们需要除以 1000。我可以在脑子里除以 1000。
弧度和角度类似。度数会让数学计算变得困难。弧度可让数学运算变得简单。一个圆有 360 度,但只有 2π 弧度。因此,一整圈是 2π 弧度。半圈为 π 弧度。1/4 圈(即 90 度)为 π/2 弧度。因此,如果您想将某个内容旋转 90 度,只需使用 Math.PI * 0.5 即可。如果您想将其旋转 45 度,请使用 Math.PI * 0.25,以此类推。
如果您开始以弧度思考,几乎所有涉及角度、圆或旋转的数学运算都会变得非常简单。快来试试吧!使用弧度,而不是度数(界面显示除外)。
WebGL 2D 缩放
扩缩与翻译一样简单。
我们将位置乘以所需的缩放比例。以下是与上一个示例相比的变化。
<script id="2d-vertex-shader" 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() {
// Scale the positon
vec2 scaledPosition = a_position * u_scale;
// Rotate the position
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y +
scaledPosition.y * u_rotation.x,
scaledPosition.y * u_rotation.y -
scaledPosition.x * u_rotation.x);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
并添加了在绘制时设置缩放比例所需的 JavaScript。
...
var scaleLocation = gl.getUniformLocation(program, "u_scale");
...
var scale = [1, 1];
...
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Set the translation.
gl.uniform2fv(translationLocation, translation);
// Set the rotation.
gl.uniform2fv(rotationLocation, rotation);
// Set the scale.
gl.uniform2fv(scaleLocation, scale);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
需要注意的一点是,按负值缩放会翻转几何图形。 希望最后这 3 章有助于您了解平移、旋转和缩放。接下来,我们将介绍矩阵的神奇之处,它能将这三种变换组合成一种更简单、通常也更有用的形式。
为什么是“F”?
我第一次看到有人使用“F”是在一个纹理上。“F”本身并不重要。重要的是,您能从任何方向辨别其朝向。例如,如果我们使用心形 ♥ 或三角形 △,就无法判断它是否水平翻转。圆圈 ○ 的效果更差。彩色矩形或许可行,每个角使用不同的颜色,但这样您就必须记住每个角代表什么。字母 F 的方向一目了然。
任何能看出方向的形状都可以,我只是从第一次接触这个概念起就一直使用“F”。
WebGL 2D 矩阵
在过去 3 章中,我们介绍了如何平移、旋转和缩放几何图形。平移、旋转和缩放均被视为一种“转换”。这些转换都需要对着色器进行更改,并且这 3 个转换都取决于顺序。
例如,这里的缩放比例为 2、1,旋转角度为 30%,平移量为 100、0。
下面是平移 100,0、旋转 30% 和缩放 2, 1 的效果
结果完全不同。更糟糕的是,如果我们需要第二个示例,则必须编写一个不同的着色器,以新的所需顺序应用平移、旋转和缩放。 一些比我聪明得多的人发现,您可以使用矩阵数学运算完成所有相同的操作。对于 2D,我们使用 3x3 矩阵。3x3 矩阵就像一个包含 9 个方框的网格。
| 1.0 | 2.0 | 3.0 |
| 4.0 | 5.0 | 6.0 |
| 7.0 | 8.0 | 9.0 |
计算时,我们将矩阵列中的位置相乘,然后将结果相加。我们的位置只有 2 个值,x 和 y,但要进行此数学运算,我们需要 3 个值,因此我们将使用 1 作为第三个值。 在这种情况下,我们的结果将是
newX = x * 1.0 + y * 4.0 + 1 * 7.0
newY = x * 2.0 + y * 5.0 + 1 * 8.0
extra = x * 3.0 + y * 6.0 + 1 * 9.0
您可能在想:“这有什么意义?”假设我们有一个平移。我们将要平移的量称为 tx 和 ty。我们来创建一个这样的矩阵
| 1.0 | 0.0 | 0.0 |
| 0.0 | 1.0 | 0.0 |
| tx | ty | 1.0 |
现在来看看
newX = x * 1.0 + y * 0.0 + 1 * tx
newY = x * 0.0 + y * 1.0 + 1 * ty
extra = x * 0.0 + y * 0.0 + 1 * 1
如果您还记得代数知识,就会知道我们可以删除任何乘以零的位置。乘以 1 实际上没有任何效果,因此我们来简化一下,看看会发生什么
newX = x + tx;
newY = y + ty;
而我们并不太关心额外的信息。这看起来与我们翻译示例中的翻译代码惊人地相似。 同样,我们来执行旋转。正如我们在旋转帖子中指出的那样,我们只需要旋转角度的正弦和余弦。
s = Math.sin(angleToRotateInRadians);
c = Math.cos(angleToRotateInRadians);
我们构建一个这样的矩阵
| c | -s | 0.0 |
| 秒 | c | 0.0 |
| 0.0 | 0.0 | 1.0 |
应用矩阵后,我们得到以下结果
newX = x * c + y * s + 1 * 0
newY = x * -s + y * c + 1 * 0
extra = x * 0.0 + y * 0.0 + 1 * 1
将所有乘以 0 和 1 的结果涂黑,我们得到
newX = x * c + y * s;
newY = x * -s + y * c;
这正是我们在旋转示例中看到的效果。 最后是规模。我们将两个缩放比例分别称为 sx 和 sy 并构建一个这样的矩阵
| sx | 0.0 | 0.0 |
| 0.0 | sy | 0.0 |
| 0.0 | 0.0 | 1.0 |
应用矩阵后,我们得到以下结果
newX = x * sx + y * 0 + 1 * 0
newY = x * 0 + y * sy + 1 * 0
extra = x * 0.0 + y * 0.0 + 1 * 1
这真的很
newX = x * sx;
newY = y * sy;
这与我们的扩缩示例相同。
现在,我相信您可能仍在思考。那又怎样?有什么意义呢?这似乎需要做很多工作,只是为了做我们已经在做的事情?
这就是神奇之处。事实证明,我们可以将矩阵相乘,并一次性应用所有转换。假设我们有一个函数 matrixMultiply,它接受两个矩阵,将它们相乘并返回结果。
为了更清楚地说明,我们来创建一些函数,用于构建平移、旋转和缩放矩阵。
function makeTranslation(tx, ty) {
return [
1, 0, 0,
0, 1, 0,
tx, ty, 1
];
}
function makeRotation(angleInRadians) {
var c = Math.cos(angleInRadians);
var s = Math.sin(angleInRadians);
return [
c,-s, 0,
s, c, 0,
0, 0, 1
];
}
function makeScale(sx, sy) {
return [
sx, 0, 0,
0, sy, 0,
0, 0, 1
];
}
现在,我们来更改着色器。旧着色器如下所示
<script id="2d-vertex-shader" 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() {
// Scale the positon
vec2 scaledPosition = a_position * u_scale;
// Rotate the position
vec2 rotatedPosition = vec2(
scaledPosition.x * u_rotation.y + scaledPosition.y * u_rotation.x,
scaledPosition.y * u_rotation.y - scaledPosition.x * u_rotation.x);
// Add in the translation.
vec2 position = rotatedPosition + u_translation;
...
我们的新着色器将简单得多。
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
void main() {
// Multiply the position by the matrix.
vec2 position = (u_matrix * vec3(a_position, 1)).xy;
...
以下是我们的使用方式
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Compute the matrices
var translationMatrix =
makeTranslation(translation[0], translation[1]);
var rotationMatrix = makeRotation(angleInRadians);
var scaleMatrix = makeScale(scale[0], scale[1]);
// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
// Set the matrix.
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
不过,您可能还是会问,那又怎样?这似乎没有多大好处。但现在,如果我们想更改顺序,就不必编写新的着色器。我们可以直接更改计算方式。
...
// Multiply the matrices.
var matrix = matrixMultiply(translationMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, scaleMatrix);
...
能够应用此类矩阵对于分层动画(例如身体上的手臂、围绕太阳的行星上的卫星或树上的树枝)尤为重要。为了简单演示分层动画,我们来绘制 5 次“F”,但每次都从上一个“F”的矩阵开始。
// Draw the scene.
function drawScene() {
// Clear the canvas.
gl.clear(gl.COLOR_BUFFER_BIT);
// Compute the matrices
var translationMatrix = makeTranslation(translation[0], translation[1]);
var rotationMatrix = makeRotation(angleInRadians);
var scaleMatrix = makeScale(scale[0], scale[1]);
// Starting Matrix.
var matrix = makeIdentity();
for (var i = 0; i < 5; ++i) {
// Multiply the matrices.
matrix = matrixMultiply(matrix, scaleMatrix);
matrix = matrixMultiply(matrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
// Set the matrix.
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// Draw the geometry.
gl.drawArrays(gl.TRIANGLES, 0, 18);
}
}
为此,我们引入了 makeIdentity 函数,用于创建单位矩阵。单位矩阵是有效表示 1.0 的矩阵,因此,如果乘以单位矩阵,则不会发生任何变化。就像
X * 1 = X
so too
matrixX * identity = matrixX
以下是创建单位矩阵的代码。
function makeIdentity() {
return [
1, 0, 0,
0, 1, 0,
0, 0, 1
];
}
再举一个例子,到目前为止,在每个示例中,我们的“F”都围绕其左上角旋转。这是因为我们使用的数学运算始终围绕原点旋转,而“F”的左上角位于原点 (0, 0)。 但现在,由于我们可以进行矩阵运算,并且可以选择应用转换的顺序,因此我们可以在应用其余转换之前移动原点。
// make a matrix that will move the origin of the 'F' to
// its center.
var moveOriginMatrix = makeTranslation(-50, -75);
...
// Multiply the matrices.
var matrix = matrixMultiply(moveOriginMatrix, scaleMatrix);
matrix = matrixMultiply(matrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
使用该方法,您可以从任意点旋转或缩放。现在,您已经知道 Photoshop 或 Flash 是如何让您移动旋转点的。 我们再来个更疯狂的。如果您回顾一下WebGL 基础知识中的第一篇文章,您可能会记得我们在着色器中有一段代码,用于将像素转换为裁剪空间,如下所示。
...
// convert the rectangle from pixels to 0.0 to 1.0
vec2 zeroToOne = position / u_resolution;
// convert from 0->1 to 0->2
vec2 zeroToTwo = zeroToOne * 2.0;
// convert from 0->2 to -1->+1 (clipspace)
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
如果您依次查看每个步骤,会发现第一步“从像素转换为 0.0 到 1.0”实际上是一个缩放操作。第二项也是扩缩操作。下一个是平移,最后一个是将 Y 缩放 -1。实际上,我们可以在传入着色器的矩阵中完成所有这些操作。我们可以创建 2 个缩放矩阵,一个用于按 1.0/分辨率进行缩放,另一个用于按 2.0 进行缩放,第 3 个用于按 -1.0、-1.0 进行平移,第 4 个用于按 -1 缩放 Y,然后将它们全部相乘,但由于数学运算很简单,我们将直接创建一个函数,为给定分辨率创建“投影”矩阵。
function make2DProjection(width, height) {
// Note: This matrix flips the Y axis so that 0 is at the top.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1
];
}
现在,我们可以进一步简化着色器。下面是整个新顶点着色器。
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform mat3 u_matrix;
void main() {
// Multiply the position by the matrix.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
</script>
在 JavaScript 中,我们需要乘以投影矩阵
// Draw the scene.
function drawScene() {
...
// Compute the matrices
var projectionMatrix =
make2DProjection(canvas.width, canvas.height);
...
// Multiply the matrices.
var matrix = matrixMultiply(scaleMatrix, rotationMatrix);
matrix = matrixMultiply(matrix, translationMatrix);
matrix = matrixMultiply(matrix, projectionMatrix);
...
}
我们还移除了用于设置分辨率的代码。通过最后一步,我们从一个包含 6-7 个步骤的相当复杂的着色器,变成了一个仅包含 1 个步骤的非常简单的着色器,这一切都归功于矩阵数学的神奇之处。
希望本文有助于您揭开矩阵数学的神秘面纱。接下来,我将介绍 3D。在 3D 矩阵中,数学运算遵循相同的原理和用法。我从 2D 开始,希望这样能让大家更容易理解。