Znak - простой 2D игровой движок

Давно не было новостей, пора исправить этот недочет. Последний месяц занимался написанием простейшего 2D-движка, способного работать как на десктопе (win/mac), так и в вебе (webgl2-wasm), похожего по своей структуре и апи на Unity, собирающийся за секунды в относительно небольшой размер, выдающий неплохую скорость рендера и с кодовой базой движка и пользовательского на одном языке.

Особенности

  • Движок получил название Znak.
  • Рендер реализован на OpenGL 3.3/WebGL2, на десктопе используется GLFW 3.4. Данные профили позволяют реализовывать кроссплатформенную и достаточную для инди-игр графику, повышение OpenGL-профиля не планируется.
  • В качестве языка был выбран Golang - низкий порог входа, неплохое быстродействие, неплохой конечный размер бинарника, быстрая сборка. Ну и основной плюс - отсутствие ООП в его классическом представлении.
  • Внутри все реализовано как 3D-движок, при необходимости можно расширить апи и функционал для поддержки нового функционала.
  • Весь рендер идет строго через инстансинг (instancing), даже если это 1 спрайт, присутствует автоматическое склеивание нескольких вызовов отрисовки одного меша одним материалом в один вызов.
  • Есть поддержка бандлов/паков (архивов контента) со сжатием и шифрацией.
  • Есть поддержка sdf-шрифтов с предварительным запеканием атласа (утилита в комплекте).
  • Есть поддержка BMFont-шрифтов (импорт в формат движка).
  • Есть поддержка пользовательского ввода с клавиатуры/мыши, минимальная поддержка touch-скрина.
  • Разрабатывается как решение без внешних зависимостей (библиотек), доступ к которым может пропасть в силу разных обстоятельств.
  • Планировался как альтернатива Ebiten - но с большей скоростью работы, меньшим размером бинарника и большей схожестью с Unity.

Пример

Давайте рассмотрим реализацию bunnymark - теста на вывод большого количества спрайтов, которые перемещаются, вращаются и выводятся на экран. За основу возьмем пример из Ebiten: https://ebitengine.org/en/examples/sprites.html, но будем красить в разные цвета, как в примере из Defold https://defold.com/examples/sprite/bunnymark/ - это “съест” порядка 15% максимальной производительности на десктопе и 30% в Web-е, но будет более честным по отношению к Defold-тесту.

Для работы движка потребуется установка golang, gcc, tinygo и binaryen - последние 2 пакета опциональны и нужны только если необходима сборка сверхмалых приложений. Все эти приложения можно установить через https://scoop.sh/ под Windows и https://brew.sh/ под Macos, инструкции есть в документации движка.

Znak реализован в качестве golang-пакета, но он недоступен для скачивания по какому-то публичному адресу, поэтому его необходимо подключить локальной папкой через "go mod edit" (инструкции есть в документации), после подключения станет доступным пакет с именем go.leopotam.ru/znak.

Инициализация движка похожа на Ebiten за исключением загрузки контента через пак:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import (
_ "embed"
"go.leopotam.ru/znak"
)

//go:embed "bundle.key"
var bundleKey []uint8

//go:embed "testpack.zap"
var bundleData []uint8

func main() {
// Устанавливаем ключ дешифрации контента.
znak.SetBundleKey(bundleKey)
// Асинхронно загружаем пак с контентом.
znak.LoadBundleFromMemory("testpack", bundleData)
// Для простоты - ждем полной загрузки пака
// с блокировкой потока.
znak.WaitForLoadingAllBundles()
// Устанавливаем размеры окна.
znak.SetWinSize(320, 240)
// Отключаем Vsync.
znak.SetWinVsync(false)
// Подключаем "сцену".
znak.AddScene("game1", &game{})
// Запускаем движок, первая сцена
// будет загружена автоматически.
znak.Run()
// Сюда вернемся только после завершения работы.
}

Вся логика разбивается на логические “сцены” (похоже на сцены в Unity с одним MonoBehaviour-ом), у которых есть следующие методы:

1
2
3
4
5
6
7
8
9
10
11
12
13
type IScene interface {
// Вызывается один раз при загрузке.
Load()
// Вызывается каждый кадр
// (нельзя ничего отрисовать).
Update()
// Вызывается каждый кадр
// (нельзя ничего менять, только отрисовывать).
Render()
// Вызывается при смене сцены
// и закрытии приложения на десктопе.
Unload()
}

В этом примере тип game1 реализует все эти методы. Полный код примера:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
package main

import (
_ "embed"
"fmt"
"math/rand/v2"

"go.leopotam.ru/znak"
"go.leopotam.ru/znak/formats/pngfile"
)

type game1 struct {
quad *znak.Mesh
mtrl *znak.Material
cam, camUI *znak.Camera
sprites []*sprite
sprW, sprH float32
font *znak.Font
}

type sprite struct {
x, y float32
speedX, speedY float32
rot float32
color znak.Vec4
}

// Количество добавляемых/удаляемых спрайтов за раз.
const ADD_COUNT = 10000

// Скорость движения спрайта.
const MOVE_SPEED = 100

// Скорость вращения спрайта.
const ROTATE_SPEED = 45

//go:embed "bundle.key"
var bundleKey []uint8

//go:embed "testpack.zap"
var bundleData []uint8

func (g *game1) Load() {
// Загружаем текстуру шрифта из пака.
asset, ok := znak.BundleAsset("testpack://fonts/Roboto-Regular.png")
if !ok {
panic("ошибка загрузки текстуры шрифта")
}
// Распаковываем текстуру шрифта из png в RGBA.
fontTexW, fontTexH, fontTexData, ok := pngfile.Decode(asset)
if !ok {
panic("ошибка распаковки текстуры шрифта")
}
// Создаем объект-текстуру шрифта с включенной билинейной фильтрацией и мипмапами.
fontTex := znak.NewTexture(fontTexW, fontTexH, true, true)
fontTex.SetBytes(fontTexData)

// Загружаем параметры шрифта (информацию о глифах, отступах и т.д).
asset, ok = znak.BundleAsset("testpack://fonts/Roboto-Regular.json")
if !ok {
panic("ошибка загрузки параметров шрифта")
}

// Создаем объект-шрифт из текстуры и параметров.
g.font, ok = znak.NewFont("roboto", fontTex, asset)
if !ok {
panic("ошибка создания шрифта")
}

// Загружаем шейдер спрайта.
asset, ok = znak.BundleAsset("testpack://shaders/sprite.glsl")
if !ok {
panic("ошибка загрузки шейдера спрайта")
}
// Создаем объект-шейдер.
sh, ok := znak.NewShader(asset)
if !ok {
panic("ошибка создания шейдера спрайта")
}

// Загружаем текстуру спрайта.
asset, ok = znak.BundleAsset("testpack://textures/ebiten.png")
if !ok {
panic("ошибка загрузки текстуры спрайта")
}
sprW, sprH, sprData, ok := pngfile.Decode(asset)
if !ok {
panic("ошибка распаковки текстуры спрайта")
}
// Сохраняем размеры спрайта - они пригодятся для рендера.
g.sprW = float32(sprW)
g.sprH = float32(sprH)
// Создаем объект-текстуру спрайта с фильтрацией и без мипмапов.
sprTex := znak.NewTexture(sprW, sprH, true, false)
sprTex.SetBytes(sprData)

// Создаем объект-материал для отрисовки спрайтов.
g.mtrl = znak.NewMaterial(sh, sprTex)

// Создаем меш для рендера спрайта - обычный квадрат размером 1x1,
// размеры будут передаваться при отрисовке инстансингом и меняться в шейдере.

// Первый параметр определяет какие атрибуты будут статичны (хранятся в меше),
// второй - какие будут передаваться через инстансинг.
g.quad = znak.NewMesh(znak.MeshAttrUV, znak.MeshAttrColor)
// Заливаем позицию (XYZ), UV-координаты (4 компонента) в статику,
// цвет (4 компонента) будет передаваться динамикой.
g.quad.SetData(
[]float32{
// x y z u v
-0.5, -0.5, 0.0, 0, 0, 0, 0,
-0.5, +0.5, 0.0, 0, 1, 0, 0,
+0.5, +0.5, 0.0, 1, 1, 0, 0,
+0.5, -0.5, 0.0, 1, 0, 0, 0,
},
[]uint32{0, 2, 1, 0, 3, 2})

_, h := znak.ScreenSize()
// Создаем камеру для рендера спрайтов.
g.cam = znak.NewCamera(znak.CameraModeWorld, float32(h))
g.cam.SetClearColor(znak.ColorBlue)
// Создаем камеру для рендера текста помощи.
g.camUI = znak.NewCamera(znak.CameraModeUI, float32(h))
g.camUI.SetClearMode(znak.CameraClearNothing)
}

func (g *game1) addSprites(count int) {
if count < 0 {
// Удаляем спрайты.
newLen := len(g.sprites) + count
if newLen < 0 {
newLen = 0
}
g.sprites = g.sprites[:newLen]
} else {
// Добавляем спрайты с рандомными позицией, поворотом и направлением.
w, h := znak.ScreenSize()
for i := 0; i < count; i++ {
a := rand.Float32() * 360 * znak.DEG2RAD
b := sprite{
x: float32(w) * (rand.Float32() - 0.5),
y: float32(h) * (rand.Float32() - 0.5),
speedX: MOVE_SPEED * znak.Cos(a),
speedY: MOVE_SPEED * znak.Sin(a),
rot: rand.Float32() * 360,
color: znak.Vec4{rand.Float32(), rand.Float32(), rand.Float32(), 0.9},
}
g.sprites = append(g.sprites, &b)
}
}
}

func (g *game1) Update() {
sWidth, sHeight := znak.ScreenSize()
g.cam.SetHeight(float32(sHeight))
g.camUI.SetHeight(float32(sHeight))

dt := znak.TimeDeltaUnscaled()
sx, sy := g.cam.Size()
sx *= 0.5
sy *= 0.5
if znak.InputKeyDown(znak.KeyMouse0) {
mx, _ := znak.InputMousePosition()
var sign int
if mx >= sWidth/2 {
// Кликаем в правую часть экрана - добавляем спрайты.
sign = 1
} else {
// Кликаем в левую часть экрана - удаляем спрайты.
sign = -1
}
g.addSprites(ADD_COUNT * sign)
}
// Обновляем позиции и вращение спрайтов.
for _, b := range g.sprites {
b.rot += ROTATE_SPEED * dt
x := b.x + b.speedX*dt
y := b.y + b.speedY*dt
if x < -sx {
x = -sx
b.speedX = -b.speedX
} else {
if x > sx {
x = sx
b.speedX = -b.speedX
}
}
if y < -sy {
y = -sy
b.speedY = -b.speedY
} else {
if y > sy {
y = sy
b.speedY = -b.speedY
}
}
b.x = x
b.y = y
}
}
func (g *game1) Unload() {
// Код очистки отсутствует для простоты примера.
}

func (g *game1) Render() {
// Рендер спрайтов.
g.cam.Use()
for _, b := range g.sprites {
mat := znak.Mat4TRS2D(b.x, b.y, b.rot*znak.DEG2RAD, g.sprW, g.sprH)
g.quad.DrawMesh(&mat, g.mtrl, b.color[:])
}
// Рендер текста помощи.
g.camUI.Use()
rs := znak.RenderStats()
statsText := fmt.Sprintf(
"FPS: %d\nDrawCalls: %d\nКоличество: %d\nКлик в правой части экрана\nдобавляет 10к спрайтов.\nКлик в левой части экрана\nудаляет 10к спрайтов",
rs.FPS, rs.DrawCalls, len(g.sprites))
// Рисуем черную подложку шрифта (на данный момент нет поддержки sdf-обводки).
g.font.DrawString(statsText, 3, 1, 18, znak.ColorBlack, g.camUI)
// Рисуем обычный текст поверх.
g.font.DrawString(statsText, 2, 0, 18, znak.ColorWhite, g.camUI)
}

func main() {
znak.SetBundleKey(bundleKey)
znak.LoadBundleFromMemory("testpack", bundleData)
znak.WaitForLoadingAllBundles()
znak.SetWinSize(320, 240)
znak.SetWinVsync(false)
znak.AddScene("game1", &game1{})
znak.Run()
}

Сборка

Для сборки вызываем утилиты из дистрибутива Znak, по умолчанию собирается под десктоп:

1
go run go.leopotam.ru/znak/tools/build -prod -o ./dist .
1
2
3
 Length Name
------ ----
2544640 bunnymark.exe

Под Windows получится 1 файл размером 2.5Мб. Неплохой размер, в дальнейшем можно будет еще уменьшить.

Давайте соберем под web штатным компилятором:

1
go run go.leopotam.ru/znak/tools/build -t wasm -prod -o ./dist .

Получим 3 файла:

1
2
3
4
5
 Length Name
------ ----
1035125 game.wasmgz
697 index.html
16687 wasm_exec.js

Размер главного файла - чуть больше 1Мб - это уже лучше, чем 10Мб у Ebiten.

Теперь попробуем сборку через TinyGO:

1
go run go.leopotam.ru/znak/tools/build -t wasm-tinygo -prod -o ./dist .

Получим 3 файла:

1
2
3
4
5
 Length Name
------ ----
281046 game.wasmgz
697 index.html
16589 wasm_exec.js

Размер 280кб - и это с контентом! Правда скорость работы сильно ниже из-за особенностей реализации tinygo - придется выбирать либо полную скорость и больший размер, либо минимальный размер и более низкую производительность.

Тесты

Проверить и сравнить производительность можно по ссылкам ниже:
Znak Golang-версия
Znak TinyGO-версия

На одном и том же железе golang-версия выдает 60к спрайтов до просаживания ниже 60фпс, tinygo-версия не держит даже 10к. Так же текущие tinygo-сборки “текут” по памяти, хотя сборщик мусора не отключен - не рекомендуется к использованию, только для тестов.

Для сравнения:

  • Znak-golang-версия выдает 60к спрайтов (размер 1Мб)
  • Znak-tinygo-версия выдает примерно 9к спрайтов (размер 280кб)
  • Ebiten-версия выдает 8к спрайтов (размер 9.57Мб)
  • Defold-версия выдает 27к спрайтов (размер 1.4Мб)

Znak доступен в виде обновляемого пакета для подписчиков.

Актуальные версии пакетов доступны в закрытом telegram-сервере для vk/boosty-подписчиков.
Оформить подписку можно здесь: