Главная » Хабрахабр » UI-компоненты на пиксельных шейдерах: пишем ваш первый шейдер

UI-компоненты на пиксельных шейдерах: пишем ваш первый шейдер

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

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

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

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

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

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

История

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Практика

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Пишем шейдер

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Фулстеки — это вечные мидлы. Не идите по этому пути, если не хотите страдать

У меня появилась идея фикс — быть разработчиком, который может всё. Когда я только начал учиться кодить, я поверил старым мудрым засранцам с их мантрой «язык программирования не важен». Но эта затея с треском провалилась. Парнем, который переносит опыт использования ...

«Я просто энтузиаст проекта и пользователь языка Dart» — интервью с Ari Lerner, автором знаменитой ng-book

Что самое важное в обучении, что такое «hallway chat» и вообще, при чём тут Dart и Flutter? Как написать девять книг по совершенно разным технологиям, включая Angular, Vue, React, React Native и другим? Какой будет дальнейшая книга, что автор думает ...