Больше гибкости и быстродействия в Web приложениях при помощи GLSL компонент
В свое время шейдеры перевернули мир игровой графики и теперь они готовы перевернуть Web. Шейдеры - небольшие программы на сиподобном языке GLSL (OpenGL Shading Language) которые позволяют описывать состояние вершин (вершинные шейдеры) или пикселов (пиксельные шейдеры) в контексте OpenGL (или WebGL) с помощью математических функций. GLSL компилируется и выполняется на GPU с беспрецендентной для HTML/CSS производительностью. Как правило шейдеры применяются для разработки игр и компьютерной графики, а использование их в UI компонентах незаслуженно обходится стороной. В данной статье рассматривается опыт использования GLSL при разработке Web приложений.

Для начала давайте получим некоторую ясность относительно того зачем нужны, как работают и выглядят шейдеры...
Что такое GLSL шейдеры?
С ростом производительности видеокарт разработчикам графических приложений перестало хватать стандартных возможностей OpenGL и появилась необходимость программно задавать алгоритм рендеринга. Шейдеры и стали программируемыми звеньями конвеера отрисовки OpenGL которые позволяют модифицировать проходящие через них данные с целью реализации специальных эффектов. Они были добавлены в OpenGL версии 2.0, который вышел в 2004 году став самым большим апдейтом OpenGL с момента утверждения первой спецификации в 1992 году.
Как уже говорилось шейдеры бывают двух видов - вершинные (vertex shader) и пиксельные (fragment shader). На рисунке мы видим их положение в конвеере.

Первым выполняется вершинный шейдер который позволяет менять геометрию и затем после ее растеризации для каждого фрагмента (пиксела) изображения будет выполнен пиксельный шейдер. Так как для преимущественно двухмерных UI работать с геометрией нам не особо интересно сфокусируем внимание на последних.
Рассмотрим пример пиксельного (fragment) шейдера описывающего круг:
Как уже говорилось, данная программа выполняется на GPU для каждого пиксела WebGL контекста и определяет его цвет. Рассмотрим по шагам ее листинг... В начале мы определеям константы radius, center
используемые программой, затем следует декларация функции main
с которой начинается выполнение любого шейдера. В начале функции main находим лежит ли текущий пиксель в окружности. Для этого найдем расстояние от текущей точки экрана gl_FragCoord.xy
до центра (100, 100) и сравним его с заданным радиусом. Соответственно inCircle будет 1 если расстояние меньше радиуса и 0 если расстояние больше и точка лежит за пределами круга. В последней строке определим значение цвета для текущего пиксела записывая цвет в системную переменную (gl_FragColor
). Цвет в GLSL задается в формате RGBA, где значение для каждого канала лежит в пределах от 0 до 1. Таким образом при inCircle
равном 0 мы будем иметь ноль по всем каналам цвета т.е. черный пиксель, и наоборот при inCircle
равном 1 пиксел будет белым R=1, G=1, B=1, A=1.
Давайте разрушим барьер между вами и GLSL. В редакторе выше попробуйте изменить радиус и координаты центра а так же последнюю строку функции main
на gl_FragColor = vec4(.75, 1., .2, 1.0) * inCircle;
.
Это то как работают пиксельные шейдеры вкратце, а теперь перейдем к повествованию о решении практической задачи.
Оптимизация через CSS и ее недостатки
Однажды меня попросили заняться оптимизацией спиннера в нашей операционной системе. Спиннер заметно подергивался в моменты фоновых нагрузок и это создавало весьма неприятное ощущение. Быстро выяснилось, что компонент анимирован через spritesheet и смену background-position что вызывало перерисовку (repaint) содержимого на каждый кадр.

Очевидно, что быстро это работать не может (тем более с учетом низкой производительности наших устройств) из-за загрузки новой текстуры в видео память при смене кадра и напрашивается оптимизация. Первое что приходит в голову - это избавиться от репэинта воспроизведя спиннер с использованием аппаратно ускоренных CSS свойств: scale / translate / skew / opacity
(подробнее). Из перечисленных трансформаций подойдет opacity
. Каждый кадр из spritesheet можно представить в виде отдельного проброшенного на GPU слоя и затем отображать поочередно эти слои изменяя их прозрачность. В результате получим 150 div`ов которые браузер отсылает как отдельные текстуры на GPU (благодаря использованию translateZ
хака) и отображает эти слои циклически изменяя opacity
с 0 на 1 и наоборот.
Что же, неплохо! Мы избавились от репэинтов и QA отдел подтвердил улучшение быстродействия.
Спустя несколько дней меня попросили сделать то же самое в другой части системы для спиннера другого цвета и другого размера. Тут начали очевидно проступать недостатки CSS решения. Во первых нам потребуется создать и загружать еще один spritesheet с соответствующим изображением, во вторых держать дополнительно 150 текстур - кадров в видеопамяти. Куда более гибким решением в этом случае могло бы быть использование GLSL шейдера.
GLSL реализация
Преимущество GLSL - возможность контроля над каждым пикселом поверхности и широкие возможности параметризации, не говоря уже про то что выполняются шейдеры на GPU по умолчанию. Давайте по шагам разберем создание спиннера аналогичного данному с помощью GLSL.
Можно представить, что для воспроизведения такого спиннера нам потребуется рисовать арку с динамически изменяющимся углом. Тут нам потребуется немного математики.
Начнем с окружности которую мы уже рассматривали в начале статьи и приведем ту программу в чуть более масштабируемый вид.
Теперь мы используем присланную из JS кода переменную u_resolution
которая отражает размер текущего WebGL контекста. Это позволяет вычислить центр поверхности и расстояние от центра до края, таким образом нарисовать максимально большую окружность для текущего контекста. Так же вынесли в отдельную функцию circle
проверку "входит ли координата в круг?" и инвертировали цвета отнимая isFilled
от единицы.
Дальше на этой окружности нужно отсечь сектор задавая начальный и конечный угол. Тут мы решаем задачу "Лежит ли текущая точка между углами A и B?". Задача не так проста как может показаться на первый взгляд и неплохо описана тут, конечно я встречал и более элегантные решения, но они имели больше недостатков чем это.
Итак мы вводим две новые функции - isAngleBetween
и sector
, определяющую угол от центра до текущей точки и возвращающую 1 если текущая точка лежит между начальным и конечным углом дуги. Перемножая выход функций circle
и sector
получим дугу.
Следующим шагом отсечем внутренний радиус и вынесем формирование арки в отдельную функцию.
Видим края нашей арки несколько пикселизованы, это происходит из-за применения оператора step
который резко отсекает значения меньше заданного. Давайте заменим его на smoothstep
что бы получить эффект сглаживания краев.
Перейдем к части требующей самой детальной проработки - анимации, которая опять же задается с помощью математических функций. Анимацию спиннера в примере можно декомпозировать на две: движение краев арки и вращение всего спиннера. Давайте сначала реализуем движение краев арки. Как видно по исходному спиннеру:
- Передний край разгоняется на протяжении всего оборота.
- В момент когда передний край завершает оборот, задний край начинает вращение сразу с максимальной скоростью, замедляясь к окончанию своего оборота.
Ускорение и замедление можно реализовать использованием функции синуса в пределе от -Pi/2 до Pi/2 передавая в виде X текущее время, точнее остаток от его деления на Pi отняв от него Pi/2. Таким образом время всегда будет в нужном промежутке от -Pi/2 до Pi/2.

Время храним в переменной periodicTime
. Для переднего края нас будет интересовать синус на промежутке от -Pi/2 до 0, а для заднего края от 0 до Pi/2. Реализуем подобное отсечение periodicTime с помощью GLSL функции clamp
(переменные startX
, endX
). Для отрицательных величин синуса добавим 1 и переведем startX/endX в градусы умножив на 360.
Вуаля. Спиннер почти готов! Добавим вращение. Оно ускоряется в начале периода и тормозит в конце, что соответствует графику синуса на периоде от -Pi/2 до Pi/2, однако с в два раза меньшей периодичностью. Так же перевернем спиннер на 90 градусов изменив формулу startAngle/endAngle что бы вращение начиналось в верхней части.
Осталось задать корректный цвет. Сделаем это через RGB код цвета - 45,121,184. Для чего добавим соответствующую функцию преобразования побайтного rgb в единичный вектор. Так же не забываем про инверсию цвета и сделаем backgroundColor
изменяемым.
Попробуйте поменять значение переменной width
и аргументы отправляемые в функцию rgb
, это моментально отразится на цвете спиннера. Итого у нас строящийся на GPU параметрически настраиваемый спиннер - это уже лучше чем можно было сделать через HTML/CSS. но не будем останавливаться на достигнутом... Попробуем лучше осознать тот факт, что GLSL в отличие от CSS позволяет управлять каждым пикселом при этом не теряя производительности.
Добавим зависимость цвета от угла вращения и зависимость толщины спиннера от расстояния до мыши.
Поводите мышью по поверхности спиннера. У вас вряд ли получится реализовать это на CSS.
Размещение в проекте
GLSL-Component создавался специально для интеграции GLSL компонент в Web приложения. Просто поместите код шейдера между тегами glsl-component.
Заключение
Некоторые типы компонент можно эффективно реализовывать с помощью пиксельных шейдеров чему способствует широкая поддержка WebGL в современных браузерах. Гибкость, контроль над каждым пикселом, высокая скорость работы GLSL - это те приемущества которые могут вдохновить вас на вклад в развитие GLSL компонент для Web. GLSL - может быть не только существенным дополнением к вашему резюме разработчика UI, но и отличным лекарством от JavaScript fatigue.
Что дальше?
Хорошим стартом в изучении GLSL может стать чтение The Book of Shaders.