В прошлый раз мы говорили о графиках и траекториях для покадровых анимаций, а сегодня речь пойдет про матрицы. Мы разберемся с тем, как строятся базовые трансформации в CSS, SVG и WebGL, построим отображение 3D-мира на экране своими руками, попутно проводя параллель с таким инструментом, как Three.js, а также поэкспериментируем с фильтрами для фотографий и разберемся, что же за магия такая лежит в их основе.

Напомню, что в этой серии статей мы знакомимся с разными штуками из области математики, которые пугают верстальщиков, но могут быть полезны при решении рабочих задач. Мы стараемся избежать излишней теоретизации, отдавая предпочтение картинкам и объяснению на пальцах, с акцентом на практическое применение во фронтенде. В связи с этим формулировки местами могут быть не совсем точными с точки зрения математики, или не совсем полными. Цель этой статьи – дать общее представление о том, что бывает, и откуда можно начать в случае чего.

Скрипты для генерирования картинок в стиле этого цикла статей находятся на GitHub, так что если если вы хотите себе сообразить такие же – вы знаете что делать.

Немного определений


Матрица в математике – это такая абстракция, можно сказать, что это тип данных в каком-то смысле, и записываетя она в виде прямоугольной таблицы. Количество столбцов и строк может быть любым, но в вебе мы почти всегда имеем дело с квадратными матрицами 2x2, 3x3, 4x4, и 5x5.

Также нам понадобится такое определение, как вектор. Думаю из школьной геометрии вы можете вспомнить определение, связанное со словами «длина» и «направление», но вообще в математике вектором может называться очень много чего. В частности мы будем говорить о векторе, как об упорядоченном наборе значений. Например координаты вида (x, y) или (x, y, z), или цвет в формате (r, g, b) или (h, s, l, a) и.т.д. В зависимости от того, сколько элементов вошло в такой набор – мы будем говорить о векторе той или иной размерности: если два элемента – двумерный, три – трехмерный и.т.д. Также, в рамках рассматриваемых тем, может быть удобно иногда думать о векторе, как о матрице размера 1x2, 1x3, 1x4 и.т.д. Технически можно было бы ограничиться только термином «матрица», но мы все же будем использовать слово «вектор», чтобы отделить эти два понятия друг от друга, хотя бы в логическом смысле.

Для матриц, равно как и для векторов, определены разные операции, которые с ними можно делать. В частности умножение. Мы постоянно их умножаем между собой. Сам алгоритм умножения не очень сложный, хотя может показаться немного запутанным:

function multiplyMatrices(a, b) {
    const m = new Array(a.length);

    for (let row = 0; row < a.length; row++) {
        m[row] = new Array(b[0].length);

        for (let column = 0; column < b[0].length; column++) {
            m[row][column] = 0;

            for (let i = 0; i < a[0].length; i++) {
                m[row][column] += a[row][i] * b[i][column];
            }
        }
    }

    return m;
}

Но для нас на самом деле не так важно постоянно помнить принцип его работы при решении бытовых задач. Здесь мы упоминаем его скорее для полноты картины, чтобы внести контекст в дальнейшие примеры.

При работе со сложными сущностями в математике очень полезно абстрагироваться. Как здесь – мы будем часто говорить про умножение, но не будем уделять внимание тому, какие именно арифметические операции в каком порядке там происходят. Мы знаем, что умножение определено – и этого достаточно для работы.

Мы будем использовать только квадратные матрицы в очень частном наборе задач, так что будет достаточно набора простых правил:

  • Умножать можно только матрицы одной размерности.
  • Умножаем матрицу на матрицу – получаем матрицу.
  • Можно умножить матрицу на вектор – получим вектор.
  • Порядок умножения важен.



Мы будем использовать в основном запись умножения слева направо, как более привычную и подходящую для объяснений, но в некоторых книгах или библиотеках вы можете встретить запись справа налево, а все матрицы будут зеркально отражены по диагонали. Это никак не влияет на суть происходящих манипуляций, так что мы не будем на этом зацикливаться, но если будете что-то копипастить – обращайте внимание.

Также для дальнейшей работы нам понадобится такое понятие, как единичная матрица. Это матрица, у которой по главной диагонали расположены единицы, а во всех остальных ячейках – нули. Алгоритм умножения построен таким образом, что умножая единичную матрицу на другую матрицу – получаем ту самую матрицу. Или вектор, если мы говорим о векторе. Иными словами единичная матрица выполняет роль единицы при обычном умножении чисел. Это нейтральная штука, которая при умножении «ни на что не влияет».



И последнее, что нам понадобится, это пример, показанный на картинке выше. Это как бы частный случай умножения матрицы на вектор, когда у матрицы последняя строка представляет собой «кусочек единичной матрицы», а у вектора последний элемент – тоже равен 1.

В этом примере мы используем буквы (x, y), и как вы уже можете догадаться, дальше речь пойдет о координатах в 2D. Но зачем добавлять третью координату и оставлять ее единицей? — спросите вы. Все дело в удобстве, или даже лучше сказать, что в универсальности. Мы очень часто добавляем +1 координату для упрощения расчетов, и работа с 2D идет с матрицами 3x3, работа с 3D – с матрицами 4x4, а работа с 4D, например с цветами в формате (r, g, b, a) идет с матрицами 5x5. На первый взгляд это кажется безумной идеей, но дальше мы увидим, насколько это унифицирует все операции. Если вы захотите более подробно разобраться в этой теме, то можете загуглить выражение «однородные координаты».

Но довольно теории, перейдем к практике.

I. Базовые трансформации в компьютерной графике


Давайте возьмем выражения из упомянутого примера и посмотрим на них как есть, вне контекста матриц:

newX = a*x + b*y + c
newY = d*x + e*y + f

Можно рассматривать это как параметрические уравнения, графики которых мы строили в прошлый раз. Что будет, если задать те или иные коэффициенты в них? Начнем со следующего варианта:

newX = 1*x + 0*y + 0 = x
newY = 0*x + 1*y + 0 = y

Здесь ничего не меняется – новые координаты (x, y) идентичны старым. Если подставить эти коэффициенты в матрицу и внимательно присмотреться, то увидим, что получится единичная матрица.

А что получится, если взять другие коэффициенты? Например вот такие:

newX = 1*x + 0*y + A = x + A
newY = 0*x + 1*y + 0 = y

Мы получим смещение по оси X. Впрочем, что еще здесь могло получиться? Если для вас это не очевидно, то лучше вернуться к первой части, где мы говорили про графики и коэффициенты.

Меняя эти 6 коэффициентов — a, b, c, d, e, f — и наблюдая за изменениями x и y, рано или поздно мы придем к четырем их комбинациям, которые кажутся полезными и удобными для практического применения. Запишем их сразу в форме матриц, возвращаясь к изначальному примеру:



Названия этих матриц говорят сами за себя. При умножении этих матриц на вектора с координатами каких-то точек, объектов на сцене и.т.д. мы получаем новые координаты для них. Причем оперируем мы интуитивно понятными трансформациями – перемещение, масштабирование, поворот и наклон, а коэффициенты определяют выраженность той или иной трансформации по соответствующим осям.

Очень часто удобно воспринимать матрицы именно как трансформации для чего-либо, например для координат. Это еще раз к слову об абстракциях.

Трансформации можно складывать. В терминах матриц мы будем использовать операцию умножения, что может немного путать, но это издержки разговорного языка. Если нам нужно какой-то объект сместить в сторону и увеличить, то мы можем взять матрицу для смещения, матрицу для масштабирования, и перемножить их. Результат будет представлять из себя матрицу, которая дает и смещение, и масштабирование одновременно. Останется только каждую точку нашего объекта подвергнуть трансформации с ее помощью.



Базовые трансформации в CSS


Но это все слова. Давайте посмотрим, как это выглядит в реальном фронтенде. В CSS у нас (внезапно) есть функция matrix. Выглядит она в контексте кода как-то так:

.example {
    transform: matrix(1, 0, 0, 1, 0, 0);
}

Многих новичков, которые впервые видят ее, накрывает вопрос – почему здесь шесть параметров? Это странно. Было бы 4 или 16 – еще куда не шло, но 6? Что они делают?

Но на самом деле все просто. Эти шесть параметров – те самые коэффициенты, из которых мы только что собирали матрицы для базовых трансформаций. Только их почему-то расставили в другом порядке:



Также в CSS есть функция matrix3d для того, чтобы задавать с помощью матрицы трансформацию в 3D. Там уже есть 16 параметров, ровно чтобы сделать матрицу 4x4 (не забываем, что мы добавляем +1 размерность).

Матрицы для базовых 3D трансформаций строятся так же, как и для 2D, только нужно больше коэффициентов, чтобы расставлять их не по двум координатам, а по трем. Но принципы те же.

Естественно, каждый раз городить матрицу и следить за корректной расстановкой коэффициентов при работе с простыми трансформациями в CSS было бы странно. Мы, программисты, обычно все же стараемся упростить себе жизнь. Так что у нас в CSS появились краткие функции для создания отдельных трансформаций – translateX, translateY, scaleX и.т.д. Обычно мы используем именно их, но важно понимать, что внутри они создают эти же самые матрицы, о которых мы говорили, просто скрывая от нас этот процесс за еще одним слоем абстракции.

Эти же самые трансформации translate, rotate, scale и skew, а также универсальная функция matrix для задания трансформаций, присутствуют и в SVG. Синтаксис немного другой, но суть такая же. При работе с трехмерной графикой, например с WebGL, мы тоже будем прибегать к этим же трансформациям. Но об этом чуть позже, сейчас важно понять, что они есть везде, и работают везде по одному и тому же принципу.

Промежуточные итоги


Обобщим вышесказанное:

  • Матрицы могут быть использованы в качестве трансформаций для векторов, в частности для координат каких-то объектов на странице.
  • Почти всегда мы оперируем квадратными матрицами и добавляем +1 размерность для упрощения и унификации вычислений.
  • Есть 4 базовых трансформации – translate, rotate, scale и skew. Они используются везде – от CSS до WebGL и везде работают схожим образом.

II. Построение 3D сцены своими руками


Логичным развитием темы про трансформации координат будет построение 3D сцены и отображение ее на экране. В той или иной форме эта задача обычно есть во всех курсах по компьютерной графике, но вот в курсах по фронтенду ее обычно нет. Мы посмотрим может быть немного упрощенный, но тем не менее полноценный вариант того, как можно сделать камеру с разными углами обзора, какие операции нужны, чтобы посчитать координаты всех объектов на экране и построить картинку, а также проведем параллели с Three.js – самым популярным инструментом для работы с WebGL.

Здесь должен возникнуть резонный вопрос – зачееем? Зачем учиться делать все руками, если есть готовый инструмент? Ответ кроется в вопросах производительности. Вероятно вы бывали на сайтах с конкурсами вроде Awwwards, CSS Design Awards, FWA и им подобных. Вспомните, насколько производительные сайты принимают участие в этих конкурсах? Да там почти все тормозят, лагают при загрузке и заставляют ноутбук гудеть как самолет! Да, конечно, основная причина обычно – это сложные шейдеры или слишком большое количество манипуляций с DOM, но вторая – невероятное количество скриптов. Это катастрофическим образом влияет на загрузку подобных сайтов. Обычно все происходит так: нужно сделать что-то на WebGL – берут какой-нибудь 3D движок (+500КБ) и немного плагинов для него (+500КБ); нужно сделать падение объекта или разлетание чего-то – берут физический движок (+1МБ, а то и больше); нужно обновить какие-то данные на странице – ну так добавляют какой-нибудь SPA-фреймворк с десятком плагинов (+500КБ) и.т.д. И таким образом набирается несколько мегабайтов скриптов, которые мало того, что нужно загрузить клиенту (и это еще вдобавок к большим картинкам), так еще и браузер с ними что-то будет делать после загрузки – они же не просто так к нему прилетают. Причем, в 99% случаев, пока скрипты не отработают, пользователь не увидит всей той красоты, которую ему бы нужно с самого начала показывать.

Народная примета гласит, что каждые 666КБ скриптов в продакшене увеличивают время загрузки страницы на время, достаточное, чтобы пользователь отправил разработчика сайта на следующий круг ада. Three.js в минимальной комплектации весит 628КБ...

При этом часто задачи просто не требуют подключения сложных инструментов. Например чтобы показать пару плоскостей с текстурами в WebGL и добавить пару шейдеров, чтобы картинки расходились волнами, не нужен весь Three.js. А чтобы сделать падение какого-то объекта не нужен полноценный физический движок. Да, он скорее всего ускорит работу, особенно если вы с ним знакомы, но вы заплатите за это временем пользователей. Тут каждый решает сам для себя, что ему выгоднее.

Цепочка преобразований координат


На самом деле суть преобразований координат для построения 3D-сцены у себя на экране довольно простая, но мы все же разберем ее по шагам, т.к. скорее всего для многих верстальщиков этот процесс будет чем-то новым.

Так вот. Допустим дизайнер нарисовал 3D-модель. Пусть это будет куб (в примерах будем использовать максимально простые построения, чтобы не усложнять иллюстрации на ровном месте):



Что эта модель из себя представляет? По сути – это набор точек в какой-то системе координат и набор взаимосвязей между ними, чтобы можно было определить, между какими из точек должны находиться плоскости. Вопрос плоскостей в контексте WebGL будет лежать на плечах самого браузера, а для нас важны координаты. Нужно понять, как именно их преобразовать.

У модели, как мы и сказали, есть система координат. Но обычно мы хотим иметь много моделей, хотим сделать сцену с ними. Сцена, наш 3D-мир, будет иметь свою, глобальную систему координат. Если мы просто будем интерпретировать координаты модели как глобальные координаты – то наша модель будет находиться как бы «в центре мира». Иными словами, ничего не поменяется. Но мы хотим добавить много моделей в разные места нашего мира, примерно так:



Что же делать? Нужно преобразовать координаты каждой отдельной модели в глобальные координаты. Расположение модели в пространстве задается смещениями, поворотами и масштабированием – мы уже видели эти базовые трансформации. Сейчас нам нужно для каждой модели построить матрицу трансформаций, которая будет хранить в себе как раз эту информацию о том, где находится модель относительно мира, и как повернута.

Например для кубиков будут примерно такие матрицы:

// Центральный кубик в центре мира.
// Единичная матрица «ничего не меняет» и координаты просто интерпретируются как глобальные.
const modelMatrix1 = [
    [1, 0, 0, 0],
    [0, 1, 0, 0],
    [0, 0, 1, 0],
    [0, 0, 0, 1]
];

// Кубик, смещенный по глобальной оси X.
const modelMatrix2 = [
    [1, 0, 0, 1.5],
    [0, 1, 0, 0  ],
    [0, 0, 1, 0  ],
    [0, 0, 0, 1  ]
];

// Кубик, смещенный по глобальной оси X в другую сторону.
const modelMatrix3 = [
    [1, 0, 0, -1.5],
    [0, 1, 0, 0   ],
    [0, 0, 1, 0   ],
    [0, 0, 0, 1   ]
];

Дальше мы будем действовать примерно следующим образом:

для каждой точки модели {
    глобальные координаты точки = [ матрица этой модели ] * локальные кординаты
}

Соответственно для каждой модели нужна своя матрица.

Аналогичным образом можно делать цепочку из каких-то объектов. Если птица должна махать крыльями, то будет уместно координаты точек крыла сначала перевести в координаты птицы, а потом уже в глобальные мировые координаты. Это будет куда проще угадывания траектории крыла сразу в глобальных координатах. Но это так, к слову.


Дальше нужно определиться с тем, с какой стороны мы будем смотреть на мир. Нужна камера.



Камера – это такая абстракция, как бы имитация физической камеры. У нее есть координаты и какие-то углы наклона, задающие ее расположение в глобальных координатах. Наша задача – преобразовать набор теперь уже глобальных координат в систему координат камеры. Принцип тот же, что и в предыдущем примере:

для каждой точки в мире {
    координаты точки для камеры = [ матрица камеры ] * глобальные кординаты
}

Если у вас сложная сцена, то будет иметь смысл произвести некоторые оптимизации и не считать ничего для точек, которые точно не попадут в поле зрения камеры. Не забывайте о производительности!

Давайте посмотрим на сцену с того места, где стоит наша условная камера:



Сейчас, преобразовав все точки в систему координат камеры, мы можем просто выбросить ось Z, а оси X и Y интерпретировать как «горизонтальную» и «вертикальную». Если нарисовать все точки моделей на экране, то получится картинка, как в примере – никакой перспективы и сложно понять, какая часть сцены на самом деле попадает в кадр. Камера как бы бесконечного размера во все стороны. Мы можем как-то все подогнать, чтобы то, что нужно, влезло на экран, но было бы неплохо иметь универсальный способ определения того, какая часть сцены попадет в поле зрения камеры, а какая – нет.

У физических камер мы можем говорить о такой штуке, как угол обзора. Почему бы и здесь его не добавить?

Для этого нам понадобится еще одна матрица – матрица проекции. Вообще строить ее можно по-разному. В зависимости от того, что взято за изначальные параметры, получится немного разный вид этой самой матрицы, но суть будет одна и та же. Мы возьмем следующий, немного упрощенный вариант:

// Пусть угол обзора будет 90 градусов
const s = 1 / (Math.tan(90 * Math.PI / 360));
const n = 0.001;
const f = 10;

const projectionMatrix  = [
    [s, 0, 0,          0],
    [0, s, 0,          0],
    [0, 0, -(f)/(f-n), -f*n/(f-n)],
    [0, 0, -1,         0]
];

Матрица проекции, так или иначе, содержит в себе три параметра – это угол обзора, а также минимальное и максимальное расстояние до точек, с которыми идет работа. Выражаться это может по-разному, использоваться тоже по-разному, но эти параметры будут в этой матрице в любом случае.

Понимаю, что ни разу не очевидно, почему матрица выглядит именно так, но для ее выведения с пояснениями понадобится формул на 2-3 страницы. Это еще раз возвращает нас к мысли, что абстрагироваться полезно – мы можем оперировать более общим результатом, не вдаваясь в мелкие детали там, где это не нужно для решения конкретной задачи.

Теперь, произведя уже знакомые преобразования:

для каждой точки в системе координат камеры {
    координаты точки = [ матрица проекции ] * координаты в камере
}

Получим в нашем поле зрения именно то, что и ожидаем. Увеличивая угол – видим больше всего по сторонам, уменьшая угол – видим только то, что ближе к направлению, куда камера направлена. PROFIT!



Но на самом деле нет. Мы забыли про перспективу. Бесперспективная картинка нужна мало где, так что нужно ее как-то добавить. И здесь, внезапно, нам не нужны матрицы. Задача выглядит очень сложной, но решается банальным делением координат X и Y на W для каждой точки:



* Здесь мы сместили камеру в сторону, и добавили параллельные линии «на полу», чтобы было понятнее, где эта самая перспектива появляется.

Подбирая коэффициенты на свой вкус получим разные варианты перспективы. В каком-то смысле коэффициенты здесь определяют тип объектива, насколько он «плющит» окружающее пространство.

Теперь мы имеем полноценную картинку. Можно брать координаты X и Y для каждой точки и рисовать ее на экране любым удобным вам способом.

Вообще этого достаточно, чтобы построить сцену, но в реальных проектах вы также можете столкнуться с дополнительной трансформацией, связанной с масштабированием, в самом конце. Идея в том, что после проекции мы получаем координаты (x, y) в пределах 1, нормализованные координаты, а потом уже умножаем их на размер экрана или канваса, получая координаты для отображения на экране. Этот дополнительный шаг выносит размер канваса из всех вычислений, оставляя его только в самом конце. Иногда это удобно.

Здесь у вас, вероятно заболела голова от количества информации, так что притормозим и повторим еще раз все трансформации в одном месте:



Если эти трансформации объединить в одну, то получится паровозик.

Как это выглядит в Three.js?


Теперь, когда мы понимаем, откуда этот паровозик взялся, посмотрим на пример вершинного шейдера по умолчанию в Three.js, который «ничего не делает»:

void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

или в более полном варианте:

void main() {
    gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(position, 1.0);
}

Ничего он вам не напоминает? Да, это именно этот паровозик. И под «ничего не делает» мы подразумеваем, что он как раз делает всю работу по пересчету координат, на основе заботливо переданных со стороны Three.js матриц. Но никто не мешает эти матрицы сделать своими руками, не так ли?

Типы камер в компьютерной графике и Three.js


Тема типов камер не касается непосредственно матриц, но все же уделим ей пару минут, раз уж все равно зашла речь про Three.js, а то порой у людей каша в голове по этому поводу.

Камера – это абстракция. Она помогает нам мыслить в 3D мире таким же образом, как мы мыслим в мире реальном. Как мы уже сказали, у камеры есть положение в пространстве, направление, куда она смотрит, и угол обзора. Все это задается с помощью двух матриц и, возможно, дополнительного деления координат для создания перспективы.

В компьютерной графике мы имеем два вида камер – это «с перспективой» и «без перспективы». Это два принципиально разных типа камер в техническом плане, требующих разных действий для получения изображения. И все. Больше ничего нет. Все остальное – это их комбинации, какие-то более сложные абстракции. Например в Three.js есть стерео-камера – это не какой-то «особый тип» камеры в техническом плане, а просто абстракция — две камеры, немного разнесенные в пространстве и расположенные под углом:



Для каждой половины экрана мы берем свою камеру и получается стерео-картинка. А CubeCamera – это 6 обычных камер, расположенных с разных сторон от какой-то точки, не более того.

Что дальше?


Следующий шаг, после получения координат объектов, это определение того, какие объекты будут видны, а какие скроются за другими. В контексте WebGL это браузер сделает сам. Ну и останутся еще сопутствующие задачи вроде наложения текстур на них, расчет освещения по нормалям, тени, постобработка картинки, и.т.д. Но самую важную и сложную для понимания часть мы уже сделали. Это очень здорово. На самом деле многие генеративные штуки не нуждаются в этих самых текстурах и освещении, так что очень может быть, что полученных сейчас знаний будет достаточно для работы с ними.

Кстати, отбрасывание тени от какого-то объекта на плоскость — есть ни что иное, как проекция этого объекта на эту самую плоскость под определенным углом с последующим смешением цветов. Процесс по своей сути очень похож на работу камеры, но там добавляется еще угол между плоскостью проекции и «направлением взгляда».

Про текстуры и эффекты для картинок на WebGL, в том числе без библиотек, мы говорили в предыдущих статьях уже не раз. Вы можете обратиться к ним, если вам эта тема интересна. Таким образом, объединив все эти знания воедино, мы сможем строить полноценные красочные 3D-штуки своими руками.

Мы действительно можем строить полноценные 3D-сцены своими руками. Но все же здесь важно соблюдать баланс – для простой генеративной графики или каких-то эффектов для картинок это будет хорошей идеей. Для сложных сцен может быть проще все же взять готовый инструмент, потому что свой по объему будет приближаться к тому же Three.js и мы ничего не выиграем при избавлении от него. А если вам нужно естественное освещение, мех, стекло, множественные отражения или еще что-то в этом духе, то из соображений производительности может иметь смысл отказаться от самой идеи рендерить что-то в реальном времени и заранее заготовить картинки с видами на сцену с разных сторон и уже их показывать пользователю. Мыслите шире, не зацикливайтесь на определенном подходе к решению задач.

Промежуточные итоги


Сейчас самое время обобщить вышесказанное, чтобы в голове появилось место для следующего варианта применения матриц.

Итак:

  • Можно построить 3D мир и рассчитать координаты объектов на экране своими руками, используя паровозик из матриц.
  • В 3D мире мы оперируем такой абстракцией, как «камера». Она имеет расположение, направление и угол обзора. Все это задается с помощью все тех же матриц. И есть два базовых вида камер – с перспективой и без перспективы.
  • В контексте WebGL ручное построение картинки на экране или физические расчеты часто позволяют выбросить тяжелые зависимости и ускорить загрузку страниц. Но важно соблюдать баланс между своими скриптами, готовыми инструментами, а также альтернативными вариантами решения задач, уделяя внимание не только своему удобству, но и вопросам скорости загрузки и конечной производительности, в том числе на телефонах.

III. Фильтры для изображений


Напоследок мы рассмотрим такую область применения матриц, как фильтры для изображений. Если мы рассмотрим цвет в формате RGBA как вектор, то можно предположить, что здесь можно применить трансформацию, аналогичную той, что мы использовали с координатами:



И применить это для картинки по очевидному принципу:

для каждого пикселя картинки {
    новый цвет пикселя = [ фильтр ] * старый цвет пикселя
}

Если в качестве матрицы будет выступать единичная матрица – ничего не изменится, это мы уже знаем. А что будет, если применить фильтры, схожие с трансформациями translate и scale?



Оу. Получились фильтры яркости и контраста. Занятно.

При экспериментах с такими фильтрами всегда следует помнить о коррекции значений, чтобы изображение не засвечивалось. Если вы что-то умножаете на большое число – скорее всего нужно где-то что-то вычесть или поделить. Что и было показано в предыдущем примере.

А как сделать черно-белое изображение из цветного? Первое, что может прийти в голову – это сложить значения каналов RGB, поделить на 3, и использовать полученное значение для всех трех каналов. В формате матрицы это будет выглядеть как-то так:



И хотя у нас действительно получилось черно-белое изображение, его все еще можно улучшить. Наш глаз на самом деле по-разному воспринимает светлость разных цветов. И для того, чтобы как-то это передать при обесцвечивании, мы делаем разные коэффициенты для каждого канала RGB в этой матрице.

В примере ниже будут представлены общепринятые значения для этих коэффициентов, но никто не мешает с ними поиграть. В сумме эти коэффициенты должны давать 1, но в зависимости от их пропорций мы получим немного разные черно-белые изображения. Это может в какой-то степени имитировать разную цветопередачу при работе с пленочными фотоаппаратами.

А если мы еще и домножим главную диагональ немного, то получится универсальный фильтр насыщенности:



Он работает как бы в обе стороны – и в обесцвечивание (можно дойти до полностью черно-белого изображения), и в насыщение. Все зависит от соответствующего коэффициента.

Вообще играться с фильтрами можно долго, получая самые разные результаты:



* Использованные в этом примере матрицы можно посмотреть на GitHub, если они вдруг вам понадобились. Для вставки в статью их объем будет избыточным.

Но давайте все же уделим немного внимания тому, где это собственно применить. Понятно, что сама идея замены цвета для каждого пикселя наводит на мысли о шейдерах для обработки фото, либо для постобработки какой-то 3D-сцены, но может быть это еще где-то есть во фронтенде?

Фильтры в CSS


В CSS у нас есть свойство filter. И там, в частности, есть вот такие варианты фильтров, связанных с цветами:

  • brightness (мы его сделали)
  • contrast (сделали)
  • invert (то же, что и контраст, только коэффициенты по главной диагонали с другим знаком)
  • saturate (сделали)
  • grayscale (как уже отметили, это частный случай saturate)
  • sepia (очень размытое понятие, разные варианты сепии получаются игрой с коэффициентами, где мы так или иначе уменьшаем присутствие синего цвета)

И эти фильтры принимают на вход коэффициенты, которые потом в том или ином виде подставляются в те матрицы, которые мы сделали ранее. Теперь мы знаем, как эта магия устроена изнутри. И теперь понятно, как эти фильтры комбинируются в недрах интерпретатора CSS, ведь здесь все строится по тому же принципу, что и с координатами: умножаем матрицы – складываем эффекты. Правда пользовательской функции matrix в этом свойстве в CSS не предусмотрено. Но зато она есть в SVG!

Фильтры-матрицы в SVG


В рамках SVG у нас есть такая штука, как feColorMatrix, которая применяется при создании фильтров для изображений. И здесь у нас уже есть полная свобода – можем сделать матрицу на свой вкус. Синтаксис там примерно такой:

<filter id=’my-color-filter’>
    <feColorMatrix in=’SourceGraphics’
        type=’matrix’,
        values=’1 0 0 0 0
                0 1 0 0 0
                0 0 1 0 0
                0 0 0 1 0
                0 0 0 0 1‘
    />
</filter>

А еще SVG фильтры можно применить к обычным DOM-элементам в рамках CSS, там для этого есть специальная функция url… Но я вам этого не говорил!

На самом деле SVG-фильтры в рамках CSS до сих пор не поддерживаются всеми браузерами (не показываю пальцем на IE), но ходят слухи, что Edge окончательно переезжает на хромиумный движок, и старые версии лишатся поддержки в обозримом будущем, так что пора эту технологию осваивать, с ней можно много всякого интересного делать.

А что еще бывает?


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

Kernel matrix


В частности во фронтенде мы встречаем такую штуку, как kernel matrix, и связанными с ней эффектами. Суть проста – есть квадратная матрица, обычно 3x3 или 5x5, хотя может быть и больше, а в ней хранятся коэффициенты. В центре матрицы – для «текущего» пикселя, вокруг центра – для соседних пикселей. Если матрица 5x5 – то появляется еще один слой вокруг центра – для пикселей, находящихся через один от текущего. Если 7x7 – то еще один слой и.т.д. Иными словами мы рассматриваем матрицу как такое двумерное поле, на котором можно расставить коэффициенты по своему усмотрению, уже без привязки к каким-то уравнениям. А трактоваться они будут следующим образом:

для каждого пикселя картинки {
    новый цвет пикселя =
        сумма цветов соседних пикселей, умноженных на их коэффициенты из матрицы
}

Чистый канвас не очень подходит для таких задач, а вот шейдеры – очень даже. Но нетрудно догадаться, что чем больше матрица, тем больше соседних пикселей мы будем использовать. Если матрица 3x3 – мы будем складывать 9 цветов, если 5x5 – 25, если 7x7 – 49 и.т.д. Больше операций – больше нагрузка на процессор или видеокарту. Это неизбежно повлияет на производительность страницы в целом.

По возможности используйте маленькие матрицы для подобных эффектов, если вам нужно их накладывать куда-то в реальном времени.

В рамках SVG у нас есть специальный тег feConvolveMatrix, который сделан как раз для создания подобных эффектов:

<filter id=’my-image-filter’>
    <feConvolveMatrix
        kernelMatrix=’0 0 0
                      0 1 0
                      0 0 0’
    />
</filter>

Здесь мы сделали простейший фильтр для картинки, который не делает ничего – новый цвет для каждого пикселя будет равен текущему, умноженному на 1, а значения для цветов соседних пикселей умножатся на 0.

Обратите внимание, что разные браузеры рендерят SVG по-разному, и цветопередача тоже может плавать в очень широких пределах. Порой разница просто катастрофическая. Так что всегда тестируйте свои SVG фильтры или используйте canvas — это более предсказуемая штука в нашем контексте.

Если мы начнем расставлять числа по слоям, от большего у меньшему, то получится blur:



Чем больше матрица, чем больше соседних пикселей мы затрагиваем, тем сильнее мылится картинка. Главное здесь не забывать нормализовать значения, а то изображение просто засветится.

Теперь, зная как работает blur, мы можем понять, почему активное его использование на странице в рамках CSS или SVG ведет к тормозам – для каждого пикселя браузер проделывает кучу расчетов.

Если начать экспериментировать со сменой знаков у коэффициентов и расставлять их в разные паттерны, то получатся эффекты sharpen, edge detection и некоторые другие. Попробуйте поиграть с ними самостоятельно. Это может быть полезно.



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

Промежуточные итоги


Обобщим сказанное в этой части:

  • Матрицы могут использоваться не только для трансформаций, связанных с координатами, но и для создания цветовых фильтров. Делается все по тому же принципу.
  • Матрицы могут использоваться как удобное 2D-хранилище для каких-то данных, в том числе разных коэффициентов для визуальных эффектов.

Заключение


Если немного абстрагироваться от запутанных алгоритмов, то матрицы станут вполне доступным инструментом для решения практических задач. С их помощью можно своими руками рассчитывать геометрические трансформации, в том числе в рамках CSS и SVG, строить 3D сцены, а также делать всевозможные фильтры для фотографий или для постобработки изображений в рамках WebGL. Все эти темы обычно выходят за рамки классического фронтенда и больше связаны с компьютерной графикой в целом, но, даже если вы не будете решать эти задачи непосредственно, знание принципов их решения позволит лучше понимать, как устроены некоторые ваши инструменты. Это лишним никогда не будет.

Надеюсь, что эта статья помогла вам разобраться в теме практического применения матриц во фронтенде, ну или по крайней мере дала основу, от которой можно оттолкнуться при своем дальнейшем развитии. Если вы считаете, что какие-то еще темы, связанные с математикой или физикой, достойны такого же обзора в контексте верстки, то пишите свои мысли в комментариях, возможно в одной из следующих статей речь пойдет и о них.