Недавно наша команда закончила разработку двухмерной бродилки-стрелялки для Android на движке AndEngine. В процессе был получен определенный опыт по решению проблем с производительностью и некоторыми особеностями движка, которым хочется поделиться с читателями Хабра. Для затравки вставлю кусочек скриншота из игры, а все технические детали и примеры кода уберу под кат.
О AndEngine есть довольно много информации т.к. это один из самых популярных движков для разработки двухмерных игр для Android. Написан он на Java, распостраняется по свободной лицензии и весь код доступен на github. Из вкусностей, которые стали для нас решающими при выборе движка, стоит отметить: быстрая отрисовка графики (включая анимированные спрайты), обработку столкновений с полноценной физикой (используя box2d) и поддержку тайлового редактора Tiled.
// Tiled вобще довольно удобный редактор уровней и заслуживает отдельной статьи. Вот так выглядит один из наших уровней:
Но вернемся к AndEngine. Начали мы довольно бодренько и после месяца работы у нас уже был играбельный прототип с несколькими уровнями, пушками и монстрами. И тут, при тестировинии новых уровней, начали проскакивать тормоза при больших скоплениях монстров. Проблема оказалась в том, что мы создавали много физических объектов (монстры, пули и т.д.) общее колличество которых нельзя было предугадать (например, паучье гнездо создает нового паука каждые несколько секунд) и даже если выделять память под них заблаговременно, то все равно сборщик мусора периодически будет вызывать сильное проседание FPS.
Выпиливать физику уже не было времени и мы занялись поиском путей оптимизации существующего кода. В итоге нашли и исправили много проблемных мест в коде, а также значительно улучшили работу с памятью. Дальше я буду рассказывать о конкретных подходах к решению проблем. Возможно эти советы покажутся кому-то банальными, но несколько месяцев назад такая статья сэкономила бы нам уйму времени.
В AndEngine есть опция, которая позволяет пропускать отрисовку для спрайтов, которые не попадают в поле зрения камеры – Culling. Актуально для игр с уровнями, которые по размерам значительно превышают игровой экран. В нашем случае одно включение Culling значительно повысило быстродействие, но появилась проблема: как только спрайт хотя бы частично выходит за границы камеры он больше не отрисовывается. Таким образом создавалось впечатление, что игровые объекты неожиданно появляются и исчезают на границах экрана.
Чтобы обойти эту проблему мы использовали свой метод для определения условий прекращения отрисовки. Выглядит он так:
После профилирования оказалось, что проверка вхождения спрайта в область видимости камеры также отъедает очень много времени. Поэтому написали свой метод в классе камеры, который значительно ускорил общее быстродействие:
У нас было обычной практикой постоянно создавать новые объекты для абсолютно всех классов, включая эффекты, монстров, пули, бонусы. Во время создания объектов и через какое-то время (когда выделенная память будет освобождаться сборщиком мусора Java-машины) наблюдаются заметные просадки FPS вплоть до нескольких кадров в секунду даже на самых мощных смартфонах.
Чтобы исключить эту проблему нужно использовать пулы объектов (object pool) – специальный класс для хранения и повторного использования объектов. Во время загрузки уровня создаются экземпляры всех необходимых игровых классов и размещаются в пулах. Когда нужно создать нового монстра, вместо того чтобы выделить новую порцию памяти, мы достаем его из “хранилища”. Когда монстра убили, мы помещаем его назад в пул. Так как новая память не выделяется для сборщика мусора просто не находится новой работы.
AndEngine включает в себя класс для работы с пулами. Давайте посмотрим на его реализацию на примере пуль. Так как в игре используется множество видов пуль будем использовать MultiPool. Все классы, которые создаются через пул наследуются от класса PoolSprite:
По такому же принципу были созданы пулы для остальных типов объектов. Частота кадров стабилизировалась, но начинались тормоза на слабых устройствах в первые секунды каждого уровня. Поэтому мы сначала заполняем пулы готовыми к использованию объектами и только после этого прячем экран загрузки уровня.
Во время профилирования игры на слабых смартфонах были замечены значительные проседания быстродействия во время выделения памяти движком в TouchEventPool. Что было понятно из соответствующих сообщений логера:
и
Поэтому мы немного изменили код движка и изначально расширили эти пулы. В классе org.andengine.input.touch.TouchEvent выделяем 20 объектов в конструкторе:
А также во внутреннем классе TouchEventPool добавляем коструктор:
В классе org.andengine.input.touch.controller.BaseTouchController при инициализации mTouchEventRunnablePoolUpdateHandler добавляем аргумент в конструктор:
После этих манипуляций выделение памяти классами отвечающими за касания стало намного скромнее.
На этом оптимизация непосредственно игрового процесса закончилась и мы перешли к другим аспектам игры. Серьезные проблемы проявлялись после подключения Google Play Service и Tapjoy. Когда игрок взаимодействует с экранами этих сервисов, то активность игры теряет фокус. После возвращения в активность происходит повторная загрузка текстур – на непродолжительное время все подвисает. Для решения этой проблемы добавляем такой код в главной активности приложения:
Для некоторых текстур имеет смысл использовать урезанный цветовой диапазон: RGBA4444 вместо RGB8888. TexturePacker позволяет это сделать через опцию Image format. Если графическая часть выполнена в стиле с малым количество цветов (например для мультяшной графики), то это позволит значительно сэкономить память и немного увеличить быстродействие.
Одна из самых раздражающих вещей при разработке на AndEngine – это время ожидания от начала компиляции и до тестирования игры. Кроме сборки apk-файла нужно также время на его копирование с компьютера на Android-устройство. В конце разработки приходилось ждать в районе одной минуты. Мы потеряли много времени на этой проблеме. В этом плане другие движки вроде Unity казались нам раем – сборка происходит очень быстро и тестировать можно сразу на десктопе. Решается эта проблема только переходом на другой движок, что мы и сделали при разработке следующей игры.
Последний комит в репозитории датируется 11 декабря 2013 года, запись в официальном блоге – 22 января. Очевидно, что проект замер.
После окончания разработки мы решили, что больше не будем работать с AndEngine. Он хорош для небольших игр, но обладает некоторыми недостатками, которых нет в альтернативных движках.
Мы провели сравнение самых популярных движков и выбрали libGDX. Сообщество огромно, движек активно развивается, хорошая документация + много примеров. Большим плюсом было то, что libGDX написан на Java. Так как есть возможность собирать игру на десктопах, то разработка и тестирование игры значительно ускоряется. Я уже не говорю о том, что разработка ведется сразу на все популярные мобильные платформы. Конечно, есть свои нюансы и нужно будет написать немного специфического кода для каждой платформы, но это намного быстрее и дешевле чем полноценная разработка под новую платформу. Сейчас мы заканчиваем работу над второй игрой на libGDX и пока он нас только радует.
Спасибо всем за внимание!
О 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:
Много кода
В классе пули выносим из конструктора всю инициализацию в метод init(). Переопределяем onRemoveFromWorld():
Суперкласс для всех пулов выглядит так:
Суперкласс для конструктора, который использует мультипул:
Типы пуль:
Конструктор пуль:
Класс пула пуль:
Создание объекта пули выглядит так:
Удаление:
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<PoolSprite> {
protected int type;
public ObjectPool(int pType) {
type = pType;
}
@Override
protected void onHandleRecycleItem(final PoolSprite pObject) {
pObject.onRemoveFromWorld();
}
@Override
protected void onHandleObtainItem(final PoolSprite pBullet) {
pBullet.reset();
}
@Override
protected PoolSprite onAllocatePoolItem() {
return getType();
}
public abstract PoolSprite getType();
}
Суперкласс для конструктора, который использует мультипул:
public abstract class ObjectConstructor {
protected MultiPool<PoolSprite> pool;
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<PoolSprite>();
this.pool.registerPool(SimpleBullet.TYPE.SIMPLE.ordinal(), new BulletPool(SimpleBullet.TYPE.SIMPLE.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.ZOMBIE.ordinal(), new BulletPool(SimpleBullet.TYPE.ZOMBIE.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.LASER.ordinal(), new BulletPool(SimpleBullet.TYPE.LASER.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.BFG.ordinal(), new BulletPool(SimpleBullet.TYPE.BFG.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.ENEMY_ROCKET.ordinal(), new BulletPool(SimpleBullet.TYPE.ENEMY_ROCKET.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.FIRE.ordinal(), new BulletPool(SimpleBullet.TYPE.FIRE.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.GRENADE.ordinal(), new BulletPool(SimpleBullet.TYPE.GRENADE.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.MINE.ordinal(), new BulletPool(SimpleBullet.TYPE.MINE.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.WEB.ordinal(), new BulletPool(SimpleBullet.TYPE.WEB.ordinal()));
this.pool.registerPool(SimpleBullet.TYPE.LAUNCHER_GRENADE.ordinal(), new BulletPool(SimpleBullet.TYPE.LAUNCHER_GRENADE.ordinal()));
}
}
Класс пула пуль:
public class BulletPool extends ObjectPool {
public BulletPool(int pType) {
super(pType);
}
public PoolSprite getType() {
switch (this.type) {
case 0:
return new SimpleBullet();
case 1:
return new ZombieBullet();
case 2:
return new LaserBullet();
case 3:
return new BfgBullet();
case 4:
return new EnemyRocket();
case 5:
return new FireBullet();
case 6:
return new Grenade();
case 7:
return new Mine();
case 8:
return new WebBullet();
case 9:
return new Grenade(ResourcesManager.getInstance().grenadeBulletRegion);
default:
return null;
}
}
}
Создание объекта пули выглядит так:
SimpleBullet simpleBullet = (SimpleBullet) GameScene.getInstance().bulletConstructor.createObject(SimpleBullet.TYPE.SIMPLE.ordinal());
simpleBullet.init(targetCoords[0], targetCoords[1], mDamage, mSpeed, mOwner, mOwner.getGunSprite().getRotation() + disperse);
Удаление:
gameScene.bulletConstructor.recycle(this);
По такому же принципу были созданы пулы для остальных типов объектов. Частота кадров стабилизировалась, но начинались тормоза на слабых устройствах в первые секунды каждого уровня. Поэтому мы сначала заполняем пулы готовыми к использованию объектами и только после этого прячем экран загрузки уровня.
TouchEventPool и BaseTouchController
Во время профилирования игры на слабых смартфонах были замечены значительные проседания быстродействия во время выделения памяти движком в TouchEventPool. Что было понятно из соответствующих сообщений логера:
TouchEventPool was exhausted, with 2 item not yet recycled. Allocated 1 more.
и
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<TouchEventRunnablePoolItem>(<b>20</b>)
После этих манипуляций выделение памяти классами отвечающими за касания стало намного скромнее.
Что делать при потере фокуса
На этом оптимизация непосредственно игрового процесса закончилась и мы перешли к другим аспектам игры. Серьезные проблемы проявлялись после подключения Google Play Service и Tapjoy. Когда игрок взаимодействует с экранами этих сервисов, то активность игры теряет фокус. После возвращения в активность происходит повторная загрузка текстур – на непродолжительное время все подвисает. Для решения этой проблемы добавляем такой код в главной активности приложения:
this.mRenderSurfaceView.setPreserveEGLContextOnPause(true);
Уменьшаем объем занимаемой памяти
Для некоторых текстур имеет смысл использовать урезанный цветовой диапазон: RGBA4444 вместо RGB8888. TexturePacker позволяет это сделать через опцию Image format. Если графическая часть выполнена в стиле с малым количество цветов (например для мультяшной графики), то это позволит значительно сэкономить память и немного увеличить быстродействие.
Долгое время компиляции
Одна из самых раздражающих вещей при разработке на AndEngine – это время ожидания от начала компиляции и до тестирования игры. Кроме сборки apk-файла нужно также время на его копирование с компьютера на Android-устройство. В конце разработки приходилось ждать в районе одной минуты. Мы потеряли много времени на этой проблеме. В этом плане другие движки вроде Unity казались нам раем – сборка происходит очень быстро и тестировать можно сразу на десктопе. Решается эта проблема только переходом на другой движок, что мы и сделали при разработке следующей игры.
Отсувствие развития AndEngine
Последний комит в репозитории датируется 11 декабря 2013 года, запись в официальном блоге – 22 января. Очевидно, что проект замер.
Что же в итоге?
После окончания разработки мы решили, что больше не будем работать с AndEngine. Он хорош для небольших игр, но обладает некоторыми недостатками, которых нет в альтернативных движках.
Мы провели сравнение самых популярных движков и выбрали libGDX. Сообщество огромно, движек активно развивается, хорошая документация + много примеров. Большим плюсом было то, что libGDX написан на Java. Так как есть возможность собирать игру на десктопах, то разработка и тестирование игры значительно ускоряется. Я уже не говорю о том, что разработка ведется сразу на все популярные мобильные платформы. Конечно, есть свои нюансы и нужно будет написать немного специфического кода для каждой платформы, но это намного быстрее и дешевле чем полноценная разработка под новую платформу. Сейчас мы заканчиваем работу над второй игрой на libGDX и пока он нас только радует.
Спасибо всем за внимание!