Этот пост является продолжением серии постов о 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 | + | newY = | x * | 2.0 | + | extra = | x * | 3.0 | + |
y * | 4.0 | + | y * | 5.0 | + | y * | 6.0 | + | |||
1 * | 7.0 | 1 * | 8.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 | + | newY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | 1.0 | + | y | * | 0.0 | + | |||
1 | * | tx | 1 | * | ty | 1 | * | 1.0 |
Если вы помните свою алгебру, мы можем удалить любое место, которое умножается на ноль. Умножение на 1 эффективно ничего не делает, поэтому давайте упростим, чтобы увидеть, что происходит
newX = | x | * | 1.0 | + | newY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | 1.0 | + | y | * | 0.0 | + | |||
1 | * | tx | 1 | * | ty | 1 | * | 1.0 |
или более кратко
newX = x + tx; newY = y + ty;
И extra нас не очень волнует. Это выглядит удивительно похоже на код перемещения из нашего примера перемещения.
Аналогично давайте сделаем поворот. Как мы указали в посте о повороте, нам просто нужны синус и косинус угла, на который мы хотим повернуть, поэтому
s = Math.sin(angleToRotateInRadians); c = Math.cos(angleToRotateInRadians);
И мы строим матрицу так
c | -s | 0.0 |
s | c | 0.0 |
0.0 | 0.0 | 1.0 |
Применяя матрицу, мы получаем это
newX = | x | * | c | + | newY = | x | * | -s | + | extra = | x | * | 0.0 | + |
y | * | s | + | y | * | c | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
Зачеркивая все умножения на 0 и 1, мы получаем
newX = | x | * | c | + | newY = | x | * | -s | + | extra = | x | * | 0.0 | + |
y | * | s | + | y | * | c | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
И упрощая, мы получаем
newX = x * c + y * s; newY = x * -s + y * c;
Что точно то же, что у нас было в примере поворота.
И наконец масштабирование. Мы назовем наши 2 фактора масштабирования sx и sy
И мы строим матрицу так
sx | 0.0 | 0.0 |
0.0 | sy | 0.0 |
0.0 | 0.0 | 1.0 |
Применяя матрицу, мы получаем это
newX = | x | * | sx | + | newY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | sy | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
что на самом деле
newX = | x | * | sx | + | newY = | x | * | 0.0 | + | extra = | x | * | 0.0 | + |
y | * | 0.0 | + | y | * | sy | + | y | * | 0.0 | + | |||
1 | * | 0.0 | 1 | * | 0.0 | 1 | * | 1.0 |
что упрощенно
newX = x * sx; newY = y * sy;
Что то же самое, что наш пример масштабирования.
Теперь я уверен, что вы все еще можете думать “И что? В чем смысл?” Это кажется большой работой только для того, чтобы делать то же самое, что мы уже делали.
Здесь вступает в игру магия. Оказывается, мы можем умножать матрицы
вместе и применять все преобразования сразу. Давайте предположим, что у нас есть
функция m3.multiply
, которая берет две матрицы, умножает их и
возвращает результат.
var m3 = {
multiply: function(a, b) {
var a00 = a[0 * 3 + 0];
var a01 = a[0 * 3 + 1];
var a02 = a[0 * 3 + 2];
var a10 = a[1 * 3 + 0];
var a11 = a[1 * 3 + 1];
var a12 = a[1 * 3 + 2];
var a20 = a[2 * 3 + 0];
var a21 = a[2 * 3 + 1];
var a22 = a[2 * 3 + 2];
var b00 = b[0 * 3 + 0];
var b01 = b[0 * 3 + 1];
var b02 = b[0 * 3 + 2];
var b10 = b[1 * 3 + 0];
var b11 = b[1 * 3 + 1];
var b12 = b[1 * 3 + 2];
var b20 = b[2 * 3 + 0];
var b21 = b[2 * 3 + 1];
var b22 = b[2 * 3 + 2];
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,
];
}
}
Чтобы сделать вещи более ясными, давайте создадим функции для построения матриц для перемещения, поворота и масштабирования.
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,
];
},
};
Теперь давайте изменим наш шейдер. Старый шейдер выглядел так
#version 300 es
in 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;
Наш новый шейдер будет намного проще.
#version 300 es
in vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
void main() {
// Умножаем позицию на матрицу.
vec2 position = (u_matrix * vec3(a_position, 1)).xy;
...
И вот как мы его используем
// Рисуем сцену.
function drawScene() {
webglUtils.resizeCanvasToDisplaySize(gl.canvas);
// Говорим WebGL, как конвертировать из clip space в пиксели
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
// Очищаем холст
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
// Говорим использовать нашу программу (пару шейдеров)
gl.useProgram(program);
// Привязываем набор атрибутов/буферов, который мы хотим.
gl.bindVertexArray(vao);
// Вычисляем матрицы
var projectionMatrix = m3.projection(
gl.canvas.clientWidth, gl.canvas.clientHeight);
var translationMatrix = m3.translation(translation[0], translation[1]);
var rotationMatrix = m3.rotation(rotationInRadians);
var scaleMatrix = m3.scaling(scale[0], scale[1]);
// Умножаем матрицы.
var matrix = m3.multiply(projectionMatrix, translationMatrix);
matrix = m3.multiply(matrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
// Устанавливаем матрицу.
gl.uniformMatrix3fv(matrixLocation, false, matrix);
// Устанавливаем цвет.
gl.uniform4fv(colorLocation, color);
// Рисуем прямоугольник.
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 18;
gl.drawArrays(primitiveType, offset, count);
}
Вот пример использования нашего нового кода. Слайдеры те же, перемещение, поворот и масштабирование. Но способ их использования в шейдере намного проще.
Все еще, вы можете спрашивать, и что? Это не кажется большой выгодой. Но теперь, если мы хотим изменить порядок, нам не нужно писать новый шейдер. Мы можем просто изменить математику.
...
// Умножаем матрицы.
var matrix = m3.multiply(scaleMatrix, rotationMatrix);
matrix = m3.multiply(matrix, translationMatrix);
...
Вот эта версия.
Возможность применять матрицы таким образом особенно важна для иерархической анимации, как руки на теле, луны на планете вокруг солнца, или ветви на дереве. Для простого примера иерархической анимации давайте нарисуем нашу ‘F’ 5 раз, но каждый раз давайте начнем с матрицы от предыдущей ‘F’.
// Рисуем сцену.
function drawScene() {
...
// Вычисляем матрицы
var translationMatrix = m3.translation(translation[0], translation[1]);
var rotationMatrix = m3.rotation(rotationInRadians);
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);
// Рисуем геометрию.
var primitiveType = gl.TRIANGLES;
var offset = 0;
var count = 18;
gl.drawArrays(primitiveType, offset, count);
}
}
Для этого мы ввели функцию 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, вы можете вспомнить, что у нас есть код в шейдере для конвертации из пикселей в clip space, который выглядит так.
...
// конвертируем прямоугольник из пикселей в 0.0 до 1.0
vec2 zeroToOne = position / u_resolution;
// конвертируем из 0->1 в 0->2
vec2 zeroToTwo = zeroToOne * 2.0;
// конвертируем из 0->2 в -1->+1 (clip space)
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
Если вы посмотрите на каждый из этих шагов по очереди, первый шаг, “конвертируем из пикселей в 0.0 до 1.0”, на самом деле является операцией масштабирования. Второй также является операцией масштабирования. Следующий - это перемещение, и самый последний масштабирует Y на -1. Мы можем на самом деле сделать все это в матрице, которую мы передаем в шейдер. Мы могли бы создать 2 матрицы масштабирования, одну для масштабирования на 1.0/resolution, другую для масштабирования на 2.0, третью для перемещения на -1.0,-1.0 и четвертую для масштабирования Y на -1, затем умножить их все вместе, но вместо этого, поскольку математика простая, мы просто создадим функцию, которая создает матрицу ‘проекции’ для заданного разрешения напрямую.
var m3 = {
projection: function (width, height) {
// Примечание: Эта матрица переворачивает ось Y так, что 0 находится сверху.
return [
2 / width, 0, 0,
0, -2 / height, 0,
-1, 1, 1,
];
},
...
Теперь мы можем упростить шейдер еще больше. Вот весь новый вершинный шейдер.
#version 300 es
in vec2 a_position;
uniform mat3 u_matrix;
void main() {
// Умножаем позицию на матрицу.
gl_Position = vec4((u_matrix * vec3(a_position, 1)).xy, 0, 1);
}
И в JavaScript нам нужно умножить на матрицу проекции
// Рисуем сцену.
function drawScene() {
...
- // Передаем разрешение холста, чтобы мы могли конвертировать из
- // пикселей в clip space в шейдере
- gl.uniform2f(resolutionUniformLocation, gl.canvas.width, gl.canvas.height);
...
// Вычисляем матрицы
var projectionMatrix = m3.projection(
gl.canvas.clientWidth, gl.canvas.clientHeight);
var translationMatrix = m3.translation(translation[0], translation[1]);
var rotationMatrix = m3.rotation(rotationInRadians);
var scaleMatrix = m3.scaling(scale[0], scale[1]);
// Умножаем матрицы.
* var matrix = m3.multiply(projectionMatrix, translationMatrix);
* matrix = m3.multiply(matrix, rotationMatrix);
matrix = m3.multiply(matrix, scaleMatrix);
...
}
Мы также удалили код, который устанавливал разрешение. С этим последним шагом мы перешли от довольно сложного шейдера с 6-7 шагами к очень простому шейдеру только с 1 шагом, все благодаря магии матричной математики.
Прежде чем мы продолжим, давайте немного упростим. Хотя обычно генерировать различные матрицы и отдельно умножать их вместе, также обычно просто умножать их по ходу дела. Эффективно мы могли бы функции как эти
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);
matrix = m3.translate(matrix, translation[0], translation[1]);
matrix = m3.rotate(matrix, rotationInRadians);
matrix = m3.scale(matrix, scale[0], scale[1]);
И вот это
Последняя вещь, мы видели выше, что порядок имеет значение. В первом примере у нас было
translation * rotation * scale
а во втором у нас было
scale * rotation * translation
И мы видели, как они разные.
Есть 2 способа смотреть на матрицы. Учитывая выражение
projectionMat * translationMat * rotationMat * scaleMat * position
Первый способ, который многие люди находят естественным, - начать справа и работать влево
Сначала мы умножаем позицию на матрицу масштабирования, чтобы получить масштабированную позицию
scaledPosition = scaleMat * position
Затем мы умножаем scaledPosition на матрицу поворота, чтобы получить rotatedScaledPosition
rotatedScaledPosition = rotationMat * scaledPosition
Затем мы умножаем rotatedScaledPosition на матрицу перемещения, чтобы получить translatedRotatedScaledPosition
translatedRotatedScaledPosition = translationMat * rotatedScaledPosition
И наконец мы умножаем это на матрицу проекции, чтобы получить позиции в clip space
clipspacePosition = projectionMatrix * translatedRotatedScaledPosition
Второй способ смотреть на матрицы - читать слева направо. В этом случае каждая матрица изменяет “пространство”, представленное холстом. Холст начинается с представления clip space (-1 до +1) в каждом направлении. Каждая примененная матрица слева направо изменяет пространство, представленное холстом.
Шаг 1: нет матрицы (или единичная матрица)
clip spaceБелая область - это холст. Синий - вне холста. Мы в clip space. Передаваемые позиции должны быть в clip space
Шаг 2: matrix = m3.projection(gl.canvas.clientWidth, gl.canvas.clientHeight)
;
из clip space в pixel spaceТеперь мы в pixel space. X = 0 до 400, Y = 0 до 300 с 0,0 в верхнем левом углу. Позиции, передаваемые с использованием этой матрицы, должны быть в pixel space. Вспышка, которую вы видите, это когда пространство переворачивается с положительного Y = вверх на положительный Y = вниз.
Шаг 3: matrix = m3.translate(matrix, tx, ty);
переместить начало координат в tx, tyНачало координат теперь перемещено в tx, ty (150, 100). Пространство переместилось.
Шаг 4: matrix = m3.rotate(matrix, rotationInRadians);
повернуть на 33 градусаПространство повернуто вокруг tx, ty
Шаг 5: matrix = m3.scale(matrix, sx, sy);
Предварительно повернутое пространство с центром в tx, ty масштабировано на 2 по x, 1.5 по y
В шейдере мы затем делаем gl_Position = matrix * position;
. Значения position
эффективно находятся в этом финальном пространстве.
Используйте тот способ, который вам легче понять.
Я надеюсь, что эти посты помогли развеять тайну матричной математики. Если вы хотите остаться с 2D, я бы предложил проверить воссоздание функции drawImage canvas 2d и следовать за этим в воссоздание матричного стека canvas 2d.
Иначе дальше мы перейдем к 3D. В 3D матричная математика следует тем же принципам и использованию. Я начал с 2D, чтобы надеюсь сохранить это простым для понимания.
Также, если вы действительно хотите стать экспертом в матричной математике проверьте эти удивительные видео.