Go gamedev: низкоуровневое API рисования в Ebitengine
- воскресенье, 30 июня 2024 г. в 00:00:07
Я уже несколько лет использую игровой движок Ebitengine, но ранее у меня получалось обходиться лишь высокоуровневым способом отрисовки объектов — DrawImage.
Функция DrawTriangles казалась не очень понятной человеку, который привык к концепции "есть спрайт — можно рисовать".
Сейчас у меня стали появляться задачи, под которые отлично подходит DrawTriangles. Сегодня я расскажу, когда и как стоит использовать эту функцию.
Давайте разбираться, что это за треугольники и с чем их едят.
В моей новой игре понадобилась система частиц (particles) для реализации некоторых визуальных эффектов.
Так как беглый поиск по awesome-ebitengine не дал результатов, я решил попробовать набросать свою. За референс я взял CPUParticles2D. С ним же сравниваю производительность.
Сама библиотека пока ещё не готова, но когда-нибудь я обязательно напишу и о ней. А пока узнаем, как DrawTriangles может помочь в её реализации.
Добавление игры в wishlist поможет мне продолжать работу над новыми библиотеками для разработки игр на Go, создавать новые обучающие материалы (в том числе статьи на хабре), и поддерживать сообщество.
Начнём с самого базового рисования, чтобы проще было понять, в чём плюсы и минусы разных вариантов.
Image — это один из самых фундаментальных и вездесущих типов в Ebitengine. Концептуально Image является прямоугольной текстурой (двумерный массив пикселей).
Image можно создать программно, а можно загрузить из какого-нибудь PNG. На уровне абстракции изображения у нас есть практически попиксельный доступ.
Ebitengine управляет автоматическими атласами за нас. Разные Image могут быть частью одного крупного атласа, который будет находиться на видеокарте.
Каждая игра на Ebitengint реализует интерфейс Game. В этом интерфейсе есть метод Draw:
type Game interface {
Draw(screen *Image)
// ...остальное
}
Игра должна во время каждого вызова Draw "рисовать" все свои графические объекты на передаваемый на вход Image (параметр screen).
Допустим, у нас есть []*ebiten.Image
, которые мы загрузили из PNG-файлов. Рендеринг этих объектов будет довольно простым:
// Вместо обычного *ebiten.Image, у нас будут обёртки
// с дополнительными полями, типа позиции на экране.
type object struct {
image *ebiten.Image
pos [2]float64 // {X, Y}
}
func (g *myGame) Draw(screen *ebiten.Image) {
for _, o := range g.objects {
var opts ebiten.DrawImageOptions
opts.GeoM.Translate(o.pos[0], o.pos[1])
screen.DrawImage(o.image, &opts)
}
}
Если добавить в myGame несколько объектов, то игра будет выводить их на экране, учитывая позиции:
В opts можно конфигурировать отрисовку: задать color scale, определить позицию на экране, менять размер (scaling) и так далее.
Есть более продвинутый вариант этой функции — DrawRectShader. Хоть в названии и нет Image, я считаю, что эта функция находится на одном уровне абстракции.
Прямоугольником для DrawRectShaderOptions чаще всего является Bounds изображения. Через опции можно передать несколько входных текстур для семплинга внутри шейдера.
В комбинации с шейдерами и возможностью создавать Image на лету, а так же богатыми опциями отрисовки, можно обходиться одним лишь высокоуровневым API для большинства инди-игр на Go.
DrawImage настолько оптимизирован, что в некоторых случаях он работает эффективнее, чем гораздо менее привычный DrawTriangles! Чтобы понять, зачем нам тогда нужен DrawTriangles, стоит найти его сильные стороны.
В репозитории github.com/quasilyte/ebitengine-draw-example можно найти весь проект целиком, со всеми примерами. Запускать их можно, через go run:
$ git clone https://github.com/quasilyte/ebitengine-draw-example.git
$ cd ebitengine-draw-example
$ go run ./cmd/drawimage
$ go run ./cmd/drawshader
# ...
Полный листинг кода: github.com/quasilyte/ebitengine-draw-example/cmd/drawimage
Сравните сигнатуры двух функций:
// Немного сократил имена, чтобы уместилось по ширине.
DrawImage (img *Image, options *DrawImageOpts)
DrawTriangles(v []Vertex, i []uint16, img *Image, o *DrawTrianglesOpts)
Для людей, которые привыкли к работе с видеокарточками через низкоуровневые драйвера, наверное всё понятно. Но лично я осваивал разработку игр не на отрисовке треугольников через OpenGL, а через создание игр на движках вроде Game Maker.
Попробуем нарисовать изображения из примера выше без DrawImage:
// Я поменял float64 на float32 чтобы уменьшить количество
// преобразований типов ниже. В реальной игре я бы так
// делать не рекомендовал.
type object struct {
image *ebiten.Image
pos [2]float32
}
func (g *myGame) Draw(screen *ebiten.Image) {
for _, o := range g.objects {
img := o.image
iw, ih := img.Size()
w := float32(iw)
h := float32(ih)
// Здесь было бы много преобразований float64->float32,
// но я отредактировал object так, чтобы уместить
// код по ширине для комфорта хабравчан.
vertices := []ebiten.Vertex{
{
DstX: o.pos[0], DstY: o.pos[1],
SrcX: 0, SrcY: 0,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0] + w, DstY: o.pos[1],
SrcX: float32(w), SrcY: 0,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0], DstY: o.pos[1] + h,
SrcX: 0, SrcY: h,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
{
DstX: o.pos[0] + w, DstY: o.pos[1] + h,
SrcX: w, SrcY: h,
ColorR: 1, ColorG: 1,
ColorB: 1, ColorA: 1,
},
}
// Эти индексы говорят, как построить треугольники из вершинок выше.
// Для прямоугольной формы достаточно двух треугольников.
// Каждый из этих треугольников описывается индексом
// нужной вершинки. Отсюда и название DrawTriangles.
indices := []uint16{
0, 1, 2, // Первый треугольник
1, 2, 3, // Второй треугольник
}
var opts ebiten.DrawTrianglesOptions
screen.DrawTriangles(vertices, indices, img, &opts)
}
}
Практического смысла в этом мало. Ускорения, скорее всего, мы не получим. Ещё и слайсы для вершинок и индексов теперь нужно менеджить самим (желательно их переиспользовать).
Однако такого опыта использования DrawTriangles уже достаточно, чтобы перейти к следующему шагу. Проблема не совсем в том, как мы используем DrawTriangles, а в том, для чего.
Полный листинг кода: github.com/quasilyte/ebitengine-draw-example/cmd/drawtriangles_single
Как уже отмечалось выше, заменять все DrawImage на DrawTriangles смысла нет. Для одиночных изображений DrawImage работает вполне неплохо.
Есть как минимум два случая, когда DrawTriangles будет к месту:
В теории, второе уже должно частично оптимизироваться движком. Вызывать несколько DrawImage подряд для одного изображения будет эффективно. Но у этих вызовов могут быть дополнительные накладные расходы, которых можно избежать, сделав часть работы на своей стороне.
Допустим, мы хотим нарисовать окружность через шейдер. Если игнорировать функции, требующие от нас треугольники, остаётся только DrawRectShader.
Для работы этой функции нужно source-изображение, так как подразумевается, что мы хотим произвести преобразование пикселей source-изображения внутри шейдера и нарисовать результат на destination-изображении.
Вот пример преобразования изображения с текстурой планеты в её финальный вид:
Ещё одним ограничением DrawRectShader является то, что все дополнительные входные изображения должны быть такого же размера, как и основной source. Это затрудняет использование источников шума, так как их приходится скейлить под этот размер.
Следовательно, если для генерации шейдерной графики используется DrawRectShader:
Пустое изображение будет занимать столько же места, как и обычное, просто все пиксели там будут иметь цвет vec4(0, 0, 0, 0)
.
Обычно второй шаг означает, что текстура с шумом имеет размер, которого хватит на любое другое изображение, а перед передачей их в DrawRectShader делается SubImage.
Используя DrawTrianglesShader можно решить обе эти проблемы.
Ниже приведены два шейдера для генерации графики: первый будет рисовать прямоугольник, а второй — окружность.
//kage:unit pixels
//go:build ignore
package main
func Fragment(_ vec4, pos vec2, _ vec4) vec4 {
return vec4(0.2, 0.2, 0.7, 1)
}
//kage:unit pixels
//go:build ignore
package main
var Radius float
func Fragment(_ vec4, pos vec2, _ vec4) vec4 {
zpos := pos
r := Radius
center := vec2(r, r)
dist := distance(zpos, center)
if dist > r {
return vec4(0)
}
return vec4(0.4, 0.7, 0.9, 1)
}
Так как сущностей будет две, лучше вынести рендеринг в отдельную функцию, которая будет работать для любой из них.
// Эта структура описывает необходимые для отрисовки параметры,
// которые можно заполнить как для окружности, так и для прямоугольника.
type drawShaderOptions struct {
pos [2]float32
shader *ebiten.Shader
width float32
height float32
uniforms map[string]any
}
func (g *myGame) drawShader(dst *ebiten.Image, opts drawShaderOptions) {
pos := opts.pos
w := opts.width
h := opts.height
// Будем рисовать относительно центра.
pos[0] -= w / 2
pos[1] -= h / 2
vertices := []ebiten.Vertex{
// Здесь уже знакомый нам код с заполнением 4 вершинок.
}
indices := []uint16{0, 1, 2, 1, 2, 3}
var drawOptions ebiten.DrawTrianglesShaderOptions
drawOptions.Uniforms = opts.uniforms
dst.DrawTrianglesShader(vertices, indices, opts.shader, &drawOptions)
}
Вызывать drawShader нужно из игрового Draw:
func (g *myGame) Draw(screen *ebiten.Image) {
for _, r := range g.rects {
g.drawShader(screen, drawShaderOptions{
shader: r.shader,
pos: r.pos,
width: r.width,
height: r.height,
})
}
for _, c := range g.circles {
g.drawShader(screen, drawShaderOptions{
shader: c.shader,
uniforms: c.uniforms,
pos: c.pos,
width: 2 * c.radius,
height: 2 * c.radius,
})
}
}
В результате получатся квадраты и окружности:
Полный листинг кода: github.com/quasilyte/ebitengine-draw-example/cmd/drawshader
Сделаем то же самое новым способом. Вместо нескольких вызовов DrawTriangles уместим весь рендеринг в один.
В крупной игре группировать множество объектов по их текстурам может быть проблематично, ведь они могут находиться на разных слоях и порядок отрисовки в этом случае будет не так прост.
Я возьму за основу ситуацию, которая отлично ложится на массовую отрисовку: система частиц. Чаще всего, все частицы имеют одинаковую текстуру, а их генератор (emitter) находится на конкретном графическом слое.
func (g *myGame) Draw(screen *ebiten.Image) {
// Здесь мы пользуемся фактом, что все объекты у нас используют
// одно и то же изображение.
img := g.objects[0].image
iw, ih := img.Size()
w := float32(iw)
h := float32(ih)
// Аллоцируем вершинки и индексы сразу для всех объектов.
vertices := make([]ebiten.Vertex, 0, 4*len(g.objects))
indices := make([]uint16, 0, 6*len(g.objects))
i := uint16(0)
for _, o := range g.objects {
vertices = append(vertices,
// Здесь уже знакомый нам код с заполнением 4 вершинок.
)
indices = append(indices,
i+0, i+1, i+2,
i+1, i+2, i+3,
)
i += 4 // Увеличиваем на количество vertices на объект
}
var opts ebiten.DrawTrianglesOptions
screen.DrawTriangles(vertices, indices, img, &opts)
}
В реальности нужно учитывать некоторые особенности. Например, не стоит использовать больше, чем MaxUint16/4 вершинок, ведь иначе не получится адресовать их в массиве indices. Чтобы этого избежать, батчи стоит разделять на кусочки. В версии v3 Ebitengine планирует поменять тип indices с uint16 на uint32, поэтому проблема будет менее заметной, хотя разделять на разумного размера кусочки всё ещё может быть правильно с точки зрения потребляемой памяти.
Полный листинг кода: github.com/quasilyte/ebitengine-draw-example/cmd/drawtriangles_batch
Точного сравнения DrawImage и DrawTriangles я не обещаю, но могу дать предварительные результаты для своей системы частиц. Абсолютные значения тут не так важны, так как у меня довольно слабая машина, но сравнить с результатом на этой же машине с тем же Godot — валидно.
Эффектом будем считать нечто похожее на взрыв, генерирующее 150-200 частиц, которые разлетаются в разные стороны со скоростью в некотором диапазоне. Частицы живут ~2 секунды. Каждый генератор (emitter) создаёт такой взрыв раз в секунду.
За baseline производительности возьмём результат CPU particles из Godot: мой ноутбук выдерживает около 200-220 генераторов партиклей на экране. Это примерно 60k одновременно симулируемых на экране частиц.
Верхним порогом считается момент, когда FPS падает со стабильных 60 до ~50.
Ниже результаты для Ebitengine:
Каждый батч в DrawTriangles версии имеет ограниченное количество частиц, которое он может обработать. Я пробовал разные значения и пока остановился на MaxUint16/16. Возможно, этот порог можно ещё понизить, не потеряв в пропускной способности.
Есть ещё одно применение DrawTriangles — рисование фигур (линии, окружности) даже без шейдеров. За примерами направляю в пакет vector.
Скорее всего, в моих играх всё ещё будут преобладать DrawImage и DrawRectShader, но теперь мне стало понятнее, как более эффективно решать некоторые задачи.
А если вам не хочется возиться с ручным draw, а хочется высокоуровневых спрайтов, то рекомендую мою библиотеку ebitengine-graphics.
P.S. — про серию "Делаем RPG на Go" я не забыл, но вторая часть уже устарела из-за некоторых ломающих обратную совместимость изменений в моих библиотеках. Я планирую сначала поправить вторую часть, а после этого уже начинать третью.
Сейчас же мне проще выпускать статьи поменьше, которые фокусируются на отдельных аспектах разработки игр на Go.
Интересен геймдев на Go? Заходи к нам в телеграм-группу!