Como otimizamos os scripts no Unity

Existem muitos excelentes artigos e tutoriais sobre desempenho do Unity. Não estamos tentando substituí-los ou aprimorá-los com este artigo; este é apenas um breve resumo das etapas que seguimos após a leitura desses artigos, bem como as etapas que nos permitiram resolver nossos problemas. Eu recomendo fortemente que você pelo menos estude os materiais em https://learn.unity.com/ .

No processo de desenvolvimento do nosso jogo, encontramos problemas que, de tempos em tempos, causavam inibição no processo do jogo. Depois de passar algum tempo no Unity Profiler, encontramos dois tipos de problemas:

  • Shaders não otimizados
  • Scripts não otimizados em C #

A maioria dos problemas foi causada pelo segundo grupo, por isso decidi me concentrar nos scripts C # deste artigo (provavelmente também porque não escrevi um único sombreador em minha vida).

Procure pontos fracos


O objetivo deste artigo não é escrever um tutorial sobre o uso de um criador de perfil; Eu só queria falar sobre o que nos interessava principalmente durante o processo de criação de perfil.

O Unity Profiler é sempre a melhor maneira de encontrar as causas dos atrasos nos scripts. Eu recomendo criar um perfil do jogo diretamente no dispositivo , e não no editor. Desde que nosso jogo foi criado para iOS, eu precisava conectar o dispositivo e usar as Configurações de compilação mostradas na imagem, após as quais o criador de perfil se conectava automaticamente.


Criar configurações para criação de perfil

Se você tentar pesquisar no Google “Atraso aleatório no Unity” ou outro pedido semelhante, verá que a maioria das pessoas recomenda se concentrar na coleta de lixo , exatamente o que eu fiz. O lixo é gerado toda vez que você para de usar algum objeto (instância de classe), após o qual o coletor de lixo do Unity inicia de tempos em tempos para limpar a bagunça e liberar memória, o que leva uma quantidade insana de tempo e leva a uma queda na taxa de quadros.

Como encontrar scripts indesejados no criador de perfil?


Basta selecionar Uso da CPU -> Escolha a exibição Hierarquia -> Classificar por alocação de GC


Opções do criador de perfil para coleta de lixo

Sua tarefa é conseguir alguns zeros na coluna de alocação do GC para a cena do jogo.

Outra boa maneira é classificar as entradas por Time ms (tempo de execução) e otimizar os scripts para que eles levem o mínimo de tempo possível. Essa etapa teve um grande impacto para nós, porque um de nossos componentes continha um loop for grande , que levou uma eternidade para ser concluído (sim, ainda não encontramos uma maneira de nos livrar do loop), portanto, para nós, era absolutamente necessário otimizar o tempo de execução de todos os scripts, porque precisávamos economizar tempo de execução nesse loop for caro, mantendo uma frequência estável de 60 fps.

Com base nos dados de criação de perfil, dividi a otimização em duas partes:

  • Disposição de lixo
  • Prazo de execução reduzido

Parte 1: lutando contra o lixo


Nesta parte, vou contar o que fizemos para nos livrar do lixo. Esse é o conhecimento mais fundamental que qualquer desenvolvedor deve entender; eles se tornaram uma parte importante de nossa análise diária em cada solicitação de extração / mesclagem.

Primeira regra: nenhum novo objeto nos métodos de Atualização


Idealmente, os métodos Update, FixedUpdate e LateUpdate não devem conter as "novas" palavras-chave . Você sempre deve usar o que você já tem.

Às vezes, criar um novo objeto fica oculto em alguns métodos internos do Unity, portanto, não é tão óbvio. Falaremos sobre isso mais tarde.

Segunda regra: crie uma vez e reutilize!


Em essência, isso significa que você deve alocar memória para tudo o que puder nos métodos Iniciar e Despertar. Esta regra é muito semelhante à primeira. Na verdade, essa é apenas outra maneira de eliminar as “novas” palavras-chave dos métodos de atualização.

Código que:

  • cria novas instâncias
  • procurando por qualquer objeto do jogo

Você sempre deve tentar passar dos métodos Update para Iniciar ou Despertar.

Aqui estão exemplos de nossas alterações:

Alocação de memória para listas no método Start, limpeza (Clear) e reutilização, se necessário.

//Bad code private List<GameObject> objectsList; void Update() { objectsList = new List<GameObject>(); objectsList.Add(......) } //Better Code private List<GameObject> objectsList; void Start() { objectsList = new List<GameObject>(); } void Update() { objectsList.Clear(); objectsList.Add(......) } 

Armazenando links e reutilizando-os da seguinte maneira:

 //Bad code void Update() { var levelObstacles = FindObjectsOfType<Obstacle>(); foreach(var obstacle in levelObstacles) { ....... } } //Better code private Object[] levelObstacles; void Start() { levelObstacles = FindObjectsOfType<Obstacle>(); } void Update() { foreach(var obstacle in levelObstacles) { ....... } } 

O mesmo se aplica ao método FindGameObjectsWithTag ou a qualquer outro método que retorne uma nova matriz.

A terceira regra: cuidado com as cordas e evite concatená-las


Quando se trata de criar lixo, as linhas são terríveis. Mesmo as operações mais simples de strings podem criar muito lixo. Porque Strings são apenas matrizes, e essas matrizes são imutáveis. Isso significa que toda vez que você concatena duas linhas, uma nova matriz é criada e a antiga se transforma em lixo. Felizmente, o StringBuilder pode ser usado para evitar ou minimizar essa criação de lixo.

Aqui está um exemplo de como você pode melhorar a situação:

 //Bad code void Start() { text = GetComponent<Text>(); } void Update() { text.text = "Player " + name + " has score " + score.toString(); } //Better code void Start() { text = GetComponent<Text>(); builder = new StringBuilder(50); } void Update() { //StringBuilder has overloaded Append method for all types builder.Length = 0; builder.Append("Player "); builder.Append(name); builder.Append(" has score "); builder.Append(score); text.text = builder.ToString(); } 

Está tudo bem com o exemplo mostrado acima, mas ainda existem muitas possibilidades para melhorar o código. Como você pode ver, quase toda a cadeia pode ser considerada estática. Dividimos a sequência em duas partes para dois objetos UI.Text. Primeiro, um contém apenas o texto estático "Player" + nome + "possui pontuação" , que pode ser atribuído no método Start, e o segundo contém o valor da pontuação, que é atualizado em cada quadro. Sempre torne as linhas estáticas realmente estáticas e gere-as no método Iniciar ou Despertar . Após essa melhoria, quase tudo está em ordem, mas um pouco de lixo ainda é gerado ao chamar Int.ToString (), Float.ToString (), etc.

Resolvemos esse problema gerando e pré-alocando memória para todas as linhas possíveis. Pode parecer um desperdício estúpido de memória, mas essa solução atende perfeitamente às nossas necessidades e resolve completamente o problema. Portanto, no final, obtivemos uma matriz estática, cujo acesso pode ser acessado diretamente usando índices para obter a string desejada que denota um número:

 public static readonly string[] NUMBERS_THREE_DECIMAL = { "000", "001", "002", "003", "004", "005", "006",.......... 

Quarta regra: valores de cache retornados pelos métodos de acesso


Isso pode ser muito difícil, porque mesmo um método simples de acessador como o mostrado abaixo gera lixo:

 //Bad Code void Update() { gameObject.tag; //or gameObject.name; } 

Tente evitar o uso de métodos de acesso no método Update. Chame o método de acesso apenas uma vez no método Start e armazene em cache o valor de retorno.

Em geral, eu recomendo NÃO chamar nenhum método de acesso de string ou método de acesso de matriz no método Update . Na maioria dos casos, basta obter o link uma vez no método Start .

Aqui estão dois exemplos mais comuns de outro código de método de acesso não otimizado:

 //Bad Code void Update() { //Allocates new array containing all touches Input.touches[0]; } //Better Code void Update() { Input.GetTouch(0); } //Bad Code void Update() { //Returns new string(garbage) and compare the two strings gameObject.Tag == "MyTag"; } //Better Code void Update() { gameObject.CompareTag("MyTag"); } 

Quinta regra: usar funções que não alocam memória


Para algumas funções do Unity, alternativas que não são de memória podem ser encontradas. No nosso caso, todas essas funções estão relacionadas à física. Nosso reconhecimento de colisão é baseado em

 Physics2D. CircleCast(); 

Nesse caso específico, você pode encontrar uma função que não seja de memória chamada

 Physics2D. CircleCastNonAlloc(); 

Muitas outras funções também têm alternativas semelhantes; portanto, verifique sempre a documentação quanto às funções NonAlloc .

Sexta regra: não use LINQ


Apenas não faça isso. Quero dizer, você não precisa usá-lo em nenhum código que seja executado com frequência. Sei que ao usar o LINQ, o código é mais fácil de ler, mas em muitos casos o desempenho e a alocação de memória desse código são terríveis. É claro que às vezes pode ser usado, mas, para ser sincero, em nosso jogo não usamos o LINQ.

Sétima regra: crie uma vez e reutilize, parte 2


Desta vez, estamos falando sobre agrupar objetos. Não entrarei nos detalhes do pool, porque isso foi dito muitas vezes, por exemplo, estude este tutorial: https://learn.unity.com/tutorial/object-pool

No nosso caso, o seguinte script de pool de objetos é usado. Temos um nível gerado cheio de obstáculos que existem por um certo período de tempo até que o jogador passe por essa parte do nível. Instâncias de tais obstáculos são criadas a partir de pré-fabricados, se determinadas condições forem atendidas. O código está no método Update. Este código é completamente ineficiente em termos de memória e tempo de execução. Resolvemos o problema gerando um conjunto de 40 obstáculos: se necessário, obtemos obstáculos do conjunto e devolvemos o objeto de volta ao conjunto quando ele não é mais necessário.

A oitava regra: mais atentamente com a transformação de embalagens (Boxe)!


Boxe gera lixo! Mas o que é boxe? Na maioria das vezes, o boxe ocorre quando você passa um tipo de valor (int, float, bool, etc.) para uma função que espera um objeto do tipo Object.

Aqui está um exemplo de boxe que precisamos corrigir em nosso projeto:

Implementamos nosso próprio sistema de mensagens no projeto. Cada mensagem pode conter uma quantidade ilimitada de dados. Os dados são armazenados em um dicionário definido da seguinte maneira:

 Dictionary<string, object> data; 

Também temos um setter que define valores neste dicionário:

 public Action SetAttribute(string attribute, object value) { data[attribute] = value; } 

O boxe aqui é bastante óbvio. Você pode chamar a função da seguinte maneira:

 SetAttribute("my_int_value", 12); 

Em seguida, o valor "12" é submetido ao boxe e isso gera lixo.

Resolvemos o problema criando contêineres de dados separados para cada tipo primitivo, e o contêiner Object anterior é usado apenas para tipos de referência.

 Dictionary<string, object> data; Dictionary<string, bool> dataBool; Dictionary<string, int> dataInt; ....... 

Também temos setters separados para cada tipo de dados:

 SetBoolAttribute(string attribute, bool value) SetIntAttribute(string attribute, int value) 

E todos esses setters são implementados de forma que eles chamam a mesma função generalizada:

 SetAttribute<T>(ref Dictionary<string, T> dict, string attribute, T value) 

O problema do boxe foi resolvido!

Leia mais sobre isso no artigo https://docs.microsoft.com/cs-cz/dotnet/csharp/programming-guide/types/boxing-and-unboxing .

A nona regra: os ciclos estão sempre sob suspeita


Essa regra é muito semelhante à primeira e à segunda. Apenas tente remover todo o código opcional dos loops por motivos de desempenho e memória.

Em geral, nos esforçamos para nos livrar dos loops nos métodos Update, mas se não pudermos ficar sem eles, pelo menos evitaremos qualquer alocação de memória nesses loops. Portanto, siga as regras de 1 a 8 e aplique-as aos loops em geral, não apenas aos métodos de atualização.

Regra 10: sem lixo nas bibliotecas externas


Caso aconteça que parte do lixo seja gerada pelo código baixado do armazenamento de ativos, esse problema terá muitas soluções. Mas antes de fazer engenharia reversa e depuração, basta voltar ao armazenamento de ativos e atualizar a biblioteca. No nosso caso, todos os ativos usados ​​ainda eram suportados por autores que continuavam lançando atualizações de melhoria de desempenho, portanto, isso resolveu todos os nossos problemas. Dependências devem ser relevantes! Prefiro me livrar da biblioteca do que ficar sem suporte.

Parte 2: maximizando o tempo de execução


Algumas das regras acima fazem uma diferença sutil se o código raramente é chamado. Há um grande loop em nosso código que é executado em cada quadro, portanto, mesmo essas pequenas alterações tiveram um grande efeito.

Algumas dessas alterações, se usadas incorretamente ou na situação errada, podem levar a um tempo de execução ainda pior. Sempre verifique o criador de perfil depois de inserir cada otimização no código para garantir que você esteja se movendo na direção certa .

Honestamente, algumas dessas regras levam a um código legível muito pior e às vezes até violam as recomendações , por exemplo, a incorporação de código mencionada em uma das regras abaixo.

Muitas dessas regras se sobrepõem às apresentadas na primeira parte do artigo. Normalmente, o desempenho do código de geração de lixo é menor comparado ao código sem a geração de lixo.

A primeira regra: a ordem de execução correta


Mova o código dos métodos FixedUpdate, Update, LateUpdate para os métodos Start e Awake . Sei que isso parece loucura, mas acredite, se você se aprofundar no seu código, encontrará centenas de linhas de código que podem ser movidas para métodos executados apenas uma vez.

No nosso caso, esse código geralmente está associado a

  • Chamadas para GetComponent <>
  • Cálculos que realmente retornam o mesmo resultado em cada quadro
  • Várias instâncias dos mesmos objetos, geralmente listas
  • Pesquisar GameObjects
  • Obtendo links para o Transform e usando outros métodos de acesso

Aqui está uma lista de códigos de exemplo que foram movidos dos métodos Update para os métodos Start:

 //There must be a good reason to keep GetComponent in Update gameObject.GetComponent<LineRenderer>(); gameObject.GetComponent<CircleCollider2D>(); //Examples of calculations returning same result every frame Mathf.FloorToInt(Screen.width / 2); var width = 2f * mainCamera.orthographicSize * mainCamera.aspect; var castRadius = circleCollider.radius * transform.lossyScale.x; var halfSize = GetComponent<SpriteRenderer>().bounds.size.x / 2f; //Finding objects var levelObstacles = FindObjectsOfType<Obstacle>(); var levelCollectibles = FindGameObjectsWithTag("COLLECTIBLE"); //References objectTransform = gameObject.transform; mainCamera = Camera.main; 

Segunda regra: execute o código somente quando necessário


No nosso caso, isso está relacionado principalmente aos scripts de atualização da interface do usuário. Aqui está um exemplo de como alteramos a implementação do código que exibe o estado atual dos itens coletados no nível.

 //Bad code Text text; GameState gameState; void Start() { gameState = StoreProvider.Get<GameState>(); text = GetComponent<Text>(); } void Update() { text.text = gameState.CollectedCollectibles.ToString(); } 

Como em cada nível há apenas alguns itens a serem coletados, não faz sentido alterar o texto da interface do usuário em cada quadro. Portanto, alteramos o texto somente quando o número muda.

 //Better code Text text; GameState gameState; int collectiblesCount; void Start() { gameState = StoreProvider.Get<GameState>(); text = GetComponent<Text>(); collectiblesCount = gameState.CollectedCollectibles; } void Update() { if(collectiblesCount != gameState.CollectedCollectibles) { //This code is ran only about 5 times each level collectiblesCount = gameState.CollectedCollectibles; text.text = collectiblesCount.ToString(); } } 

Esse código é muito melhor, especialmente se as ações forem muito mais complicadas do que apenas alterar a interface do usuário.

Se você estiver procurando por uma solução mais abrangente, recomendo implementar o modelo Observer usando eventos C # ( https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/ ).

De qualquer forma, isso ainda não era suficiente para nós, e queríamos implementar uma solução completamente generalizada, então criamos uma biblioteca que implementa o Flux in Unity. Isso levou a uma solução muito simples, na qual todo o estado do jogo é armazenado no objeto Store e todos os elementos da interface do usuário e outros componentes são notificados quando o estado muda e reagem a essa alteração sem código no método Update.

A terceira regra: os ciclos estão sempre sob suspeita


Essa é exatamente a mesma regra que mencionei na primeira parte do artigo. Se houver um loop no código que ignore iterativamente um grande número de elementos, para melhorar o desempenho do loop, use as duas regras de ambas as partes do artigo.

Quarta Regra: Para Melhor que para Foreach


O loop Foreach é muito fácil de escrever, mas "muito difícil" de executar. Dentro do loop Foreach, o Enumerator é usado para processar iterativamente o conjunto de dados e retornar o valor. Isso é mais complicado do que iterar sobre índices em um loop For simples.

Portanto, em nosso projeto, sempre que possível, substituímos os loops Foreach por For:

 //Bad code foreach (GameObject obstacle in obstacles) //Better code var count = obstacles.Count; for (int i = 0; i < count; i++) { obstacles[i]; } 

No nosso caso, com um loop for grande, essa alteração é muito significativa. Um loop for simples acelera o código duas vezes .

Quinta regra: matrizes são melhores que listas


Em nosso código, descobrimos que a maioria das listas tem comprimento constante ou podemos calcular o número máximo de elementos. Portanto, nós os reimplementamos com base em matrizes e, em alguns casos, isso levou a uma dupla aceleração de iterações sobre os dados.

Em alguns casos, listas ou outras estruturas de dados complexas não podem ser evitadas. Acontece que você geralmente precisa adicionar ou remover elementos e, nesse caso, é melhor usar listas. Mas, em geral, as matrizes sempre devem ser usadas para listas de tamanho fixo .

Sexta regra: operações flutuantes são melhores que operações vetoriais


Essa diferença é quase imperceptível se você não realizar milhares dessas operações, como foi o caso em nosso caso, portanto, para nós, o aumento da produtividade acabou sendo significativo.

Fizemos alterações semelhantes:

 Vector3 pos1 = new Vector3(1,2,3); Vector3 pos2 = new Vector3(4,5,6); //Bad code var pos3 = pos1 + pos2; //Better code var pos3 = new Vector3(pos1.x + pos2.x, pos1.y + pos2.y, ......); Vector3 pos1 = new Vector3(1,2,3); //Bad code var pos2 = pos1 * 2f; //Better code var pos2 = new Vector3(pos1.x * 2f, pos1.y * 2f, ......); 

Sétima regra: procure objetos corretamente


Sempre pense se você realmente precisa usar o método GameObject.Find (). Este método é pesado e leva uma quantidade insana de tempo. Você nunca deve usar esse método nos métodos de atualização. Descobrimos que a maioria das chamadas de localização pode ser substituída por links diretos no editor , o que, é claro, é muito melhor.

 //Bad Code GameObject player; void Start() { player = GameObject.Find("PLAYER"); } //Better Code //Assign the reference to the player object in editor [SerializeField] GameObject player; void Start() { } 

Se isso for impossível, considere pelo menos usar tags (Tag) e procurar um objeto por seu rótulo usando GameObject.FindWithTag .

Portanto, no caso geral: Link direto> GameObject.FindWithTag ()> GameObject.Find ()

Oitava regra: trabalhar apenas com objetos relevantes


No nosso caso, isso foi importante para o reconhecimento de colisões usando RayCast-s (CircleCast, etc.). Em vez de reconhecer colisões e decidir quais delas são importantes no código, movemos os objetos do jogo para as camadas apropriadas, para que possamos calcular colisões apenas para os objetos necessários.

Aqui está um exemplo

 //Bad Code void DetectCollision() { var count = Physics2D.CircleCastNonAlloc( position, radius, direction, results, distance); for (int i = 0; i < count; i++) { var obj = results[i].collider.transform.gameObject; if(obj.CompareTag("FOO")) { ProcessCollision(results[i]); } } } //Better Code //We added all objects with tag FOO into the same layer void DetectCollision() { //8 is number of the desired layer var mask = 1 << 8; var count = Physics2D.CircleCastNonAlloc( position, radius, direction, results, distance, mask); for (int i = 0; i < count; i++) { ProcessCollision(results[i]); } } 

A nona regra: use rótulos corretamente


Não há dúvida de que os rótulos são muito úteis e podem melhorar o desempenho do código, mas lembre-se de que existe apenas uma maneira correta de comparar os rótulos dos objetos !

 //Bad Code gameObject.Tag == "MyTag"; //Better Code gameObject.CompareTag("MyTag"); 

A décima regra: cuidado com os truques com a câmera!


É tão fácil usar o Camera.main , mas o desempenho desta ação é muito baixo. O motivo é que, nos bastidores de cada chamada para Camera.main, o mecanismo do Unity realmente executa o resultado FindGameObjectsWithTag (), então já entendemos que você não precisa chamá-lo com frequência e é melhor resolver esse problema armazenando o link no método Iniciar. ou acordado.

 //Bad code void Update() { Camera.main.orthographicSize //Some operation with camera } //Better Code private Camera cam; void Start() { cam = Camera.main; } void Update() { cam.orthographicSize //Some operation with camera } 

Décima Primeira Regra: Posição Local é Melhor que Posição


Sempre que possível, use Transform.LocalPosition para getters e setters em vez de Transform.Position . Dentro de cada chamada Transform.Position, muito mais operações são executadas, por exemplo, calculando a posição global no caso de uma chamada getter ou calculando a posição local a partir da global no caso de uma chamada setter. Em nosso projeto, descobriu-se que você pode usar LocalPositions em 99% dos casos usando Transform.Position e não é necessário fazer outras alterações no código.

Décima segunda regra: não use o LINQ


Isso já foi discutido na primeira parte. Só não use, é tudo.

Décima terceira regra: não tenha medo (às vezes) de quebrar as regras


Às vezes, mesmo chamar uma função simples pode ser muito caro. Nesse caso, você deve sempre considerar a incorporação de código (Code Inlining). O que isso significa? De fato, apenas pegamos o código da função e o copiamos diretamente para o local em que queremos usar a função para evitar a chamada de métodos adicionais.

Na maioria dos casos, isso não terá nenhum efeito, porque a incorporação do código é realizada automaticamente no estágio de compilação, mas existem certas regras pelas quais o compilador decide se deve incorporar o código (por exemplo, métodos virtuais nunca são incorporados; para obter mais detalhes, consulte https: //docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html ). Então, basta abrir o criador de perfil, iniciar o jogo no dispositivo de destino e ver se algo pode ser melhorado.

No nosso caso, havia várias funções que decidimos integrar para melhorar o desempenho, especialmente no loop for grande.

Conclusão


Aplicando as regras listadas no artigo, conseguimos facilmente 60 fps estáveis ​​no jogo para iOS, mesmo no iPhone 5S. Talvez algumas regras possam ser específicas apenas ao nosso projeto, mas acho que a maioria delas deve ser lembrada ao escrever um código ou verificá-lo para evitar problemas no futuro. É sempre melhor escrever código constantemente com base no desempenho do que depois para refatorar grandes partes de código.

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


All Articles