— Pixels Commander

[ In English, На русском ]

Больше гибкости и быстродействия в 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) содержимого на каждый кадр.

Preloader with repaints

Очевидно, что быстро это работать не может (тем более с учетом низкой производительности наших устройств) из-за загрузки новой текстуры в видео память при смене кадра и напрашивается оптимизация. Первое что приходит в голову - это избавиться от репэинта воспроизведя спиннер с использованием аппаратно ускоренных 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.

Sine graph for spinner easings

Время храним в переменной 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.