Кого можно назвать «пиксельных шейдеров начальник и пикселов командир»? Дениса Радина, работающего в Evolution Gaming над фотореалистичными веб-играми с использованием React и WebGL: он известен многим как раз под именем Pixels Commander.

В декабре на нашей конференции HolyJS он выступил с докладом о том, как использование GLSL может улучшить работу с UI-компонентами по сравнению с «обычным джаваскриптом». А теперь для Хабра мы подготовили текстовую версию этого доклада — добро пожаловать под кат! Заодно прикладываем видеозапись выступления:



Для начала вопрос к залу: сколько языков хорошо поддерживается в вебе? (Голос из зала: «Ни одного!»)
Ну, языков в браузере, скажем так. Три? Давайте предположим, что их четыре: HTML, CSS, JS и SVG. SVG тоже можно считать декларативным языком, ещё одним видом, это все-таки не HTML.

Но на самом деле их ещё больше. Есть VRML, он умер, его можно не считать. А ещё есть GLSL («OpenGL Shading Language»). И GLSL — это для веба очень особенный язык.

Потому что остальные (JS, CSS, HTML) зародились в вебе, и с веб-страниц начали победное шествие по другим платформам (например, мобильным). А GLSL зародился в мире компьютерной графики, в С++, и пришел в веб оттуда. И что в нём прекрасно: он работает везде, где работает OpenGL, так что если вы его выучили, то сможете использовать где угодно (в Unity, Swift, Java и так далее).

GLSL стоит за сумасшедшими спецэффектами в компьютерных играх. А мне нравится, что с его помощью можно разрабатывать интересные и необычные UI-компоненты, позже о них мы и поговорим. Также это технология для параллельных вычислений, а значит, можно майнить криптовалюты с помощью GLSL. Что, заинтересовались?

История


Давайте начнем с истории GLSL. Когда и зачем он появился? Эта диаграмма отображает pipeline рендеринга OpenGL:



Изначально, в первой версии OpenGL, pipeline рендеринга выглядел так: на вход подаются вершины, из вершин собираются примитивы, примитивы растеризуются, происходит обрезание и затем вывод framebuffer.

Здесь есть проблема: это не кастомизируется. Так как у нас четко заданный pipeline, туда можно загрузить текстуры, но ничего особенного по какому-то точному запросу вы с этим сделать не можете.

Давайте рассмотрим простейший пример: нарисуем туман. Есть сцена. Это все состоит из вершин, на них наложена текстура. В первой версии OpenGL это выглядело таким образом:



Каким образом можно сделать туман? В формуле fogvalue — это дистанция до камеры умноженная на густоту тумана, а цвет пикселя равен текущему цвету пикселя умноженному на цвет тумана и на количество тумана. Если мы выполним эту операцию для каждого пиксела на экране, то мы получим такой результат:



GLSL-шейдеры появились в 2004 году в OpenGL v2, и это стало самым большим прорывом за историю OpenGL. Он появился в 1991 году, и вот, спустя 13 лет, вышла следующая версия.

С этих пор pipeline рендеринга стал выглядеть вот так:



Вершины подаются на вход, здесь выполняется первым вершинный шейдер, который позволяет изменять геометрию объекта, затем строятся примитивы, они растеризуются, затем выполняется фрагментный шейдер («фрагментный» значит «пиксельный», в англоязычной терминологии часто используется «fragment shader»), затем он обрезается и выводится на экран.

Окей, давайте поговорим о некоторых особенностях GLSL, потому что у него очень много-много вещей, которые необычные и звучат странно, для JS-разработчиков так точно.

Ну, сначала, что важно для нас, для веб-девелоперов: GLSL — это часть спецификации WebGL. Воротами в GLSL будет <canvas />.

GLSL компилируется с помощью драйвера GPU. Благодаря этому он кроссплатформенный, поскольку компилируется под каждую конкретную платформу, и он поразительно быстрый. Он очень быстр, в тысячи раз быстрее JavaScript, потому что компилируется специально под платформу и запускается на специальной железке, для которой он был предназначен.

При этом запускается он во множество процессов, например, карточка, если вы следите за новостями железа, карточка GTX 970, у нее одновременно работает 1664 шейдерных процесса. Представляете, сколько намайнить можно?

В общем-то таким образом и выполняется и майнинг, и все остальное, все параллельные вычисления – это CUDA-платформы, работают через шейдеры. Они бывают разных видов, не всегда GLSL, но в вебе у нас есть GLSL-шейдеры, часть OpenGL-спецификации.

Есть определенные особенности, связанные с тем, что данные отправляются только один раз. Так как выполнение параллельное, для всего прохода, для всего экрана, то данные в шейдер загружаются единожды, и это стоит учитывать.

GLSL — это язык со строгой типизацией. Есть типы float, integer, boolean, векторы 2-3-4 компонентные (которые, по сути, являются массивами 2-3-4-элементными), 2-3-4-размерные матрицы также есть.

Это язык, заточенный под математику, и у него есть все замечательные тригонометрические и математические функции, которые вы можете себе представить: радианы в градусы, градусы в радианы, синус, арккосинус, тангенс, перемножение матриц, перемножение векторов, разные производные и т.д.

Практика


Хорошо. От теории давайте перейдем к практике. Рассмотрим самый простейший пиксельный шейдер.



Сначала задаем радиус круга, это переменная типа float. Затем центр – это двухкомпонентный вектор. Заметьте, что начало координат в GLSL не в левом верхнем углу, а в левом нижнем. Можно поиграться немного с координатами, куда-то передвинуть этот круг.

Затем идет функция main, это точка входа в любой шейдер. В ней сначала вычисляется дистанция до центра с помощью встроенной функции distance, координат текущего пиксела и координат центра.

Дальше вычисляется float-переменная inCircle: если наш пиксел внутри круга, она равна единице, а если снаружи — нулю.

И последняя операция — это выходящий параметр gl_FragColor, определяющий цвет, который будет на экране. Мы присваиваем ему вышеупомянутый inCircle. То есть если мы внутри круга, здесь будет один.

Вот это очень интересный shorthand: четырёхкомпонентный вектор создается из одной float-переменной, сразу всем компонентам вектора присваивается значение этой переменной. Здесь используется RGBA-нотация, то есть четыре компонента — это RGB и альфа-канал.

И можно изменить это так:



Что здесь происходит? Мы присваиваем получающееся значение не всем каналам сразу, а только зелёному.

Окей, давайте перейдём от простейшего примера, который практически бесполезен, к решению практической задачи. Однажды грустным амстердамским осенним утром я получил задачу в JIRA, которая сделала мою жизнь немножко веселее.

Задача была про спиннер. Мы писали операционную систему на JS, и у нас в ОС был такой прикольный спиннер. Он работал, но была одна небольшая проблема: когда шел какой-то бэкграунд-процесс, спиннер иногда подёргивался. Меня попросили разобраться.



Я начал копать и увидел, что спиннер был реализован с помощью sprite sheet: у элемента менялся background-position, и прокручивались все эти кадры.



В принципе, это работало, но вы, наверное, знаете, что если меняется background-position, то что происходит? Repaint. Происходит постоянный рипейнт, и это загружало процессор, он работал не очень быстро.

Как это можно исправить? Можно через CSS. Я, естественно, не стал сразу лезть в дебри GLSL, сначала мы сделали это все под самым простым образом, через CSS, через аппаратно-ускоренные свойства. Вы многие знаете, что есть аппаратно-ускоренные свойства, которые без репейнта позволяют выполнять какие-то анимации. Здесь это все можно изменить на opacity, то есть с background-position мы перемещаемся на opacity.

Каким образом можно это с помощью непрозрачности сделать? Разложить все кадры на слои и с помощью opacity постепенно их скрывать и показывать, в общем-то, получается тот же самый эффект, но без всяких репейнтов. Ура, QA-отдел подтвердил увеличение быстродействия, все довольны.

На следующий день я получил еще один таск в JIRA. Денис, мы знаем, ты уже специалист в спиннерах, у нас есть точно такой же спиннер, только синий и немного другой ширины.

Я знал, что там много спиннеров, и понял, что там есть небольшая проблема. Во-первых, этот спиннер на 150 кадров в видеопамяти разворачивается на 8 с лишним мегабайт, я специально считал по разрешению и битности этих текстур (потому что на каждый кадр в результате создается текстура. И 10 мегабайт в RAM он занимает. И для этого нужно загрузить 100 килобайт. В целом, каждый спиннер стоит примерно 20-30 мегабайт, учитывая, что его нужно разжать. Для спиннера 30 мегабайт — это, честно говоря, много. Если их 3-4 – это 100 Мб оперативной памяти на спиннеры.

У нас в браузере было ограничение в 256 мегабайт: как только они достигались, система вся валилась. Думаю, на мобилках даже 100 мегабайт на спиннеры — тоже непозволительная роскошь.

Окей, я понял, у нас есть проблема. Ее можно решить с помощью GLSL. Насколько это прагматично, мы позже разберем.

Пишем шейдер


А сейчас можем вместе написать пиксельный шейдер. Спиннер можно представить в виде анимированной арки, которая схлопывается и расхлопывается: у неё изменяется угол начала и конца, и эта арка вращается с определенной периодичностью, затуханием, ускорением. Поэтому нам нужно в GLSL с помощью математики научиться рисовать арку.

Для начала установим расширение для Chrome Refined GitHub, оно нужно, чтобы копипастить diff из коммитов. Если вы его не поставите, у вас при попытке скопировать текст диффа будут копироваться номера строк, и вам придется их удалять вручную. Поэтому Refined Github очень сильно помогает: он выносит в отдельный список номера строк, и он крут.

Затем откроем онлайн-редактор шейдеров и GitHub-репозиторий PixelsCommander/pixel-shaders-workshop, в котором надо проходить по шагам.

С чего мы начинаем — копипастим в GLSL-редактор первый шаг, благодаря которому у нас появится круг:



Что здесь происходит? Вверху новый блок, его не было в прошлом примере, здесь приходит uniform-переменная. «Uniform» означает переменную, отправленную из JavaScript. Мы видим здесь u_time, u_resolution и u_mouse из JavaScript. Интереснее всего из них u_resolution. Что она говорит: это размерность canvas. JavaScript снял размерность канваса и отправил нам двухкомпонентный вектор в GLSL, теперь мы знаем размер канваса в GLSL.

В PI мы определили число пи, чтобы не писать его постоянно руками. Затем умножили u_resolution на 0.5: это двухкомпонентный вектор (там width и height), а при умножении вектора на 0.5 сразу все его компоненты умножатся на 0.5. Так мы нашли половину нашей размерности. После этого взяли радиус как минимальное от width и height.

Теперь у нас есть функция Circle: раньше мы просто определяли в main, лежим мы внутри круга или нет, а теперь вынесли это в отдельную функцию, куда запускаем координату текущего пиксела, центр и радиус.

А в main мы получаем isFilled как результат выполнения функции Circle, и отнимаем от единицы isFilled, потому что хотим, чтобы не круг был белым, а фон. То есть инвертировали все это богатство.

Теперь шаг второй: мы будем отсекать сектор на окружности.



У нас добавляются функция, которая рисует сектор, и функция, которая говорит, лежит ли угол между двумя заданными углами. А кроме того, isFilled мы теперь делаем произведением результатов circle и sector. Если в обоих случаях единица, тогда мы находимся в пределах нашей фигуры. Если бы не учитывался circle, то сектор получался бы бесконечным, а не ограниченным окружностью. Итог выглядит так:



Теперь третий шаг. Рисуем арку.



Здесь добавляется новая функция арки. Теперь нам нужно знать ее толщину, для этого будем вычислять внутренний радиус и что мы видим?

Теперь у нас isFilled — это результат выполнения функции arc, которой мы передаем начальный угол, конечный угол, внутренний и внешний радиусы. Здесь это все строится внутри на секторе, который у нас уже есть, и на двух функциях круга, которые друг друга инвертируют. То есть два круга отсечено, один скрывает другой.

Все здорово, все хорошо, у нас есть арка, мы почти готовы, но если вы присмотритесь, а я вам попробую помочь, то вы заметите, что арка пикселизирована, идут «зубчики» без сглаживания:



Это потому, что, нет сглаживания, это потому, что когда мы рисуем круг, мы здесь используем функцию step, когда мы определяем лежит ли точка в круге, либо нет, а функция step жестко отсекает, дискретно, 0 либо 1, если значение ниже заданного, то это 1, если выше – это 0. Соответственно, у нас пиксель может быть либо черным, либо белым.

Давайте от этого избавимся, это будет шаг 4. Добавляем антиалиазинг.



Мы заменяем функцию step на smoothstep. А smoothstep не просто говорит «либо 0, либо 1», а интерполирует между двумя значениями. Здесь у нас есть «distanceToCenter минус два пиксела» и есть просто distanceToCenter, то есть у нас происходит антиалиазинг размазыванием на 2 пиксела. Тут можно спорить о терминах, но реально мы только что добавили в наш шейдер антиалиазинг.



И арка стала гладкая и шелковистая.

Теперь перейдем к самому сложному — к анимации. Нарисовать арку — это, в общем-то, тригонометрия 5 класса, и ничего сложного нет. С анимацией все немножко сложнее, потому что ее для начала нужно распознать и декомпозировать.

Декомпозируя анимацию спиннера, мы обнаруживаем, что на самом деле анимации там две. Одна — это анимация схлопывания-расхлопывания, а вторая — анимация вращения. Кроме того, в начале цикла анимация ускоряется, а в конце замедляется. Это очень похоже на поведение функции синуса: в промежутке от – pi / 2 до pi / 2 сначала идет ускорение, резко взмывает вверх, и затем замедляется.



Шаг пятый. Мы применим эту функцию к нашим углам начала и конца арки. Получаем анимацию схлопывания-расхлопывания, пусть и пока что слегка побаживающую (это поправим). Что здесь происходит? Время замыкается в периоде от — pi / 2 до pi / 2, затем к этому применяется функция синуса, и все время получаем значение от нуля до единицы — насколько мы схлопнуты-расхлопнуты. То есть, по сути, здесь используется easing-функция, это то, что в твинах, в CSS используется повсеместно, здесь реализуется на GLSL. Затем умножаем 360 на результат выполнения этой easing-функции и получаем угол начала, угол конца, который передаем в функцию арки, которую мы написали раньше.

Следующий шаг — это вращение всего спиннера.



С вращением все просто, у нас уже база теоретическая подготовлена, мы знаем, что синус рулит и мы к startAngle и endAngle добавляем величину, которая получается, опять же, от синуса, но с в два раза большим периодом, потому что у нас за два схлопывания-расхлопывания получается всего один оборот.

Таким образом, мы получили спиннер, который уже практически соответствует нашему техническому заданию. Осталось добавить немножко параметризации:



Для этого понадобится функция RGB. Использовать её не обязательно, но хорошо, потому что мы, как правило, берем цвета из Photoshop, а у них побайтные значения каналов от 0 до 255, вы видели, что в GLSL от 0 до 1, и вот эта функция позволяет отправить в нее привычные нам 255/255/255 и на выходе получить 1/1/1.

Эту функцию применяем в main, и там также добавляется кастомизация бэкграунда, на всякий пожарный случай.



Получился прекрасный анимированный векторный спиннер, у которого можно менять ширину и цвет. Компонент готов, он работает, рендерится на GPU, и все это добро занимает 70 строк кода. Если упороться, наверное, можно ужать до 5 строк, что, конечно, не идет ни в какое сравнение с тем объемом информации, которую мы передавали в sprite sheet — просто небо и земля. Если там у нас 30 мегабайт просто картинок было, плюс нужно те же самые контексты инициализировать для текстур и так далее, то здесь есть очевидный прогресс.

Что мы с этим можем сделать


Как использовать GLSL компонент в вашем веб-приложении? Как уже говорилось, это делается через WebGL-контекст.



Есть простой способ. Есть веб-компонент, который называется GLSL-компонент, и вы его ставите в нужное место на вашей странице, этот тег, внутрь кладете вот тот GLSL-код, который у нас получился в редакторе. И вы получите в том размере, в котором у вас вот этот блок, вы получите ваш GLSL-компонент, работающий онлайн.

Ранее мы реализовали то, что на CSS можно было с натяжкой сделать через sprite sheet или другие трюки, пусть и не всегда быстро. Но на самом деле, шейдеры — это намного круче: они дают контроль над каждым пикселом.

Вот гифка, которая показывает спиннер, реагирующий на курсор:



А на видеозаписи можно увидеть ещё более впечатляющий пример того, как GLSL дает несоизмеримо больше возможностей и позволяет управлять каждым пикселом. Там спиннер уже превратился во что-то другое.

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

И чем ещё хорош GLSL: кроме этих безграничных возможностей, он дает JavaScript-разработчикам, фронтенд-разработчикам, глоток свежего воздуха. Вы пишете сколько-то лет JavaScript, понимаете, что вы в нем хороши, и вам хочется чего-то нового, но не хочется в бэкенд. В этом случае GLSL — это неплохой вариант изменить и разнообразить свою жизнь.

Спасибо большое!

Если доклад понравился, обратите внимание: уже на следующей неделе состоится HolyJS 2018 Piter, и там Денис тоже выступит, теперь с темой «Mining crypto in browser: GPU, WebAssembly, JavaScript and all the good things to try». А в дискуссионной зоне после доклада можно будет как следует расспросить его и по теме нового доклада, и про шейдеры. Кроме Дениса, там будут и десятки других спикеров — смотрите все подробности на сайте HolyJS.

Комментарии (12)


  1. Koneru
    10.05.2018 19:09

    Учитывая поддержку браузерами, стоит изучить, возможно придет вдохновение как использовать. Вероятно и svg графики можно будет заменить, что то уж долговато их прорисовать на CPU.


  1. Dave_by
    10.05.2018 20:49

    «GLSL компилируется на GPU». Это меня повеселило. На CPU он компилируется, в драйвере.
    Теперь ближе к сути atan в шейдере — это не хорошо, это медленно. В данном случае лучше передать 2 прямые, которые определяют сектор и проверять, по какую сторону от каждой прямой находится данный пиксель. Дальше додумывайте сами, если не получится — обращайтесь. Я подобное реализовывал когда делал анимацию кулдауна на кнопке аля World of Warcraft.


    1. Koneru
      10.05.2018 21:01
      -1

      Ммм, нет. Вот про WebGl, и даже CSS рендерится в GPU. Для доказательства открой dev tools в режиме профилирования.


      1. Dave_by
        10.05.2018 21:05
        +2

        Рендерится на GPU, а компилируется на CPU. Компилируется — транслирует high-level язык GLSL в инструкции GPU. Подтяни терминологию.


        1. Koneru
          10.05.2018 21:14

          Хм, ваша правда(про CPU не нашел, но и про GPU тоже ни слова, везде фигурирует в драйвере). GLSL рендерится на GPU. Так правильнее или не уловил суть?


          1. Dave_by
            10.05.2018 21:22

            Ну почти, не сам GLSL рендерится, а то во что он скомпилировался. Ну и слово тоже неудачно подобрано — рендерится это синоним слова отрисовывается. Программа выполняется, а картинка отрисовывается. Вобщем лучше написать что шейдер выполняется на GPU.


            1. Koneru
              10.05.2018 21:40
              -1

              Спасибо, за объяснение терминологии(без сарказма). Кармы не хватает,
              но +.


    1. phillennium Автор
      11.05.2018 09:19

      Спасибо за уточнения, про компиляцию в тексте подправили.


  1. iOrange
    11.05.2018 04:26

    <Зануда_mode_on>

    «фрагментный» значит «пиксельный», в англоязычной терминологии часто используется «fragment shader»

    Не то чтобы я придирался, но хотелось бы уточнить, иначе потом новички неверно улавливают суть.
    После того как отработал вершинный шейдер, вершинные аттрибуты уходят в интерполятор, на выходе из которого мы получаем фрагменты — это потенциальные будущие пикселы на экране (или вне-экранном буффере), но пока — это фрагменты. Именно с фрагментами работает фрагментный шейдер, на выходе из которого мы получим глубину и от ноля до N цветов.

    Понятие «пиксельный шейдер» пришло из DirectX и является не совсем корректным, хоть и более широко известным.
    <Зануда_mode_off>


  1. poxvuibr
    11.05.2018 10:54

    Чего, операционная система на JS никого, кроме меня, не заинтересовала?


  1. Lsh
    11.05.2018 16:21

    Выглядит прикольно. Но есть вопрос. Я правильно понимаю, что вычисления производятся для каждого пикселя канваса, и для того, что закрашивается, и для того, который остаётся нетронутым? Т.е. если у нас канвас на весь экран, а спиннер мелкий и по центру, то нагрузка на GPU будет такая же, как если бы спиннер занимал весь экран?


  1. deej
    12.05.2018 09:41

    Посмотрите внимательней, нет там ускорения. Так лишь кажется, оттого что вращение совмещено со схлопыванием/расхлопыванием арки.