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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.


Привет Хабр! В этой публикации хочу поделиться опытом разработки массивной мобильной игры, с большим городом и трафиком. Примеры и приемы описанные в публикации не претендуют называться эталонными и идеальными. Я не являюсь дипломированным специалистом и не призываю повторять свой опыт. Целью работы над игрой было — получение интересного опыта, получение оптимизированной игры с открытым миром. При разработке я старался максимально упрощать код. К сожалению, я не использовал 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<AudioSource>();
        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<AudioSource>();
        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<Enemy> enemyPool = new List<Enemy>();

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

Скрины:



Источник: https://habr.com/ru/post/439220/


Интересные статьи

Интересные статьи

Занимательное дело — создавать образовательные модели. Приятно видеть, что человек понял что-то, взаимодействуя с твоей программой. Начинал делать модели в Matlab, пробов...
Мне приходится работать с огромной кодовой базой, написанной на Python. Этот код, с помощью системы непрерывной интеграции, проверяется с помощью Pylint. Подобная проверка всегда была нем...
Номер 8 журнала «Радиолюбитель» за 1924 год был посвящён «кристадину» Лосева. Слово «кристадин» было составлено из слов «кристалл» и «гетеродин», а «кристадинный эффект» заключался в том, что...
Уже не помню когда, и при каких обстоятельствах услышал, что работу нужно менять каждые пять лет. Этого времени достаточно, чтобы достичь профессионализма и… чтобы работа стала рутиной, наскучила...
«Семнадцатилетний Марти МакФлай пришел вчера домой пораньше. На 30 лет раньше.» У меня есть несколько увлечений — фильм «Назад в будущее», старые телефоны и рисование. Предлагаю отправитьс...