Большой город для мобильных устройств на Unity. Опыт разработки и оптимизации
Привет Хабр! В этой публикации хочу поделиться опытом разработки массивной мобильной игры, с большим гордом и трафиком. Примеры и приемы описанные в публикации не претендуют называться эталонными и идеальными. Я не являюсь дипломированным специалистом и не призываю повторять свой опыт. Целью работы над игрой было — получение интересного опыта, получение оптимизированной игры с открытым миром. При разработке я старался максимально упрощать код. К сожалению, я не использовал ECS, а грешил с singleton.
Игра
Игра на тематику мафии. В игре я попытался воссоздать Америку 30–40. По сути игра является экономической стратегий от первого лица. Игрок захватывает бизнес и старается удержать его на плаву.
Реализовано: автомобильный трафик (светофоры, избегание столкновений), human трафик, бар, казино, клуб, квартира игрока, покупка костюма, смена костюма, покупка/покраска/заправка автомобиля, копы, охрана/гангстеры, экономика, продажа/покупка ресурсов.
Архитектура
Я жалею, что не использовал ECS, а пытался в велосипед. В итоге получилось все громоздко и слишком зависимо. У приложения одна точка входа — игровой объект application (go), на котором висит одноименный класс Application. Он отвечает за предварительную загрузку БД, заполнение пулов и первичные настройки. Кроме того, на плечи application (go) ложатся и несколько других singleton классов-компонентов-менеджеров.
- AudioManager
- UIManager
- InputManager
Я фанатично пытался создать такую архитектуру, при которой я смогу управлять различными составляющими из менеджера. К примеру AudioManager управляет всеми звуками, UIManager содержит на себе все UI элементы и методы для управления. Весь ввод обрабатывается через InputManager при помощи событий и делегатов.
Упрощенный AudioManager. Он позволяет добавить сколько угодно Audio компонентов к игровому объекту и при необходимости воспроизводить звук:
public class AudioManager : MonoBehaviour {
public static AudioManager instance = null;
// аудио
public AudioClip metalHitAC;
// компонент звука
private AudioSource metalHitAS;
// контроллер проигрывания звука
public bool isMetalHit = false;
private void Awake()
{
if (instance == null)
instance = this;
else if (instance == this)
Destroy(gameObject);
}
void Start()
{
metalHitAS = AddAudio(metalHitAC, false, false, 0.3f, 1);
}
void LateUpdate()
{
if (isMetalHit)
{
metalHitAS.Play();
isMetalHit = false;
}
}
AudioSource AddAudio(AudioClip clip, bool loop, bool playAwake, float vol, float pitch)
{
var newAudio = gameObject.AddComponent();
newAudio.clip = clip;
newAudio.loop = loop;
newAudio.playOnAwake = playAwake;
newAudio.volume = vol;
newAudio.pitch = pitch;
newAudio.minDistance = 10;
return newAudio;
}
public AudioSource AddAudioToGameObject(AudioClip clip, bool loop, bool playAwake, float vol, float pitch, float minDistance, float maxDistance, GameObject go)
{
var newAudio = go.AddComponent();
newAudio.spatialBlend = 1;
newAudio.clip = clip;
newAudio.loop = loop;
newAudio.playOnAwake = playAwake;
newAudio.volume = vol;
newAudio.pitch = pitch;
newAudio.minDistance = minDistance;
newAudio.maxDistance = maxDistance;
return newAudio;
}
}
При старте метод AddAudio добавляет компонент, и затем из любого места мы может воспроизвести нужный нам звук:
AudioManager.instance.isMetalHit = true;
В данном примере, было бы разумнее вынести oneshot проигрывание в метод.
Как выглядит упрощенный InputManager:
public class InputManager : MonoBehaviour {
public static InputManager instance = null;
public float horizontal, vertical;
public delegate void ClickAction();
public static event ClickAction OnAimKeyClicked;
//public delegate void ClickActionFloatArg(float arg);
//public static event ClickActionFloatArg OnRSliderValueChange, OnGSliderValueChange, OnBSliderValueChange;
public void AimKeyDown()
{
OnAimKeyClicked();
}
}
На кнопку я вешаю метод AimKeyDown, а скрипт управляющий оружием подписываю на OnAimKeyClicked:
InputManager.instance.OnAimKeyClicked += GunShot;
Вся система ввода у меня реализована подобным способом. Каких либо проблем со скоростью я не заметил. Это позволило собрать все обработчики нажатий в одном месте — InputManager.
Оптимизация
Перейдем к самому интересному. Для новичков тема оптимизации в Unity болезненна и таит множество подводных камней. Я поделюсь тем, с чем я имел дело.
1. Кэширования компонентов (начнем с простых основ)
Часто на Toster можно встретить вопросы с примерами когда, где GetComponent используют в Update. Так делать нельзя, GetComponent занимается поиском компонента на объекте. Эта операция медленная и вызывая ее в Update, вы рискуете потерять драгоценные FPS. Вот тут есть неплохое объяснение кэширования компонентов.
2. Использование SendMessage
Использование SendMessage () медленнее чем GetComponent (). SendMessage проходи через каждый скрипт, чтобы найти метод с нужным именем, используя сравнение строк. GetComponent находит скрипт через сравнение типов и вызывает метод напрямую.
3. Сравнение тегов объекта
Используйте метод CompareTag вместо obj.tag == «string». В Unity извлечение строк из игровых объектов создает дубликат строки, что прибавляет работы для сборщика мусора. Лучше избегать получения названия игрового объекта. Нельзя вызывать CompareTag в Update как и прочите тяжелые операции.
4. Материалы
Чем меньше материалов тем лучше. Сократите количество материалов насколько это возможно. Добиться этого помогают текстурные атласа. К примеру почти весь город в моей игре собран из 2–3 атласов. Тут нужно учесть, что не все мобильные устройства способны работать с большими атласами. Поэтому если вы хотите поддерживать устройства 11–13 годов, стоит это учитывать. Я решил отказать от поддержки андроид ниже 5.1, так как в основном это старые устройства. Тем более, игра работает на OpenGL 3.x из-за Linear Rendering.
5. Физика
Тут легко просадить FPS до 10. Как оказалось, даже статичные объекты взаимодействуют и участвуют в расчетах. Я ошибочно думал, что статичные физические объекты (объекты у которых есть компонент RigidBody) полностью пассивны до востребования. В заблуждение меня ввел старый туториал в котором говорилось, что везде где есть коллайдер должен быть RigidBody. Теперь все мои статичные объекты это Static+ BoxCollider. Там где мне нужна физика, к примеру фонарные столбы которые можно сбить, я думаю подрубать компонент RigidBody при необходимости.
Слои — спасательный круг при оптимизации. Отключайте ненужное взаимодействие при помощи слоев. При рейкастинге используйте маски слоев. Зачем нам лишние просчеты? Помните, что если у вашего объекта сложная коллайдерная сетка и вы стреляете в него лучем, то лучше создать простой родительский коллайдер для «ловли» лучей. Чем сложнее колладер, тем больше просчетов.
6. Occlusion culling + Lod
При крупной сцене, без occlusion culling не обойтись. Для отключения объектов (деревья, столбы и.т.д) на большом расстоянии я использую Lod.
7. Пул объектов
Все готовые реализации пула объектов которые я нашел, используют instantiate. Также они удаляют и создают объекты. Я боюсь instantiate во всех его проявлениях. Медленная операция, которая фризит игру, при более менее крупном объекте. Я решил пойти по простому и быстрому пути — весь мой пул существует в виде физических gameobjects которые я просто отключаю и включаю при необходимости. Это бьет по оперативной памяти, но лучше уж так. Оперативной памяти у современных устройств от 1GB, игра потребляет 300–500 МБ.
Простой пул для управления боевыми ботами:
public List enemyPool = new List();
private void Start()
{
// получаем родительский объект Enemy
Transform enemyGameObjectContainer = Application.instance.objectPool.Find("Enemy");
// заполняем enemyPool объектами
for (int i = 0; i < enemyGameObjectContainer.childCount; i++)
{
enemyPool.Add(new Enemy() { Id = i, ParentRoomId = 0, GameObj = enemyGameObjectContainer.GetChild(i).gameObject });
}
}
public void SpawnEnemyForRoom(int roomId, int amount, Transform spawnPosition, bool combatMode)
{
//Stopwatch sw = new Stopwatch();
//sw.Start();
foreach (Enemy enemy in enemyPool)
{
if (amount > 0)
{
if (enemy.ParentRoomId == 0 && enemy.GameObj.activeSelf == false)
{
// id комнаты родителя
enemy.ParentRoomId = roomId;
enemy.GameObj.transform.position = spawnPosition.position;
enemy.GameObj.transform.rotation = spawnPosition.rotation;
enemy.AICombat = enemy.GameObj.GetComponent();
enemy.AICombat.parentRoomId = roomId;
// id объекта
enemy.AICombat.id = enemy.Id;
// активация объекта
enemy.GameObj.SetActive(true);
// активация боевого режима если нужно
if (combatMode) enemy.AICombat.ActivateCombatMode();
amount--;
}
}
if (amount == 0) break;
}
}
База данных
В качестве БД я использую sqlite — удобно и быстро. Данные представлены в виде таблицы, можно составлять сложные запросы. В классе для работы с БД 800 строк когда. Я не представляю как бы это смотрелось на XML/JSON.
Проблемы и планы на будущее
Для перемещения из города в «комнаты» я выбрал реализацию «телепортами». Игрок подходит к двери, загружается сцена-комната и игрок телепортируется. Это спасает от необходимости держать комнаты в городе. Если реализовать комнаты в городе, а это +15 комнат с наполнением, то потребление памяти повысится до 1GB минимум. Эта реализация мне не нравится, она не реалистичная и накладывает кучу ограничений. Недавно Unity показали демо своего Megacity, это впечатляет. Я хочу постепенно перевести игру на UCS и для загрузки зданий и помещений использовать технологию из Megacity. Это увлекательный и интересный опыт, я думаю получится по настоящему живой город. Почему я не использовал async load scene? Все просто, это не работает, нет никакого async load scene из коробки в 2018.3 версии. Изначально я понадеялся async load scene при планировании города, но как оказывается, на больших сценах он фризит игру как и обычный load scene. Это подтвердили на форуме Unity, обойти можно, но нужны костыли.
Немного статистики:
Textures: 304 / 374.3 MB
Meshes: 295 / 304.0 MB
Materials: 101 / 148.0 KB (тут скорее всего несоответствие)
AnimationClips: 24 / 2.8 MB
AudioClips: 22 / 30.3 MB
Assets: 21761
GameObjects in Scene: 29450
Total Objects in Scene: 111645
Total Object Count: 133406
GC Allocations per Frame: 70 / 2.0 KB
Всего 4800 строк кода на C#.
Кто то мне сказал, что такую игру можно сделать за неделю. Возможно я не производительный, возможно этот человек талантливый, но для себя я понял одно — в одиночку строить подобные игры сложно. Мне хотелось создать нечто интересное на фоне казуальных «пальцатыкалок», мне кажется я приблизился к своей мечте.
Провести тест открытой беты и пощупать можно тут: play.google.com/store/apps/details? id=com.ag.mafiaProject01 (если сборка вдруг не работает, нужно немного обожать, обновления прилетают каждый вечер). Я надеюсь это не сочтут рекламной ссылкой, так как это бета и скачивания не принесут мне рейтинг и дивиденды. К тому же я не думаю что habr это целевая аудитория моей игры.
Скрины: