[Из песочницы] Как мы боролись с тормозами в AndEngine

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

// Tiled вобще довольно удобный редактор уровней и заслуживает отдельной статьи. Вот так выглядит один из наших уровней:

2d Tile editor – 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 { 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 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(); 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(20) После этих манипуляций выделение памяти классами отвечающими за касания стало намного скромнее.Что делать при потере фокуса На этом оптимизация непосредственно игрового процесса закончилась и мы перешли к другим аспектам игры. Серьезные проблемы проявлялись после подключения Google Play Service и Tapjoy. Когда игрок взаимодействует с экранами этих сервисов, то активность игры теряет фокус. После возвращения в активность происходит повторная загрузка текстур — на непродолжительное время все подвисает. Для решения этой проблемы добавляем такой код в главной активности приложения:

this.mRenderSurfaceView.setPreserveEGLContextOnPause (true); Уменьшаем объем занимаемой памяти Для некоторых текстур имеет смысл использовать урезанный цветовой диапазон: RGBA4444 вместо RGB8888. TexturePacker позволяет это сделать через опцию Image format. Если графическая часть выполнена в стиле с малым количество цветов (например для мультяшной графики), то это позволит значительно сэкономить память и немного увеличить быстродействие.Texture Packer

Долгое время компиляции Одна из самых раздражающих вещей при разработке на AndEngine — это время ожидания от начала компиляции и до тестирования игры. Кроме сборки apk-файла нужно также время на его копирование с компьютера на Android-устройство. В конце разработки приходилось ждать в районе одной минуты. Мы потеряли много времени на этой проблеме. В этом плане другие движки вроде Unity казались нам раем — сборка происходит очень быстро и тестировать можно сразу на десктопе. Решается эта проблема только переходом на другой движок, что мы и сделали при разработке следующей игры.Отсувствие развития AndEngine Последний комит в репозитории датируется 11 декабря 2013 года, запись в официальном блоге — 22 января. Очевидно, что проект замер.Что же в итоге? После окончания разработки мы решили, что больше не будем работать с AndEngine. Он хорош для небольших игр, но обладает некоторыми недостатками, которых нет в альтернативных движках.Мы провели сравнение самых популярных движков и выбрали libGDX. Сообщество огромно, движек активно развивается, хорошая документация + много примеров. Большим плюсом было то, что libGDX написан на Java. Так как есть возможность собирать игру на десктопах, то разработка и тестирование игры значительно ускоряется. Я уже не говорю о том, что разработка ведется сразу на все популярные мобильные платформы. Конечно, есть свои нюансы и нужно будет написать немного специфического кода для каждой платформы, но это намного быстрее и дешевле чем полноценная разработка под новую платформу. Сейчас мы заканчиваем работу над второй игрой на libGDX и пока он нас только радует.

Спасибо всем за внимание!

© Habrahabr.ru