WebGL-ветер и программирование GPU. Лекция на FrontTalks 2018
Для отрисовки сложной графики на веб-страницах существует библиотека Web Graphics Library, сокращенно WebGL. Разработчик интерфейсов Дмитрий Васильев рассказал о программировании GPU с точки зрения верстальщика, о том, что из себя представляет WebGL и как мы с помощью этой технологии решили проблему визуализации больших погодных данных.
— Я занимаюсь разработкой интерфейсов в екатеринбургском офисе Яндекса. Начинал я в группе Спорт. Мы занимались разработкой спортивных спецпроектов, когда были Чемпионаты мира по хоккею, футболу, Олимпиады, Паралимпиады и прочие классные события. Также я занимался разработкой специальной поисковой выдачи, которая была посвящена новой сочинской трассе.
Ссылка со слайда
Кроме того, в полторы каски мы перезапустили сервис Работа над ошибками. И потом началась работа в Погоде, где я занимался поддержкой работоспособности API, его развитием, написанием инфраструктуры вокруг этого API и писал нодовые биндинги к обученным формулам машинного обучения.
Затем началась работа поинтереснее. Участвовал в редизайне наших погодных сервисов. Десктопы, тачи.
После того, как мы привели стандартные прогнозы в порядок, мы решили сделать тот прогноз, которого нет ни у кого. Этим прогнозом стал прогноз перемещения осадков по территориям.
Есть специальные метеорадары, которые в радиусе 2000 км детектят осадки, они знают их плотность и расстояние до них.
Используя эти данные и прогнозируя при помощи машинного обучения их дальнейшее перемещение, мы сделали такую визуализацию на карте. Можно двигать вперед-назад.
Ссылка со слайда
Посмотрели на отзывы людей. Людям понравилось. Начали появляться всякие мемасики, и были классные картинки, когда Москву заливало к чертям.
Так как формат всем понравился, мы решили двигаться дальше и посвятить следующий прогноз ветру.
Сервисы, которые показывают прогноз ветра, уже есть. Это пара классных, особо выделяющихся.
Посмотрев на них, мы поняли, что хотим сделать так же — или, по крайней мере, не хуже.
Поэтому мы решили делать визуализацию частиц, которые плавно перемещаются по карте в зависимости от скорости ветра, и оставляют за собой какой-то шлейф, чтобы их было видно, было видно траекторию ветра.
Так как мы уже молодцы и сделали классную карту с осадками, используя 2D canvas, мы решили сделать то же самое с частицами.
Посоветовавшись с дизайнером, мы поняли, что нам нужно примерно 6% экрана заполнить частицами, чтобы был классный эффект.
Чтобы отрисовать такое количество частиц стандартным подходом, у нас минимальный тайминг составил 5 мс.
Если подумать, что нам нужно еще двигать частицы и наводить какую-то красоту типа рисования хвоста частиц, можно предположить, что мы вывалимся за минимальный тайминг 40 мс, чтобы показывать плавную анимацию, чтобы выдавать не менее 25 кадров в секунду.
Проблема в том, что здесь каждая частица обрабатывалась бы последовательно. Но что, если обрабатывать их в параллельном режиме?
Наглядное различие работы центрального и графического процессора показали «Разрушители легенд» на одной из конференций. Они выкатили машинку, на которой был установлен пейнтбольный маркер, задача которого была нарисовать смайлик одним цветом. Примерно за 10 секунд он нарисовал такую картинку. (Ссылка на видео — прим. ред.)
Потом ребята выкатили байду, которая представляет собой GPU, и парочкой плевков нарисовали Мона Лизу. Примерно так отличается скорость вычислений CPU и GPU.
Чтобы воспользоваться такими возможностями в браузере, придумана технология WebGL.
Что это такое? С этим вопросом я и полез в интернет. Добавив пару слов с анимацией частиц и ветром, я нашел пару статей.
Ссылки со слайда: первая, вторая
Одна из них — демка Владимира Агафонкина, инженера из Mapbox, который сделал именно ветер на WebGL и ссылался на блог Криса Веллонса, который рассказывал о том, как двигать и хранить состояние частиц на GPU.
Берем и копируем. Ожидаем такой результат. Здесь частицы плавно перемещаются.
Получаем не пойми что.
Пытаемся разобраться с кодом. Улучшаем, снова получаем неудовлетворительный результат. Лезем еще глубже — получаем дождь вместо ветра.
Окей, решаем сделать самостоятельно.
Чтобы работать с WebGL, существуют фреймворки. Почти все они нацелены на работу с 3D-объектами. Нам эти 3D-возможности не нужны. Нам нужно только частицу нарисовать и подвинуть ее. Поэтому мы решаем сделать все руками.
На данный момент существует две версии технологии WebGL. Вторая версия, которая классная, имеет высокую современную версию языка программирования, на котором выполняется программа в графическом адаптере, умеет проводить прямо вычисления, а не просто отрисовку. Но имеет плохую совместимость.
Что ж, мы решаем использовать старую проверенную WebGL 1, у которой хорошая поддержка, кроме Opera Mini, которая никому не нужна.
WebGL представляет из себя двухкомпонентную вещь. Это JS, который выполняет состояние программ, которые выполняются на графической карте. И есть компоненты, которые выполняются прям на графической карте.
Начнем с JS. WebGL — это просто соответствующий контекст элемента canvas. Притом при получении этого контекста выделяется не просто какой-то определенный объект, выделяются железные ресурсы. И в случае, если мы запустим что-то красивое на WebGL в браузере, а потом решим поиграть в Quake, то вполне возможно, что будут утеряны эти ресурсы, и контекст может быть потерян, и вся программа ваша сломается.
Поэтому при работе с WebGL нужно слушать также утерю контекста и уметь восстанавливать его. Поэтому я подчеркнул, что init есть.
Дальше вся работа JS сводится к тому, чтобы собрать программы, которые выполняются на GPU, отправить их графическую карту, задать какие-то параметры и сказать «отрисуй».
В WebGL, если посмотреть на сам элемент контекста, видна куча констант. Эти константы означают ссылки на адреса в памяти. Они на самом деле не константы в процессе работы программы. Потому что если контекст потеряется и восстановится заново, может выделиться другой пул адресов, и эти константы будут другие для текущего контекста. Поэтому почти все операции в WebGL на стороне JS выполняются через утилиты. Никому не хочется выполнять рутинную работу по поиску адресов и прочей фигне.
Переходим к тому, что выполняется на самой видеокарте — программе, состоящей из двух наборов инструкций, написанных на С-подобном языке GLSL. Эти инструкции называются вершинный шейдер и фрагментный шейдер. Из их пары создается программа.
Чем отличаются эти шейдеры? Вершинный шейдер устанавливает поверхность, на которой должно быть что-то отрисовано. После того, как установлен примитив, который должен быть закрашен, вызывается фрагментный шейдер, попавший в этот диапазон.
В коде это выглядит так. В шейдере есть секция объявления переменных, которые задаются снаружи, из JS, определяется их тип и название. А также секция main, которая выполняет код, нужный для данной итерации.
Вершинный шейдер в большинстве случаев ожидается, что он установит переменную gl_Position в какую-то координату в четырехмерном пространстве. Это x, y, z и ширина пространства, которую не очень надо знать на данный момент.
Фрагментный шейдер ожидает установку цвета конкретного пикселя.
В этом примере у нас цвет пикселя выбирается из подключенной текстуры.
Чтобы перенести это в JS, достаточно завернуть исходный код шейдеров в переменные.
Дальше эти переменные преобразуются в шейдеры. Это WebGL контекст, мы из исходных кодов создаем шейдеры, параллельно создаем программку, по программке привязываем пару шейдеров. Получаем работоспособную программу.
По пути проверяем, что компиляция шейдеров совершилась успешно, что программа успешно собралась. Говорим, что нужно использовать эту программу, потому что может быть несколько программ для разных значений отрисовки.
Настраиваем ее, и говорим отрисовать. Получается какая-то картинка.
Полезли глубже. В вершинном шейдере все вычисления выполняются в пространстве от -1 до 1, независимо от того, какого размера у вас точка вывода. Например, пространство от -1 до 1 может занимать весь экран 1920×1080 Чтобы отрисовать треугольник в центре экрана, нужно отрисовать поверхность, которая покрывает координату 0, 0.
Фрагментный шейдер работает в пространстве от 0 до 1, и цвета здесь выдаются четырьмя компонентами: R, G, B, Alpha.
На примере CSS вы могли сталкиваться с похожей записью цвета, если использовать проценты.
Чтобы отрисовать что-то, нужно сказать, какие данные нужно отрисовать. Конкретно для треугольника мы задаем типизированный массив из трех вершин, каждая состоит из трех компонентов, x, y и достаточно.
Для такого случая вершинный шейдер выглядит как получение текущей пары точек, координаты, а также установки этой координаты на экран. Тут как есть, без преобразований, мы точку ставим на экран.
Фрагментный шейдер может закрашивать цветом константы, переданные из JS, тоже без дополнительных вычислений. При этом если какие-то переменные во фрагментном шейдере передаются извне или из предыдущего шейдера, то нужно указывать точность. В данном случае средней точности достаточно, да и почти всегда ее достаточно.
Переходим к JS. Те же самые шейдеры мы присваиваем переменным и объявляем функцию, которая будет создавать эти шейдеры. То есть создается шейдер, в него заливается исходник, и потом компилируется.
Делаем два шейдера, вершинный и фрагментный.
После этого создаем программу, закачиваем в нее уже скомпилированные шейдеры. Связываем программу, потому что шейдеры умеют обмениваться переменными друг между другом. И на этом этапе проверяется соответствие типов переменных, которыми обмениваются эти шейдеры.
Говорим, что используй эту программу.
Дальше мы формируем список вершин, который хотим визуализировать. В WebGL существует интересная особенность для некоторых переменных. Чтобы изменить определенный тип данных, нужно задать глобальный контекст на редактирование array_buffer, а затем уже по этому адресу что-то закачать. Тут нет явного присвоения переменной каких-то данных. Тут все выполняется через включение какого-то контекста.
Надо также установить правила чтения из этого буфера. Видно, что мы задали массив из шести элементов, но программе нужно объяснить, что каждая вершина состоит из двух компонент, тип которых float, это сделано в последней строчке.
Чтобы задать цвет, выполняется поиск адреса в программе для переменной u_color и установка значения для этой переменной. Мы устанавливаем цвет, красный 255, 0.8 от зеленого, 0 синего и полностью непрозрачный пиксель — получается желтый цвет. И говорим выполнить эту программу, используя примитивы треугольник, в WebGL можно рисовать точки, линии, треугольники, треугольники сложной формы и так далее. И сделай три вершины.
Также можно указать, что массив, по которому выполняем отрисовку, надо считать с самого начала.
Ссылка со слайда
Если чуть усложнить пример, можно добавить зависимость цвета от положения курсора. При этом fps зашкаливает.
Чтобы отрисовать на всем мире частицы, нужно знать скорость ветра в каждой точке этого мира.
Чтобы увеличивать и как-то двигать карту, нужно создать контейнеры, которые соответствуют текущему положению карты.
Чтобы перемещать сами частицы, нужно придумать формат данных, который мог бы быть обновляем при помощи графического процессора. Сделать саму отрисовку и отрисовку шлейфа.
Все данные мы делаем через текстурку. Мы используем 22 канала для определения горизонтальной и вертикальной скоростей, где нулевая скорость ветра соответствует середине цветового диапазона. Это 128 примерно. Так как скорость может быть отрицательной и положительной, мы устанавливаем цвет относительно середины диапазона.
Получается такая картинка.
Чтобы загрузить ее на карту, нам нужно ее нарезать. Чтобы картинку подключить к карте, мы воспользуемся стандартным средством Яндекс.Карт Layer, в котором мы определяем адрес, из которого нужно получить нарезанные тайлы, и добавить этот слой на карту.
Ссылка со слайда
Получаем картинку, где неприятный зеленый цвет — кодированные скорости ветра.
Далее нужно получить место, в котором мы будем отрисовывать саму анимацию, при этом это место должно соответствовать координатам карты, ее перемещениям и прочим действиям.
По умолчанию можно предположить, что мы бы использовали Layer, но карточный Layer создает canvas, из которого сразу же захватывает 2D-контекст, который у него получается захватить. Но если попытаться взять из canvas, у которого уже есть контекст другого типа, и взять из него GL-контекст, то получим в результате null. Если к нему обращаться, то программа падает.
Ссылка со слайда
Поэтому мы воспользовались Pane, это контейнеры для лэйеров, и добавили туда свой canvas, из которого уже забрали нужный нам контекст.
Чтобы частицы как-то расположить на экране и уметь их двигать, был использован формат положения частиц в текстуре.
Как это работает? Создается квадратная текстурка для оптимизации, и тут известен размер ее стороны.
Отрисовывая частицы по порядку и зная порядковый номер частицы и размеры текстуры, в которой они хранятся, можно вычислить конкретный пиксель, в котором закодировано положение на реальном экране.
В самом шейдере это выглядит как считывание отрисовываемого индекса, текстуры с текущим положением частиц и размер стороны. Дальше определяем координаты x, y для этой частицы, считываем это значение и декодируем его. Что это за магия: rg/255 + ba?
Для положения частиц у нас используется 20 двойных канала. Цветовой канал имеет значение от 0 до 255, и для экрана 1080 на сколько-то мы не можем поставить частицы в любое положение экрана, потому что максимум можем поставить частицу в 255 пиксель. Поэтому в одном канале мы храним знание о том, сколько раз частица прошла 255 пикселей, и во втором канале храним точное значение, сколько она прошла после.
Дальше вершинный шейдер должен преобразовать эти значения к своему пространству работы, то есть от -1 до 1, и установить эту точку на дисплее.
Чтобы просто посмотреть на наши частицы, достаточно закрасить их белым цветом. В GLSL есть такой сахар, что если мы определим тип переменной и передали в него в константу, то эта константа будет распределена по всем четырем компонентам, например.
Отрисовав такую программу, мы видим набор одинаковых квадратиков. Попробуем добавить им красоты.
Во-первых, добавим зависимость этих квадратиков от текущей скорости ветра. Мы просто для каждой частички считываем текущую скорость и соответствующие текстуры. Получаем длину вектора, которая соответствует абсолютной скорости в точке, и добавляем эту скорость к размеру частицы.
Далее, чтобы не отрисовывать квадратики, в фрагментном шейдере мы отсекаем все пиксели, которые попадают вне радиуса, которые не входят в радиус вписанной окружности. То есть наш шейдер превращается в такую штуку.
Вычисляем дистанцию до отрисовываемого пикселя от центра. Если он превышает половину пространства, то мы его не показываем.
Получаем более разнообразную картинку.
Далее нужно как-то перемещать эти вещи. Так как WebGL 1 не умеет вычислять что-то, работать непосредственно с данными, то мы воспользуемся возможностями отрисовки программ в специальные компоненты, фрейм-буферы.
К фрейм-буферам можно привязать, например, текстуры, которые можно обновлять. Если фрейм-буфер не объявлен, то отрисовка по умолчанию выполняется на экран.
Переключая вывод из одной текстуры положения в другую, мы можем обновлять их поочередно и потом использовать для отрисовки.
Сама же процедура обновления положения выглядит так: считать текущее положение, сложить с текущим вектором скорости и сложить, закодировать в новый цвет.
В коде это выглядит как считывание текущей позиции, декодирование, считывание текущей скорости, приведение скорости к нормальному виду, складывание двух компонент, кодирование в цвет.
Получается такая картинка. У нас постоянно меняется состояние частиц, и появляется какая-то анимация.
Если такую анимацию запустить на 5–10 минут, будет видно, что все частицы придут в конечный пункт назначения. Они все скатятся в воронку. Получится вот такая картинка.
Чтобы этого избежать, мы вводим фактор перестановки частицы в случайное место.
Он зависит от текущей скорости ветра, от текущей позиции частицы и случайного числа, которое мы передаем из JS — потому что в первой версии WebGL не встроена функция рандомизации и какие-то noise-функции.
На этом примере мы вычисляем прогнозируемое положение частицы, случайное положение, и выбираем либо то, либо другое, в зависимости от фактора reset.
Ссылки со слайда: первая, вторая, третья, четвертая
Чтобы понять, что было на прошлом слайде, можно почитать эти статьи. Первая обеспечивает огромный буст в понимании того, что дает WebGL, из чего он состоит и как в нем не ошибиться. На Khronos, это группа, которая занимается развитием стандарта, есть описание всех функций.
Последний пункт нашей задачи — отрисовать след от частиц. Чтобы это сделать, мы так же, как с текстурами обновления положений, будем записывать текущее положение на экране в две текстуры, и выводить на экран актуальное положение, слегка увеличивая его прозрачность, поверх накладываем новое положение частиц, потом снова увеличиваем прозрачность этой картинки и еще поверх накладываем новое положение.
У нас получается такая анимация шлейфа.
Если сравнить полный цикл отрисовки WebGL с выводом одних точек на экран при помощи 2D canvas, можно видеть большой разрыв в скорости. Чтобы отрисовать 64 тыс. точек на 2D canvas, в среднем уходит 25 мс, в то время как WebGL блокирует основной поток на 0,3 мс. Это разница в сто раз.
Таким образом, использование WebGL позволяет меньше блокировать основой поток, и это особенно важно при работе с картой, когда важна высокая отзывчивость самой карты.
Изначально все разработчики, наверное, привыкли использовать консоль браузера, чтобы поставить какие-то break points, консоль-логи и посмотреть, что творится внутри. WebGL — черный ящик.
Но есть некоторые инструменты, которые позволяют с ним работать. Например, в Firefox есть встроенная вкладка «шейдеры», где на лету можно найти на экране WebGL-канвасы, извлечь из них программы, извлечь из них шейдеры, и на лету поменять значения. Например, здесь на лету цвет точек из белого превращается в голубой.
Второй инструмент, который очень облегчает жизнь, это расширение для браузера Spector.js. Он также захватывает canvas из WebGL-контекста и позволяет видеть все выполненные операции относительного этого canvas, тайминги и переданные переменные.
Итого за неделю работы у нас с нуля получилось готовое решение по ветру. Надеюсь, мне удалось рассказать, что это за технология, WebGL, из чего она состоит, и привести реальный пример ее использования в проде. На этом всё.