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”这个想法,就用了“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。我们构建了一个如下所示的矩阵,
六 | 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
同样
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 个矩阵将 Y 乘以 -1,第四个矩阵将 Y 乘以 -1,然后将所有矩阵相乘,但鉴于数学比较简单,我们只需创建一个函数,即可直接生成指定分辨率的“投影”矩阵。
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。在三维矩阵数学中,遵循相同的原则和用法。我从 2D 开始,希望能让它易于理解。