Разработка игр на Go: шейдеры
- среда, 17 мая 2023 г. в 00:04:49
Давненько я не писал никаких статей на Хабре.
Я планировал вести серию заметок о разработке игр на Go и начал я с рендеринга текста, но меня не хватило даже на второй текст. Что же, настало время возвращаться, ведь с того момента я успел создать ещё несколько игрушек.
Сегодня я расскажу вам о шейдерах в Ebitengine. Большая часть примеров будет взята из Roboden и Decipherism (обе игры имеют открытые исходные коды и вы можете найти их на гитхабе).
Я буду говорить только о фрагментных шейдерах (они же пиксельные), так как только такие поддерживаются в Ebitengine.
Фрагментный шейдер — это такой алгоритм, который описывает как преобразить пиксели изображения перед его отображением. Чаще всего этот алгоритм описан в виде кода, но существуют визуальные способы создавать шейдеры. На каком именно диалекте языка шейдеров описываются эти программы зависит от движка, который вы используете, так как они могут пытаться скрыть от вас детали того, под какую именно видеокарту шейдер вы пишите (но об этом позже).
Простейшим шейдером может быть программа, которая умножает alpha-канал каждого пикселя на 0.5, делая изображение полупрозрачным. Шейдеры могут быть очень комплексными и создавать эффекты, похожие на анимацию: искажения, волны, динамическое изменение цвета.
А зачем нам вообще нужны шейдеры? Для изменения alpha-канала чаще всего есть способы, не требующие шейдеров. Создать анимацию волны можно и через несколько кадров.
И отчасти это даже правда: задачу, которую можно решить шейдерами, можно решить и без них. Однако, у шейдеров есть неоспоримые преимущества:
Статья будет в формате задач и их решения. Мы ставить перед собой цель реализовать некоторый эффект, а затем будем воплощать это в жизнь через шейдеры.
Предположим, что у нас в игре есть здания. Вы можете захотеть графически отображать степень повреждённости здания. Как мы будем это делать?
Спрайты зданий, вид сверху, четыре разновидности:
Как насчёт маски повреждений, которую мы будем накладывать поверх здания? Когда повреждений нет, у этой маски будет нулевая непрозрачность. По мере получения повреждений, альфа-канал увеличивается в своём значении и маска становится более заметной.
Я нарисовал только одну маску и покрутил её шагом в 90 градусов, чтобы получить 4 спрайта.
Теперь попробуем наложить их. Пусть количество урона равно ~100% и видимость маски близка к абсолютной.
Выглядит не очень аккуратно: такая маска подходит только для квадратных спрайтов.
Что только люди не придумают, чтобы не прибегать к шейдерам:
А давайте попробуем решить задачу через шейдер, не меняя картинку повреждений.
Сначала я напишу шейдер, а затем уже покажу как его подключать к изображениям.
package main
var HP float // Значение уровня здоровья, от 0 до 1
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
c := imageSrc0At(texCoord) // Пиксель из спрайта здания
mask := imageSrc1At(texCoord) // Пиксель из маски
if c.a != 0.0 && mask.a != 0.0 {
a := clamp(HP+(1.0-mask.a), 0.0, 1.0)
// Создаём более тёмный пиксель при повреждениях.
return vec4(c.r*a, c.g*a, c.b*a, c.a)
}
return c // Используем пиксель как есть
}
Шейдеру требуются:
Src0 и Src1 должны быть идентичных размеров, поэтому каждому пикселю из Src0 есть какой-то соответствующий пиксель из Src1. Для каждого пересечения непрозрачных пикселей из Src0 и Src1 мы вычисляем новый цвет.
Шейдерный результат выглядит так:
При желании, можно доработать шейдер так, чтобы текстура повреждений не накладывалась на контуры объекта. Проверяя не только на c.a
, можно определить, нужно ли слияние текстур в этом пикселе или нет.
Вы обратили внимание, что шейдер мы написали на Go-подобном языке?
В Ebitengine для написания шейдеров используется Kage, собственная разработка движка. Kage транслятор парсит Go-код, а затем генерирует из него сниппет на нужном диалекте. Например, на моей машине шейдер из прошлого примера преобразуется в следующий код:
#if defined(GL_ES)
precision highp float;
#else
#define lowp
#define mediump
#define highp
#endif
int modInt(int x, int y) {
return x - y*(x/y);
}
uniform vec2 U0;
uniform vec2 U1[4];
// ... много других uniform-деклараций.
varying vec2 V0;
varying vec4 V1;
vec4 F5(in vec2 l0);
vec4 F7(in vec2 l0);
vec4 F12(in vec4 l0, in vec2 l1, in vec4 l2);
vec4 F5(in vec2 l0) { /* ... */ }
vec4 F7(in vec2 l0) { /* ... */ }
vec4 F12(in vec4 l0, in vec2 l1, in vec4 l2) {
vec4 l3 = vec4(0);
vec4 l4 = vec4(0);
l3 = F5(l1);
l4 = F7(l1);
if ((((l3).a) != (0.0)) && (((l4).a) != (0.0))) {
float l5 = float(0);
l5 = clamp((U8) + ((1.0) - ((l4).a)), 0.0, 1.0);
return vec4(((l3).r)*(l5), ((l3).g)*(l5), ((l3).b)*(l5), (l3).a);
}
return l3;
}
void main(void) {
gl_FragColor = F12(gl_FragCoord, V0, V1);
}
Знакомые для Go концепции тоже неплохо переводятся:
// func F0() (int, int) { return 1, 2 }
void F0(out int l0, out int l1) {
l0 = 1;
l1 = 2;
return;
}
Моё мнение насчёт Kage неоднозначное. С одной стороны, я понимаю, почему добавили этот слой абстракции. С другой стороны, Kage затрудняет работу с шейдерами как новичкам, там и опытным создателям шейдеров. Первым сложнее изучать нечто с минимальным количеством документации, а вторым сложнее применить уже существующие знания.
Плюсы Kage:
Минусы Kage:
Вот некоторые полезные сведения о Kage, которые нам вскоре пригодятся:
Fragment()
, её параметры мы будем разбирать отдельноint
, float
, vec2
, vec4
distance()
, clamp()
и imageSrc0At()
*
так же работают для векторов (vec2
, vec4
)Координаты описываются через vec2
(x, y), цвета через vec4
(r, g, b, a).
Будем считать, что на игровой сцене у нас находятся объекты Sprite
. Они содержат в себе *ebiten.Image
и, опционально, скомпилированный шейдер.
type Sprite struct {
x, y float64
img *ebiten.Image
shader *ebiten.Shader
shaderTexture *ebiten.Image
shaderParams map[string]any
}
Отрисовка спрайтов без шейдеров может выглядеть так:
func (s *Sprite) Draw(dst *ebiten.Image) {
var options ebiten.DrawImageOptions
options.GeoM.Translate(s.x, s.y)
dst.DrawImage(s.img, &options)
}
Далее мы в своём корневом game.Draw
вызываем Sprite.Draw()
и получаем отрисовку всех спрайтов на экране.
Теперь добавим рендеринг с шейдерами:
func (s *Sprite) Draw(dst *ebiten.Image) {
// Если шейдера нет, то делаем всё как раньше.
if s.shader == nil {
var options ebiten.DrawImageOptions
options.GeoM.Translate(s.x, s.y)
dst.DrawImage(s.img, &options)
return
}
// Здесь нам нужен другой options-тип.
var options ebiten.DrawRectShaderOptions
options.GeoM.Translate(s.x, s.y)
options.Images[0] = s.img // Src0
options.Images[1] = s.shaderTexture // Src1
options.Uniforms = s.shaderParams
b := s.img.Bounds()
drawDest.DrawRectShader(b.Dx(), b.Dy(), s.shader, &options)
}
Кода стало больше, но ничего принципиально сложного там нет. Нам нужно правильно заполнить DrawRectShaderOptions
и вызвать DrawRectShader()
вместо DrawImage()
.
Откуда берутся s.shaderParams
и s.shaderTexture
? Я предлагаю закреплять их за спрайтом единожды при установке шейдера:
type ShaderParams struct {
Compiled *ebiten.Shader
Uniforms map[string]any
Src1 *ebiten.Image
// ... при желании можно добавить поля Src2, Src3
}
func (s *Sprite) SetShader(params ShaderParams) {
s.shader = params.Compiled
s.shaderParams = params.Uniforms
s.shaderTexture = params.Src1
}
*ebiten.Shader
можно переиспользовать для всех спрайтов, которым нужен эффект, реализуемый шейдером. Аналогично с *ebiten.Image
, который будет использоваться как Src1
. А вот "данные" (uniforms) для каждого спрайта будут свои.
Так как map
— это обёртка над указателем, изменения снаружи будут видны внутри Sprite
. Этим мы будем пользоваться для изменения параметров шейдера.
Код объекта, который использует спрайт с шейдером, будет похож на такой:
func (b *Building) Init() {
b.shaderData = map[string]any{"HP": 1.0}
b.sprite = NewSprite()
b.sprite.SetShader(damageShader, damageMask, b.shaderData)
}
func (b *Building) OnDamage(damage float64) {
b.hp -= damage
if b.hp <= 0 {
b.destroy()
return
}
// Обновляем параметр шейдера.
// Обратите внимание: использовать нужно float32.
// Поддерживаются типа int, float32 и []float32, но не float64.
b.shaderData["HP"] = float32(b.hp / b.maxHP)
}
damageShader
— это *ebiten.Shader
, созданный из нашего шейдер-сниппетаdamageMask
— это *ebiten.Image
, который содержит маску поврежденийb.shaderData
принадлежит объекту Building
, а шейдер эти данные лишь читаетНаш скрипт шейдера — это обычный файл, данные. Хранить его можно или рядом с приложением, либо встраивать прямо в бинарник через go:embed
. Чтобы скомпилировать шейдер, нам нужно байтики исходного кода шейдера передать функции ebiten.NewShader()
.
В интернете можно найти шрифты, которые выглядят как что-то рукописное. Однако каждая буква будет выглядеть идентично, что нереалистично. Нужна какая-то энтропия.
Достичь этой энтропии можно по-разному, но я в игре Decipherism просто рандомно перемешивал некоторые соседние пиксели при отрисовке текста:
Давайте вспомним сигнатуру фрагментного шейдера (игнорируя неинтересные параметры):
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4
texCoord
— это тексельная координата на текстуре, из которой мы читаем пиксели (source) для наложения на целевое изображение (destination).
О текселях нам достаточно знать то, что они имеют значение в диапазоне от 0 до 1. Условно, если изображение имеет размер 500 пикселей, то 0.5 текселей будут описывать размер в 250 пикселей в контексте этого изображения.
Функция imageSrc0At()
принимает тексельные координаты. Но что, если мы хотим оперировать на уровне пикселей? Преобразования между текселями в пиксели и обратно возможны.
Ebitengine позволяет определять функции для шейдеров, чем мы и воспользуемся:
// tex2pixCoord преобразует тексельную координату texCoord
// в пиксельную координату, учитывая смещение на атласе.
func tex2pixCoord(texCoord vec2) vec2 {
pixSize := imageSrcTextureSize()
originTexCoord, _ := imageSrcRegionOnTexture()
actualTexCoord := texCoord - originTexCoord
actualPixCoord := actualTexCoord * pixSize
return actualPixCoord
}
Ebitengine объединяет несколько изображений в атласы, поэтому чаще всего наш source image находится на каком-то смещении от настоящей нулевой координаты. Из-за этого нам нужно вычитать origin для транслирования тексельной координаты в такую, которую мы затем можем интерпретировать как обычную пиксельную координату на изображении.
Алгоритм у нас будет такой:
Для последнего шага нужна будет обратная tex2pixCoord()
операция:
func pix2texCoord(actualPixCoord vec2) vec2 {
pixSize := imageSrcTextureSize()
actualTexCoord := actualPixCoord / pixSize
originTexCoord, _ := imageSrcRegionOnTexture()
texCoord := actualTexCoord + originTexCoord
return texCoord
}
Далее нам нужно применить что-то вроде фильтра pick. Я могу предложить такую реализацию:
func applyPixPick(pixCoord vec2, dist float, m, hash int) vec2 {
// dist - на сколько пикселей сдвигаем;
// dir - куда именно сдвигаем.
// В Kage (язык шейдеров) пока нет switch,
// поэтому используем if/else.
dir := hash % m
// Если явно не приводить литерал к int, то возникнет ошибка
// "operands of `==' must have the same type",
// потому что Ebitengine конвертирует литерал 0 в 0.0
// и драйвер будет считать это типом float.
if dir == int(0) {
pixCoord.x += dist
} else if dir == int(1) {
pixCoord.x -= dist
} else if dir == int(2) {
pixCoord.y += dist
} else if dir == int(3) {
pixCoord.y -= dist
}
// А иначе никуда не сдвигаем.
return pixCoord
}
Чем выше параметр m
, тем чаще пиксель не будет сдвигаться ни в одну из сторон.
Остаётся лишь один вопрос — а откуда взять hash
? По идее, это некоторое псевдорандомное значение, которое определяет что делать с конкретным пикселем. Никакого rand()
внутри шейдеров, конечно же, нет.
Напишем функцию генерации псевдослучайных чисел:
func shaderRand(pixCoord vec2) int {
return int(pixCoord.x+pixCoord.y) * int(pixCoord.y*5)
}
С помощью всех созданных выше функций выразим фрагментный процессор:
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
c := imageSrc0At(texCoord)
actualPixCoord := tex2pixCoord(texCoord)
if c.a != 0.0 {
h := shaderRand(actualPixCoord)
p := applyPixPick(actualPixCoord, 1.0, 15, h)
return imageSrc0At(pix2texCoord(p))
}
return c
}
Этот шейдер будет производить желаемый нами pick-эффект.
В Decipherism мне нужно было реализовать терминальный экран, который выглядел бы в стиле ретро. На экране терминала выводились элементы схемы, реализующие некий кодирующий алгоритм.
Вот что из этого получилось:
Если выключить шейдер:
Здесь нам потребуется более качественная генерация псевдорандомных чисел. Для этого мы введём два внешних параметра:
Tick
— некоторое скользящее со временем значениеSeed
— для каждого элемента будет создан свой сид для рандомаshaderRand()
станет выглядеть следующим образом:
func shaderRand(pixCoord vec2) (seedMod, randValue int) {
pixSize := imageSrcTextureSize()
pixelOffset := int(pixCoord.x) + int(pixCoord.y*pixSize.x)
seedMod = pixelOffset % int(Seed)
pixelOffset += seedMod
return seedMod, pixelOffset + int(Seed)
}
seedMod
нам понадобится как дополнительный источник энтропии.
Кроме этого, мы хотим создавать некие анимированные помехи. Я бы сказал, что это похоже на эффект video degradation, но менее сильно выраженный.
func applyVideoDegradation(y float, c vec4) vec4 {
if c.a != 0.0 {
// Каждый 4-ый пиксель по оси Y будет затенён.
if int(y+Tick)%4 != int(0) {
return c * 0.6
}
}
return c
}
Финальный код фрагментного шейдера:
func Fragment(pos vec4, texCoord vec2, _ vec4) vec4 {
c := imageSrc0At(texCoord)
actualPixCoord := tex2pixCoord(texCoord)
if c.a != 0.0 {
seedMod, h := shaderRand(actualPixCoord)
dist := 1.0
if seedMod == int(0) {
dist = 2.0
}
p := applyPixPick(actualPixCoord, dist, 5, h)
return applyVideoDegradation(pos.y, imageSrc0At(pix2texCoord(p)))
}
return c
}
Здесь я впервые использую параметр pos
. Это позиция в целевом (destination) изображении в пикселях. Используя это значение я избегаю проблем при вращении source текстур. Таким образом, волны помех всегда идут сверху вниз, а не справа-налево, как в случае поворота на 90 градусов.
Возьмём текстуру энергетического луча:
… и начнём циклично перемещать её по оси X:
Вот ещё пример:
Первая попытка решения:
var Time float
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
pixSize := imageSrcTextureSize()
_, srcRegion := imageSrcRegionOnTexture()
width := pixSize.x * srcRegion.x
actualPixCoord := tex2pixCoord(texCoord)
p := vec2(slide(actualPixCoord.x, width), actualPixCoord.y)
return imageSrc0At(pix2texCoord(p))
}
func slide(v, size float) float {
return mod(v-(100*Time), size)
}
Результат применения:
Направление движения анимации зависит от того, уменьшается или увеличивается Time
.
Это почти то, что нам нужно, но цикл получается резким из-за грубого перехода на обоих концах отрезка. Чтобы получить результат, как в примерах выше, нужно добавить немного кода в этот шейдер:
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
pixSize := imageSrcTextureSize()
_, srcRegion := imageSrcRegionOnTexture()
width := pixSize.x * srcRegion.x
actualPixCoord := tex2pixCoord(texCoord)
p := vec2(slide(actualPixCoord.x, width), actualPixCoord.y)
c := imageSrc0At(pix2texCoord(p))
const cutoffThreshold = 10.0
if actualPixCoord.x <= cutoffThreshold {
c *= actualPixCoord.x * 0.1
} else if actualPixCoord.x >= (width - cutoffThreshold) {
c *= (width - actualPixCoord.x) * 0.1
}
return c
}
Мы добавили градиент, уменьшающий непрозрачность изображения. Чем ближе к концам отрезка, тем выше прозрачность.
А знаете, что ещё можно реализовать через похожий шейдер? Планеты. Нам потребуется прямоугольная текстура.
Шейдер будет похож на предыдущие, но с добавлением тени и радиуса отрисовки:
var Time float
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
_, srcRegion := imageSrcRegionOnTexture()
pixSize := imageSrcTextureSize()
sizes := pixSize * srcRegion
width := sizes.x
height := sizes.y
actualPixCoord := tex2pixCoord(texCoord)
// То, что дальше радиуса окружности (32) мы рендерить не будем.
// Так мы оставляем из всей текстуры только центральную часть.
const planetSize = 64.0
center := vec2(width, height) * 0.5
if distance(center, actualPixCoord) > planetSize {
return vec4(0)
}
// Свет будет падать чуть левее и выше от центра.
lightPos := vec2(center.x*0.85, center.y*0.9)
lightDist := distance(lightPos, actualPixCoord) / planetSize
colorMultiplier := vec4(1, 1, 1, 1)
// Чем больше дистанция от освещённой точки, тем темнее будет цвет.
colorMultiplier.xyz *= clamp(1.8-lightDist*1.6, 0.0, 1.0)
// А дальше применяем уже известную нам анимацию.
p := vec2(slide(actualPixCoord.x, width), actualPixCoord.y)
return imageSrc0At(pix2texCoord(p)) * colorMultiplier
}
Результат применения шейдера:
В Roboden можно строить базы и турели. Анимация конструирования нового здания сделана через шейдеры.
В игре это выгдядит следующим образом:
Для удобства, вот фреймы из анимации выше, в изоляции:
Параметр t
(в шейдере назван Time
) управляется логикой игры. Когда рабочие строят здание, t
увеличивается. t
— это нормализованное значение прогресса строительства (от 0 до 1).
Шейдер будет представлять из себя смесь того, что мы сегодня уже использовали:
Начнём с введения хелпер-функций:
func shaderRand(p vec2) int {
return int(p.x+p.y) * int(p.y*5)
}
func sourceSize() vec2 {
pixSize := imageSrcTextureSize()
_, srcRegion := imageSrcRegionOnTexture()
return pixSize.x * srcRegion
}
Сам шейдер имеет много параметров, которые я вручную подбирал для желаемого результата. Специально для статьи я немного изменил его, чтобы он стал более универсальным.
func Fragment(_ vec4, texCoord vec2, _ vec4) vec4 {
// texCoord гарантированно в пределах Src0, поэтому можно
// использовать unsafe версию, которая работает немного быстрее,
// но out-of-bounds доступ будет вести к неопределённому поведению.
c := imageSrc0UnsafeAt(texCoord)
if c.a == 0 {
return c
}
actualPixPos := tex2pixCoord(texCoord)
// Вычисления будем завязывать на вычисляемый размер текстуры.
// Это позволит использовать шейдер для изображений разного размера.
sizes := sourceSize()
width := sizes.x // Изображение квадратное, поэтому достаточно width
// Задаём окружность прорисовки и её перемещение по dt.
initialY := -2.0
offsetY := width * 0.15 * Time
circleCenter := vec2(width*0.5, initialY-offsetY)
dist := distance(actualPixPos, circleCenter)
progress := 1.4 - Time
if dist > ((width * 0.95) * progress) {
// То, что уже далеко от окружности, рисуем без искажений.
return c
}
spread := 0
colorMultiplier := vec4(0)
// Определим несколько колец по диапазону дистанций.
// Свича нет, поэтому идём через if/else.
if dist > ((width * 0.85) * progress) {
spread = 15
colorMultiplier = vec4(1, 1.1, 1.3, 1.0)
} else if dist > ((width * 0.75) * progress) {
spread = 11
colorMultiplier = vec4(0.9, 1.2, 1.6, 1.0)
} else if dist > ((width * 0.65) * progress) {
spread = 7
colorMultiplier = vec4(0.8, 1.4, 2.0, 1.0)
} else if dist > ((width * 0.62) * progress) {
spread = 6
colorMultiplier = vec4(0.25, 0.25, 0.25, 1.0)
} else {
// Слишком близко к окружности, эту область пропускаем.
return vec4(0)
}
h := shaderRand(actualPixPos)
p := applyPixPick(actualPixPos, 1, spread, h)
if p == actualPixPos {
// Если пиксель не переместился, рисуем его без изменения цвета.
return c
}
return imageSrc0At(pix2texCoord(p)) * colorMultiplier
}
С увеличением Time
мы смещаем абстрактную окружность вверх, что меняет распределение отображаемых пикселей из-за обновлённой дистанции от центра окружности.
Это был последний из шейдеров, который я хотел вам показать в рамках этой статьи.
Хочется ещё шейдеров? Откройте examples/shader из репозитория Ebitengine, там можно найти:
Напоследок поделюсь с вами несколькими рекомендациями по работе с шейдерами в Ebitengine:
go:embed
.*ebiten.Shader
.DrawImage
, а не DrawRectShader
.(*) Пример: маска повреждения приHP=1.0
не будет менять отображение, поэтому можно рисовать спрайт черезDrawImage()
, а неDrawRectShader()
.
Хотите попробовать писать игрушки на Go, но не знаете, с чего начать?
Параллельно с этим:
Понравилась эта статья и вы хотите сказать автору спасибо? Ставьте плюсик. Мне ещё есть, о чём рассказать, а ваша поддержка может увеличить шанс появления следующих текстов из серии.
Если хочется порадовать меня ещё сильнее, то можете посмотреть на мои игры. Они все с открытыми исходными кодами, но только две из них имеют отдельный репозиторий. Ссылочки в конце статьи.