Сервис развивается: тестируем формат, собираем идеи, улучшаем сервис. Есть идеи? Написать
Дайджесты новостей
Пиксельные шейдеры в Ebitengine: трансформация текстур и эффекты в языке Kage

Пиксельные шейдеры в Go: архитектура транслятора Kage и процедурные визуальные эффекты в Ebitengine

Разработка 2D-игр на Go долгое время сталкивалась с компромиссом при реализации визуальных эффектов. Создание взрывов, повреждений техники, водной глади или интерфейсных подсветок традиционно требует сотен покадровых текстур (sprite sheets), что раздувает размер игры и перегружает видеопамять. Альтернатива — попиксельная обработка кадров силами процессора — быстро снижает FPS.

Популярный открытый 2D-движок Ebitengine предлагает другое решение: встроенный язык Kage. Он позволяет писать пиксельные (фрагментные) шейдеры на строгом подмножестве Go, которые движок на лету компилирует в нативные инструкции GPU для OpenGL, Metal и DirectX.

Архитектура транслятора Kage: подмножество Go на GPU

Kage — это предметно-ориентированный язык (DSL), созданный для того, чтобы Go-разработчикам не приходилось изучать диалекты GLSL, HLSL или MSL. Исходный код форматируется утилитой gofmt, проверяется линтерами и выглядит как обычный Go-файл.

Однако код Kage исполняется не в рантайме Go, а параллельно на тысячах вычислительных ядер GPU для каждого пикселя. Из-за этого язык накладывает строгие ограничения:

  • Поддерживаемые типы: скаляры float и int, векторы (vec2, vec3, vec4, ivec*), матрицы (mat2, mat3, mat4), фиксированные массивы, векторная арифметика и swizzling (color.rgba, pos.xy).
  • Исключенные абстракции: в Kage нет строк, срезов (slices), указателей, структур, интерфейсов, импортов, горутин и методов.
  • Единицы координат: документация рекомендует начинать файл директивой //kage:unit pixels, переводящей вычисления в абсолютные экранные пиксели вместо устаревших нормализованных texels.

Важная деталь — точность чисел с плавающей точкой: мобильные GPU могут снижать точность float, поэтому сравнения на строгое равенство в шейдерах исключаются.

Анатомия функции Fragment и передача параметров

Точкой входа любого шейдера служит функция Fragment. В движке нет вертексной стадии: Kage отвечает только за цвет фрагментов.

Сигнатура функции принимает до четырех аргументов:

func Fragment(dstPos vec4, src0Pos vec2, color vec4, custom vec4) vec4
  • dstPos: экранные координаты целевого пикселя (vec4(x, y, 0, 1)).
  • src0Pos: координаты точки в исходной текстуре в пикселях.
  • color: цвет вершины для модуляции оттенка.
  • custom: пользовательские параметры для отрисовки треугольников.
  • Результат: вектор цвета vec4(r, g, b, a) с каналами от 0.0 до 1.0.

Динамические параметры из игрового цикла передаются через глобальные экспортируемые переменные с заглавной буквы (uniforms). Внутри шейдера перезаписывать их нельзя: они неизменны для всего кадра отрисовки.

Каталог процедурных визуальных эффектов

Практическая сила Kage раскрывается в пяти базовых алгоритмах, проверенных в открытых коммерческих играх Roboden и Decipherism:

  1. Маска повреждений (Damage Mask) В шейдер передаются две текстуры: спрайт объекта и маска сколов. Функция считывает пиксель через imageSrc0At(src0Pos) и проверяет альфа-канал: только если c.a != 0.0 && mask.a != 0.0, накладывается потемнение в зависимости от уровня здоровья HP, нормализованного через clamp(HP + (1.0 - mask.a), 0.0, 1.0). Прозрачный фон остается нетронутым.
  2. Контур выбора объекта (Pick Outline) Для подсветки выбранного юнита шейдер проверяет прозрачность соседних точек со смещением. Обнаружив границу спрайта, Kage плавно подмешивает пульсирующий цвет через синус от uniform-переменной времени Time, избавляя от рисования рамок вручную.
  3. Имитация ЭЛТ-монитора и глитчи (CRT Display) Для стилизации под ретро-экран координаты src0Pos искажаются выпуклой линзой к центру. Поверх накладывается синусоидальная модуляция по оси Y, создающая полосы развертки (scanlines), а сдвиг координат каналов формирует цветовую аберрацию.
  4. Зацикленная процедурная анимация (UV Shift) Вода или конвейерные ленты анимируются смещением текстурных координат: координата src0Pos.x складывается с Time * Speed, после чего берется остаток от деления на ширину текстуры. Эффект работает без тяжелых спрайт-листов.
  5. Эффект растворения и постройки (Dissolve) Вертикальная координата фрагмента сравнивается с переменной прогресса Progress. Пиксели выше порога отсекаются вызовом discard(), а на линии среза рисуется яркая полоса свечения.

Сравнение графических подходов в 2D-играх

Метод реализацииПамять под ассетыНагрузка на CPUСложность кодаПортируемость
Шейдеры KageМинимальная (1 спрайт)Нулевая (GPU)Низкая (Go-синтаксис)Полная (OpenGL, Metal, DX)
Растровые спрайт-листыВысокая (сотни кадров)НизкаяВысокая (ручной арт)Полная
Попиксельный рендер CPUСредняяЭкстремальнаяСредняяНизкая масштабируемость
Нативный GLSL / MetalМинимальнаяНулеваяВысокая (разные языки)Требует отдельных файлов

Пошаговое руководство по внедрению шейдера

Подключение шейдера в Ebitengine состоит из четырех шагов:

1. Написание исходного кода шейдера:

//kage:unit pixels
package main

var Time float

func Fragment(dstPos vec4, src0Pos vec2, color vec4) vec4 {
    pos := src0Pos
    pos.x += sin(src0Pos.y/10.0 + Time*2.0) * 3.0
    return imageSrc0At(pos) * color
}

2. Однократная компиляция при загрузке ассетов:

shader, err := ebiten.NewShader(shaderSourceBytes)
if err != nil {
    log.Fatalf("Ошибка компиляции Kage: %v", err)
}

3. Передача параметров и отрисовка в кадре:

opts := &ebiten.DrawRectShaderOptions{}
opts.Images[0] = spriteImage
opts.Uniforms = map[string]any{"Time": float32(gameTime)}
dst.DrawRectShader(spriteWidth, spriteHeight, shader, opts)

4. Проверка и диагностика: Официальный демонстрационный пример запускается командой:

go run github.com/hajimehoshi/ebiten/v2/examples/shader@latest

При сбоях в рендере:

  • Проверьте ошибку от ebiten.NewShader.
  • Замените тело функции на тестовый цвет return vec4(1.0, 0.0, 1.0, 1.0).
  • Убедитесь, что размеры исходных изображений в opts.Images строго совпадают с шириной и высотой прямоугольника отрисовки DrawRectShader.

Ограничения и инженерные выводы

Главное архитектурное правило Ebitengine: все входные текстуры в массиве Images обязаны совпадать по размеру с областью отрисовки, иначе чтение соседних слоев приведет к искажению координат.

Кроме того, в релизе Ebitengine 2.10 появилось экспериментальное API предкомпиляции шейдеров. Следует помнить, что внутренний идентификатор SourceID может меняться между версиями движка, требуя инвалидации кэша при обновлении go.mod.

Использование Kage позволяет создавать выразительные визуальные эффекты без раздувания ассетов, сохраняя кодовую базу в рамках единой экосистемы Go.