Как мы делали игру и боролись с тормозами в AndEngine

в 5:44, , рубрики: AndEngine, android development, game development, Gamedev, оптимизация, Разработка под android

Недавно наша команда закончила разработку игры Galaxy Recon — двухмерной бродилки-стрелялки для Android на движке AndEngine. В процессе был получен определенный опыт по решению проблем с производительностью и некоторыми особеностями движка, которым хочется поделиться с читателями Хабра. Для затравки вставлю кусочек скриншота из игры, а все технические детали и примеры кода уберу под кат.

Как мы делали игру и боролись с тормозами в AndEngine

О 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<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. Если графическая часть выполнена в стиле с малым количество цветов (например для мультяшной графики), то это позволит значительно сэкономить память и немного увеличить быстродействие.

Texture Packer

Долгое время компиляции

Одна из самых раздражающих вещей при разработке на AndEngine – это время ожидания от начала компиляции и до тестирования игры. Кроме сборки apk-файла нужно также время на его копирование с компьютера на Android-устройство. В конце разработки приходилось ждать в районе одной минуты. Мы потеряли много времени на этой проблеме. В этом плане другие движки вроде Unity казались нам раем – сборка происходит очень быстро и тестировать можно сразу на десктопе. Решается эта проблема только переходом на другой движок, что мы и сделали при разработке следующей игры.

Отсувствие развития AndEngine

Последний комит в репозитории датируется 11 декабря 2013 года, запись в официальном блоге – 22 января. Очевидно, что проект замер.

Что же в итоге?

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

Мы провели сравнение самых популярных движков и выбрали libGDX. Сообщество огромно, движек активно развивается, хорошая документация + много примеров. Большим плюсом было то, что libGDX написан на Java. Так как есть возможность собирать игру на десктопах, то разработка и тестирование игры значительно ускоряется. Я уже не говорю о том, что разработка ведется сразу на все популярные мобильные платформы. Конечно, есть свои нюансы и нужно будет написать немного специфического кода для каждой платформы, но это намного быстрее и дешевле чем полноценная разработка под новую платформу. Сейчас мы заканчиваем работу над второй игрой на libGDX и пока он нас только радует.

Кстати, в процессе разработки игры мы вели дневник, который я люблю иногда полистать. Возможно вам тоже будет интересно почитать ;)

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

PS. И небольшая видеонарезка из геймплея:

Автор:

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js