В предыдущей статье я рассказывал, почему в 2026 году выбрал LibGDX для разработки игр на Java и какие преимущества вижу у этого фреймворка.

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

В этой статье разберём пять технических ошибок, которые чаще всего приводят к проблемам с производительностью.

Эта статья не про геймдизайн, монетизацию, продвижение или причины, почему ваша игра никому не нужна. Речь исключительно о технических ошибках при разработке игр на LibGDX, которые могут привести к низкому FPS, микрофризам, большому потреблению памяти, повышенному расходу батареи или проблемам при сборке под WebGL.

В первую очередь речь о мобильных играх и WebGL‑версиях, которые запускаются непосредственно в браузере. На мощном десктопном компьютере многие из этих проблем могут быть практически незаметны.

Я сам разрабатываю игры на LibGDX и периодически наступаю на разные грабли. Поэтому ниже не академический список оптимизаций, а вещи, на которые я бы обратил внимание уже в начале разработки.

1. Не делайте гигантские Texture Atlas

Texture Atlas — одна из самых полезных вещей в LibGDX. Мы собираем множество небольших картинок в одну большую текстуру, а затем используем отдельные TextureRegion. Это позволяет SpriteBatch дольше работать с одной текстурой и уменьшает количество переключений текстур и render calls.

Но тут легко переборщить.

Для мобильной игры я стараюсь держать максимальный размер страницы атласа примерно в пределах 4096 × 4096.

Это не означает, что LibGDX или конкретный телефон обязательно не смогут работать с 8192×8192. Всё зависит от GPU и используемого backend.

Проблема в другом: большая текстура — это большая текстура. Даже если вы используете из неё всего несколько маленьких картинок, сама текстура имеет соответствующий размер и требует памяти GPU.

Например, RGBA8888-текстура 4096×4096 — это примерно:

4096 × 4096 × 4 ≈ 64 МБ

в несжатом виде.

А 8192×8192 — уже примерно 256 МБ. И это только одна текстура.

Поэтому «давайте запихнём вообще всё в один огромный атлас» — не всегда хорошая идея.

Я бы лучше делил ресурсы на несколько логических атласов:

  • UI;

  • персонажи;

  • эффекты;

  • объекты уровня;

  • отдельные наборы для конкретных игровых сцен.

Особенно важно учитывать это при WebGL. Ограничения конкретного браузера, GPU и backend могут отличаться, поэтому огромные текстуры становятся дополнительным источником проблем.

Мой практический ориентир для мобильной игры — 4096×4096 как верхняя граница страницы атласа, если нет конкретной причины делать больше.

И не нужно воспринимать 4096 как магическое число. Если игра прекрасно работает с 2048×2048 — это вполне может быть лучшим вариантом.

2. Не заставляйте физический движок работать за вас каждый кадр

Особенно это касается игр с большим количеством объектов.

Представьте игру, где одновременно находятся несколько сотен зомби, пуль, частиц, предметов и физических объектов. И вы начинаете каждый кадр активно спрашивать Box2D о состоянии каждого объекта, а затем ещё что‑то менять обратно.

Например:

for (Zombie zombie : zombies) {
    float x = zombie.body.getPosition().x;
    float y = zombie.body.getPosition().y;

    if(x > ...) {
      ...
    }
}

Сам по себе такой код не обязательно является проблемой. Она появляется, когда подобных обращений становятся тысячи за кадр, особенно если между Java‑кодом и нативным Box2D происходит много вызовов.

Box2D в LibGDX — это Java‑обёртка над нативным физическим движком. Поэтому постоянное обращение к физическому миру из игрового кода имеет цену. Не нужно каждый кадр бессмысленно ходить по физическому миру и делать лишнюю работу.

Не надо без необходимости дёргать физический движок сотни или тысячи раз за кадр.

Box2D должен заниматься физикой. Вы задаёте ему силы, импульсы и другие параметры, вызываете world.step(), а затем получаете необходимые результаты симуляции.

LibGDX сам показывает типичный подход, когда после физического шага позиции игровых объектов синхронизируются с позициями Body.

Особенно плохо, если вы начинаете вручную управлять позициями динамических тел, одновременно ожидая, что Box2D будет нормально рассчитывать их столкновения и импульсы.

А если я вообще не использую Box2D?

Тогда принцип остаётся тем же.

Допустим, у вас собственная простая физика:

Rectangle player;
Array<Rectangle> enemies;

а столкновения вы проверяете через Intersector.

Это совершенно нормальный подход для некоторых игр. Если полноценный физический движок вам не нужен, нет смысла тащить Box2D только ради пары столкновений.

Но тогда следите за собственным кодом.

Если у вас тысяча объектов, а каждый кадр вы делаете:

проверка объекта №1
проверка объекта №2
проверка объекта №3
...
проверка объекта №1000

а внутри каждой проверки ещё несколько пересечений, расстояний, sqrt, аллокаций и других операций — никакой Box2D вам уже не нужен, чтобы получить лаги.

Физика должна быть максимально дешёвой.

Если объектов становится много, не проверяйте столкновение каждого объекта с каждым. Разбейте игровое пространство на области (например, через spatial hash или quadtree) и сначала отбрасывайте объекты, которые физически не могут столкнуться.

3. Не заставляйте SpriteBatch постоянно переключать текстуры

Это одна из классических проблем 2D‑рендеринга.

Предположим, у нас есть три атласа:

Atlas A
Atlas B
Atlas C

И мы рисуем:

слой 1 → Atlas A
слой 2 → Atlas B
слой 3 → Atlas A
слой 4 → Atlas C
слой 5 → Atlas B
слой 6 → Atlas A

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

Когда SpriteBatch получает текстуру, отличную от предыдущей, ему приходится отправлять накопленную геометрию на GPU и переключать текстуру. Именно поэтому частые texture switches уменьшают эффективность batching и увеличивают количество render calls.

Здесь есть важное уточнение:

Текстура не загружается заново в видеопамять при каждом draw().

Меняется используемая GPU texture, SpriteBatch сбрасывает накопленный batch, и начинается новая партия геометрии.

Чем больше render calls, тем больше работы приходится делать GPU и CPU.

В LibGDX есть специальное поле:

spriteBatch.renderCalls

которое позволяет посмотреть, сколько раз batch отправлял накопленную геометрию на GPU за последний begin/end.

Поэтому порядок отрисовки имеет значение.

Вместо:

A
B
A
C
B
A
C

старайтесь, где это возможно, организовать:

A
A
A
B
B
B
C
C
C

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

Например:

фон
земля
персонаж
стена
персонаж
эффект
UI

порядок отрисовки может быть принципиален.

Но именно поэтому стоит заранее продумать структуру атласов и слоёв.

Иногда гораздо полезнее иметь два хорошо организованных атласа, чем десять маленьких, которые постоянно чередуются.

4. Не превращайте мобильную игру в 500-мегабайтный клиент

Здесь я бы вообще поставил себе практическое правило:

старайтесь держать стартовую версию игры небольшой.

Например, ориентир:

до 100 МБ для мобильной версии
и ещё меньше для WebGL.

Для WebGL особенно важно учитывать ограничения площадки, на которой вы собираетесь размещать игру. У конкретных площадок они могут отличаться: где‑то 50 МБ, где‑то 100 МБ и больше.

И тут возникает интересная проблема.

Допустим, вы сделали игру размером 500 МБ. Она работает. Вы нашли маленькую ошибку, исправили одну строчку — и теперь вам снова приходится распространять огромный пакет.

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

Вместо этого клиент можно сделать относительно небольшим, а дополнительный контент загружать отдельно:

Клиент
 ├── код игры
 ├── базовые текстуры
 ├── UI
 ├── первый уровень
 └── необходимые ресурсы

Сервер
 ├── уровень 2
 ├── уровень 3
 ├── дополнительные карты
 ├── музыка
 ├── дополнительные текстуры
 └── новые игровые события

Игрок дошёл до определённого места? Можно загрузить необходимые ресурсы.

Особенно хорошо это работает в играх с серверной частью, где контент всё равно частично управляется сервером.

Разумеется, здесь есть компромиссы:

  • нужен интернет;

  • нужно продумывать кеширование;

  • нужно решать вопрос версий ресурсов;

  • нужно обрабатывать ситуацию, когда загрузка прервалась.

Но зато основной клиент остаётся небольшим.

И ещё одна причина не складывать всё в APK/AAB/WebGL‑сборку сразу:

вы сами себе усложняете разработку.

Пока игра активно развивается, вам приходится постоянно собирать, тестировать и распространять новые версии.

Лучше иметь компактное ядро игры и возможность независимо обновлять контент.

5. Не используйте new в render() без необходимости

Это очень распространённая ошибка в Java‑играх.

Допустим:

@Override
public void render() {
    Rectangle rect = new Rectangle();

    // ...
}

Или:

@Override
public void render() {
    Vector2 position = new Vector2();

    // ...
}

А теперь представьте, что таких объектов создаётся не один, а несколько тысяч.

И всё это происходит каждый кадр.

При 60 FPS:

1000 объектов × 60 FPS = 60 000 объектов в секунду

И это только один тип объектов.

Через некоторое время эти объекты становятся мусором, который должен убрать Garbage Collector. Сборка мусора может приводить к неприятным скачкам времени кадра — тем самым микрофризам, которые особенно хорошо заметны на мобильных устройствах.

Вместо постоянного:

new Vector2()
new Rectangle()
new Bullet()
new Particle()

можно переиспользовать существующие объекты.

Для динамических игровых сущностей удобно использовать object pool.

Например:

Пул пуль:

[Bullet]
[Bullet]
[Bullet]
[Bullet]
[Bullet]

Когда нужна новая пуля:

взяли из пула
      ↓
использовали
      ↓
пуля исчезла
      ↓
вернули в пул

Следующий выстрел снова использует тот же объект.

Это особенно полезно для:

  • пуль;

  • частиц;

  • врагов;

  • временных эффектов;

  • всплывающего текста;

  • повреждений;

  • объектов, которые постоянно появляются и исчезают.

Но здесь тоже не нужно впадать в фанатизм.

Сам по себе new не является преступлением.

Создать объект один раз при загрузке игры — совершенно нормально.

Плохо, когда вы создаёте огромное количество временных объектов каждый кадр.

Поэтому правило я бы сформулировал так:

Не допускайте массовых аллокаций в горячем цикле render/update.

Особенно если игра должна работать в 60 FPS на телефоне.

И да — чем меньше игра заставляет процессор работать без необходимости, тем меньше энергии он потребляет.

А значит, хорошая оптимизация — это не только про FPS.

Это ещё и про батарею.

Бонус. № 6: Не доверяйте ИИ свой render loop

Ну и куда же сейчас без искусственного интеллекта.

Я сам активно использую ИИ при разработке игр. Он помогает писать код, искать ошибки, разбираться с API и вообще здорово ускоряет разработку.

Но однажды я без особого внимания взял кусок кода, который мне сгенерировал ИИ, вставил его в игру...

И игра начала падать.

Причём не где‑нибудь. Именно там, где я меньше всего ожидал.

Пришлось разбираться, что же такого прекрасного насоветовал мне искусственный интеллект.

Впрочем, есть и обратная сторона: ИИ в моём коде тоже находит ошибки, которые я сам не заметил.

Поэтому я не отношусь к подходу:

«ИИ нельзя использовать».

Наоборот — использовать можно и нужно.

Но относиться к нему надо примерно как к очень быстрому junior‑разработчику.

Он может за пять минут написать тебе 200 строк кода.

А потом ты потратишь два часа, объясняя ему и себе, почему эти 200 строк вообще не должны были существовать.

Особенно внимательно проверяйте код, который ИИ предлагает для:

  • многопоточности;

  • Box2D;

  • работы с ресурсами;

  • OpenGL;

  • render loop;

  • Android;

  • WebGL;

  • жизненного цикла LibGDX.

И обязательно проверяйте код на реальном устройстве.

Потому что:

«У меня работает на десктопе» — это ещё не значит, что оно работает на телефоне.

А что из этого действительно важно?

Если свести всю статью к пяти коротким правилам:

1. Следите за размером текстур.
4096×4096 — хороший практический верхний ориентир для страниц атласа, если нет причины делать больше.

2. Не перегружайте физику.
Не опрашивайте и не пересчитывайте тысячи объектов без необходимости.

3. Контролируйте render calls.
Старайтесь рисовать объекты с одной текстурой группами и не заставляйте SpriteBatch постоянно переключать текстуры.

4. Держите клиент небольшим.
Не обязательно запихивать в APK/AAB/WebGL‑сборку весь контент игры.

5. Следите за аллокациями.
Особенно в render() и других горячих циклах.

И главное — профилируйте.

Потому что оптимизация без измерений очень быстро превращается в гадание.

Если у вас 20 render calls — это не обязательно проблема.

Если у вас 2000 — уже стоит выяснить почему.

Если один getPosition() вызывается несколько раз — это может быть совершенно нормально.

Если он вызывается миллион раз за секунду — пора задуматься.

То же самое с памятью, физикой, текстурами и GC.

В конечном итоге задача разработчика не в том, чтобы написать код, который выглядит «оптимизированным».

Задача — написать код, который достаточно быстро работает на реальном устройстве.