Больше гибкости и быстродействия в 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) шейдера описывающего круг:
float radius = 20.;
vec2 center = vec2(100., 100.);
void main() {
float distanceToCenter = distance(gl_FragCoord.xy, center);
float inCircle = float(distanceToCenter < radius);
gl_FragColor = vec4(inCircle);
}
Как уже говорилось, данная программа выполняется на 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.
Можно представить, что для воспроизведения такого спиннера нам потребуется рисовать арку с динамически изменяющимся углом. Тут нам потребуется немного математики.
Начнем с окружности которую мы уже рассматривали в начале статьи и приведем ту программу в чуть более масштабируемый вид.
float PI = 3.14159;
vec2 center = u_resolution * 0.5;
float radius = min(u_resolution.x, u_resolution.y) * .5;
float circle(vec2 coord, vec2 center, float radius) {
float distanceToCenter = distance(coord, center);
return step(distanceToCenter, radius);
}
void main() {
vec2 coord = vec2(gl_FragCoord);
float isFilled = circle(coord, center, radius);
gl_FragColor = vec4(1. - isFilled);
}
Теперь мы используем присланную из JS кода переменную u_resolution
которая отражает размер текущего WebGL контекста. Это позволяет вычислить центр поверхности и расстояние от центра до края, таким образом нарисовать максимально большую окружность для текущего контекста. Так же вынесли в отдельную функцию circle
проверку “входит ли координата в круг?” и инвертировали цвета отнимая isFilled
от единицы.
Дальше на этой окружности нужно отсечь сектор задавая начальный и конечный угол. Тут мы решаем задачу “Лежит ли текущая точка между углами A и B?“. Задача не так проста как может показаться на первый взгляд и неплохо описана тут, конечно я встречал и более элегантные решения, но они имели больше недостатков чем это.
Итак мы вводим две новые функции – isAngleBetween
и sector
, определяющую угол от центра до текущей точки и возвращающую 1 если текущая точка лежит между начальным и конечным углом дуги. Перемножая выход функций circle
и sector
получим дугу.
bool isAngleBetween(float target, float angle1, float angle2) {
float startAngle = min(angle1, angle2);
float endAngle = max(angle1, angle2);
if (endAngle - startAngle < 0.1) {
return false;
}
target = mod((360. + (mod(target, 360.))), 360.);
startAngle = mod((3600000. + startAngle), 360.);
endAngle = mod((3600000. + endAngle), 360.);
if (startAngle < endAngle) return startAngle <= target && target <= endAngle;
return startAngle <= target || target <= endAngle;
}
float sector(vec2 coord, vec2 center, float startAngle, float endAngle) {
vec2 uvToCenter = coord - center;
float angle = degrees(atan(uvToCenter.y, uvToCenter.x));
if (isAngleBetween(angle, startAngle, endAngle)) {
return 1.0;
} else {
return 0.;
}
}
void main() {
vec2 coord = vec2(gl_FragCoord);
float isFilled = circle(coord, center, radius) * sector(coord, center, 0., 75.);
gl_FragColor = vec4(1. - isFilled);
}
Следующим шагом отсечем внутренний радиус и вынесем формирование арки в отдельную функцию.
float arc(vec2 uv, vec2 center, float startAngle, float endAngle, float innerRadius, float outerRadius) {
float result = 0.0;
result = sector(uv, center, startAngle, endAngle) * circle(uv, center, outerRadius) * (1.0 - circle(uv, center, innerRadius));
return result;
}
void main() {
vec2 coord = vec2(gl_FragCoord);
float width = 7.;
float outerRadius = min(u_resolution.x, u_resolution.y) * .5;
float innerRadius = outerRadius - width;
float isFilled = arc(coord, center, 0., 75., innerRadius, outerRadius);
gl_FragColor = vec4(1. - isFilled);
}
Видим края нашей арки несколько пикселизованы, это происходит из-за применения оператора step
который резко отсекает значения меньше заданного. Давайте заменим его на smoothstep
что бы получить эффект сглаживания краев.
float circle(vec2 coord, vec2 center, float radius) {
float distanceToCenter = distance(coord, center);
//Before
//return step(distanceToCenter, radius);
//After
return smoothstep(distanceToCenter - 2., distanceToCenter, radius);
}
Перейдем к части требующей самой детальной проработки – анимации, которая опять же задается с помощью математических функций. Анимацию спиннера в примере можно декомпозировать на две: движение краев арки и вращение всего спиннера. Давайте сначала реализуем движение краев арки. Как видно по исходному спиннеру:
- Передний край разгоняется на протяжении всего оборота.
- В момент когда передний край завершает оборот, задний край начинает вращение сразу с максимальной скоростью, замедляясь к окончанию своего оборота.
Ускорение и замедление можно реализовать использованием функции синуса в пределе от -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.
void main() {
vec2 coord = vec2(gl_FragCoord);
float width = 7.;
float halfPI = PI * .5;
float periodicTime = mod(u_time, PI) - halfPI;
float outerRadius = min(u_resolution.x, u_resolution.y) * .5;
float innerRadius = outerRadius - width;
float startX = clamp(periodicTime, -halfPI, 0.);
float endX = clamp(periodicTime, 0., halfPI);
float angleVariation = sin(startX) + 1.;
float endAngleVariation = sin(endX);
float startAngle = 360. * angleVariation;
float endAngle = 360. * endAngleVariation;
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
gl_FragColor = vec4(1. - isFilled);
}
Вуаля. Спиннер почти готов! Добавим вращение. Оно ускоряется в начале периода и тормозит в конце, что соответствует графику синуса на периоде от -Pi/2 до Pi/2, однако с в два раза меньшей периодичностью. Так же перевернем спиннер на 90 градусов изменив формулу startAngle/endAngle что бы вращение начиналось в верхней части.
//main
float rotation = 180. * (sin(periodicTime) + 1.);
float startAngle = 360. * angleVariation + rotation - 90.;
float endAngle = 360. * endAngleVariation + rotation - 90.;
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
gl_FragColor = vec4(1. - isFilled);
Осталось задать корректный цвет. Сделаем это через RGB код цвета – 45,121,184. Для чего добавим соответствующую функцию преобразования побайтного rgb в единичный вектор. Так же не забываем про инверсию цвета и сделаем backgroundColor
изменяемым.
float width = 2.;
vec4 color = rgb(45., 121., 184.);
vec4 backgroundColor = vec4(1.);
float innerRadius = outerRadius - width;
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
gl_FragColor = (backgroundColor - (backgroundColor - color) * isFilled);
Попробуйте поменять значение переменной width
и аргументы отправляемые в функцию rgb
, это моментально отразится на цвете спиннера. Итого у нас строящийся на GPU параметрически настраиваемый спиннер – это уже лучше чем можно было сделать через HTML/CSS. но не будем останавливаться на достигнутом… Попробуем лучше осознать тот факт, что GLSL в отличие от CSS позволяет управлять каждым пикселом при этом не теряя производительности.
Добавим зависимость цвета от угла вращения и зависимость толщины спиннера от расстояния до мыши.
float distanceToMouse = distance(u_mouse, gl_FragCoord.xy) * 0.085;
float width = 50. - clamp(5. * distanceToMouse, 5., 45.);
vec4 color = rgb(255. * (sin(periodicTime) + 1.), 60. * distanceToMouse / 2., 160.) * (radius / distance(gl_FragCoord.xy, center));
vec4 backgroundColor = vec4(0.);
float innerRadius = outerRadius - width;
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
gl_FragColor = (backgroundColor - (backgroundColor - color) * isFilled);
Поводите мышью по поверхности спиннера. У вас вряд ли получится реализовать это на CSS.
Размещение в проекте
GLSL-Component создавался специально для интеграции GLSL компонент в Web приложения. Просто поместите код шейдера между тегами glsl-component.
Заключение
Некоторые типы компонент можно эффективно реализовывать с помощью пиксельных шейдеров чему способствует широкая поддержка WebGL в современных браузерах. Гибкость, контроль над каждым пикселом, высокая скорость работы GLSL – это те приемущества которые могут вдохновить вас на вклад в развитие GLSL компонент для Web. GLSL – может быть не только существенным дополнением к вашему резюме разработчика UI, но и отличным лекарством от JavaScript fatigue.
Что дальше?
Хорошим стартом в изучении GLSL может стать чтение The Book of Shaders.