Cómo optimizamos los scripts en Unity

Hay muchos excelentes artículos y tutoriales de rendimiento de Unity. No estamos tratando de reemplazarlos o mejorarlos con este artículo, este es solo un breve resumen de los pasos que tomamos después de leer estos artículos, así como los pasos que nos permitieron resolver nuestros problemas. Le recomiendo que al menos estudie los materiales en https://learn.unity.com/ .

En el proceso de desarrollo de nuestro juego, encontramos problemas que ocasionalmente inhibían el proceso del juego. Después de pasar un tiempo en Unity Profiler, encontramos dos tipos de problemas:

  • Sombreadores no optimizados
  • Scripts no optimizados en C #

La mayoría de los problemas fueron causados ​​por el segundo grupo, así que decidí centrarme en los scripts de C # en este artículo (quizás también porque no he escrito un solo sombreador en mi vida).

Buscar debilidades


El propósito de este artículo no es escribir un tutorial sobre el uso de un generador de perfiles; Solo quería hablar sobre lo que nos interesaba principalmente durante el proceso de creación de perfiles.

Unity Profiler es siempre la mejor manera de encontrar las causas de los retrasos en los scripts. Recomiendo perfilar el juego directamente en el dispositivo y no en el editor. Como nuestro juego fue creado para iOS, necesitaba conectar el dispositivo y usar la Configuración de compilación que se muestra en la imagen, después de lo cual el generador de perfiles se conectó automáticamente.


Configuración de compilación para perfilado

Si intenta buscar en Google "Retraso aleatorio en Unity" u otra solicitud similar, encontrará que la mayoría de las personas recomiendan centrarse en la recolección de basura , que es exactamente lo que hice. La basura se genera cada vez que deja de usar algún objeto (instancia de clase), después de lo cual el recolector de basura de Unity comienza de vez en cuando para limpiar el desorden y liberar memoria, lo que lleva una cantidad increíble de tiempo y conduce a una caída en la velocidad de fotogramas.

¿Cómo encontrar scripts basura en el generador de perfiles?


Simplemente seleccione Uso de CPU -> Elegir vista de jerarquía -> Ordenar por asignación de GC


Opciones de perfilador para recolección de basura

Su tarea es lograr algunos ceros en la columna de asignación de GC para la escena del juego.

Otra buena manera es ordenar las entradas por Tiempo ms (tiempo de ejecución) y optimizar los scripts para que tomen el menor tiempo posible. Este paso tuvo un gran impacto para nosotros, porque uno de nuestros componentes contenía un gran bucle for , que tardó una eternidad en completarse (sí, todavía no hemos encontrado una manera de deshacernos del bucle), por lo que era absolutamente necesario optimizar el tiempo de ejecución de todos los scripts, porque necesitábamos ahorrar tiempo de ejecución en este costoso bucle, manteniendo una frecuencia estable de 60 fps.

Basado en los datos de perfil, dividí la optimización en dos partes:

  • Desechar la basura
  • Plazo de ejecución reducido

Parte 1: luchando contra la basura


En esta parte te contaré lo que hicimos para deshacernos de la basura. Este es el conocimiento más fundamental que cualquier desarrollador debe entender; se han convertido en una parte importante de nuestro análisis diario en cada solicitud de extracción / fusión.

Primera regla: no hay nuevos objetos en los métodos de actualización


Idealmente, los métodos Update, FixedUpdate y LateUpdate no deberían contener las palabras clave "nuevas" . Siempre debes usar lo que ya tienes.

A veces, crear un nuevo objeto está oculto en algunos métodos internos de Unity, por lo que no es tan obvio. Hablaremos de esto más tarde.

Segunda regla: ¡crea una vez y reutiliza!


En esencia, esto significa que debe asignar memoria para todo lo que pueda en los métodos Start y Awake. Esta regla es muy similar a la primera. En realidad, esta es otra forma de eliminar las "nuevas" palabras clave de los métodos de actualización.

Codifica eso:

  • crea nuevas instancias
  • buscando cualquier objeto del juego

Siempre debe intentar pasar de los métodos de actualización a Inicio o Despertar.

Aquí hay ejemplos de nuestros cambios:

Asignación de memoria para listas en el método de Inicio, su borrado (Borrar) y reutilización si es necesario.

//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(......) } 

Almacenamiento de enlaces y reutilización de la siguiente manera:

 //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) { ....... } } 

Lo mismo se aplica al método FindGameObjectsWithTag o cualquier otro método que devuelva una nueva matriz.

La tercera regla: tenga cuidado con las cadenas y evite concatenarlas.


Cuando se trata de crear basura, las líneas son terribles. Incluso las operaciones de cadena más simples pueden crear mucha basura. Por qué Las cadenas son solo matrices, y estas matrices son inmutables. Esto significa que cada vez que concatena dos líneas, se crea una nueva matriz y la anterior se convierte en basura. Afortunadamente, StringBuilder se puede usar para evitar o minimizar dicha creación de basura.

Aquí hay un ejemplo de cómo puede mejorar la situación:

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

Todo está bien con el ejemplo que se muestra arriba, pero todavía hay muchas posibilidades para mejorar el código. Como puede ver, casi toda la cadena puede considerarse estática. Dividimos la cadena en dos partes para dos objetos UI.Text. Primero, uno contiene solo el texto estático "Jugador" + nombre + "tiene puntaje" , que puede asignarse en el método de Inicio, y el segundo contiene el valor de puntaje, que se actualiza en cada cuadro. Siempre haga que las líneas estáticas sean realmente estáticas y generelas en el método Inicio o Despertar . Después de esta mejora, casi todo está en orden, pero aún se genera un poco de basura al llamar a Int.ToString (), Float.ToString (), etc.

Resolvimos este problema generando y preasignando memoria para todas las líneas posibles. Puede parecer un estúpido desperdicio de memoria, pero esa solución es ideal para nuestras necesidades y resuelve completamente el problema. Entonces, al final, obtuvimos una matriz estática, a la que se puede acceder directamente usando índices para tomar la cadena deseada que denota un número:

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

Cuarta regla: valores de caché devueltos por métodos de acceso


Esto puede ser muy difícil, porque incluso un método de acceso simple como el que se muestra a continuación genera basura:

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

Intente evitar el uso de métodos de acceso en el método Actualizar. Llame al método de acceso solo una vez en el método de Inicio y guarde en caché el valor de retorno.

En general, recomiendo NO llamar a ningún método de acceso a cadenas o métodos de acceso a matriz en el método Actualizar . En la mayoría de los casos, es suficiente obtener el enlace una vez en el método de Inicio .

Aquí hay dos ejemplos más comunes de otro código de método de acceso no optimizado:

 //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 regla: utilizar funciones que no asignen memoria


Para algunas funciones de Unity, se pueden encontrar alternativas sin memoria. En nuestro caso, todas estas funciones están relacionadas con la física. Nuestro reconocimiento de colisión se basa en

 Physics2D. CircleCast(); 

Para este caso particular, puede encontrar una función sin memoria llamada

 Physics2D. CircleCastNonAlloc(); 

Muchas otras funciones también tienen alternativas similares, así que siempre revise la documentación para las funciones NonAlloc .

Sexta regla: no use LINQ


Solo no lo hagas. Quiero decir, no necesitas usarlo en ningún código que se ejecute con frecuencia. Sé que cuando utilizo LINQ, el código es más fácil de leer, pero en muchos casos el rendimiento y la asignación de memoria de dicho código son terribles. Por supuesto, a veces se puede usar, pero, para ser sincero, en nuestro juego no usamos LINQ en absoluto.

Séptima regla: crear una vez y reutilizar, parte 2


Esta vez estamos hablando de agrupar objetos. No entraré en los detalles de la agrupación, porque esto se ha dicho muchas veces, por ejemplo, estudie este tutorial: https://learn.unity.com/tutorial/object-pooling

En nuestro caso, se utiliza el siguiente script de agrupación de objetos. Tenemos un nivel generado lleno de obstáculos que existen durante un cierto período de tiempo hasta que el jugador pasa esta parte del nivel. Las instancias de tales obstáculos se crean a partir de prefabricados si se cumplen ciertas condiciones. El código está en el método de actualización. Este código es completamente ineficiente en términos de memoria y tiempo de ejecución. Resolvimos el problema generando un grupo de 40 obstáculos: si es necesario, obtenemos obstáculos del grupo y devolvemos el objeto al grupo cuando ya no es necesario.

La octava regla: ¡más atento con la transformación de empaquetado (Boxeo)!


¡El boxeo genera basura! ¿Pero qué es el boxeo? Con mayor frecuencia, el boxeo ocurre cuando pasa un tipo de valor (int, float, bool, etc.) a una función que espera un objeto de tipo Object.

Aquí hay un ejemplo de boxeo que debemos arreglar en nuestro proyecto:

Implementamos nuestro propio sistema de mensajería en el proyecto. Cada mensaje puede contener una cantidad ilimitada de datos. Los datos se almacenan en un diccionario definido de la siguiente manera:

 Dictionary<string, object> data; 

También tenemos un setter que establece valores en este diccionario:

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

El boxeo aquí es bastante obvio. Puede llamar a la función de la siguiente manera:

 SetAttribute("my_int_value", 12); 

Entonces el valor "12" se somete a boxeo y esto genera basura.

Resolvimos el problema creando contenedores de datos separados para cada tipo primitivo, y el contenedor de objetos anterior se usa solo para tipos de referencia.

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

También tenemos configuradores separados para cada tipo de datos:

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

Y todos estos configuradores se implementan de tal manera que llaman a la misma función generalizada:

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

¡El problema del boxeo ha sido resuelto!

Lea más sobre esto en el artículo https://docs.microsoft.com/cs-cz/dotnet/csharp/programming-guide/types/boxing-and-unboxing .

La novena regla: los ciclos siempre están bajo sospecha


Esta regla es muy similar a la primera y segunda. Solo intente eliminar todo el código opcional de los bucles por razones de rendimiento y memoria.

En el caso general, nos esforzamos por deshacernos de los bucles en los métodos de actualización, pero si no podemos prescindir de ellos, al menos evitaremos cualquier asignación de memoria en dichos bucles. Por lo tanto, siga las reglas 1–8 y aplíquelas a los bucles en general, no solo a los métodos de actualización.

Regla 10: no hay basura en bibliotecas externas


En caso de que parte de la basura se genere mediante el código descargado del almacén de activos, este problema tiene muchas soluciones. Pero antes de realizar la ingeniería inversa y la depuración, simplemente regrese a la tienda de Activos y actualice la biblioteca. En nuestro caso, todos los activos utilizados todavía fueron respaldados por autores que continuaron lanzando actualizaciones para mejorar el rendimiento, por lo que esto resolvió todos nuestros problemas. ¡Las dependencias deben ser relevantes! Prefiero deshacerme de la biblioteca que mantenerme sin soporte.

Parte 2: maximizar el tiempo de ejecución


Algunas de las reglas anteriores hacen una sutil diferencia si rara vez se llama al código. Hay un gran bucle en nuestro código que se ejecuta en cada cuadro, por lo que incluso estos pequeños cambios tuvieron un gran efecto.

Algunos de estos cambios, si se usan incorrectamente o en una situación incorrecta, pueden conducir a un tiempo de ejecución aún peor. Siempre verifique el generador de perfiles después de ingresar cada optimización en el código para asegurarse de que se está moviendo en la dirección correcta .

Honestamente, algunas de estas reglas conducen a un código legible mucho peor , y a veces incluso violan las recomendaciones , por ejemplo, la incrustación de código mencionada en una de las reglas a continuación.

Muchas de estas reglas se superponen con las presentadas en la primera parte del artículo. Por lo general, el rendimiento del código generador de basura es menor en comparación con el código sin generación de basura.

La primera regla: el orden de ejecución correcto


Mueva el código de los métodos FixedUpdate, Update, LateUpdate a los métodos Start y Awake . Sé que esto suena loco, pero créanme, si profundizan en su código, encontrarán cientos de líneas de código que se pueden mover a métodos que se ejecutan solo una vez.

En nuestro caso, este código generalmente está asociado con

  • Llamadas a GetComponent <>
  • Cálculos que realmente devuelven el mismo resultado en cada cuadro
  • Múltiples instancias de los mismos objetos, generalmente listas
  • Buscar GameObjects
  • Obtener enlaces a Transform y usar otros métodos de acceso

Aquí hay una lista de código de muestra que hemos movido de Métodos de actualización a Métodos de inicio:

 //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 regla: ejecutar código solo cuando sea necesario


En nuestro caso, esto está relacionado principalmente con los scripts de actualización de la interfaz de usuario. Aquí hay un ejemplo de cómo cambiamos la implementación del código que muestra el estado actual de los elementos recopilados en el nivel.

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

Dado que en cada nivel solo hay unos pocos elementos para recopilar, no tiene sentido cambiar el texto de la interfaz de usuario en cada marco. Por lo tanto, cambiamos el texto solo cuando cambia el número.

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

Este código es mucho mejor, especialmente si las acciones son mucho más complicadas que simplemente cambiar la interfaz de usuario.

Si está buscando una solución más completa, le recomiendo implementar la plantilla Observer usando eventos C # ( https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/events/ ).

De todos modos, esto aún no era suficiente para nosotros, y queríamos implementar una solución completamente generalizada, por lo que creamos una biblioteca que implementa Flux in Unity. Esto condujo a una solución muy simple, en la que todo el estado del juego se almacena en el objeto "Almacenar", y todos los elementos de la interfaz de usuario y otros componentes son notificados cuando el estado cambia y reacciona a este cambio sin código en el método de Actualización.

La tercera regla: los ciclos siempre están bajo sospecha


Esta es exactamente la misma regla que mencioné en la primera parte del artículo. Si el código tiene algún tipo de bucle que omite de forma iterativa una gran cantidad de elementos, para mejorar el rendimiento del bucle, use ambas reglas de ambas partes del artículo.

Cuarta regla: para mejor que Foreach


El bucle Foreach es muy fácil de escribir, pero "muy difícil" de ejecutar. Dentro del bucle Foreach, Enumerator se usa para procesar iterativamente el conjunto de datos y devolver el valor. Esto es más complicado que iterar sobre índices en un bucle For simple.

Por lo tanto, en nuestro proyecto, siempre que sea posible, reemplazamos los bucles Foreach con For:

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

En nuestro caso con un bucle for grande, este cambio es muy significativo. Un bucle simple para acelera el código dos veces .

Quinta regla: las matrices son mejores que las listas


En nuestro código, descubrimos que la mayoría de las listas son de longitud constante, o podemos calcular el número máximo de elementos. Por lo tanto, los volvimos a implementar en función de las matrices y, en algunos casos, esto condujo a una aceleración doble de las iteraciones sobre los datos.

En algunos casos, las listas u otras estructuras de datos complejas no se pueden evitar. Sucede que a menudo tiene que agregar o eliminar elementos, y en este caso es mejor usar listas. Pero en general, las matrices siempre deben usarse para listas de longitud fija .

Sexta regla: las operaciones de flotación son mejores que las operaciones de vectores


Esta diferencia apenas se nota si no realiza miles de tales operaciones, como fue el caso en nuestro caso, por lo que para nosotros el aumento de la productividad resultó ser significativo.

Hicimos cambios similares:

 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éptima regla: busca objetos correctamente


Siempre piense si realmente necesita usar el método GameObject.Find (). Este método es pesado y lleva una cantidad de tiempo increíble. Nunca debe usar este método en los métodos de actualización. Descubrimos que la mayoría de nuestras llamadas de búsqueda se pueden reemplazar con enlaces directos en el editor , lo que, por supuesto, es mucho mejor.

 //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() { } 

Si esto es imposible de hacer, entonces al menos considere usar etiquetas (Tag) y buscar un objeto por su etiqueta usando GameObject.FindWithTag .

Entonces, en el caso general: Enlace directo> GameObject.FindWithTag ()> GameObject.Find ()

Octava regla: solo trabajar con objetos relevantes


En nuestro caso, esto fue importante para reconocer colisiones usando RayCast-s (CircleCast, etc.). En lugar de reconocer las colisiones y decidir cuáles de ellas son importantes en el código, movimos los objetos del juego a las capas apropiadas para que podamos calcular las colisiones solo para los objetos necesarios.

Aquí hay un ejemplo

 //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]); } } 

La novena regla: usar etiquetas correctamente


No hay duda de que las etiquetas son muy útiles y pueden mejorar el rendimiento del código, pero recuerde que solo hay una forma correcta de comparar etiquetas de objetos .

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

La décima regla: ¡cuidado con los trucos con la cámara!


Es muy fácil usar Camera.main , pero el rendimiento de esta acción es muy pobre. La razón es que detrás de escena de cada llamada a Camera.main, el motor de Unity realmente se ejecuta para encontrar el resultado FindGameObjectsWithTag (), por lo que ya entendemos que no es necesario llamarlo con frecuencia, y es mejor resolver este problema almacenando en caché el enlace en el método de Inicio o despierto

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

Undécima regla: la posición local es mejor que la posición


Siempre que sea posible, use Transform.LocalPosition para getters y setters en lugar de Transform.Position . Dentro de cada llamada Transform.Position, se realizan muchas más operaciones, por ejemplo, calculando la posición global en el caso de una llamada getter o calculando la posición local desde la global en el caso de una llamada setter. En nuestro proyecto, resultó que puedes usar LocalPositions en el 99% de los casos usando Transform.Position, y no necesitas hacer ningún otro cambio en el código.

Duodécima regla: no use LINQ


Esto ya se discutió en la primera parte. Simplemente no lo uses, eso es todo.

Decimotercera regla: no tengas miedo (a veces) de romper las reglas


A veces, incluso llamar a una función simple puede ser demasiado costoso. En este caso, siempre debe considerar incrustar código (Code Inlining). ¿Qué significa esto? De hecho, simplemente tomamos el código de la función y lo copiamos directamente al lugar donde queremos usar la función para evitar llamar a métodos adicionales.

En la mayoría de los casos, esto no tendrá ningún efecto, ya que la incrustación del código se realiza automáticamente en la etapa de compilación, pero hay ciertas reglas por las cuales el compilador decide si incrustar el código (por ejemplo, los métodos virtuales nunca se incrustan; para obtener más detalles, consulte https: //docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity8.html ). Así que solo abra el generador de perfiles, inicie el juego en el dispositivo objetivo y vea si se puede mejorar algo.

En nuestro caso, había varias funciones que decidimos integrar para mejorar el rendimiento, especialmente en el bucle for grande.

Conclusión


Aplicando las reglas enumeradas en el artículo, logramos fácilmente 60 fps estables en el juego para iOS, incluso en el iPhone 5S. Quizás algunas de las reglas pueden ser específicas solo para nuestro proyecto, pero creo que la mayoría de ellas deben recordarse al escribir código o verificarlo para evitar problemas en el futuro. Siempre es mejor escribir constantemente código basado en el rendimiento que más tarde para refactorizar grandes piezas de código.

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


All Articles