Умение писать графические шейдеры открывает перед вами всю мощь современных GPU, которые сегодня уже содержат в себе тысячи ядер, способных выполнять ваш код быстро и параллельно. Программирование шейдеров требует несколько иного взгляда на некоторые вещи, но открывающийся потенциал стоит некоторых затрат времени на его изучение.
Практически каждая современная графическая сцена являет собой результат работы некоторого кода, написанного специально для GPU — от реалистичных эффектов освещения в новейших ААА-играх до 2D-эффектов и симуляции жидкости.
Сцена в Minecraft до и после применения нескольких шейдеров.
Цель этой инструкции
Программирование шейдеров иногда кажется загадочной черной магией. Тут и там можно встретить отдельные куски кода шейдеров, которые обещают вам невероятные эффекты и, возможно, вправду способны их обеспечить — но при этом совершенно не объясняют, что именно они делают и как добиваются столь впечатляющих результатов. Данная статья попробует закрыть этот пробел. Я сфокусируюсь на базовых вещах и терминах, касающихся написания и понимания шейдерного кода, так что впоследствии вы сами сможете менять код шейдеров, комбинировать их или писать свои собственные с нуля.
Что такое шейдер?
Шейдер — это просто программа, которая запускается на одном из графических ядер и говорит видеокарте, как нужно отрисовать каждый пиксель. Программы называются «шейдерами», поскольку они часто используются для контроля эффектов освещения и затенения («shading»). Но, конечно, нет никаких причин ограничиваться только этими эффектами.
Шейдеры пишутся на специальном языке программирования. Не беспокойтесь, вам не нужно прямо сейчас идти и изучать с нуля новый язык программирования. Мы будем использовать GLSL (OpenGL Shading Language), который имеет С-подобный синтаксис. Существуют и другие языки программирования шейдеров под различные платформы, но, поскольку их конечной целью является всё тот же запуск кода на GPU, они имеют достаточно схожие принципы.
Данная статья будет рассказывать лишь о так называемых пиксельных (или фрагментных) шейдерах. Если вам стало интересно, а какие они бывают ещё — вам следует почитать о графическом конвейере (например, в OpenGL Wiki).
Поехали!
Для наших экспериментов мы воспользуемся ShaderToy. Это позволит вам взять и начать писать шейдерный код здесь и сейчас, не откладывая это дело на потом из-за необходимости устанавливать какие-то определённые инструменты или SDK. Единственное, что вам необходимо — это браузер с поддержкой WebGL. Создавать аккаунт на ShaderToy не обязательно (только, если вы захотите сохранить там свой код).
Заметка: ShaderToy сейчас в стадии беты, так что на момент прочтения вами этой статьи некоторые нюансы его UI могут измениться.
Итак, нажимаем кнопку New в правом углу, что приведёт к созданию нового шейдера:
Маленькая чёрная стрелка под кодом компилирует и запускает шейдер.
Что здесь происходит?
Я сейчас объясню, как работает шейдер, ровно одним предложением. Вы готовы? Вот оно. Единственным предназначением шейдера является вернуть четыре числа: r, g, b и a.
Это всё, что может и должен сделать шейдер.
Функция, которую вы видите выше, запускается для каждого пикселя на экране. И для каждого из них она возвращает четыре вышеуказанных числа, которые и становятся цветом данного пикселя. Так работают Пиксельные Шейдеры (иногда также называемые фрагментными).
Итак, теперь у нас есть достаточно знаний для того, чтобы, например, залить весь экран чистым красным цветом. Значения каждой из компонент rgba (red, green, blue и «alpha» — то есть «прозрачность») может быть в диапазоне от 0 до 1, так что в нашем случае мы просто вернем r,g,b,a = 1,0,0,1. ShaderToy ожидает финальный цвет пикселя в переменной fragColor.
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
fragColor = vec4(1.0,0.0,0.0,1.0);
}
Мои поздравления! Это ваш первый работающий шейдер!
Мини-задание: сможете залить весь экран серым цветом?
vec4 — это просто тип данных, так что мы можем объявить наш цвет как переменную:
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec4 solidRed = vec4(1.0,0.0,0.0,1.0);
fragColor = solidRed;
}
Данный пример не слишком захватывающий. У нас есть мощь сотен или тысяч вычислительных ядер, способных работать эффективно и параллельно, а мы из это пушки стреляем по воробьям, заливая весь экран одним цветом.
Давайте хотя бы нарисуем градиент. Для этого, как вы можете догадаться, нам нужно знать позицию текущего пикселя на экране.
Входные параметры шейдера
Каждый пиксельный шейдер имеет в своём распоряжении несколько полезных переменных. В нашем случае наиболее полезной будет fragCoord, которая содержит координаты x и y (а также z, если нужно будет работать в 3D) текущего пикселя. Для начала попробуем закрасить все пиксели в левой половине экрана в черный цвет, а в правой — в красный:
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 xy = fragCoord.xy; // координаты текущего пикселя
vec4 solidRed = vec4(0,0.0,0.0,1.0);// чёрный цвет
if(xy.x > 300.0){// некоторое число, мы не знаем реального размера экрана
solidRed.r = 1.0;// красный цвет
}
fragColor = solidRed;
}
Заметка: для доступа к компонентам переменных типа vec4 вы можете использовать obj.x, obj.y, obj.z, obj.w или obj.r, obj.g, obj.b, obj.a. Это эквивалентные записи. Таким способом мы получаем возможность именовать компоненты vec4 в зависимости от того, чем они являются в каждом конкретном случае.
Вы уже видите проблему с кодом выше? Попробуйте нажать кнопку перехода в полноэкранный режим. Пропорции красной и черной частей экрана изменятся (в зависимости от размера вашего экрана). Для того, чтобы закрасить ровно половину экрана, нам нужно знать его размер. Размер экрана не является встроенной переменной, поскольку это нечто, что программист приложения контролирует сам. В нашем случае это ответственность разработчиков ShaderToy.
Если что-то не является встроенной переменной, вы можете переслать эту информацию от CPU (основного кода вашего приложения) к GPU (вашему шейдеру). ShaderToy делает это за вас. Вы можете просмотреть все доступные шейдеру переменные во вкладке Shader Inputs. В GLSL они называются uniform-переменными.
Давайте исправим наш код таким образом, чтобы он корректно определял середину экрана. Для этого нам понадобится uniform-переменная iResolution:
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 xy = fragCoord.xy; // координаты текущего пикселя
xy.x = xy.x / iResolution.x; // делим на разрешение экрана
xy.y = xy.y / iResolution.y;
// теперь х будет равен 0 для самого левого пикселя и 1 для самого правого
vec4 solidRed = vec4(0,0.0,0.0,1.0); // чёрный цвет
if(xy.x > 0.5){
solidRed.r = 1.0; // красный цвет
}
fragColor = solidRed;
}
Теперь даже при увеличении окна предпросмотра (или переходе в полноэкранный режим) мы получим поделенный ровно пополам черно-красный прямоугольник.
От разделения экрана к градиенту
Изменить наш код для получения градиентной заливки достаточно просто. Компоненты цветов могут быть в пределах от 0 до 1, и наши координаты тоже теперь представлены в том же диапазоне.
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 xy = fragCoord.xy; // координаты текущего пикселя
xy.x = xy.x / iResolution.x; // делим на разрешение экрана
xy.y = xy.y / iResolution.y;
// теперь х будет равен 0 для самого левого пикселя и 1 для самого правого
vec4 solidRed = vec4(0,0.0,0.0,1.0); // чёрный цвет
solidRed.r = xy.x; // устанавливаем красную компоненту цвета в нормализированное значение х
fragColor = solidRed;
}
Вуаля!
Мини-задание: попробуете сами сделать вертикальный градиент? Диагональный? Как на счёт перехода между более чем двумя цветами?
Если вы не пропустили вышеуказанное задание с вертикальным градиентом, то уже знаете, что верхний левый угол имеет координаты (0;1), а не (0;0), как можно было бы предположить. Это важно, запомните это.
Рисование изображений
Развлекаться с заливкой цветом, конечно, забавно, но, если мы хотим реализовать какой-нибудь по-настоящему захватывающий эффект, наш шейдер должен быть способен принимать на вход картинку и изменять её. Таким образом мы можем написать шейдер, который может влиять, например, на отрисовку всего кадра в игре (реализовать эффекты движения жидкостей или выполнять цветокоррекцию) или наоборот, выполнять лишь отдельные операции для некоторых объектов сцены (например, реализовать часть системы освещения).
Если бы мы писали шейдеры на какой-нибудь обычной платформе, то должны были бы передать изображение шейдеру как uniform-переменную (таким же образом, как передавалось разрешение экрана). ShaderToy делает это за нас. Есть четыре входных канала внизу:
Кликните на канале iChannel0 и выберите любую текстуру (изображение). Теперь у вас есть картинка, которая будет передана вашему шейдеру. Но есть одна проблема: функции DrawImage() у нас нет. Вы ведь помните — всё, что может сделать шейдер, это вернуть значение rgba для одного пикселя.
Итак, если мы можем лишь вернуть значение цвета, то как же нам отрисовать картинку на экране? Мы должны как-то соотнести пиксель в картинке с пикселем, для которого был вызван шейдер:
Мы можем сделать это с помощью функции texture(textureData,coordinates), которая принимает на вход текстуру и координаты (x, y), а возвращает цвет текстуры в данной точке в виде переменной типа vec4.
Вы можете соотнести пиксели текстуры и экрана как-угодно. Можно, например, растянуть текстуру на четверть экрана или нарисовать лишь её часть. В нашем случае мы всего лишь хотим увидеть оригинальное изображение:
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 xy = fragCoord.xy / iResolution.xy; // сжимаем в одну линию
vec4 texColor = texture(iChannel0,xy); // берём пиксель с координатами (x;y) из канала iChannel0
fragColor = texColor; // устанавливаем цвет пикселя на экране
}
И вот она, наша картинка!
Теперь, когда вы умеете вытягивать данные из текстуры, вы можете манипулировать ими как захотите. Вы можете растянуть или сжать изображение, поиграть с его цветами.
Давайте добавим сюда уже известный нам градиент:
texColor.b = xy.x;
Поздравляю, вы только что написали свой первый пост-процессинг эффект!
Мини-задание: сможете ли вы написать шейдер, который преобразует входную картинку в черно-белое изображение?
Заметьте, хотя мы используем статическую картинку, то, что вы видите на экране рендерится в реальном времени, много раз в секунду. Вы можете убедиться в этом, заменив во входном канале статическую картинку на видео (просто кликните на канале iChannel0 и выберите видео).
Добавляем немного движения
До этого момента все наши эффекты были статические. Мы можем делать намного более интересные вещи, используя входные параметры, предоставляемые нам разработчиками ShaderToy. iGlobalTime это постоянно увеличивающаяся переменная — мы можем использовать её в качестве основы для переодических эффектов. Давайте попробуем поиграть с цветами:
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 xy = fragCoord.xy / iResolution.xy; // сжимаем в одну линию
vec4 texColor = texture(iChannel0,xy); // берём пиксель с координатами (x;y) из канала iChannel0
texColor.r *= abs(sin(iGlobalTime));
texColor.g *= abs(cos(iGlobalTime));
texColor.b *= abs(sin(iGlobalTime) * cos(iGlobalTime));
fragColor = texColor; // устанавливаем цвет пикселя на экране
}
В GLSL есть встроенные функции синуса и косинуса (да и много других полезных). Компоненты цвета не должны быть негативными, так что мы используем функцию abs.
Мини-задание: можете ли вы сделать шейдер, который будет периодически плавно делаеть картинку черно-белой, а потом снова полноцветной?
Отладка шейдеров
При написании обычных программ вы, возможно, использовали возможность отладочного вывода или логирования, но для шейдеров это не очень-то возможно. Вы можете найти какие-то отладочные средства под вашу конкретную платформу, но в общем случае лучше всего представить нужное вам значение в виде некоторой графической информации, которую вы можете увидеть в выводе невооруженным взглядом.
Заключение
Мы рассмотрели лишь базовые средства разработки шейдеров, но вы уже можете экспериментировать с ними и пробовать делать что-то своё. Просмотрите доступные на ShaderToy эффекты и попробуйте понять (или самостоятельно воспроизвести) какие-то из них.
Одна из (многих) вещей, которые я не упомянул в данной статье, это вершинные шейдеры (Vertex Shaders). Они пишутся на том же языке, но запускаются не для пикселей, а для вершин, возвращая, соответственно, новую позицию вершины и её цвет. Вершинные шейдеры занимаются, например, отображением 3D-сцены на экран.
Последнее мини-задание: сможете ли вы написать шейдер, который заменит зелёный фон (есть в некоторых видео на ShaderToy) на другую картинку или видео?
Вот и всё, что я хотел рассказать в данной статье. В следующих я попробую рассказать о системах освещения, симуляции жидкостей и разработке шейдеров под конкретные платформы.
Автор: Инфопульс Украина