— 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) шейдера описывающего круг:

 

1
float radius = 20.;
2
vec2 center = vec2(100., 100.);
3
4
void main() {
5
  float distanceToCenter = distance(gl_FragCoord.xy, center);
6
  float inCircle = float(distanceToCenter < radius);
7
  gl_FragColor = vec4(inCircle);
8
}

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

Можно представить, что для воспроизведения такого спиннера нам потребуется рисовать арку с динамически изменяющимся углом. Тут нам потребуется немного математики.

Начнем с окружности которую мы уже рассматривали в начале статьи и приведем ту программу в чуть более масштабируемый вид.

 

1
float PI = 3.14159;
2
vec2 center = u_resolution * 0.5;
3
float radius = min(u_resolution.x, u_resolution.y) * .5;
4
5
float circle(vec2 coord, vec2 center, float radius) {
6
  float distanceToCenter = distance(coord, center);
7
  return step(distanceToCenter, radius);
8
}
9
10
void main() {
11
  vec2 coord = vec2(gl_FragCoord);
12
  float isFilled = circle(coord, center, radius);
13
  gl_FragColor = vec4(1. - isFilled);
14
}

Теперь мы используем присланную из JS кода переменную u_resolution которая отражает размер текущего WebGL контекста. Это позволяет вычислить центр поверхности и расстояние от центра до края, таким образом нарисовать максимально большую окружность для текущего контекста. Так же вынесли в отдельную функцию circle проверку “входит ли координата в круг?” и инвертировали цвета отнимая isFilled от единицы.

Дальше на этой окружности нужно отсечь сектор задавая начальный и конечный угол. Тут мы решаем задачу “Лежит ли текущая точка между углами A и B?“. Задача не так проста как может показаться на первый взгляд и неплохо описана тут, конечно я встречал и более элегантные решения, но они имели больше недостатков чем это.

Итак мы вводим две новые функции – isAngleBetween и sector, определяющую угол от центра до текущей точки и возвращающую 1 если текущая точка лежит между начальным и конечным углом дуги. Перемножая выход функций circle и sector получим дугу.

 

1
bool isAngleBetween(float target, float angle1, float angle2) {
2
  float startAngle = min(angle1, angle2);
3
  float endAngle = max(angle1, angle2);
4
5
  if (endAngle - startAngle < 0.1) {
6
    return false;
7
  }
8
9
  target = mod((360. + (mod(target, 360.))), 360.);
10
  startAngle = mod((3600000. + startAngle), 360.);
11
  endAngle = mod((3600000. + endAngle), 360.);
12
13
  if (startAngle < endAngle) return startAngle <= target && target <= endAngle;
14
  return startAngle <= target || target <= endAngle;
15
}
16
17
float sector(vec2 coord, vec2 center, float startAngle, float endAngle) {
18
  vec2 uvToCenter = coord - center;
19
  float angle = degrees(atan(uvToCenter.y, uvToCenter.x));
20
  if (isAngleBetween(angle, startAngle, endAngle)) {
21
    return 1.0;
22
  } else {
23
    return 0.;
24
  }
25
}
26
27
void main() {
28
  vec2 coord = vec2(gl_FragCoord);
29
  float isFilled = circle(coord, center, radius) * sector(coord, center, 0., 75.);
30
  gl_FragColor = vec4(1. - isFilled);
31
}

Следующим шагом отсечем внутренний радиус и вынесем формирование арки в отдельную функцию.

1
float arc(vec2 uv, vec2 center, float startAngle, float endAngle, float innerRadius, float outerRadius) {
2
  float result = 0.0;
3
  result = sector(uv, center, startAngle, endAngle) * circle(uv, center, outerRadius) * (1.0 - circle(uv, center, innerRadius));
4
  return result;
5
}
6
7
void main() {
8
  vec2 coord = vec2(gl_FragCoord);
9
  float width = 7.;
10
  float outerRadius = min(u_resolution.x, u_resolution.y) * .5;
11
  float innerRadius = outerRadius - width;
12
  float isFilled = arc(coord, center, 0., 75., innerRadius, outerRadius);
13
  gl_FragColor = vec4(1. - isFilled);
14
}

Видим края нашей арки несколько пикселизованы, это происходит из-за применения оператора step который резко отсекает значения меньше заданного. Давайте заменим его на smoothstep что бы получить эффект сглаживания краев.

1
float circle(vec2 coord, vec2 center, float radius) {
2
  float distanceToCenter = distance(coord, center);
3
//Before
4
//return step(distanceToCenter, radius);
5
//After
6
  return smoothstep(distanceToCenter - 2., distanceToCenter, radius);
7
}

Перейдем к части требующей самой детальной проработки – анимации, которая опять же задается с помощью математических функций. Анимацию спиннера в примере можно декомпозировать на две: движение краев арки и вращение всего спиннера. Давайте сначала реализуем движение краев арки. Как видно по исходному спиннеру:

  • Передний край разгоняется на протяжении всего оборота.
  • В момент когда передний край завершает оборот, задний край начинает вращение сразу с максимальной скоростью, замедляясь к окончанию своего оборота.

Ускорение и замедление можно реализовать использованием функции синуса в пределе от -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.

1
void main() {
2
  vec2 coord = vec2(gl_FragCoord);
3
  float width = 7.;
4
  float halfPI = PI * .5;
5
  float periodicTime = mod(u_time, PI) - halfPI;
6
7
  float outerRadius = min(u_resolution.x, u_resolution.y) * .5;
8
  float innerRadius = outerRadius - width;
9
  
10
  float startX = clamp(periodicTime, -halfPI, 0.);
11
  float endX = clamp(periodicTime, 0., halfPI);
12
  
13
  float angleVariation = sin(startX) + 1.;
14
  float endAngleVariation = sin(endX);
15
  
16
  float startAngle = 360. * angleVariation;
17
  float endAngle = 360. * endAngleVariation;
18
19
  float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
20
  gl_FragColor = vec4(1. - isFilled);
21
}

Вуаля. Спиннер почти готов! Добавим вращение. Оно ускоряется в начале периода и тормозит в конце, что соответствует графику синуса на периоде от -Pi/2 до Pi/2, однако с в два раза меньшей периодичностью. Так же перевернем спиннер на 90 градусов изменив формулу startAngle/endAngle что бы вращение начиналось в верхней части.

1
//main
2
float rotation = 180. * (sin(periodicTime) + 1.);
3
4
float startAngle = 360. * angleVariation + rotation - 90.;
5
float endAngle = 360. * endAngleVariation + rotation - 90.;
6
7
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
8
gl_FragColor = vec4(1. - isFilled);

Осталось задать корректный цвет. Сделаем это через RGB код цвета – 45,121,184. Для чего добавим соответствующую функцию преобразования побайтного rgb в единичный вектор. Так же не забываем про инверсию цвета и сделаем backgroundColor изменяемым.

1
float width = 2.;
2
vec4 color = rgb(45., 121., 184.);
3
vec4 backgroundColor = vec4(1.);
4
float innerRadius = outerRadius - width;
5
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
6
gl_FragColor = (backgroundColor - (backgroundColor - color) * isFilled);

Попробуйте поменять значение переменной width и аргументы отправляемые в функцию rgb, это моментально отразится на цвете спиннера. Итого у нас строящийся на GPU параметрически настраиваемый спиннер – это уже лучше чем можно было сделать через HTML/CSS. но не будем останавливаться на достигнутом… Попробуем лучше осознать тот факт, что GLSL в отличие от CSS позволяет управлять каждым пикселом при этом не теряя производительности.

Добавим зависимость цвета от угла вращения и зависимость толщины спиннера от расстояния до мыши.

 
1
float distanceToMouse = distance(u_mouse, gl_FragCoord.xy) * 0.085;
2
3
float width = 50. - clamp(5. * distanceToMouse, 5., 45.); 
4
5
vec4 color = rgb(255. * (sin(periodicTime) + 1.), 60. * distanceToMouse / 2., 160.) * (radius / distance(gl_FragCoord.xy, center));
6
7
vec4 backgroundColor = vec4(0.);
8
9
float innerRadius = outerRadius - width;
10
float isFilled = arc(coord, center, - startAngle, - endAngle, innerRadius, outerRadius);
11
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.