Содержание

WebGL2Fundamentals.org

Fix, Fork, Contribute

WebGL2 2D Матрицы

Этот пост является продолжением серии постов о WebGL. Первый начался с основ, а предыдущий был о масштабировании 2D геометрии.

Математика vs Программирование vs WebGL

Прежде чем мы начнем, если вы ранее изучали линейную алгебру или в целом имеете опыт работы с матрицами, то пожалуйста, прочитайте эту статью перед продолжением ниже..

Если у вас мало или нет опыта с матрицами, то смело пропустите ссылку выше пока и продолжайте чтение.

В последних 3 постах мы прошли, как перемещать геометрию, поворачивать геометрию и масштабировать геометрию. Перемещение, поворот и масштабирование каждый считается типом ‘преобразования’. Каждое из этих преобразований требовало изменений в шейдере, и каждое из 3 преобразований зависело от порядка. В нашем предыдущем примере мы масштабировали, затем поворачивали, затем перемещали. Если бы мы применили их в другом порядке, мы получили бы другой результат.

Например, вот масштабирование 2, 1, поворот на 30 градусов и перемещение на 100, 0.

А вот перемещение на 100,0, поворот на 30 градусов и масштабирование 2, 1

Результаты совершенно разные. Еще хуже, если бы нам нужен был второй пример, нам пришлось бы написать другой шейдер, который применял перемещение, поворот и масштабирование в нашем новом желаемом порядке.

Ну, некоторые люди намного умнее меня поняли, что вы можете делать всё то же самое с матричной математикой. Для 2D мы используем матрицу 3x3. Матрица 3x3 похожа на сетку с 9 ячейками:

1.02.03.0
4.05.06.0
7.08.09.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.00.00.0
0.01.00.0
txty1.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-s0.0
sc0.0
0.00.01.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

И мы строим матрицу так

sx0.00.0
0.0sy0.0
0.00.01.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, так что если вы умножаете на единичную матрицу, ничего не происходит. Так же как

X * 1 = X

так и

matrixX * identity = matrixX

Вот код для создания единичной матрицы.

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, чтобы надеюсь сохранить это простым для понимания.

Также, если вы действительно хотите стать экспертом в матричной математике проверьте эти удивительные видео.

Что такое clientWidth и clientHeight?

До этого момента, когда я ссылался на размеры холста, я использовал canvas.width и canvas.height, но выше, когда я вызывал m3.projection, я вместо этого использовал canvas.clientWidth и canvas.clientHeight. Почему?

Матрицы проекции касаются того, как взять clip space (-1 до +1 в каждом измерении) и конвертировать его обратно в пиксели. Но в браузере есть 2 типа пикселей, с которыми мы имеем дело. Один - это количество пикселей в самом холсте. Так, например, холст, определенный так.

  <canvas width="400" height="300"></canvas>

или один, определенный так

  var canvas = document.createElement("canvas");
  canvas.width = 400;
  canvas.height = 300;

оба содержат изображение 400 пикселей в ширину на 300 пикселей в высоту. Но этот размер отделен от того размера, который браузер фактически отображает этот 400x300 пиксельный холст. CSS определяет, какого размера отображается холст. Например, если мы сделали холст так.


  <style>
  canvas {
    width: 100%;
    height: 100%;
  }
  </style>
  ...
  <canvas width="400" height="300"></canvas>

Холст будет отображаться любого размера, каким является его контейнер. Это, вероятно, не 400x300.

Вот два примера, которые устанавливают CSS размер отображения холста на 100%, так что холст растягивается, чтобы заполнить страницу. Первый использует canvas.width и canvas.height. Откройте его в новом окне и измените размер окна. Обратите внимание, как 'F' не имеет правильного соотношения сторон. Она искажается.

В этом втором примере мы используем canvas.clientWidth и canvas.clientHeight. canvas.clientWidth и canvas.clientHeight сообщают размер, который холст фактически отображается браузером, так что в этом случае, даже хотя холст все еще имеет только 400x300 пикселей, поскольку мы определяем наше соотношение сторон на основе размера, который холст отображается, F всегда выглядит правильно.

Большинство приложений, которые позволяют изменять размер их холстов, пытаются сделать canvas.width и canvas.height соответствующими canvas.clientWidth и canvas.clientHeight, потому что они хотят, чтобы был один пиксель в холсте для каждого пикселя, отображаемого браузером. Но, как мы видели выше, это не единственный вариант. Это означает, что почти во всех случаях более технически правильно вычислять соотношение сторон матрицы проекции, используя canvas.clientHeight и canvas.clientWidth. Тогда вы получите правильное соотношение сторон независимо от того, соответствуют ли ширина и высота холста размеру, который браузер рисует холст.

Есть предложения или замечания? Создайте issue на GitHub.
comments powered by Disqus