Big city for mobile devices on Unity. Experience in development and optimization

    Hi Habr! In this publication I want to share the experience of developing a massive mobile game, with a big city and traffic. The examples and techniques described in the publication do not claim to be called reference and ideal. I am not a certified specialist and do not urge to repeat my experience. The goal of the game was to get interesting experience, to get an optimized game with an open world. During development, I tried to simplify the code as much as possible. Unfortunately, I did not use ECS, but sinned with singleton.

    The game

    A game on the theme of the mafia. In the game, I tried to recreate America 30-40. Essentially, a game is a first-person economic strategy. The player captures the business and tries to keep it afloat.
    Implemented: car traffic (traffic lights, collision avoidance), human traffic, bar, casino, club, player’s apartment, buying a suit, changing a suit, buying / painting / refueling, cops, security / gangsters, economics, selling / buying resources.



    I regret that I did not use ECS, but tried to bike. In the end, everything turned out to be cumbersome and too dependent. The application has one entry point - the application (go) game object, on which the Application class of the same name hangs. He is responsible for preloading the database, populating pools and initial settings. In addition, several other singleton manager component classes fall on the shoulders of application (go).

    • Audiomanager
    • UIManager
    • Inputmanager

    I fanatically tried to create such an architecture in which I can manage various components from the manager. For example, AudioManager manages all sounds, UIManager contains all the UI elements and methods for management. All input is processed through the InputManager using events and delegates.

    Simplified AudioManager. It allows you to add as many Audio components to the game object and, if necessary, play sound:

    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)
        void Start()
            metalHitAS = AddAudio(metalHitAC, false, false, 0.3f, 1);
        void LateUpdate()
            if (isMetalHit)
                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;

    At startup, the AddAudio method adds a component, and then from anywhere we can play the sound we need:

    AudioManager.instance.isMetalHit = true;

    In this example, it would be wiser to put the oneshot playing back into the method.

    What a simplified InputManager looks like:

    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()

    I put the AimKeyDown method on the button , and sign the weapon control script on OnAimKeyClicked:

    InputManager.instance.OnAimKeyClicked += GunShot;

    My entire input system is implemented in a similar way. I did not notice any problems with speed. This allowed us to collect all the click handlers in one place - the InputManager.


    Let's move on to the most interesting. For beginners, the topic of optimization in Unity is painful and fraught with many pitfalls. I will share what I was dealing with.

    1. Caching components (let's start with simple basics)

    Often on Toster you can come across questions with examples when, where GetComponent is used in Update. You can’t do this, GetComponent is looking for a component on the object. This operation is slow and causing it in Update, you risk losing precious FPS. Here is a good explanation of component caching .

    2. Using SendMessage

    Using SendMessage () is slower than GetComponent (). SendMessage go through each script to find the method with the desired name using string comparison. GetComponent finds the script through type comparison and calls the method directly.

    3. Comparison of object tags

    Use the CompareTag method instead of obj.tag == “string”. In Unity, extracting strings from game objects creates a duplicate string, which adds work to the garbage collector. It is better to avoid getting the name of the game object. You cannot call CompareTag in Update as well as read heavy operations.

    4. Materials

    The less materials the better. Reduce the amount of materials as possible. To achieve this, help texture satin. For example, almost the whole city in my game is made up of 2-3 atlases. It should be noted that not all mobile devices are able to work with large atlases. Therefore, if you want to support devices 11-13 years old, it is worth considering. I decided to refuse support for android below 5.1, since these are mostly old devices. Moreover, the game runs on OpenGL 3.x because of Linear Rendering.

    5. Physics

    It is easy to draw FPS down to 10. It turned out that even static objects interact and participate in calculations. I mistakenly thought that static physical objects (objects that have a RigidBody component) are completely passive on demand. I was led astray by the old tutorial which said that wherever there is a collider there should be RigidBody. Now all my static objects are Static + BoxCollider. Where I need physics, for example, lampposts that can be knocked down, I think to cut the RigidBody component if necessary.

    Layers are the lifeline for optimization. Disable unnecessary interaction using layers. When recasting, use layer masks. Why do we need extra miscalculations? Remember that if your object has a complex collider grid and you shoot at it with a ray, it is better to create a simple parent collider to "catch" the rays. The more complex the collider, the more miscalculations.

    6. Occlusion culling + Lod

    In a large scene, occlusion culling is indispensable. To disable objects (trees, poles, etc.) at a great distance, I use Lod.



    7. Object Pool

    All ready-made implementations of the pool of objects that I found use instantiate. They also delete and create objects. I am afraid of instantiate in all its manifestations. Slow operation, which freezes the game, with a more or less large object. I decided to go along a simple and quick path - my entire pool exists in the form of physical gameobjects that I just turn off and on if necessary. It hits RAM, but it’s better. RAM for modern devices from 1GB, the game consumes 300-500 MB.

    Simple pool for managing combat bots:

     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();
                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.Id;
                            // активация объекта
                            // активация боевого режима если нужно
                            if (combatMode) enemy.AICombat.ActivateCombatMode();
                    if (amount == 0) break;


    I use sqlite as a database - conveniently and quickly. The data is presented in the form of a table, you can make complex queries. In the class for working with the database, 800 lines when. I can’t imagine how it would look in XML / JSON.

    Problems and plans for the future

    To move from the city to the “rooms” I chose the implementation of “teleports”. The player approaches the door, the scene room is loaded and the player is teleported. This saves you from having to keep rooms in the city. If you implement rooms in the city, which is +15 rooms with filling, then memory consumption will increase to a minimum of 1GB. I do not like this implementation, it is not realistic and imposes a bunch of restrictions. Unity recently showed a demo of its Megacity , it's impressive. I want to gradually transfer the game to esc and use technology from Megacity to load buildings and premises. This is a fascinating and interesting experience, I think it will turn out to be a truly vibrant city. Why I did not use async load scene? It's simple, it does not work, there is no async load scene out of the box in 2018.3 version. Initially, I hoped async load scene when planning a city, but as it turns out, on large scenes it freezes the game like a regular load scene. This was confirmed on the Unity forum, you can get around, but crutches are needed.

    Some statistics:

    Textures: 304 / 374.3 MB
    Meshes: 295 / 304.0 MB
    Materials: 101 / 148.0 KB (most likely a discrepancy here)
    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

    A total of 4800 lines of C # code.

    Someone told me that such a game can be done in a week. Maybe I'm not productive, maybe this person is talented, but for myself I understood one thing - it’s difficult to build such games alone. I wanted to create something interesting against the background of casual “fingers”, it seems to me that I approached my dream.

    You can run an open beta test and feel it here: (if the assembly doesn’t work, you need to adore it a bit, updates arrive every night). I hope this is not considered an advertising link, since this beta and downloads will not bring me a rating and dividends. In addition, I do not think that habr is the target audience of my game.


    Also popular now: