что такое пиксельные шейдеры
Shader — это не магия. Написание шейдеров в Unity. Введение
Всем привет! Меня зовут Дядиченко Григорий, и я основатель и CTO студии Foxsys. Сегодня хочется поговорить про шейдеры. Умение писать шейдеры (и в целом работать с рендером) очень важно при разработке под мобильные платформы или AR/VR, если хочется добиться крутой графики. Многие разработчики считают, что шейдеры — это магия. Что по ним мало хорошей информации, и что чтобы их писать нужно иметь, как мимимум, звание кандидата наук. Да, разработка шейдеров по своим принципам сильно отличается от клиентской разработки. Но основное понимать базовые принципы работы шейдеров, а так же знать их суть, чтобы в этом не было ничего магического и поиск информации по этой теме был простой задачей. Данная серия статей рассчитана на новичков, так что если вы разбираетесь в программировании шейдеров, данная серия вам не будет интересна. Всем же кто хочет разобраться в этой теме — добро пожаловать под кат!
Это вводная статья в которой я расскажу общие принципы написания шейдеров. Если тема будет интересна, то мы разберём уже подробнее в отдельных статьях: вершинные шейдеры, геометрические шейдеры, фрагментные/пиксельные шейдеры, трипланарные шейдеры, скринспейс эффекты и компьют шейдеры (OpenCL, СUDA и т.п.). И в целом всю ту магию, которую можно делать на GPU. Разбираться это будет в контексте стандартного рендер пайплайна Unity. Так LWRP и HDRP мне пока кажутся немного сыроватыми.
Что такое шейдер?
По сути это программа выполняемая на гпу, выходными данными которых является разная информация. В вершинных шейдерах — это параметры вершин меша. Пиксельные шейдеры выполняются попиксельно.
Для понимания того, как работают шейдеры нужно рассказать, что такое графический конвейер (graphic pipeline). Очень часто про эту тему говорят довольно сложными словами, но мы это немного упростим для понимания. Возьмём на примере OpenGL. В этом плане мне очень нравится эта картинка.
Если опустить детали связанные с освещением и т.п. То в целом с точки написания тех же Unlit шейдеров на hlsl суть такова. У нас есть в шейдере
где мы определяем, что вертексная часть шейдера будет писаться в функции vert, а фрагментная — в функции frag.
Структуры которые мы описываем в шейдере определяют какие данные мы будем забирать из меша и после обработки вертексным шейдером, которые висят на нашем MeshRenderer и MeshFilter объекте.
Дальше вертексный шейдер вычисляет получив на вход данные appdata и отдаёт результат в виде структуры v2f, которая дальнейшем пойдёт в фрагментный шейдер. Который в свою очередь уже рассчитает цвет пикселя. Так как информация v2f пишется только в вершины (которых меньше, чем пикселей), данные в фрагментной части интерполируются. Всё это можно представить как то, что vert считается в каждом вертексе независимо. Потом результат передаётся в фрагментную часть, где frag для каждого пикселя считается так же независимо. Так как вычисления производятся параллельно, в данных частях нет никакой информации о соседях (если не передавать её как-то хитро).
Более детально все нюансы, а так же множество примеров описаны в документации Unity docs.unity3d.com/Manual/SL-Reference.html
Языки программирования шейдеров
Дальше с точки зрения изучения шейдеров, когда эти языки уже не вызывают вопросов можно посмотреть какие возможности предоставляет сам по себе«UnityCG.cginc» и другие библиотеки написанные юнити, чтобы упростить себе работу.
Почему if в шейдерах — это плохо?
Тут важно понимать, как шейдеры исполняются на уровне железа и почему они такие быстрые, что могут выполнять миллионы операций не напрягаясь.
Основная идея графических процессоров — это максимальная параллельность вычислений. Тут нужно ввести такое понятие, как “волновой фронт”. По сути оно довольно простое, волновой фронт — это группа шейдеров выполняющая одну и туже последовательность операции. То есть с точки зрения гпу самый лучший вариант, когда в одно и тоже время выполняются одни и те же инструкции. Единственно различие в выполнении — это входные данные. Проблема ветвления в том, что может случиться ситуация, когда в одной группе шейдеров, шейдеры должны вызывать разные операции. Что в свою очередь приводит к созданию нового волнового фронта, копированию в него данных и т.п. А это очень дорого.
Там есть нюансы и исключения, но для того чтобы спокойно писать if, вы должны понимать, как он себя поведёт на целевой версии графического апи. Так как тот же самый OpenGL ES 2 или DX11 в этом плане сильно отличаются.
Зачем мне это знать, ведь есть нодовые редакторы?
Важно понимать, что нодовые редакторы — это в первую очередь инструмент для техникал артистов. Это специалисты, которые имеют экспертизу в математике, но в большей степени являются дизайнерами. Шейдеры типа wireframe (где требуется понимание барицентрических координат) или же преобразование к картезианским координатам, которое используется для хитрых проекций, в разы проще делать кодом, так же как и многие математические модели физических материалов. При этом с точки зрения шейдерного программиста вы по сути делаете кастомные ноды и инструменты для техникал артистов, чтобы творить реальную магию. Нодовые редакторы имеют ограниченный функционал с этой точки зрения. Поэтому важно уметь писать шейдеры на языках типа hlsl. Понимать то, как работает рендер и т.п.
Полезные ресурсы для изучения
С точки зрения изучения шейдерного программирования хорошим упражнением является переписывание шейдеров с www.shadertoy.com или glslsandbox.com. Кроме того существует крутой профиль специалиста из Unity, где можно посмотреть много интересного github.com/keijiro
Всё остальное — это математика и понимание физики эффектов. Это в чём-то похоже на смешивание ингредиентов, если не решается конкретная задача физического моделирования. Много любопытного можно сделать смешивая между собой шум, преломление, подповерхностное рассеивание света, каустику, эффект Френеля, реакцию диффузии и прочие физические свойства объектов. В целом шейдерное программирование это безусловно не элементарно, и там есть куда копать в глубину.
Если тема шейдеров будет интересно, то постараюсь выпустить серию на статей на эту тему, уже с конкретными примерами и туториалами на тему создания разных эффектов. Предлагайте в комментариях про что вам было бы интересно прочитать и какие темы изучить. Спасибо за внимание!
Все эффекты в статье — это запись эффектов шейдеров с shadertoy.
OpenGL pixel and vertex shaders.
Значения, вычисленные в вертексном шейдере, интерполируются по треугольнику. На видео картах, не поддерживающих пиксельные шейдеры, для каждого пикселя определяется его цвет и цвет текстуры (или нескольких текстур) в данной точке. Потом эти цвета умножаются или складываются, в зависимости от параметров выполненной ранее функции glTexEnv(), и результат записывается в буфер кадра. Если же видео карта поддерживает пиксельные шейдеры, то все намного интереснее. Интерполированные по треугольнику значения поступают на вход некоторой программы, называемой пиксельным шейдером. Это программа, состоящая из ряда арифметических и других инструкций, рассчитывает цвет пикселя, который записывается в буфер кадра. По сравнения с вертексными программами, скорость выполнения пиксельных шейдеров намного выше. Можно почти моментально выполнять штук 10 векторных инструкций для каждого пикселя! На CPU такое сделать просто невозможно.
Пиксельные и вертексные шейдеры позволяют на аппаратном уровне создавать потрясающие эффекты: освещение на пиксельном уровне, bump mapping, отражение и преломление, волны на воде, скелетная анимация персонажей, тени и многое другое!
Часть II. Вертексные программы
Чтобы использовать вертексные программы на OpenGL, надо подключить соответствующее расширение. В таблице приведены видео карты и расширения, которые они поддерживают:
GeForce 3,4 | NV_vertex_program NV_vertex_program1_1 ARB_vertex_program |
Radeon 8500 | EXT_vertex_shader |
Radeon 9700 | EXT_vertex_shader ARB_vertex_program |
NV30 | NV_vertex_program NV_vertex_program1_1 ARB_vertex_program NV_vertex_program2 |
Первая программа на NV_vertex_program.
Вертексная программа представляет собой массив символов (строку). Хранить ее можно либо в исходном файле программы, либо в отдельном текстовом файле и загружать непосредственно во время выполнения программы.
Думаю, пора приступить к написанию нашего первого шейдера.
Пока не будем задумываться, что делает такой шейдер. Загрузим его.
Теперь рисуем треугольники:
Что произойдет в результате такой операции? Нарисуется треугольник заданного цвета (при помощи glColor или glColorPointer) с текстурой (если она была включена, и были заданы текстурные координаты).
В данном примере в константы c[0..3] автоматически записывались нужные значение. Если требуется записать туда что-то другое, то применяется функция glProgramParameter. Например, чтобы записать в константу c[20] значение <1, 1, 0.5, 0>надо вызвать следующую функцию:
XNA Draw или пишем систему частиц. Часть II: шейдеры
Привет всем разработчикам игр и просто людям, которые интересуются геймдевом.
Пришло время рассказать вам о пиксельных шейдерах и о том, как сделать post-proccesing. Это вторая часть статьи о графических методах в XNA, в прошлой статье — мы рассматривали методы Draw и Begin у spriteBatch. Для примера: улучшим нашу систему частиц добавлением пиксельного шейдера, который будет искажать пространство.
Шейдер
Немного поговорим о шейдарах. Существуют два типа шейдера (в Shader Model 2.0, её то мы и используем): вертексный и пиксельный.
Вершинный шейдер оперирует данными, сопоставленными с вершинами многогранников. К таким данным, в частности, относятся координаты вершины в пространстве, текстурные координаты, тангенс-вектор, вектор бинормали, вектор нормали. Вершинный шейдер может быть использован для видового и перспективного преобразования вершин, генерации текстурных координат, расcчета освещения и т. д.
Пиксельные шейдеры выполняются для каждого фрагмента в фазе растеризации треугольников. Фрагмент (или пиксель) — точка, с оконными координатами, полученная растеризатором после выполнения над ней ряда операций. Проще говоря, результирующая точка буфере кадра, совокупность этих точек потом формирует изображение. Пиксельные шейдеры оперируют над фрагментами до заключительных стадий, т.е. до тестов глубины, альфы и stencil. Пиксельный шейдер получает интерполированные данные (цвет, текстурные координаты) из вершинного шейдера.
Если сказать очень коротко про пиксельный шейдер, то это обработчик готового изображения.
В случае с Displacement-шейдером — вершинные шейдеры не нужны, рассмотрим пиксельные.
Post-processing
Если быть ленивым лаконичным человеком, то Post-processing шейдеры выполняются тогда, когда вся картинка игры уже отрисована: шейдер накладывается сразу на всю картинку, никак не на отдельные спрайты.
-Ведь у spriteBatch.Begin есть параметр, effect, не проще применять шейдер сразу, как мы его рисуем?
Отвечаю: вот именно, что такой шейдер применяется к единичным спрайтам, как итог, Displacement-шейдер будет функционировать криво.
Для создания Post-process обработки, нужно сначала рисовать то, что должно быть нарисовано на экране — на отдельную текстуру, а потом рисовать эту самую текстуру с использованием Post-process шейдера. Таким образом, шейдер воздействует не на единичные спрайты, а на картинку в целом.
-Стоп, а как рисовать на отдельную текстуру?
Отвечаю: знакомьтесь — RenderTarget2D
RenderTarget2D
И опять, привет мой друг — лаконичность. RenderTarget2D — по сути является текстурой, на которую можно рисовать.
Идем туда, где обычно мы рисуем сцену, перед отчищением вставляем:
Теперь все будет рисоваться не на экран, а на RenderTarget2D.
Чтобы переключиться опять на экран, используем конструкцию:
Не забудьте очистить RenderTarget, перед прорисовкой.
Искажающий шейдер с Displacemenet-map
Идея такого пиксельного шейдера очень проста: на вход поступает текстура, которую нужно «погнуть», на второй вход — карта, о том, как гнуть.
Карту мы будем генерировать, о том как — в практике.
Кстати, о карте. Карта представляет собой такое же по размерам изображение, как и текстура сцены, за исключением того, пожалуй, что нарисованного изображения — не увидим.
Более подобно о карте и о том, как действует шейдер:
В процессе обработки изображения — получаем текущую позицию пикселя, получаем цвет. Тоже самое делаем и для карты. Т.е. в конечном итоге, у нас будет доступно для модификации: цвет пикселя, позиция пикселя, цвет пикселя на карте соответствующего позиции пикселя на изображении.
Будем использовать цвета карты, чтобы передать информацию шейдеру, как погнуть пиксель.
К примеру, R-канал (красный) получает значения от 0f до 1f. Если мы видим на карте искажения R=0.5f, то просто сдвигаем позицию пикселя изображения на 10f * 0.5f пикселя. 10f — это сила, с которой мы сдвигаем.
Соответственно, R-канал будет соответствовать X координате, а G-канал — Y.
Если вам нужны картинки, получите их:
Исходная картинка:
Карта:
Итоговая картинка:
Так, с теорией вроде разборались, сейчас попробуем это все реализовать кодом.
Практика: дорабатываем систему частиц
Дорабатываем исходный код из прошлой статьи.
Сразу добавим какую-нибудь картинку, чтобы искажения были заметны, например эту:
Копируем ParticleController и называем его ShaderController, в нем нам нужно изменить только сам процесс создания частицы, а конкретно:
Реализуем post-processing, создаем новые переменные:
Идем к методу Draw главного класса и пишем:
Post-processing готов, теперь создадим шейдер.
Создаем новый Effect (fx) файл (это файл шейдера, написанного на HLSL), вписываем туда, что-то вроде:
Шейдер создан, загрузить его можно так же, как и обычную текстуру, за исключением того, что тип не Texture2D, а Effect.
Теперь обновим наш Draw:
Запускаем, любуемся красивыми, реалистичными животными искажениями (лучше посмотреть демо):
На самом деле, эта реализация системы частиц (не шейдеры, а то что было в первом уроке) в целом — не совсем хороша для производительности. Сущесвтуют другие методы, более сложные в понимании, о них я расскажу как-нибудь потом.
Прикладываю исходники и демо (на этот раз, запустится на любом компьютере с XNA 4.0 и аппаратной подержкой DirectX9, inc sh 2.0)
Может быть на этой неделе, может быть неизвестно когда — расскажу о методе Update и как реализовать физику, используя Box2D.
Удачи вам и еще раз с праздником программиста 0xFF+1 днем! 😉
UI-компоненты на пиксельных шейдерах: пишем ваш первый шейдер
Кого можно назвать «пиксельных шейдеров начальник и пикселов командир»? Дениса Радина, работающего в 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 компилируется с помощью драйвера 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.