История одной анимации +39


Однажды фронтендеру позвонил дизайнер и попросил сделать «паутинку» за запотевшим стеклом. Но потом оказалось, что это не «паутинка», а гексагональная сетка, и не за стеклом, а она уходит вдаль, и с WebGL фронтендер не знаком, а всей анимации пришлось учиться в процессе рисования. Тем фронтендером был Юрий Артюх (akella).



Юрий давно занимается версткой, а по воскресеньям записывает стримы с разбором реальных проектов. Он не профи в WebGL, не делает на нем карты, не пишет на Web-ассемблере, но ему нравится учиться чему-то новому. На FrontendConf РИТ++ Юрий рассказал, как провести одну анимацию от макета до сдачи клиенту так, чтобы все были довольны, и по дороге изучить WebGL. История идет от первого лица и включает в себя: Three.js, GLSL, Canvas 2D, графы и немного математики.


Паутинка за запотевшим стеклом


Как-то я сидел и работал над важным проектом. Тут звонит дизайнер из студии, в которой очень любят спецэффекты, и спрашивает: «А можешь сделать паутинку, как будто за запотевшим стеклом?»

Это, конечно, сразу описывает всю задачу. Как потом оказалось, «паутинкой» за запотевшим стеклом было вот это.



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

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

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

Первое, что меня спросили дизайнеры, насколько это сложно и сколько это будет стоить. В голове пробежало несколько мыслей: как рисовать линию и такой Grid, как сделать, чтобы не тормозило, как это вообще должно работать. Я раньше с таким не сталкивался. Но, как человек, который занимается разработкой, ответил: «Та, несложно, сделаем...»

Я люблю ввязываться в непонятные авантюры, потому что когда я это делаю, обычно страдаю.

Через страдания приходит рост. Он неизбежно связан со страданиями — нельзя быть довольным всем, жить счастливо и при этом профессионально развиваться.

Three.js


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

В Three.js есть много готовых объектов. PlaneGeometry — первый объект, который мне показался идеально подходящим. Это примитивная плоскость. В библиотеке есть всякие шестиугольники, додекаэдры, икосаэдры, цилиндры, но есть простая плоскость из множества треугольников.


Треугольников много, потому что мне нужны детализированные волны — поверхность должна волноваться.

Если заглянуть внутрь Three.js, то по факту эта плоскость — простой JS-объект со списком всех координат точек.



В моем случае у меня плоскость 50?50 квадратиков, поэтому мне нужна была 2601 вершина. Почему 50?50 = 2601? Это школьная математика. Координата z = 0, потому что плоскость, y = 1, потому что это первый ряд вершин из 50 штук, а x меняется.

Но зачем мне плоскость, ее же нужно как-то искривлять? Первое, что можно сделать с массивом — произвести с ним математические операции. Например, пройтись циклом for each и присвоить координате z значение синуса от координаты x.



Получилось что-то похожее на синусоиду, потому что значение высоты каждой вершины будет равно синусу этого значения по оси x. Чтобы это как-то усложнить (сейчас будет сложный математических момент, приготовьтесь) — добавлю время в синус, и это полотно будет двигаться, просто потому что так работает математика. Если добавить время к координате x, то график движется по горизонтали. Если к координате y — будет двигаться по вертикали. Меня интересовало движение по горизонтали — я же хотел, чтобы у меня океан волновался.

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

Суть рандома в том, что он случайный и независимый. Каждая рандомная вершина никак не зависит от соседних. В рандом не передается никаких параметров, ему все равно на соседей.



Получилась ломаная кривая, которая напоминает океан или паутину лишь отдаленно. Больше подойдет как иллюстрация к фильму о «хакерах» и кибервзломах.

Если посмотреть на рандом с точки зрения каждой точки на экране, то это выглядит как белый шум — множество маленьких белых и черных точек. Каждая точка черно-белая — 0 или 1.

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


Напоминает и туман, и облака, и горы.

Есть рандомные функции, которые возвращают такие картинки. Они называется шумы или noise: Simplex noise, Perlin noise. Перлин в названии шума — это фамилия создателя алгоритма градиентного шума, который возвращает красивый рандом. Он создал его, работая над спецэффектами первой части фильма «Трон». Этот математический алгоритм существовал и раньше, но сейчас он активно применяется в кино и играх.

Когда генерируются рандомные карты в «Heroes of Might and Magic III» (для тех, кому за 30) или в стратегиях., то обычно можно увидеть нечто похожее. Это всегда одна и та же функция, которая возвращает эти шумы.

Существует целое движение «Generative art». Участники генерируют художественные произведения, пейзажи, с помощью функции noise. Например, на картинке ниже псевдоприродный пейзаж от одного из художников. Сразу непонятно, это математика или топография какой-то горы. Задача Generative-искусства как раз в том, чтобы математически сгенерировать пейзаж, который неотличим от настоящего.



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


Черное и белое — это просто высота: 0 — это черные долины, 1 — белые вершины. Получается волнистая поверхность.

Эта функция есть на всех ЯП, потому что это просто алгоритм — синусы, косинусы, умножение.

Я могу сделать искажением так же, пройдя все вершины моего объекта PlaneGeometry, присвоив каждой значение функции noise:

geometry.vertices.forEach(v => { 
  v.z = noise(v.x, v.y, time);
});

Функция занимает всего 30-40 строк, но математически сложная.

Существуют функции noise всех измерений: одномерные, двухмерные, трехмерные. В моем случае это трехмерный noise, потому что в него передаются три параметра. Кроме пространственных координат x и y плоскости я передаю время — поверхность будет постоянно извиваться, менять свое положение.

Three.js! == GPU


Когда я запустил алгоритм, волны начали двигаться. Когда я делаю что-то для Web, то всегда смотрю в профайлер, и сейчас тоже в него заглянул.Вот как волны выглядели там.



На экране — один фрейм, отрисованный браузером. Фреймы отображаются вертикальными серыми пунктирными линиями. Внутри фрейма 2/3 времени занимает исполнение функции noise. Когда вы что-то анимируете в Web, то используете фрейм request animation, который исполняется каждые 16 мс, в лучшем случае. Фрейм каждые 16 мс считает функцию noise для 2600 вершин. Для каждой вершины считается движение вверх-вниз и высота. На каждом следующем фрейме значения пересчитываются, потому что поверхность должна жить во времени.

Оказалось, что функция noise, которая исполнилась 2600 раз уже занимает 2/3 фрейма на моем компьютере. И это еще не весь фрейм. При разработке анимаций это уже красный флаг.

Никакие анимации не должны занимать больше, чем половина фрейма.

Если больше, то высока опасность потерять фрейм при любой интеракции, любой кнопочке, любом mouseover.

Поэтому это был жёсткий красный флаг. Я понял, что Three.js — это не обязательно WebGL. Несмотря на то, что я вроде бы использовал Three.js, рисовал все в 3D, оно рендерилось в WebGL, я не получил фантастической производительности из WebGL. У меня всего 2600 вершин — для WebGL это мало. Например, на каждой карте тысячи объектов, каждый состоит из десятков треугольников. Оцените масштабы: сотни тысяч — это нормально, а здесь всего 2600 вершин.

Verteх Shader


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

  • Vertex Shader;
  • Fragment Shader.

Мне был интересен вершинный шейдер — Vertex Shader. Если переписать анимацию на него, то она выглядит так:

position.z = noise( vec3(position.x, position.y, time) );

Position.z — составляющая z координаты каждой точки со своими типами данных. vec3 указывает на то, что здесь будет три параметра.

В шейдере нет цикла.

Перед этим в скрипте я ставил цикл for each, и для каждой вершины расчёты проходили в цикле. Отличие шейдеров от нешейдеров — отсутствие цикла.

Шейдер — это и есть цикл.

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

На видеокарте GPU больше ядре, в отличии от главного процессора CPU. На процессоре их гораздо меньше, но он способен быстрее выполнять универсальные вычисления. На видеокарте доступны очень простые вычисления, но много ядер, поэтому она позволяет параллелить множество вычислений. Как раз это обычно и происходит в шейдерах. Смысл вершинного шейдера в том, что расчёт noise произойдет параллельно для 2600 вершин в шейдере на видеокарте.

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



На CPU не исполняется вообще ничего. Конечно, внизу добавился еще один тред на GPU. Также есть треды на GPU, CPU, Web-workers, но эти вычисления будут производиться уже в отдельном треде на видеокарте.

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


Получилось такая поверхность — это обычный perlin-noise. Если его запустить и менять только время, получаются клевые волны.

Но это еще не все. От меня еще требовалась «паутинка» — гексагональная сетка на поверхности. Имея опыт в верстке, самый простой и очевидный способ — выделить фрагмент, который можно повторить. Интересно, что для гексагональной сетки он не квадратный, а прямоугольный. Если повторить паттерн как прямоугольник, то получится сетка. Библиотека Three.js позволяет наложить png и не учить весь WebGL перед этим. Я вырезал png и наложил на поверхность, получилось нечто такое.



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

Когда вы используете png-текстуры, и они близко к камере, видно, что у ближайшего элемента размыты края. Нет ощущения, что картинка четкая. Кажется, что png растянули в браузере. Беда в том, что в WebGL нет возможности использовать векторные текстуры в полном смысле этого слова. Поэтому я поплакал, а потом прочитал в интернете, что GLSL решает эту проблему.

GLSL — это C-подобный язык, на котором пишутся шейдеры. Всем страшно им пользоваться, потому что это же шейдеры, WebGL — ничего не понятно! Но я узнал, что на нем можно сделать четкие изображения, и обратился ко второму виду шейдеров.

Fragment shader


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

Самая базовая функция fragment shader – step(a,b). Она возвращает только 0 и 1:

  • если a > b, то 0;
  • если a < b, то 1.

Я сделал псевдореализацию в JS, чтобы было понятно, насколько проста эта функция.

function step(a, b) { 
  if (a < b) return 0 
  else return 1
}

Когда вы работаете в WebGL, обычно на любом объекте есть система координат. Если это квадратный объект, то система координат примитивная: точки (0,0), (0,1), (1,0), (1,1).

Для каждого пикселя исполняется Fragment Shader. Если Vertex Shader у меня исполнился 2600 раз на каждый фрейм, то Fragment Shader исполняется столько раз, сколько пикселей. Может и миллион раз за каждый фрейм, если поверхность 1000?1000 px. Звучит страшно, но просто потому, что мало кто знаком с ресурсами видеокарт в наше время.

Если использовать функцию step(a,b) с координатами этих пикселей, то можно исполнить функцию step с параметром 0,4 и передавать координату x каждого пикселя в каждую точку.

Получается, все, что меньше 0,4, будет 0, все, что больше — 1. В WebGL числа и цвета — это одно и то же. Каждый цвет это одно число. Белый — 1, черный — 0. В RGB их три, но все равно это 0,0,0 и 1,1,1.



Если исполнить эту функцию step посложнее, то получим белое слева. Эта функция исполнится для каждой точки на экране и посчитает, что это либо 0, либо 1. Это нормально, не стоит переживать по этому поводу.

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


Это должна бы быть кульминация — мы нарисовали белый квадрат!

С помощью комбинаций всего одной функции можно нарисовать все, что угодно.

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

Smoothstep


В шейдерах есть функция smoothstep. Она выполняет то же, что и step, но между 0 и 1 интерполирует, чтобы был градиент.


Слева до, справа — после максимального сжатия.

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

Так я смог сделать белый квадрат со сглаженными краями. Если есть один белый квадрат, можно сделать 3 белых квадрата.


Квадраты можно вращать, применять функции синуса и косинуса.

Дальше мне пришлось использовать бумагу и листик, чтобы разбить свой паттерн.


Cкриншот с продакшн.

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



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

Я покрутил параметры и нашел еще бесконечное количество паттернов. Это как «Generative art» — непонятно, что сделано, но красиво.

SDF


Дальше я узнал, что есть еще signed distance fields — генерация изображений с картой расстояний. SDF используется в картах или в компьютерных играх для рисования текстов и объектов, потому что он оптимален. В WebGL тяжело рисовать текст по-другому, особенно сглаженный и с обводкой.



Это математический формат, который тяжело использовать вне WebGL. Идея проста, но изящна и дает красивый эффект.

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

Например, если взять картинку размером 128?128 px, то из картинки в маленьком размере можно получить четкое изображение в несколько раз больше исходника. Это одна из причин, почему используют SDF — размытый шрифт часто весит меньше, чем в оптимизированном векторном формате.

Конечно, есть ограничение. Невозможно увеличить буквы до 1000 px, даже 100 px будет выглядеть некрасиво. Но как часто нужны шрифты такого размера?

Fragment shader, рисование прямоугольников, разворот — с помощью этих пертурбаций, у меня наконец получилось найти нужную поверхность.



Новые условия


Она была такая, как надо: извивалась, все элементы были четкие. Все было так, как я хотел, но оказалось, что это еще не все:

— А еще пусть он двигается мышью и путь новый прокладывается. А соты подсвечиваются!

Предполагалось, что когда пользователь двигает мышкой, то метафорично прокладывает свой тернистый путь по ломаной «паутинке», используя сервис.



Словами описать задачу не сложно, но как это реализовать? Первое, что я подумал — раз у меня есть гексагональная сетка, наверное, она уже изучена. Тут я наткнулся на интересную статью «Hexagonal grid reference and implementation guide». В ней автор собрал материалы за 20 лет. Он без вопросов крутой, а статья божественна для тех, кто увлекается алгоритмами и математикой. В ней много интересных данных про гексагональные сетки.

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


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


В «Цивилизации» все вообще очевидно.


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



Сечение трехмерного куба дает двумерную гексагональную сетку. Было забавно узнать, что трёхмерные кубы как-то связаны с двумерными шестигранниками.

В статье, в том числе, был алгоритм по поиску пути по гексагональной сетке. Мне же нужно было искать путь к высоте через мышку.

Алгоритмы поиска пути бывают сложные и простые. Самый примитивный — это провести линию между точками и посмотреть, в какие шестиугольники попадает эта линия. Так получается путь, по которому в былые времена юниты шли из точки А в точку В.


Мне было нужно что-то такое.

Но это не то, что мне нужно. Здесь путь прокладывается по областям шестиугольников, а мне нужно по ребрам. Пришлось решать проблему по-другому.

Canvas2D


Возможно, есть пути лучше, но мой интересней. Сначала я просто нарисовал Canvas2D для своего debug — шаг № 1.



До этого были WebGL, Three.js, шейдеры, а это — просто Canvas2D! Я нарисовал на нем все точки шестигранной сетки. Если присмотреться, это те же шестиугольники. Потом вспомнил про графы, которые хранят информацию о том, как точки соединены друг с другом, и соединил каждую точку с тремя соседними и получил граф — шаг № 2. Для этого использовал Open Source Beautiful Graphs.

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

Это выглядит примерно так.

graph = createGraph( );
graph.addNode(..); // 1000 nodes 
graph.addLink(..); // 3000 links
graph.pathFinder(Start, Finish); //0.01s

Мы строим граф, добавляем 1000 точек и все соединения между ними, Дальше передаем id каждой точки — первая соединена с третьей, третья с пятой. Не нужно ничего выдумывать, есть оптимизированный алгоритм с готовой функцией, которая позволит найти этот путь.

Этот алгоритм выполняется меньше, чем фрейм. Конечно, он занимает какой-то ресурс, но не нужно его выполнять каждые 16 мс, а только когда меняется путь.

Так я смог построить этот маршрут на шаге № 3. В Canvas2D это стало выглядеть так: кратчайший путь из точки А в точку В — все, как в жизни. На первый взгляд кажется, что это не самый кратчайший путь, но оказывается, что кратчайших путей из точки А в точку В по гексагональной сетке очень много.

В WebGL все картинки — это числа. Там можно передавать текстуры в шейдере, например, я пытался передать png. Для браузера нет никакой разницы — передается png или Canvas2D. Для браузера Canvas2D — то же самое, что готовая картинка, bitmap. Поэтому я сначала нарисовал эту картинку в виде змейки. Это видно на картинке шага № 4.

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

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

Зная, какие вершины должны быть закрашены, и храня эту информацию в Canvas2D, который был скрыт от пользователя, я смог внутри шейдера путем максимально простых для видеокарты операций, сделать поверхность с траекторией. Я построил граф в Canvas2D в виртуальной памяти, потом передавал информацию о пути как текстуру в WebGL, и закрашивал нужные в моей анимации вершины.


Слева направо: наложил текстуру, закрасил путь в свой цвет, добавил траекторию на поверхность.

Не могу сказать, что получился супер-вау-эффект. Наверное, я делал и красивее, но при этом использовал столько всего в этой «несложной» анимации.

Ради чего я это делал?


Часто слышал подобный вопрос: «Зачем столько всего использовано? Зачем все это?» Кроме денег, я получил благодарность от дизайнера: «Спасибо, клёво получилось!». Несмотря на мою иронию, это важно. Благодарность от дизайнера попадает в самое сердечко.

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

Есть, конечно, много других моих анимаций. Возможно, вы некоторые видели.

Как только вы получаете операции над каждым пикселем, у вас появляются новые возможности. Зная математические функции, можно делать искажения, или цветок, который целиком построен без 3D за час — это полностью математический расчет цветов пикселей.

Я бы хотел, чтобы рассказ именно про эту анимацию сделал эти технологии для вас осязаемыми. Почему там нужны были шейдеры, что они дали, и как вы можете использовать их.
До FrontendConf осталось меньше месяца. Если вам понравилась статья по докладу Юрия, то скорее всего заинтересуют и выступления о рисовании карт на Canvas, о подводных камнях разработки на RxJS или программировании на JSX без React.

Бронируйте билеты до повышения цен 30 сентября и подписывайтесь на рассылку. В нее собираем интересные доклады в программе, новости конференции, видео и статьи.




К сожалению, не доступен сервер mySQL