Практически каждая современная графическая сцена являет собой результат работы некоторого кода, написанного специально для 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) на другую картинку или видео?
Вот и всё, что я хотел рассказать в данной статье. В следующих я попробую рассказать о системах освещения, симуляции жидкостей и разработке шейдеров под конкретные платформы.
Комментарии (40)
UncleAndy
21.03.2017 15:33+3Спасибо! Очень интересно! Давно интересовала данная тема, но не мог найти простого описания. Надеюсь что вы продолжите писать на эту тему для начинающих.
fireSparrow
21.03.2017 16:20+2Очень интересно.
Было бы здорово в будущем увидеть статью о тех подходах, которые используют в реальных шейдерах для игр. Например, хотя все примеры из статьи мне показались тривиальными, я всё равно не могу понять, как таким методом попиксельной обработки можно из верхней половины картинки из майнкрафта получить нижнюю.
Например, вода. Как я понимаю, там каким-то образом должен учитываться наклон поверхности относительно линии взгляда и окружающие объекты, причём с учётом их положения в 3D-пространстве. Я даже предположить не могу, как всё это получается только лишь попиксельными манипуляциями с плоской картинкой.
Или освещённость. Там явно как-то учитывается, какой стороной поверхности повёрнуты к солнцу. Как этого добиться с помощью принципов, описанных в статье?mib
21.03.2017 17:30+2Иногда сцена рендерится несколькими камерами. Вид из одной камеры является текстурой для смешивания с текстурами предметов, либо становится картой глубин, картой теней, еще можно смешивать текстуры ориентируясь на угол между нормалями плоскостей текстуры и экрана, и так далее, (что знал — всё написал)
VioletGiraffe
21.03.2017 19:07+8Хорошая ремарка про картинку с Майнкрафтом. Опять получилась статья «Как нарисовать сову.»
Barafu
21.03.2017 22:01Движок игры создаёт дополнительные однобитные (обычно) маски — например, картинку, в которой каждый пиксель тем темнее, чем под более острым углом повёрнута к камере поверхность, с которой он взят. Или картинку, в которой каждый пиксель тем темнее, чем дальше от камеры поверхность под ним. И всё это тоже скармливается шейдеру. Вместе с картами теней и прочей мурой. Не всякий шейдер, кстати, как-то влияет на выходную картинку — некоторые выполняют чисто технические задачи.
fireSparrow
21.03.2017 22:05О, спасибо, наличие дополнительных масок всё проясняет :)
MrShoor
21.03.2017 22:29Нет там битовых масок. GPU вообще битовые операции раньше не умел, и с интами там все плохо. В идеале GPU работает с флоатами.
Например диффузная компонента обычного Фонга — это косинус угла между инвертированной нормалью и направлением света.
Скалярное произведение двух векторов это результат умножения длин векторов на косинус между ними: |a||b|cos(ab)
Если длины векторов равны единицы, то скалярное произведение вернет косинус. Именно то, что нам нужно для освещения.
Для specular компоненты фонга — вектор луча сначала отражают вдоль нормали, а потом смотрят насколько он попадает в глаз. Через то же самое скалярное произведение.
MrShoor
21.03.2017 22:20О каких однобитовых масках вы говорите? Для классического фонга например там одно скалярное произведение на дифузный цвет, и два на спекуляр.
Углы там никто не считает, а скалярное произведение от нормализованных векторов возвращает косинус угла, вот его и используют.Barafu
21.03.2017 22:29А теперь то же самое, но по русски и чуть подробнее. Я не игродел, а описал решение как оно было сделано в одной программе для проектирования шкафов (из ДСП). Там уровень освещенности поверхности рассчитывался, реально, в шейдере.
Млиииин! ОдноБАЙТные маски у меня там были.MrShoor
21.03.2017 22:34Вот выше описал:
https://habrahabr.ru/company/infopulse/blog/324476/#comment_10129732
А вот в псевдокоде вычисление коэффициента диффузного освещения (diffK)
vec3 N = normalize(pixelNormal); // pixelNormal - нормаль к поверхности в освещаемом пикселе vec3 lightN = normalize(lightDir); // lightDir - вектор от источника света на освещаемый пиксель float diffK = dot(-N, lightN); //косинус угла между этими векторами, наш коэффициент диффузного освещения diffK = clamp(diffK, 0, 1); //но нас интересуют только положительные косинусы, обрезаем отрицательную часть
leshabirukov
22.03.2017 14:42Простой эффект «волны на воде» даёт displacement shader:
new_pic(x,y) = old_pic( x+dx(x,y), y+dy(x,y) )
где d — ограниченная и (почти) гладкая функция.
Тут подробнее ответил
k12th
21.03.2017 17:18+2Интересно, почему переменные называются юниформные? И откуда они берутся? Если их передает ShaderToy, то, получается, в голом JS без обвязки написанные шейдеры перестанут работать? Если нет, то где полный список?
HellMaster_HaiL
21.03.2017 18:57+2Премного благодарен. Вы приоткрыли занавесу тайны =)
Крайне надеюсь на продолжение.VioletGiraffe
21.03.2017 19:13+2У оригинального автора есть ч. 2 и ч. 3
https://gamedevelopment.tutsplus.com/tutorials/a-beginners-guide-to-coding-graphics-shaders-part-2--cms-24111
https://gamedevelopment.tutsplus.com/tutorials/a-beginners-guide-to-coding-graphics-shaders-part-3--cms-24351HellMaster_HaiL
21.03.2017 19:33+6Не заметил, что статья — перевод, пока Вы явно на это не указали. И не поверил бы, если не соответствующая плашка. Текст читается хорошо, и даже комментарии в коде на русском. Перечитал еще раз отдельные абзацы — очень хороший перевод, побольше бы таких на хабре.
Так что да, все равно надеюсь на продолжение =)VioletGiraffe
21.03.2017 21:02+2Перевод действительно хороший, но пара английских шаблонов всё-таки в него просочилась. Я на середине заподозрил, что это перевод, и оказался прав :) Но — да, однозначно отличный материал и хороший перевод. Уже не в первый раз замечаю качественный материал от Инфопульс.
iga2iga
22.03.2017 01:56+1Спасибо огромное! Статья — что надо! Есть необходимость научиться выводить через шейдер 10 битные yuv планы видеокадра и собирать из них целый кадр. С 8ми битными разобрался без проблем. А вот 10-ти битные пока — магия.
MrShoor
22.03.2017 02:31Посмотрите в сторону формата текстуры DXGI_FORMAT_R10G10B10A2_UNORM и DXGI_FORMAT_R10G10B10A2_UINT если у вас DX. Для OGL можно нагуглить аналоги констант для этих форматов.
perfect_genius
22.03.2017 20:02А есть низкоуровневый доступ? Например, писать на ассемблере или отлаживать пошагово в отладчике.
python273
23.03.2017 00:57Залип на пол дня
https://www.shadertoy.com/view/XdfcWltangro
23.03.2017 00:59Я бы на таких штуках тригонометрию в школе преподавал. Наглядно же видно как арктангенс работает и к чему тут число Пи. А то «пишем формулу: синуск квадрат альфа плюс косинус квадрат альфа...» — скукота.
MetaDriver
25.03.2017 16:15прикольно.
даже руки зачесались. слегка перепилил, вроде стало ещё круче. ))
https://www.shadertoy.com/view/lssyDlpython273
25.03.2017 16:23На мой вкус слишком шумно получается :)
Еще сделал вариацию своего прошлого: https://www.shadertoy.com/view/4dscWs
BlackBagel
23.03.2017 21:16Спасибо большое! Читать интересно, примеры вроде не очень сложные, но решать прямо приятно :)
Conso
28.03.2017 16:39+1Есть отличная подборка небольших уроков: https://www.shadertoy.com/view/Md23DV
sashaz
28.03.2017 17:07+1Единственным предназначением шейдера является вернуть четыре числа: r, g, b и a.
Люблю когда простые вещи объясняют просто. Отличная статья, спасибо за перевод!
Mysterious
Огромное спасибо за такую познавательную статью!
Для тех у кого «шейдеры = магия», это как раз то, что позволяет дать начальное понимание этой кухни.
VioletGiraffe
Shadertoy — вот это настоящая магия. И целые игры с продвинутой графикой, которые там на шейдерах написаны. Ну ОК, не игры а демосцены (user input нет), но там уже полшага остаётся.
MrShoor
Уже давно все есть. Вот, полюбуйтесь:
https://www.shadertoy.com/results?query=tag%3Dgame
VioletGiraffe
Супер! А как? Это ещё одна фича Shadertoy — проброс кликов мыши в шейдер? Не видел такого в описании.
MrShoor
Проброс кликов мыши и позиции там был с ооочень давных времен, как юниформ переменная:
Инпут с клавиатуры тоже появился вскоре как текстура. Но основная проблема была в том, что негде было промежуточный стейт хранить. И только с появлением рендера в текстуру стало возможным писать вот такие игры.VioletGiraffe
Проморгал, спасибо. Тестура в качестве буфера данных — красивый ход :)