Давненько я не писал никаких статей на Хабре. Я планировал вести серию заметок о разработке игр на Go и начал я с рендеринга текста, но меня не хватило даже на второй текст. Что же, настало время возвращаться, ведь с того момента я успел создать ещё несколько игрушек. Сегодня я расскажу вам о шейдерах в Ebitengine. Большая часть примеров будет взята из Roboden и Decipherism (обе игры имеют открытые исходные коды и вы можете найти их на гитхабе). Я буду говорить только о фрагментных шейдерах (они же пиксельные), так как только такие поддерживаются в Ebitengine. Фрагментный шейдер — это такой алгоритм, который описывает как преобразить пиксели изображения перед его отображением. Чаще всего этот алгоритм описан в виде кода, но существуют визуальные способы создавать шейдеры. На каком именно диалекте языка шейдеров описываются эти программы зависит от движка, который вы используете, так как они могут пытаться скрыть от вас детали того, под какую именно видеокарту шейдер вы пишите (но об этом позже). Простейшим шейдером может быть программа, которая умножает alpha-канал каждого пикселя на 0.5, делая изображение полупрозрачным. Шейдеры могут быть очень комплексными и создавать эффекты, похожие на анимацию: искажения, волны, динамическое изменение цвета. А зачем нам вообще нужны шейдеры? Для изменения alpha-канала чаще всего есть способы, не требующие шейдеров. Создать анимацию волны можно и через несколько кадров. И отчасти это даже правда: задачу, которую можно решить шейдерами, можно решить и без них. Однако, у шейдеров есть неоспоримые преимущества: Статья будет в формате задач и их решения. Мы ставить перед собой цель реализовать некоторый эффект, а затем будем воплощать это в жизнь через шейдеры. Предположим, что у нас в игре есть здания. Вы можете захотеть графически отображать степень повреждённости здания. Как мы будем это делать? Спрайты зданий, вид сверху, четыре разновидности: 
 Как насчёт маски повреждений, которую мы будем накладывать поверх здания? Когда повреждений нет, у этой маски будет нулевая непрозрачность. По мере получения повреждений, альфа-канал увеличивается в своём значении и маска становится более заметной. Теперь попробуем наложить их. Пусть количество урона равно ~100% и видимость маски близка к абсолютной. 
 Выглядит не очень аккуратно: такая маска подходит только для квадратных спрайтов. Что только люди не придумают, чтобы не прибегать к шейдерам: А давайте попробуем решить задачу через шейдер, не меняя картинку повреждений. Сначала я напишу шейдер, а затем уже покажу как его подключать к изображениям. Шейдеру требуются: 
 Src0 и Src1 должны быть идентичных размеров, поэтому каждому пикселю из Src0 есть какой-то соответствующий пиксель из Src1. Для каждого пересечения непрозрачных пикселей из Src0 и Src1 мы вычисляем новый цвет. Шейдерный результат выглядит так: 
 При желании, можно доработать шейдер так, чтобы текстура повреждений не накладывалась на контуры объекта. Проверяя не только на c.a, можно определить, нужно ли слияние текстур в этом пикселе или нет. Вы обратили внимание, что шейдер мы написали на Go-подобном языке? В Ebitengine для написания шейдеров используется Kage, собственная разработка движка. Kage транслятор парсит Go-код, а затем генерирует из него сниппет на нужном диалекте. Например, на моей машине шейдер из прошлого примера преобразуется в следующий код: Знакомые для Go концепции тоже неплохо переводятся: Моё мнение насчёт Kage неоднозначное. С одной стороны, я понимаю, почему добавили этот слой абстракции. С другой стороны, Kage затрудняет работу с шейдерами как новичкам, там и опытным создателям шейдеров. Первым сложнее изучать нечто с минимальным количеством документации, а вторым сложнее применить уже существующие знания. Плюсы Kage: Минусы Kage: Вот некоторые полезные сведения о Kage, которые нам вскоре пригодятся: Координаты описываются через vec2 (x, y), цвета через vec4 (r, g, b, a). Будем считать, что на игровой сцене у нас находятся объекты Sprite. Они содержат в себе *ebiten.Image и, опционально, скомпилированный шейдер. Отрисовка спрайтов без шейдеров может выглядеть так: Далее мы в своём корневом game.Draw вызываем Sprite.Draw() и получаем отрисовку всех спрайтов на экране. Теперь добавим рендеринг с шейдерами: Кода стало больше, но ничего принципиально сложного там нет. Нам нужно правильно заполнить DrawRectShaderOptions и вызвать DrawRectShader() вместо DrawImage(). Откуда берутся s.shaderParams и s.shaderTexture? Я предлагаю закреплять их за спрайтом единожды при установке шейдера: *ebiten.Shader можно переиспользовать для всех спрайтов, которым нужен эффект, реализуемый шейдером. Аналогично с *ebiten.Image, который будет использоваться как Src1. А вот "данные" (uniforms) для каждого спрайта будут свои. Так как map — это обёртка над указателем, изменения снаружи будут видны внутри Sprite. Этим мы будем пользоваться для изменения параметров шейдера. Код объекта, который использует спрайт с шейдером, будет похож на такой: Наш скрипт шейдера — это обычный файл, данные. Хранить его можно или рядом с приложением, либо встраивать прямо в бинарник через go:embed. Чтобы скомпилировать шейдер, нам нужно байтики исходного кода шейдера передать функции ebiten.NewShader(). В интернете можно найти шрифты, которые выглядят как что-то рукописное. Однако каждая буква будет выглядеть идентично, что нереалистично. Нужна какая-то энтропия. Достичь этой энтропии можно по-разному, но я в игре Decipherism просто рандомно перемешивал некоторые соседние пиксели при отрисовке текста: 
 Давайте вспомним сигнатуру фрагментного шейдера (игнорируя неинтересные параметры): texCoord — это тексельная координата на текстуре, из которой мы читаем пиксели (source) для наложения на целевое изображение (destination). О текселях нам достаточно знать то, что они имеют значение в диапазоне от 0 до 1. Условно, если изображение имеет размер 500 пикселей, то 0.5 текселей будут описывать размер в 250 пикселей в контексте этого изображения. Функция imageSrc0At() принимает тексельные координаты. Но что, если мы хотим оперировать на уровне пикселей? Преобразования между текселями в пиксели и обратно возможны. Ebitengine позволяет определять функции для шейдеров, чем мы и воспользуемся: Ebitengine объединяет несколько изображений в атласы, поэтому чаще всего наш source image находится на каком-то смещении от настоящей нулевой координаты. Из-за этого нам нужно вычитать origin для транслирования тексельной координаты в такую, которую мы затем можем интерпретировать как обычную пиксельную координату на изображении. Алгоритм у нас будет такой: Для последнего шага нужна будет обратная tex2pixCoord() операция: Далее нам нужно применить что-то вроде фильтра pick. Я могу предложить такую реализацию: Остаётся лишь один вопрос — а откуда взять hash? По идее, это некоторое псевдорандомное значение, которое определяет что делать с конкретным пикселем. Никакого rand() внутри шейдеров, конечно же, нет. Напишем функцию генерации псевдослучайных чисел: С помощью всех созданных выше функций выразим фрагментный процессор: Этот шейдер будет производить желаемый нами pick-эффект. В Decipherism мне нужно было реализовать терминальный экран, который выглядел бы в стиле ретро. На экране терминала выводились элементы схемы, реализующие некий кодирующий алгоритм. Вот что из этого получилось: 
 Если выключить шейдер: 
 Здесь нам потребуется более качественная генерация псевдорандомных чисел. Для этого мы введём два внешних параметра: shaderRand() станет выглядеть следующим образом: Кроме этого, мы хотим создавать некие анимированные помехи. Я бы сказал, что это похоже на эффект video degradation, но менее сильно выраженный. Финальный код фрагментного шейдера: Здесь я впервые использую параметр pos. Это позиция в целевом (destination) изображении в пикселях. Используя это значение я избегаю проблем при вращении source текстур. Таким образом, волны помех всегда идут сверху вниз, а не справа-налево, как в случае поворота на 90 градусов. Возьмём текстуру энергетического луча: 
 … и начнём циклично перемещать её по оси X: 
 Вот ещё пример: 
 Первая попытка решения: Результат применения: Это почти то, что нам нужно, но цикл получается резким из-за грубого перехода на обоих концах отрезка. Чтобы получить результат, как в примерах выше, нужно добавить немного кода в этот шейдер: Мы добавили градиент, уменьшающий непрозрачность изображения. Чем ближе к концам отрезка, тем выше прозрачность. А знаете, что ещё можно реализовать через похожий шейдер? Планеты. Нам потребуется прямоугольная текстура. 
 Шейдер будет похож на предыдущие, но с добавлением тени и радиуса отрисовки: Результат применения шейдера: В Roboden можно строить базы и турели. Анимация конструирования нового здания сделана через шейдеры. В игре это выгдядит следующим образом: 
 Для удобства, вот фреймы из анимации выше, в изоляции: 
 Параметр t (в шейдере назван Time) управляется логикой игры. Когда рабочие строят здание, t увеличивается. t — это нормализованное значение прогресса строительства (от 0 до 1). Шейдер будет представлять из себя смесь того, что мы сегодня уже использовали: Начнём с введения хелпер-функций: Сам шейдер имеет много параметров, которые я вручную подбирал для желаемого результата. Специально для статьи я немного изменил его, чтобы он стал более универсальным. С увеличением Time мы смещаем абстрактную окружность вверх, что меняет распределение отображаемых пикселей из-за обновлённой дистанции от центра окружности. Это был последний из шейдеров, который я хотел вам показать в рамках этой статьи. Хочется ещё шейдеров? Откройте examples/shader из репозитория Ebitengine, там можно найти: Напоследок поделюсь с вами несколькими рекомендациями по работе с шейдерами в Ebitengine: Хотите попробовать писать игрушки на Go, но не знаете, с чего начать? Параллельно с этим: Понравилась эта статья и вы хотите сказать автору спасибо? Ставьте плюсик. Мне ещё есть, о чём рассказать, а ваша поддержка может увеличить шанс появления следующих текстов из серии. Если хочется порадовать меня ещё сильнее, то можете посмотреть на мои игры. Они все с открытыми исходными кодами, но только две из них имеют отдельный репозиторий. Ссылочки в конце статьи. Making developer tools & video games