Як ми боролися з гальмами в AndEngine

Недавно наша команда закінчила розробку двомірної бродилки-стрілялки для Android на движку 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 і поки він нас тільки радує.
 
Спасибі всім за увагу!

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.