Большой город для мобильных устройств на 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 , это впечатляет. Я хочу постепенно перевести игру на 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 это целевая аудитория моей игры.

    Скрины:



    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

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

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


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

      Спасибо за статью. Как раз планирую браться за юнити, полезной информации много не бывает.
        +1
        Благодарю за поддержку!
        +2
        Все готовые реализации пула объектов которые я нашел, используют instantiate. Также они удаляют и создают объекты

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

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

                0
                Получил, спасибо! Я вот думаю перевести игру на вид сверху, думаю играться будет легче и интересней. Тем более город позволяет, крыши нарисованы.
                0
                А вы видели ECS, я через него спавнил 50к домиков(гибридным способом) в 60фпс на юнити.
                Не пробовали его использовать?
                  0
                  Я хочу перейти на ECS. Можете рассказать подробней? Было бы очень интересно почитать статью!
                    0

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

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

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

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

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

                            0
                            В районе 200 DC
                              0
                              Не многовато для мобилок?
                                0
                                Еще в 2012 писали, что лучше не переваливать за 200 DC. 40 фпс держит:
                                image
                                  0
                                  На мобилках же 30 фпс ограничение, кроме топовых с их 60 фпс.
                                    0
                                    Это данные из консоли GP (инфа от устройств тестеров), ограничивать я буду вручную, для экономии батареи.
                            0
                            у меня Doogee x5 MAX PRO, android 6, mt6737, GLES 3.1 видео, mali t710 mp1. Просто не дало установить, пишет «не поддерживается на вашем устройстве»
                              0
                              Это из OpenGL image
                                0
                                так чего на ставится ваша игруха? Вроде ж версия андроида подходит, видеокарта тоже.
                                  0
                                  OpenGL у вас 3.0, а нужна 3.1 или выше. Но скорее всего я упрощу игру и скачать будет возможно. Я думаю переделать игру на вид сверху.
                                    0
                                      0
                                      Могу сделать нотариально заверенные скриншоты с экрана. Если это поможет, версия адроди 6.0, api 23
                                        0
                                        Странно, значит ошибка в базе данных Google Play. Скрин из каталога устройств GP.
                            0
                            Выглядит как City of Lost Heaven

                            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                            Самое читаемое