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

Скрины:



Комментарии (30)


  1. GRaAL
    05.04.2019 21:02

    Кто то мне сказал, что такую игру можно сделать за неделю.


    Как правило такое говорят люди, которые не пробовали. Не принимайте всерьез.

    Спасибо за статью. Как раз планирую браться за юнити, полезной информации много не бывает.


    1. p4p Автор
      06.04.2019 02:17

      Благодарю за поддержку!


  1. SpyceR
    05.04.2019 23:22

    Все готовые реализации пула объектов которые я нашел, используют instantiate. Также они удаляют и создают объекты

    Смысл пула объектов как раз в том, что бы не создавать наново объекты и не удалять их, а использовать некоторое заданое количество объектов (ну и увеличивать это кол-во по необходимости). Или вы что-то не то искали, или не так воспринимали эти реализации


  1. p4p Автор
    05.04.2019 23:37

    60% сам


  1. LionisIAm
    06.04.2019 10:24

    Интересен вот какой вопрос: Пробовали ли реализовывать похожий функционал, но используя нативную разработку, а не кроссплатформенную? Интересно, можно ли это реализовать с помощью нативных движков? На сколько я знаю, всем известный libdgx больше заточен как 2D движок, нежели 3д. Есть ли какая-то возможность ускорить разработку, но пользуясь нативном (пусть даже с NDK), но не пользуясь такими гигантами, как Unity/Unreal/etc.


    1. p4p Автор
      06.04.2019 14:20

      Я не пробовал, да и зачем? Unity — мощный и удобный инструмент.


  1. Prog-Maker
    06.04.2019 16:24

    В целом даже очень круто. Сама реализация достойная. На данном этапе конечно есть косяки. Я там тебе на почту скинул обнаруженные баги. Но у меня есть мнение, что жанр не приживется на мобилках, из за сложного управления игра быстро надоедает. Может все таки переключиться на пк.


    1. p4p Автор
      06.04.2019 20:06

      Получил, спасибо! Я вот думаю перевести игру на вид сверху, думаю играться будет легче и интересней. Тем более город позволяет, крыши нарисованы.


  1. vitaxaxxx
    06.04.2019 18:16

    А вы видели ECS, я через него спавнил 50к домиков(гибридным способом) в 60фпс на юнити.
    Не пробовали его использовать?


    1. p4p Автор
      06.04.2019 18:17

      Я хочу перейти на ECS. Можете рассказать подробней? Было бы очень интересно почитать статью!


      1. Prog-Maker
        06.04.2019 19:45

        Ecs если теперь перекочевало в unity 2019, и то и то на стадии беты. Я бы не советовал пока его использовать. В каждом обновлении, что то меняется.


        1. p4p Автор
          06.04.2019 20:08

          У меня печальный опыт перехода на нестабильные версии, буду осторожен)



  1. Prog-Maker
    06.04.2019 19:49

    Минимальная поддерживаемая версия unity 2019b


  1. OpenMind4423
    07.04.2019 17:14

    на моём 6 андроиде не пошло, хотя 2 гб озу должно было хватить. Судя по картинкам, похоже на старые ГТА в стиле мафия 1 одновременно. Если вы хотите оптимизации, то надо юзать текстуры не более 512 и поменьше количеством, буквально нормал/high/metalness. В идеале стиль левелов должен быть как в Half Life 2: минимум полигонов, всё что не видно не существует, многие объекты это перепеченные картинки, если к ним нельзя подобраться. А так супер! Жаль не могу оценить в действии…


    1. p4p Автор
      07.04.2019 17:16

      Очень странно, не пошло в смысле фризы, или вообще не удалось установить? Что за модель телефона у вас? Для меня это очень важно! Если не удается установить то тут виной OpenGL.


      1. KpoKec
        07.04.2019 18:35

        А сколько DC в сцене?
        По пулу: при запросе объекта при отсутствии свободных конечно будет инстанцироваться ещё один.


        1. p4p Автор
          08.04.2019 00:09

          В районе 200 DC


          1. KpoKec
            08.04.2019 00:34

            Не многовато для мобилок?


            1. p4p Автор
              08.04.2019 16:38

              Еще в 2012 писали, что лучше не переваливать за 200 DC. 40 фпс держит:
              image


              1. KpoKec
                08.04.2019 17:09

                На мобилках же 30 фпс ограничение, кроме топовых с их 60 фпс.


                1. p4p Автор
                  08.04.2019 17:29

                  Это данные из консоли GP (инфа от устройств тестеров), ограничивать я буду вручную, для экономии батареи.


      1. OpenMind4423
        07.04.2019 19:47

        у меня Doogee x5 MAX PRO, android 6, mt6737, GLES 3.1 видео, mali t710 mp1. Просто не дало установить, пишет «не поддерживается на вашем устройстве»


        1. p4p Автор
          08.04.2019 00:09

          Это из OpenGL image


          1. OpenMind4423
            08.04.2019 01:47

            так чего на ставится ваша игруха? Вроде ж версия андроида подходит, видеокарта тоже.


            1. p4p Автор
              08.04.2019 17:30

              OpenGL у вас 3.0, а нужна 3.1 или выше. Но скорее всего я упрощу игру и скачать будет возможно. Я думаю переделать игру на вид сверху.



              1. OpenMind4423
                08.04.2019 17:51

                Могу сделать нотариально заверенные скриншоты с экрана. Если это поможет, версия адроди 6.0, api 23


                1. p4p Автор
                  09.04.2019 01:31

                  Странно, значит ошибка в базе данных Google Play. Скрин из каталога устройств GP.


  1. Happy_dayZ
    08.04.2019 10:21

    Выглядит как City of Lost Heaven