Хабрахабр

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

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

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

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

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

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

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

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

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

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

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

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

Three.js

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Я понял, что Three.js — это не обязательно WebGL. Поэтому это был жёсткий красный флаг. У меня всего 2600 вершин — для WebGL это мало. Несмотря на то, что я вроде бы использовал Three.js, рисовал все в 3D, оно рендерилось в WebGL, я не получил фантастической производительности из 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 вершин в шейдере на видеокарте.

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

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

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

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

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

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

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

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

Fragment shader

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

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

  • если 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).

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

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

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

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

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

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

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

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

Smoothstep

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


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

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

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

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

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

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

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

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

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

SDF

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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 мс, а только когда меняется путь. Этот алгоритм выполняется меньше, чем фрейм.

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть