[Из песочницы] Как мы боролись с тормозами в AndEngine
Недавно наша команда закончила разработку двухмерной бродилки-стрелялки для Android на движке AndEngine. В процессе был получен определенный опыт по решению проблем с производительностью и некоторыми особеностями движка, которым хочется поделиться с читателями Хабра. Для затравки вставлю кусочек скриншота из игры, а все технические детали и примеры кода уберу под кат.
О AndEngine есть довольно много информации т.к. это один из самых популярных движков для разработки двухмерных игр для Android. Написан он на Java, распостраняется по свободной лицензии и весь код доступен на github. Из вкусностей, которые стали для нас решающими при выборе движка, стоит отметить: быструя отрисовка графики (включая анимированные спрайты), обработку столкновений с полноценной физикой (используя box2d) и поддержку тайлового редактора Tiled.
// Tiled вобще довольно удобный редактор уровней и заслуживает отдельной статьи. Вот так выглядит один из наших уровней:

Но вернемся к AndEngine. Начали мы довольно бодренько и после месяца работы у нас уже был играбельный прототип с несколькими уровнями, пушками и монстрами. И тут, при тестировинии новых уровней, начали проскакивать тормоза при больших скоплениях монстров. Проблема оказалась в том, что мы создавали много физических объектов (монстры, пули и т.д.) общее колличество которых нельзя было предугадать (например, паучье гнездо создает нового паука каждые несколько секунд) и даже если выделять память под них заблаговременно, то все равно сборщик мусора периодически будет вызывать сильное проседание FPS.
Выпиливать физику уже не было времени и мы занялись поиском путей оптимизации существующего кода. В итоге нашли и исправили много проблемных мест в коде, а также значительно улучшили работу с памятью. Дальше я буду рассказывать о конкретных подходах к решению проблем. Возможно эти советы покажутся кому-то банальными, но несколько месяцев назад такая статья сэкономила бы нам уйму времени.
CullingВ AndEngine есть опция, которая позволяет пропускать отрисовку для спрайтов, которые не попадают в поле зрения камеры — Culling. Актуально для игр с уровнями, которые по размерам значительно превышают игровой экран. В нашем случае одно включение Culling значительно повысило быстродействие, но появилась проблема: как только спрайт хотя бы частично выходит за границы камеры он больше не отрисовывается. Таким образом создавалось впечатление, что игровые объекты неожиданно появляются и исчезают на границах экрана.Чтобы обойти эту проблему мы использовали свой метод для определения условий прекращения отрисовки. Выглядит он так:
private void optimize () { setVisible (RectangularShapeCollisionChecker.isVisible (new Camera (ResourcesManager.getInstance ().camera.getXMin () — mFullWidth, ResourcesManager.getInstance ().camera.getYMin () — mFullHeight, ResourcesManager.getInstance ().camera.getWidth () + mFullWidth, ResourcesManager.getInstance ().camera.getHeight () + mFullHeight), this)); } После профилирования оказалось, что проверка вхождения спрайта в область видимости камеры также отъедает очень много времени. Поэтому написали свой метод в классе камеры, который значительно ускорил общее быстродействие: public boolean contains (int pX, int pY, int pW, int pH) { int w = (int) this.getWidth () + pW * 2; int h = (int) this.getHeight () + pH * 2; if ((w | h | pW | pH) < 0) { return false; } int x = (int) this.getXMin() - pW; int y = (int) this.getYMin() - pH; if (pX < x || pY < y) { return false; } w += x; pW += pX; if (pW <= pX) { if (w >= x || pW > w) return false; } else { if (w >= x && pW > w) return false; } h += y; pH += pY; if (pH <= pY) { if (h >= y || pH > h) return false; } else { if (h >= y && pH > h) return false; } return true; } Работа с памятью У нас было обычной практикой постоянно создавать новые объекты для абсолютно всех классов, включая эффекты, монстров, пули, бонусы. Во время создания объектов и через какое-то время (когда выделенная память будет освобождаться сборщиком мусора Java-машины) наблюдаются заметные просадки FPS вплоть до нескольких кадров в секунду даже на самых мощных смартфонах.Чтобы исключить эту проблему нужно использовать пулы объектов (object pool) — специальный класс для хранения и повторного использования объектов. Во время загрузки уровня создаются экземпляры всех необходимых игровых классов и размещаются в пулах. Когда нужно создать нового монстра, вместо того чтобы выделить новую порцию памяти, мы достаем его из «хранилища». Когда монстра убили, мы помещаем его назад в пул. Так как новая память не выделяется для сборщика мусора просто не находится новой работы.
AndEngine включает в себя класс для работы с пулами. Давайте посмотрим на его реализацию на примере пуль. Так как в игре используется множество видов пуль будем использовать MultiPool. Все классы, которые создаются через пул наследуются от класса PoolSprite:
Много кода
public abstract class PoolSprite extends AnimatedSprite {
public int poolType;
public PoolSprite (float pX, float pY, ITiledTextureRegion pTextureRegion, VertexBufferObjectManager pVertexBufferObjectManager) {
super (pX, pY, pTextureRegion, pVertexBufferObjectManager);
}
public abstract void onRemoveFromWorld ();
}
В классе пули выносим из конструктора всю инициализацию в метод init (). Переопределяем onRemoveFromWorld ():
@Override
public void onRemoveFromWorld () {
try {
mBody.setActive (false);
mBody.setAwake (false);
mPhysicsWorld.unregisterPhysicsConnector (mBulletConnector);
mPhysicsWorld.destroyBody (mBody);
detachChildren ();
detachSelf ();
mIsAlive = false;
} catch (Exception e) {
Log.e («Bullet», «Recycle Exception», e);
} catch (Error e) {
Log.e («Bullet», «Recycle Error», e);
}
}
Суперкласс для всех пулов выглядит так:
public abstract class ObjectPool extends GenericPool
public ObjectConstructor () {
}
public PoolSprite createObject (int type) {
return this.pool.obtainPoolItem (type);
}
public void recycle (PoolSprite poolSprite) {
this.pool.recyclePoolItem (poolSprite.poolType, poolSprite);
}
}
Типы пуль:
public static enum TYPE {
SIMPLE, ZOMBIE, LASER, BFG, ENEMY_ROCKET, FIRE, GRENADE, MINE, WEB, LAUNCHER_GRENADE
}
Конструктор пуль:
public class BulletConstructor extends ObjectConstructor {
public BulletConstructor () {
this.pool = new MultiPool
и
org.andengine.util.adt.pool.PoolUpdateHandler$1 was exhausted, with 2 item not yet recycled. Allocated 1 more.
Поэтому мы немного изменили код движка и изначально расширили эти пулы. В классе org.andengine.input.touch.TouchEvent выделяем 20 объектов в конструкторе:
private static final TouchEventPool TOUCHEVENT_POOL = new TouchEventPool (20);
А также во внутреннем классе TouchEventPool добавляем коструктор:
TouchEventPool (int size) {
super (size);
}
В классе org.andengine.input.touch.controller.BaseTouchController при инициализации mTouchEventRunnablePoolUpdateHandler добавляем аргумент в конструктор:
… = new RunnablePoolUpdateHandler
this.mRenderSurfaceView.setPreserveEGLContextOnPause (true);
Уменьшаем объем занимаемой памяти
Для некоторых текстур имеет смысл использовать урезанный цветовой диапазон: RGBA4444 вместо RGB8888. TexturePacker позволяет это сделать через опцию Image format. Если графическая часть выполнена в стиле с малым количество цветов (например для мультяшной графики), то это позволит значительно сэкономить память и немного увеличить быстродействие.
Долгое время компиляции Одна из самых раздражающих вещей при разработке на AndEngine — это время ожидания от начала компиляции и до тестирования игры. Кроме сборки apk-файла нужно также время на его копирование с компьютера на Android-устройство. В конце разработки приходилось ждать в районе одной минуты. Мы потеряли много времени на этой проблеме. В этом плане другие движки вроде Unity казались нам раем — сборка происходит очень быстро и тестировать можно сразу на десктопе. Решается эта проблема только переходом на другой движок, что мы и сделали при разработке следующей игры.Отсувствие развития AndEngine Последний комит в репозитории датируется 11 декабря 2013 года, запись в официальном блоге — 22 января. Очевидно, что проект замер.Что же в итоге? После окончания разработки мы решили, что больше не будем работать с AndEngine. Он хорош для небольших игр, но обладает некоторыми недостатками, которых нет в альтернативных движках.Мы провели сравнение самых популярных движков и выбрали libGDX. Сообщество огромно, движек активно развивается, хорошая документация + много примеров. Большим плюсом было то, что libGDX написан на Java. Так как есть возможность собирать игру на десктопах, то разработка и тестирование игры значительно ускоряется. Я уже не говорю о том, что разработка ведется сразу на все популярные мобильные платформы. Конечно, есть свои нюансы и нужно будет написать немного специфического кода для каждой платформы, но это намного быстрее и дешевле чем полноценная разработка под новую платформу. Сейчас мы заканчиваем работу над второй игрой на libGDX и пока он нас только радует.
Спасибо всем за внимание!
