Cidade grande para dispositivos móveis no Unity. Experiência em desenvolvimento e otimização



Oi Habr! Nesta publicação, quero compartilhar a experiência de desenvolver um jogo móvel massivo, com uma cidade grande e tráfego. Os exemplos e técnicas descritas na publicação não pretendem ser chamados de referência e ideal. Não sou um especialista certificado e não desejo repetir minha experiência. O objetivo do jogo era obter experiência interessante, obter um jogo otimizado com um mundo aberto. Durante o desenvolvimento, tentei simplificar o código o máximo possível. Infelizmente, eu não usei ECS, mas pequei com singleton.

O jogo


Um jogo sobre o tema da máfia. No jogo, tentei recriar a América 30-40. Essencialmente, um jogo é uma estratégia econômica em primeira pessoa. O jogador captura o negócio e tenta mantê-lo à tona.
Implementado: tráfego de carros (semáforos, prevenção de colisões), tráfego humano, bar, cassino, clube, apartamento do jogador, compra de um terno, troca de terno, compra / pintura / reabastecimento, policiais, segurança / bandidos, economia, recursos de venda / compra.

Arquitetura


imagem

Lamento não ter usado o ECS, mas tentei andar de bicicleta. No final, tudo acabou sendo complicado e muito dependente. O aplicativo possui um ponto de entrada - o objeto de jogo do aplicativo (go), no qual a classe Application com o mesmo nome está pendurada. Ele é responsável pelo pré-carregamento do banco de dados, preenchendo pools e configurações iniciais. Além disso, várias outras classes de componentes do gerenciador singleton ficam sobre os ombros do aplicativo (go).

  • Audiomanager
  • UIManager
  • Inputmanager

Fanaticamente, tentei criar uma arquitetura na qual eu pudesse gerenciar vários componentes do gerente. Por exemplo, o AudioManager gerencia todos os sons, o UIManager contém todos os elementos e métodos da interface do usuário para gerenciamento. Todas as entradas são processadas através do InputManager usando eventos e delegados.

AudioManager simplificado. Permite adicionar tantos componentes de áudio ao objeto do jogo e, se necessário, reproduzir som:

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; } } 

Na inicialização, o método AddAudio adiciona um componente e, em qualquer lugar, podemos reproduzir o som que precisamos:

 AudioManager.instance.isMetalHit = true; 

Neste exemplo, seria mais sensato colocar a reprodução em execução no método.

Como é um InputManager simplificado:

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

Coloquei o método AimKeyDown no botão e assino o script de controle de armas no OnAimKeyClicked:

 InputManager.instance.OnAimKeyClicked += GunShot; 

Todo o meu sistema de entrada é implementado de maneira semelhante. Não notei nenhum problema com a velocidade. Isso nos permitiu coletar todos os manipuladores de cliques em um único local - o InputManager.

Otimização


Vamos para o mais interessante. Para iniciantes, o tópico de otimização do Unity é doloroso e cheio de muitas armadilhas. Vou compartilhar com o que estava lidando.

1. Armazenamento em cache de componentes (comece com noções básicas simples)

Muitas vezes, no Toster, você pode encontrar perguntas com exemplos de quando, onde GetComponent é usado na atualização. Você não pode fazer isso, GetComponent está procurando um componente no objeto. Essa operação é lenta e, na atualização, você corre o risco de perder o precioso FPS. Aqui está uma boa explicação do armazenamento em cache do componente .

2. Usando o SendMessage

Usar SendMessage () é mais lento que GetComponent (). SendMessage percorre cada script para encontrar o método com o nome desejado usando a comparação de cadeias. GetComponent localiza o script através da comparação de tipos e chama o método diretamente.

3. Comparação de tags de objeto

Use o método CompareTag em vez de obj.tag == "string". No Unity, a extração de cadeias de objetos de jogos cria uma cadeia duplicada, que adiciona trabalho ao coletor de lixo. É melhor evitar o nome do objeto do jogo. Você não pode chamar o CompareTag na atualização nem ler operações pesadas.

4. Materiais

Quanto menos materiais, melhor. Reduza a quantidade de materiais possível. Para conseguir isso, ajude a textura acetinada. Por exemplo, quase toda a cidade do meu jogo é composta de 2 a 3 atlas. Note-se que nem todos os dispositivos móveis são capazes de trabalhar com grandes atlas. Portanto, se você deseja oferecer suporte a dispositivos de 11 a 13 anos, vale a pena considerar. Decidi recusar o suporte para o Android abaixo da versão 5.1, pois esses dispositivos são na sua maioria antigos. Além disso, o jogo roda no OpenGL 3.x por causa da renderização linear.

5. Física

É fácil reduzir o FPS para 10. Acontece que mesmo objetos estáticos interagem e participam de cálculos. Eu pensei erroneamente que objetos físicos estáticos (objetos que possuem um componente RigidBody) são completamente passivos sob demanda. Fui desviado pelo antigo tutorial, que dizia que onde quer que haja um colisor, deve haver RigidBody. Agora todos os meus objetos estáticos são Static + BoxCollider. Onde eu preciso de física, por exemplo, postes de iluminação que podem ser derrubados, acho que cortarei o componente RigidBody, se necessário.

Camadas são a tábua de salvação para otimização. Desative a interação desnecessária usando camadas. Ao reformular, use máscaras de camada. Por que precisamos de erros de cálculo extras? Lembre-se de que se o seu objeto tiver uma grade colisor complexa e você atirar nele com um raio, é melhor criar um colisor pai simples para "capturar" os raios. Quanto mais complexo o colisor, mais erros de cálculo.

6. Seleção de oclusão + Lod

Em uma cena ampla, o abate por oclusão é indispensável. Para desativar objetos (árvores, postes, etc.) a uma grande distância, eu uso o Lod.

imagem

imagem

7. Pool de Objetos

Todas as implementações prontas do pool de objetos que eu achei usam instanciar. Eles também excluem e criam objetos. Eu tenho medo de instanciar todas as suas manifestações. Operação lenta, que congela o jogo, com um objeto mais ou menos grande. Decidi seguir um caminho simples e rápido - toda a minha piscina existe na forma de objetos físicos de jogos que eu apenas desligo e ligo se necessário. Ele atinge a RAM, mas é melhor. RAM para dispositivos modernos de 1 GB, o jogo consome 300-500 MB.

Pool simples para gerenciar bots de combate:

  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; } } 

Banco de Dados


Eu uso o sqlite como um banco de dados - de forma conveniente e rápida. Os dados são apresentados na forma de uma tabela, você pode fazer consultas complexas. Na classe para trabalhar com o banco de dados, 800 linhas quando. Não consigo imaginar como ficaria em XML / JSON.

Problemas e planos para o futuro


Para passar da cidade para as "salas", escolhi a implementação de "teleporte". O jogador se aproxima da porta, a sala de cena é carregada e o jogador é teleportado. Isso evita que você tenha que manter quartos na cidade. Se você implementar salas na cidade, com mais de 15 salas com enchimento, o consumo de memória aumentará para um mínimo de 1 GB. Não gosto desta implementação, não é realista e impõe um monte de restrições. Unity recentemente mostrou uma demonstração do seu Megacity , é impressionante. Quero transferir gradualmente o jogo para esc e usar a tecnologia da Megacity para carregar edifícios e instalações. Esta é uma experiência fascinante e interessante, acho que será uma cidade verdadeiramente vibrante. Por que não usei cena de carregamento assíncrono ? É simples, não funciona, não há cena de carregamento assíncrona pronta para uso na versão 2018.3. Inicialmente, eu esperava uma cena de carregamento assíncrono ao planejar uma cidade, mas, como se vê, em cenas grandes, congela o jogo como uma cena de carregamento regular. Isso foi confirmado no fórum do Unity, você pode se locomover, mas são necessárias muletas.

Algumas estatísticas:

Texturas: 304 / 374.3 MB
Malhas: 295 / 304.0 MB
Materiais: 101 / 148,0 KB (provável discrepância aqui)
Clipes de animação: 24 / 2.8 MB
Clipes de áudio: 22 / 30,3 MB
Ativos: 21761
Objetos de Jogo em Cena: 29450
Total de objetos em cena: 111645
Contagem total de objetos: 133406
Alocações de GC por quadro: 70 / 2.0 KB
Um total de 4800 linhas de código C #.

Alguém me disse que esse jogo pode ser feito em uma semana. Talvez eu não seja produtivo, talvez essa pessoa seja talentosa, mas eu entendi uma coisa: é difícil criar esses jogos sozinhos. Eu queria criar algo interessante no contexto de “dedos” casuais, parece-me que me aproximei do meu sonho.

Você pode testar a versão beta aberta e senti-la aqui: play.google.com/store/apps/details?id=com.ag.mafiaProject01 (se a montagem não funcionar, você precisa adorá-la um pouco, as atualizações chegam todas as noites). Espero que isso não seja considerado um link de publicidade, pois esta versão beta e os downloads não me trarão uma classificação e dividendos. Além disso, não acho que habr seja o público-alvo do meu jogo.

Imagens:



Source: https://habr.com/ru/post/pt439220/


All Articles