Игра дошла до стадии, когда она похожа на игру: мир, камера, частицы, звук, сцены. И тут на слабом ноутбуке картинка дёргается. Типовая реакция новичка — наугад крутить код: убрать пару спрайтов, уменьшить окно, переписать цикл. Это лотерея: без измерений вы не знаете даже, какая половина игрового цикла виновата — Update или Draw. У Ebitengine ответ всегда начинается с двух чисел, и оба движок отдаёт вам сам. Задача урока — научиться их читать, выводить на экран, связывать с симптомами и только потом трогать код. Профилирование игры — не отдельный талант, а дисциплина из четырёх шагов: померить, найти, починить, померить снова.
Два числа: TPS и FPS#
TPS — ticks per second, частота вызовов Update. Цель — 60: константа DefaultTPS в движке равна именно ей, и один тик получает бюджет около 16.6 миллисекунд. FPS — frames per second, фактическая частота вызовов Draw, которую движок подстраивает под вертикальную синхронизацию монитора. Числа связаны, но не склеены: если кадр вышел дорогим и FPS упал ниже TPS, движок не даёт логике замедлиться — он вызывает Update несколько раз подряд перед одним Draw, чтобы за секунду тиков всё равно набралось шестьдесят. Игрок видит это как рывки картинки при нормальной скорости игры.
Отсюда первый диагностический приём: TPS просел — виновата логика, то есть Update; FPS просел при целых TPS — виновата отрисовка, то есть Draw. Симптомы у игрока разные: низкий TPS ощущается как замедление всего мира, низкий FPS — как дёрганая картинка при верной скорости. Функции для HUD: ebiten.TPS() возвращает целевую частоту тиков, ebiten.CurrentTPS() и ebiten.CurrentFPS() — измеренные значения. Разница цен тоже разная: тик съедают аллокации и тяжёлые циклы, кадр — вызовы отрисовки и размеры картинок. Смешивать эти две истории — самый дорогой способ не починить ничего. Отсюда же правило бюджетов: любой вызов в цикле оплачивается из тех же 16.6 миллисекунд тика или кадра, и чем раньше числа HUD станут привычными глазами, тем дешевле обойдётся каждая следующая система игры — частицы, звук, тайлмапы.
Отладочный HUD: TPS, FPS и память
GOtype Game struct {
frame int
mem runtime.MemStats
}
func (g *Game) Update() error {
g.frame++
if g.frame%ebiten.TPS() == 0 { // раз в секунду
runtime.ReadMemStats(&g.mem)
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
// ...отрисовка мира...
ebitenutil.DebugPrint(screen, fmt.Sprintf(
"TPS: %0.2f FPS: %0.2f Heap: %d KB",
ebiten.CurrentTPS(), ebiten.CurrentFPS(),
g.mem.HeapAlloc/1024))
}HUD печатает все три числа: измеренные TPS и FPS плюс HeapAlloc — объём кучи. ReadMemStats вызывается раз в секунду по счётчику тиков: он останавливает все горутины программы, и такой ценой нельзя платить каждый тик. Sprintf в HUD сам аллоцирует строку, поэтому HUD — отладочный инструмент, который в релизной сборке убирается флагом.
TPS стабильно 60, а CurrentFPS показывает 25. Где искать виноватого?
Аллокации: производительность тика#
Главный враг тика — сборщик мусора. Каждая новая строка, срез или структура, созданная в Update, попадает в кучу; чем чаще куча растёт, тем чаще GC запускается, а его циклы съедают бюджет тика рывками — TPS не падает плавно, а проваливается волнами. Типовые источники: append по срезу без заданной ёмкости, конкатенация строк в цикле, Sprintf в логике, срезы и map, создаваемые заново на каждом тике, замыкания в горячем коде. Лечение — переиспользование: выделить буфер один раз при загрузке и внутри тика сбрасывать длину конструкцией s = s[:0], а объекты, живущие коротко и массово, держать в пуле. Пул частиц из урока про частицы — ровно этот приём: частицы не создаются и не уничтожаются, а переезжают из свободного списка обратно в бой.
Проверить себя помогает go test с флагом -benchmem: бенчмарк на горячую функцию покажет число аллокаций на операцию, и цель — свести его к нулю или константе, не растущей с количеством сущностей. Компилятор Go часть мелких аллокаций убирает сам, рассчитывать на это не стоит: срез частиц на десять тысяч элементов он никуда не денет. Map в горячем пути тоже не заводят заново: встроенный clear очищает карту без новой аллокации, а известный заранее размер подсказывают при создании. Эффект второго порядка приятен: меньше мусора — короче паузы — ровнее TPS — предсказуемее кадр.
Update на каждом тике выполняет particles = append([]Particle{}, hot...). Что произойдёт?
Кэш изображений и цена DrawImage#
Вторая классическая дыра — создание картинок в Draw. NewImageFromImage загружает пиксели в текстуру на стороне видеокарты, и это дорогая операция: она для загрузки уровня, а не для кадра. Правило жёсткое: все изображения создаются один раз при старте или входе в сцену и хранятся в полях структур, а в Draw живут только вызовы DrawImage. Если картинка нужна по условию — например, вспышка урона, — она загружается заранее и просто не рисуется, пока условие ложно.
Со спрайтами честнее всего считается так: у DrawImage цена вызова, у большой картинки — цена заполненных пикселей. Движок батчит вызовы внутри — собирает отрисовку в пакеты к видеокарте, — поэтому сотня мелких спрайтов из атласа обычно дешевле, чем кажется. Но батчинг не делает вызовы бесплатными: тысячи DrawImage за кадр заметны на любом железе. Утверждение «один крупный спрайт дешевле сотни мелких» проверяется только замером: крупный экономит вызовы, но тратит заполнение, сотня мелких — наоборот. Атласы из урока про атласы снижают обе цены: меньше смен текстур — дешевле пакеты. Мелочь того же сорта: DrawImageOptions и GeoM не создаются на каждый вызов — структура опций заводится полем и переиспользуется, а нулевой options в аргументе честнее, чем пустой объект ради привычки. Сверяйте спор не мнением, а CurrentFPS до и после.
Что сделает с игрой такая Draw?
Прочитайте код и выберите, что он выведет:
func (g *Game) Draw(screen *ebiten.Image) {
img := ebiten.NewImageFromImage(g.rawFrame) // каждый кадр
screen.DrawImage(img, nil)
// img больше нигде не используется
}Симптом, замер, лечение#
| Симптом | Что мерить | Лечение |
|---|---|---|
| TPS ниже 60 | HeapAlloc в HUD, аллокации бенчмарком | убрать аллокации из тика, s[:0], пул |
| FPS ниже 60 при TPS 60 | число DrawImage и размеры картинок | атлас, меньше слоёв, кэш изображений |
| Память растёт минутами | HeapAlloc раз в секунду | NewImageFromImage — один раз, sync.Pool |
| Рывки без явной причины | CPU-профиль 30 секунд | pprof top, убрать горячую функцию |
| Просадка в момент события | профиль, снятый в этой сцене | переиспользование объектов при спавне |
pprof: CPU-профиль за 30 секунд#
HUD отвечает на вопрос «какая половина цикла болит», но не отвечает «какая именно функция». Для этого в Go есть pprof: подключаете net/http/pprof, поднимаете локальный HTTP-сервер, и профилировщик доступен, пока игра работает. CPU-профиль снимается командой go tool pprof с адресом debug/pprof/profile?seconds=30: тридцать секунд движок собирает стеки всех горутин, включая игровой цикл, и сохраняет профиль в файл. Дальше top показывает самые горячие функции по доле процессорного времени, а list накладывает счётчики на строки конкретной функции.
Два правила снимка. Первое: профиль снимают под той же сценой, где наблюдается просадка — профиль пустого уровня расскажет про пустой уровень. Второе: секунды указывайте явно, тридцати обычно хватает, чтобы горячая функция вылезла в топ. Вся эта кухня живёт в локальном Go: профилировщику нужен настоящий процесс и сетевой интерфейс. Детальный разбор pprof, бенчмарков и трассировок — в продвинутом курсе Go; здесь достаточно связки сервер плюс одна команда. Там же лежит профиль кучи по маршруту debug/pprof/heap: он показывает, кто держит память, и хорошо дополняет счётчик HeapAlloc из HUD.
Подключение pprof к игре
GOimport (
"log"
"net/http"
_ "net/http/pprof" // регистрирует обработчики /debug/pprof
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
if err := ebiten.RunGame(&Game{}); err != nil {
log.Fatal(err)
}
}
// снимок: go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30Импорт с подчёркиванием нужен ради побочного эффекта: пакет регистрирует обработчики профилей на стандартном маршрутизаторе. Сервер крутится в горутине и не мешает игровому циклу. Адрес localhost:6060 слушает только ваш компьютер, и порт не публикуется наружу. Дальше top и list показывают, чей код съедает кадр.
Игра подключена к pprof. Как получить CPU-профиль на полминуты?
Классика первой оптимизации: Sprintf с тремя строками вызывается каждый кадр в Draw, а ReadMemStats — каждый тик, потому что «так надёжнее». Дальше измерение начинает врать: HeapAlloc скачет, FPS падает от самого HUD, и вы лечите болезнь, которую принесли измерителем. Что сломалось бы дальше: стоп-мир каждые 16 миллисекунд съедал бы бюджет тика, и виноватым казался бы невинный Update. Дисциплина такая: тяжёлые замеры — раз в секунду, строку HUD собирать только когда отладка включена флагом, а в релизе HUD вырезается целиком; флаг удобнее держать в переменной окружения или аргументе запуска.
Правила производительности одним списком#
- TPS — частота тиков Update, цель 60; FPS — фактические кадры Draw
- TPS просел — виноват Update: аллокации и тяжёлая логика тика
- FPS просел при целом TPS — виноват Draw: вызовы и размеры картинок
- TPS, CurrentTPS и CurrentFPS живут в отладочном HUD с первого дня
- ReadMemStats — раз в секунду: он останавливает все горутины
- NewImageFromImage — один раз при загрузке; в Draw только DrawImage
- Буферы переиспользуются: ёмкость наперёд, s[:0], sync.Pool для массовых объектов
- Сначала замер и профиль, потом правка; без замера оптимизация — лотерея
Соберите показания HUD
Восстановите два пропуска: имена функций движка для целевой и фактической частоты.
В песочнице сайта нет ни сетевого порта, ни файлов, поэтому pprof там не поднять. Числа же повторить можно: TPS, CurrentTPS и CurrentFPS печатаются обычным DebugPrint, и на демонстрации с частицами видно, как нагрузка давит на FPS. CPU-профиль, стоп-мир и бенчмарки с -benchmem остаются делом локального запуска — это честная граница: измерять частоту можно везде, разбирать стеки — у себя на машине.
Тик с редким замером памяти
Соберите Update с замером кучи раз в секунду.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Дисциплина измерений#
Техники урока работают только в связке с порядком действий. Первый шаг — всегда HUD: без него спор о производительности — обмен мнениями. Второй — локализация: TPS и FPS делят проблему пополам, pprof доводит до функции. Третий — правка одного подозреваемого за раз: починили, померили, сравнили; две правки за раз смешивают эффекты, и вы не узнаете, которая помогла. Четвёртый — откат: если правка не изменила числа, она вернулась из кода, каким бы умной ни казалась.
Бюджет стоит держать в голове арифметически: 60 тиков и 60 кадров в секунду — это около 16.6 миллисекунд на каждый, и любой вызов в цикле стоит свою долю этих миллисекунд. Оптимизация, которая усложняет код ради выигрыша в долю процента, платит читаемостью за ничто; правка, которая убирает тысячу аллокаций на тик, окупается с лихвой. Разницу между этими случаями знает только замер, поэтому HUD не демонтируется после урока — он остаётся в проекте флагом до самого итогового проекта, где пригодится при первой же волне врагов. Пятый шаг — календарь замеров: числа снимают заново после каждой крупной добавки — новой системы, волн врагов, смены разрешения, потому что бюджет съедается тихо и постепенно.
Производительность в Ebitengine не требует знания видеокарт и шейдеров — она требует честных чисел и порядка действий: HUD с TPS, FPS и памятью; аллокации в тике как первый подозреваемый; кэш изображений и уважение к цене каждого DrawImage; CPU-профиль за тридцать секунд, когда глаза уже не помогают. Следующий урок отложит инструменты и вернётся к структуре: как разложить разросшуюся игру по пакетам, чтобы и профиль, и правки вносились в правильные места. Домашнее задание состоит из замеров — выполняйте его с включённым HUD и записанными числами до и после: именно эта привычка отличает инженера от угадывателя, и она дешевле любой теории. Числа из HUD полезно записывать в заметки проекта рядом с датой: история замеров покажет, какая система съела бюджет.
Термины урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Производительность держится на двух числах: TPS — тики Update, FPS — кадры Draw; целевые 60 у обоих.
- TPS просел — чистим Update от аллокаций; FPS просел при целом TPS — разгружаем Draw.
- Отладочный HUD с TPS, CurrentTPS, CurrentFPS и HeapAlloc заводится с первого дня проекта.
- ReadMemStats останавливает все горутины, поэтому меряется раз в секунду по счётчику тиков.
- Аллокации в тике кормят GC: ёмкость наперёд, s[:0], пул объектов вместо новых срезов.
- NewImageFromImage — один раз при загрузке; батчинг смягчает DrawImage, но не обнуляет его цену.
- CPU-профиль снимается pprof-ом под нагрузкой за 30 секунд; правки вносятся по одной и проверяются замером.
Домашнее задание
Подключите HUD из урока: TPS, FPS и HeapAlloc раз в секунду, включение флагом командной строки. Запустите свою игру в самой тяжёлой сцене и запишите числа. Затем включите pprof, снимите CPU-профиль на тридцать секунд под этой же сценой и выпишите топ-3 функции из top. Найдите в Update хотя бы одну аллокацию на тик — срез, строку или Sprintf — и уберите её переиспользованием буфера; проверьте HeapAlloc в HUD до и после. Проверьте проект на NewImageFromImage вне загрузки: каждая такая картинка переезжает в поле структуры. Прогоните go test -benchmem по горячей функции и запишите число аллокаций на операцию. В конце сравните записи до и после всех правок и оставьте в коде только те, что изменили числа.
Личные заметкиПусто
Вопросы и ответы по уроку
Есть ли смысл гнаться за FPS выше 60?
Почти никогда: Draw подстраивается под вертикальную синхронизацию монитора, а выше герцовки кадры всё равно не показываются. Важнее другое — стабильность числа: ровные 60 с целым TPS ощущаются лучше, чем скачки между 90 и 40. Поэтому усилия вкладываются в честный тик и предсказуемый кадр, а не в рекордные значения CurrentFPS.
Можно ли настроить сам сборщик мусора вместо чистки аллокаций?
Технически да — переменные среды вроде GOGC и трассировка GODEBUG=gctrace=1 существуют, и трассировка полезна как диагноз: она покажет частоту и длительность циклов GC. Но тюнинг среды не убирает источник мусора, а только откладывает провал на другой машине. Порядок всегда один: сначала переиспользование в коде, и только потом — настройки среды.
Какое число вызовов DrawImage за кадр можно считать пределом?
Универсального числа нет: цена зависит от железа, размеров картинок, фильтров и того, насколько хорошо вызовы складываются в батчи. Сотни мелких спрайтов из одного атласа обычно незаметны, тысячи — уже повод мерить. Правильный ответ даёт только ваш замер: CurrentFPS до и после правки на целевом железе важнее любой таблички из интернета.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chromedp в Go: автоматизация Chrome без Selenium
Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.
- Запускать Chrome из Go и управлять страницами
- Находить элементы селекторами и ждать их появления
- Кликать, заполнять формы и извлекать данные
- Делать скриншоты, PDF и выполнять JavaScript