Как оптимизировать 2D-игру: практическое руководство до стабильных 60 FPS
2D-игра, в которой падают кадры, почти всегда проигрывает одному из четырёх: слишком много вызовов отрисовки, выделение памяти в каждом кадре, которое запускает сборку мусора, работа над тем, чего игрок не видит, и симуляция, привязанная к частоте кадров. Лечится это всегда одной и той же последовательностью — сначала измерить, затем сократить вызовы отрисовки через объединение спрайтов и атласы, убрать выделения памяти с помощью пула объектов, перестать рисовать то, что вне кадра, и вести симуляцию фиксированным шагом. Это руководство проходит каждый шаг в рамках бюджета 16,67 миллисекунды, который даёт кадр при 60 FPS, с привычками профилирования, отличающими плавную игру от дёрганой. Никаких выдуманных замеров — каждая техника опирается на то, как рендереры и среда выполнения .NET работают на самом деле.
2D-игра, которая заикается, почти никогда не страдает от загадочной причины. Стоит её измерить — и кадровый бюджет укажет на одного из четырёх привычных подозреваемых: слишком много вызовов отрисовки, сжигающих время процессора на накладных расходах; выделение памяти в каждом кадре, запускающее паузы сборки мусора; работа над объектами, которых камера не видит; и симуляция, привязанная к частоте кадров, из-за чего игра бежит быстрее или медленнее на разных машинах. Лечится это одинаково в любом движке: сначала измерить, затем бить по этим четырём по порядку.
Кадр при 60 FPS располагает бюджетом в 16,67 миллисекунды. Всё — ввод, симуляцию, отрисовку — нужно успеть за это время, иначе кадр пропускается и игрок видит рывок. Оптимизация — это дисциплина, которая помогает уложить игру в этот бюджет и удержать её там. Это руководство — практическая версия, написанная для 2D-игр и опирающаяся на то, как рендереры и среда выполнения .NET работают в действительности, а не на выдуманные проценты ускорения.
Если замедление происходит при сборке, а не во время игры, это другая проблема — см. наше руководство по ускорению сборки игры. Эта статья — о кадре, который вы отдаёте игроку.
Первый шаг всегда такой: профилировать, а не угадывать
Самая дорогая ошибка в оптимизации — менять код без измерений. У большинства игр есть одна-две функции, которые доминируют в кадре, и редко это те, в которых вы подозреваете проблему. Решение — сначала их найти.
| Инструмент | Что показывает | Когда обращаться |
|---|---|---|
| График времени кадра | Стоимость каждого кадра в миллисекундах, с пиками | Начинать всегда отсюда — выявляет рывки, которые прячет средний счётчик FPS |
| Профилировщик процессора | Время в каждой функции | Когда кадр ограничен процессором и нужен виновник |
| Профилировщик видеокарты / отладчик кадра | Вызовы отрисовки, перерисовку, стоимость шейдеров | Когда кадр ограничен видеокартой или скоростью заполнения |
| Профилировщик памяти | Выделения на кадр, события сборки мусора | Когда игра заикается, а счётчик FPS выглядит нормально |
Читайте цифры, а не свою интуицию. Функция, которая «кажется тяжёлой», но занимает 0,1 мс, — не ваша проблема; скучный цикл на 6 мс — вот он. Каждая оптимизация ниже имеет смысл только там, где профилировщик говорит, что она нужна.
Сократите вызовы отрисовки через объединение спрайтов и атласы
Каждый вызов отрисовки — это одна команда от процессора к видеокарте: «нарисуй эту геометрию с этим материалом в такой позиции». Цена — не само рисование (современные видеокарты рисуют миллионы пикселей мгновенно), а фиксированные накладные расходы на подготовку каждого вызова. Сцена, рисующая тысячу спрайтов по одному, тратит почти весь кадровый бюджет на эти накладные расходы, а видеокарта в это время простаивает в ожидании следующей команды.
Объединение спрайтов (sprite batching) решает задачу, собирая множество спрайтов с одним материалом в один вызов отрисовки. Чтобы объединение работало, спрайты должны иметь общую текстуру — отсюда и парный приём: атлас спрайтов — одна большая текстура, в которую плотно упаковано множество мелких спрайтов. С атласом целый уровень из тайлов, кадры анимации персонажа или рой одинаковых врагов могут свернуться в один или несколько вызовов отрисовки вместо сотен.
| Сцена | Базовый подход (вызов на спрайт) | С атласом и объединением |
|---|---|---|
| 500 тайлов | ~500 вызовов отрисовки | ~1 вызов |
| Анимированный персонаж (8 кадров) | 1 на видимый кадр | 1 |
| Шквал снарядов (300 штук) | ~300 | ~1 (общий атлас) |
Практические правила: держите спрайты, которые появляются рядом, на одном атласе; не разрывайте группы переключением текстуры или режима наложения посреди сцены; упаковывайте атласы размерами, кратными степени двойки, где этого требует платформа. Это задокументированное поведение любого крупного 2D-рендерера, а не специфические для движка хитрости.
Не выделяйте память в каждом кадре: используйте пул объектов
В среде со сборкой мусора, такой как .NET (её используют и Unity, и MonoGame), занятую память рано или поздно приходится возвращать. Этим занимается сборщик мусора, и когда он запускается, то приостанавливает игру на несколько миллисекунд — достаточно, чтобы пропустить кадр и выдать видимый рывок. Приём в том, чтобы перестать выделять память во время игры, а главный нарушитель — создание и уничтожение объектов на горячем участке.
Пул объектов — стандартный ответ. Вместо того чтобы создавать новую пулю при выстреле и уничтожать её при попадании, вы держите заранее созданный набор пуль. Выстрел берёт неактивную пулю, включает её и сбрасывает позицию. Попадание выключает пулю и возвращает её в пул. Никаких выделений, никакого уничтожения, никакой сборки мусора.
Пулируйте всё, что часто появляется и исчезает:
- Снаряды и частицы
- Врагов в волновой игре
- Всплывающие числа урона и текст
- Источники звука для повторяющихся эффектов
Более глубокая привычка — вообще избегать выделений в каждом кадре: не пользоваться LINQ и склейкой строк в часто выполняемых циклах, избегать упаковки значимых типов и переиспользовать коллекции вместо создания новых. Кадр, который ничего не выделяет, идёт плавно; кадр, который выделяет по несколько килобайт сотню раз, рано или поздно дёрнется.
Не рисуйте то, чего игрок не видит
Отрисовка объектов вне экрана — это впустую проделанная работа, которую видеокарте всё равно приходится принимать, а процессору — отправлять. Два простых приёма убирают большую её часть:
- Отсечение (culling): пропускайте объекты целиком, если они вне поля камеры. Если враг за тремя экранами отсюда, ему незачем рисоваться в этом кадре.
- Дисциплина слоёв и порядка отрисовки: группируйте статичный фоновый арт так, чтобы его можно было нарисовать один раз или закэшировать, а не обрабатывать заново каждый кадр.
Та же логика применима к логике, а не только к отрисовке. Врагу вне экрана не нужно полное обновление ИИ каждый кадр; удалённую систему частиц можно обновлять вдвое реже. Тратьте бюджет там, куда смотрит игрок.
Отвяжите симуляцию от частоты кадров
Если ваша игра двигает объекты по принципу «сдвинуть позицию на скорость в каждом кадре», то на мониторе 144 Гц игра бежит быстрее, а на устройстве с 30 FPS — медленнее. Это уже не оптимизация, а корректность, но напрямую влияет на воспринимаемую плавность: фиксированная частота симуляции в сочетании с интерполяцией выглядит и ощущается стабильно на любом дисплее.
Установившийся приём — фиксированный шаг с аккумулятором: симуляция продвигается фиксированными порциями (скажем, 60 раз в секунду) вне зависимости от частоты обновления экрана, а рендерер интерполирует между двумя последними состояниями симуляции для текущего кадра. Широко цитируемая статья Гленна Фидлера «Fix Your Timestep!» — классическое объяснение этого метода. Выгода двойная: детерминированная, воспроизводимая физика и игра, которая одинаково ощущается на любой машине.
Всегда держите в голове кадровый бюджет
Чтобы удержать 60 FPS, кадр должен укладываться в 16,67 мс. Это число и есть вся игра. Вот примерно как здоровый кадр 2D-игры его тратит:
| Этап | Типичная доля из 16,67 мс | Что ломается |
|---|---|---|
| Ввод и симуляция | 2–5 мс | Слишком много активных объектов, выделения в кадре |
| Отсечение и подготовка групп | 1–3 мс | Нет объединения спрайтов, частые переключения текстур |
| Отрисовка на видеокарте и выдача кадра | 2–6 мс | Большая перерисовка, крупные прозрачные частицы |
| Запас | всё остальное | Рывки съедают его первым |
Колонка «запас» — и есть суть. Кадр, использующий 16 мс, не имеет места для всплеска, поэтому следующее выделение памяти, следующий взрыв частиц или очередной запуск сборщика мусора его уронит. Старайтесь тратить заметно меньше бюджета, а не впритык к нему.
Ошибки, которые сводят всё на нет
- Оптимизировать до измерений. Вы будете настраивать не то.
- Гнаться за средним FPS. Плавный график времени кадра важнее высокого числа. Следите за всплесками.
- Игнорировать сборку мусора. Главная причина «FPS высокий, а всё равно заикается».
- Оптимизировать редко выполняемый код. Меню, которое грузится раз в минуту, не нуждается в пуле.
- Привязывать скорость игры к частоте кадров. Это ломает ощущение на разных дисплеях и перечёркивает все остальные усилия.
- Добавлять память вместо правки логики. Больший кэш помогает, только если он прогрет; холодный кэш импортирует всё заново.
Роль Egmatic
Egmatic — это 2D-редактор и движок, построенный на базе MonoGame. Этот выбор важен для производительности по двум честным причинам. Во-первых, MonoGame — компактная среда выполнения C#: она даёт прямой контроль над конвейером отрисовки без тяжёлого слоя абстракции между вами и «железом», поэтому при плотном кадровом бюджете узким местом оказывается ваш собственный код, а не движок, который вам сопротивляется. Во-вторых, живой предпросмотр Egmatic позволяет поменять значение и тут же увидеть влияние на время кадра, превращая профилирование из медленного цикла «правка — сборка — запуск» в быструю обратную связь — ту самую привычку, которую рекомендует вся остальная статья.
Egmatic нацелен на десктоп, мобильные устройства и консоли через базу MonoGame, поэтому та же работа по оптимизации сохраняется на всех платформах. Если вы только знакомитесь с лежащим в основе движком, наше руководство о MonoGame объясняет, что это за база и почему она компактна.
Заключение
Плавная 2D-игра — не плод секретных приёмов. Она рождается из измерений в рамках бюджета 16,67 мс, сокращения вызовов отрисовки через атласы и объединение спрайтов, устранения выделений памяти в каждом кадре с помощью пула объектов, отсечения того, чего камера не видит, и ведения симуляции фиксированным шагом. Делайте эти пять вещей в таком порядке, всегда опираясь на профилировщик, а не на интуицию, — и кадровый бюджет перестанет быть борьбой и станет привычкой.