Большой город для мобильных устройств на Unity. Опыт разработки и оптимизации

oidakx1nj0lkcsy_rux-iiwkyhs.png

Привет Хабр! В этой публикации хочу поделиться опытом разработки массивной мобильной игры, с большим гордом и трафиком. Примеры и приемы описанные в публикации не претендуют называться эталонными и идеальными. Я не являюсь дипломированным специалистом и не призываю повторять свой опыт. Целью работы над игрой было — получение интересного опыта, получение оптимизированной игры с открытым миром. При разработке я старался максимально упрощать код. К сожалению, я не использовал ECS, а грешил с singleton.

Игра


Игра на тематику мафии. В игре я попытался воссоздать Америку 30–40. По сути игра является экономической стратегий от первого лица. Игрок захватывает бизнес и старается удержать его на плаву.
Реализовано: автомобильный трафик (светофоры, избегание столкновений), human трафик, бар, казино, клуб, квартира игрока, покупка костюма, смена костюма, покупка/покраска/заправка автомобиля, копы, охрана/гангстеры, экономика, продажа/покупка ресурсов.

Архитектура


image

Я жалею, что не использовал 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.

image

image

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 это целевая аудитория моей игры.

Скрины:

mj0v6m5udqzb3s9igiryqbqth_8.jpeg

r_zw3w2czfi1gaakjy2i9g35y2a.png

© Habrahabr.ru