Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~20 мин +50 XP

60 FPS: профилирование игры

У ebitengine производительность описывается двумя числами: TPS — сколько раз в секунду успевает выполниться Update, и FPS — сколько кадров в секунду успевает нарисовать Draw. Игра в Go тормозит, когда хотя бы одно падает ниже шестидесяти, а лечатся эти две болезни разными лекарствами: тяжёлой логике тика — одно, тяжёлой отрисовке — другое. В этом уроке вы заведёте отладочный HUD с TPS, FPS и памятью, найдёте главный враг тика — аллокации, наведёте порядок с кэшем изображений и снимете CPU-профиль через pprof, а правки вносить будете по одной, под контролем замера.

Содержание урока · 10 разделов

Игра дошла до стадии, когда она похожа на игру: мир, камера, частицы, звук, сцены. И тут на слабом ноутбуке картинка дёргается. Типовая реакция новичка — наугад крутить код: убрать пару спрайтов, уменьшить окно, переписать цикл. Это лотерея: без измерений вы не знаете даже, какая половина игрового цикла виновата — 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 и память

GO
type 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 ниже 60HeapAlloc в 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 к игре

GO
import (
        "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-профиль на полминуты?

Правила производительности одним списком#

  • TPS — частота тиков Update, цель 60; FPS — фактические кадры Draw
  • TPS просел — виноват Update: аллокации и тяжёлая логика тика
  • FPS просел при целом TPS — виноват Draw: вызовы и размеры картинок
  • TPS, CurrentTPS и CurrentFPS живут в отладочном HUD с первого дня
  • ReadMemStats — раз в секунду: он останавливает все горутины
  • NewImageFromImage — один раз при загрузке; в Draw только DrawImage
  • Буферы переиспользуются: ёмкость наперёд, s[:0], sync.Pool для массовых объектов
  • Сначала замер и профиль, потом правка; без замера оптимизация — лотерея

Соберите показания HUD

Восстановите два пропуска: имена функций движка для целевой и фактической частоты.

ebitenutil.DebugPrint(screen, fmt.Sprintf( "TPS: %0.2f FPS: %0.2f", ebiten.(), ebiten.()))

Тик с редким замером памяти

Соберите Update с замером кучи раз в секунду.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Дисциплина измерений#

Техники урока работают только в связке с порядком действий. Первый шаг — всегда HUD: без него спор о производительности — обмен мнениями. Второй — локализация: TPS и FPS делят проблему пополам, pprof доводит до функции. Третий — правка одного подозреваемого за раз: починили, померили, сравнили; две правки за раз смешивают эффекты, и вы не узнаете, которая помогла. Четвёртый — откат: если правка не изменила числа, она вернулась из кода, каким бы умной ни казалась.

Бюджет стоит держать в голове арифметически: 60 тиков и 60 кадров в секунду — это около 16.6 миллисекунд на каждый, и любой вызов в цикле стоит свою долю этих миллисекунд. Оптимизация, которая усложняет код ради выигрыша в долю процента, платит читаемостью за ничто; правка, которая убирает тысячу аллокаций на тик, окупается с лихвой. Разницу между этими случаями знает только замер, поэтому HUD не демонтируется после урока — он остаётся в проекте флагом до самого итогового проекта, где пригодится при первой же волне врагов. Пятый шаг — календарь замеров: числа снимают заново после каждой крупной добавки — новой системы, волн врагов, смены разрешения, потому что бюджет съедается тихо и постепенно.

Производительность в Ebitengine не требует знания видеокарт и шейдеров — она требует честных чисел и порядка действий: HUD с TPS, FPS и памятью; аллокации в тике как первый подозреваемый; кэш изображений и уважение к цене каждого DrawImage; CPU-профиль за тридцать секунд, когда глаза уже не помогают. Следующий урок отложит инструменты и вернётся к структуре: как разложить разросшуюся игру по пакетам, чтобы и профиль, и правки вносились в правильные места. Домашнее задание состоит из замеров — выполняйте его с включённым HUD и записанными числами до и после: именно эта привычка отличает инженера от угадывателя, и она дешевле любой теории. Числа из HUD полезно записывать в заметки проекта рядом с датой: история замеров покажет, какая система съела бюджет.

Термины урока

Термин

TPS

нажмите, чтобы перевернуть
Определение

Частота вызовов Update, цель 60 тиков в секунду. Проседает, когда логика или GC не укладываются в бюджет тика около 16.6 миллисекунд.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 по горячей функции и запишите число аллокаций на операцию. В конце сравните записи до и после всех правок и оставьте в коде только те, что изменили числа.

Личные заметкиПусто
Хранятся только в вашем браузере (Ebitengine · 60 FPS: профилирование игры)

Вопросы и ответы по уроку

Есть ли смысл гнаться за FPS выше 60?

Почти никогда: Draw подстраивается под вертикальную синхронизацию монитора, а выше герцовки кадры всё равно не показываются. Важнее другое — стабильность числа: ровные 60 с целым TPS ощущаются лучше, чем скачки между 90 и 40. Поэтому усилия вкладываются в честный тик и предсказуемый кадр, а не в рекордные значения CurrentFPS.

Можно ли настроить сам сборщик мусора вместо чистки аллокаций?

Технически да — переменные среды вроде GOGC и трассировка GODEBUG=gctrace=1 существуют, и трассировка полезна как диагноз: она покажет частоту и длительность циклов GC. Но тюнинг среды не убирает источник мусора, а только откладывает провал на другой машине. Порядок всегда один: сначала переиспользование в коде, и только потом — настройки среды.

Какое число вызовов DrawImage за кадр можно считать пределом?

Универсального числа нет: цена зависит от железа, размеров картинок, фильтров и того, насколько хорошо вызовы складываются в батчи. Сотни мелких спрайтов из одного атласа обычно незаметны, тысячи — уже повод мерить. Правильный ответ даёт только ваш замер: CurrentFPS до и после правки на целевом железе важнее любой таблички из интернета.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Chromedp25 уроков~11 чпрактика25 уроков уже доступны

Chromedp в Go: автоматизация Chrome без Selenium

Курс chromedp для Go: навигация, селекторы, клики и формы, скриншоты и PDF, JS-оценка, вкладки, прокси, пул браузеров и скрапинг. 25 уроков.

  • Запускать Chrome из Go и управлять страницами
  • Находить элементы селекторами и ждать их появления
  • Кликать, заполнять формы и извлекать данные
  • Делать скриншоты, PDF и выполнять JavaScript