Estrategias multijuego

Durante las revisiones de proyectos como consultor del equipo de éxito de clientes, trabajo a menudo con clientes que crean aplicaciones que cambian el juego. Estas aplicaciones tienen un menú principal o menú temático, que presenta múltiples opciones de juegos para que elija el jugador. En esas configuraciones, las principales preocupaciones son cómo garantizar que el tiempo entre los cambios de juego sea lo más corto posible y cómo asegurar un rendimiento óptimo en todos los juegos. En esta entrada del blog exploraremos diferentes enfoques basados en las necesidades del proyecto, así como algunas mejores prácticas que pueden ser útiles para cualquier entorno de juego, con o sin una configuración de cambio de juego.
Cuando se planifica un entorno multiaplicación -ya sea para juegos, entretenimiento o simulación industrial- la decisión más importante que hay que tomar es cómo gestionar los ejecutables de los juegos. Hay muchos factores que pueden influir en esta decisión:
- ¿Cuántos juegos podrá manejar la plataforma?
- ¿Qué tamaño tienen los juegos?
- ¿Están los juegos hechos con las mismas versiones de Unity? ¿Cuáles son los cuellos de botella de la aplicación?
- Otros factores son el hardware de destino, la memoria y la CPU, y la velocidad del disco (SSD vs HDD vs Tarjeta SD).
Responder a estas preguntas y decidir cómo manejar los ejecutables es crucial para saber si necesitamos ejecutables separados para cada juego; un solo ejecutable compartido para varios juegos, o una combinación de ambos para garantizar que las aplicaciones rindan de forma óptima.
Tener múltiples ejecutables es una gran opción para manejar juegos que se hacen con diferentes versiones de Unity. Con este enfoque es posible reducir el tiempo de cambio entre juegos almacenando el ejecutable en la memoria caché y dejando cada instancia en segundo plano. Sin embargo, mantener todos los ejecutables en la memoria no siempre es la mejor opción, ya que puede suponer un esfuerzo para la memoria. Debe evitarse en los casos en que los juegos individuales tengan una mayor huella de memoria, y/o cuando haya muchos juegos en la aplicación de cambio de juego.
Para aliviar la restricción de memoria, es posible que los juegos compartan un único ejecutable. Los juegos pueden estar en un único proyecto Unity, o tener cada uno su propio proyecto, siempre que los juegos compartan la misma versión de Unity. Desde Unity 2022 LTS en Windows es posible utilizar el argumento -datafolder para pasar una ruta variable a través de la línea de comandos ( -datafolder <ruta_a_carpeta> ), especificando la carpeta de datos de los juegos seleccionados para cambiar. Una desventaja potencial de este enfoque son los tiempos de cambio de juego más lentos; por lo tanto, es importante seguir las mejores prácticas de carga para reducir este inconveniente.
Independientemente de la naturaleza del juego que estemos desarrollando o en qué plataforma, es importante emplear el menor tiempo posible desde el momento de la selección del juego hasta que está completamente cargado en la pantalla. Este objetivo cobra especial importancia en las aplicaciones de conmutación de juegos.
Una buena forma de manejar la carga es utilizando Addressables. Con Addressables, los contenidos se descargan y liberan en función de las necesidades. Esta estrategia de carga diferida es la forma más eficaz de reducir los tiempos de carga de los juegos, ya que limita la cantidad de datos que deben cargarse durante el arranque inicial. Además, puede ayudar a evitar cualquier actividad en segundo plano de la CPU relacionada con los juegos en segundo plano, que pueden contribuir a los cuellos de botella de la CPU. Addressables: Planificación y mejores prácticas La entrada del blog es un buen punto de partida para saber más sobre los Addressables y cómo pueden ayudarle a mejorar su juego.
Una buena forma de garantizar una carga más rápida, independientemente del número de ejecutables que estemos utilizando, es a través de las API de carga asíncrona. Cuando se carga de forma asíncrona, el hilo principal de Unity ejecutará un proceso denominado "integración del hilo principal" que se encarga de la inicialización de los objetos nativos y gestionados de forma fragmentada en el tiempo. Dado que este proceso realiza algunas operaciones que no son seguras para los hilos, se producirá en el hilo principal, y el tiempo permitido para ejecutar la integración del hilo principal está limitado para evitar que el juego se congele durante mucho tiempo. La cantidad de tiempo que se puede dedicar a las integraciones viene definida por la propiedad Application.backgroundLoadingPriority. Recomendamos establecer la prioridad de carga de fondo (backgroundLoadingPriority) en Alta, o 50 ms, durante las pantallas de carga y luego devolverla a Por Debajo de lo Normal (4 ms) o Baja (2 ms) cuando finalice la carga.
Una forma adicional de acelerar la carga es mediante la carga asíncrona de texturas. La carga asíncrona de texturas puede disminuir el tiempo de carga coordinando cuánto tiempo y memoria se utiliza para cargar texturas y mallas en la configuración de la GPU. La entrada del blog Understanding Async Upload Pipeline proporciona información detallada sobre el funcionamiento de este proceso.
Estas prácticas le ayudarán a acelerar los tiempos de carga:
- Minimice al máximo el contenido de su escena. Utilice una escena bootstrap para cargar sólo lo necesario para que el juego esté en un estado jugable, y luego cargue escenas adicionales cuando sea necesario.
- Desactive las cámaras durante las pantallas de carga.
- Desactive los lienzos de interfaz de usuario mientras se rellenan durante la carga.
- Paralelice las solicitudes de red.
- Evite las complejas implementaciones Awake/Start y haga uso de los hilos worker.
- Utilice siempre la compresión de texturas.
- Transmita archivos multimedia de gran tamaño (como archivos de audio y texturas) en lugar de mantenerlos en memoria.
- Evite el serializador JSON y utilice en su lugar serializadores binarios.
Como ya se ha mencionado, la memoria no es la única preocupación para los entornos multijuego, la actividad de la CPU en segundo plano también es algo que puede pasar factura a la experiencia de juego del jugador. Cuando los juegos no se están jugando activamente, su CPU sigue funcionando, lo que provoca que el juego activo tenga un rendimiento subóptimo al crear una inanición de CPU. Una forma de evitar que la CPU se bloquee para el juego activo, y cualquier otro proceso de la plataforma backend es establecer el reproductor Ejecutar en segundo plano en false en los Ajustes de Unity. Ejecutar en segundo plano hará que el bucle de juego de Unity se detenga mientras el juego no esté enfocado. El ajuste también puede modificarse dinámicamente mediante script
public class ExampleClass : MonoBehaviour
{
void Example()
{
Application.runInBackground = false;
}
}
Una cosa a tener en cuenta es que el ajuste Ejecutar en segundo plano no detendrá la ejecución de ningún hilo de scripting personalizado, por lo que es importante poner a dormir cualquier hilo de juegos que no se esté ejecutando mediante el método Thread.Sleep de C#. Recuerde que trabajar con hilos en segundo plano en Unity requiere una programación cuidadosa. Dado que estos hilos no tienen acceso directo a la API de Unity, puede haber una mayor probabilidad de crear problemas, como bloqueos y condiciones de carrera. Para evitarlo, se requiere una sincronización adecuada con el hilo principal de Unity. Para implementar correctamente el multihilo, revise la sección Limitaciones de las tareas async y await de la página del manual Visión general de .NET en Unity y el artículo de MSDN sobre el uso de hilos e hilos. Unity 6 introduce la clase Awaitable que ofrece un mejor soporte para async/await.
Puede resultar difícil y llevar mucho tiempo identificar y solucionar las causas de las fugas de memoria, especialmente en las últimas fases del desarrollo. Aunque suene a tópico, la prevención siempre es mejor que la cura. He aquí algunas recomendaciones que pueden ayudar a evitar las fugas en cualquier entorno de juego:
- Cuando cree nuevos objetos/activos en la memoria, asegúrese de borrarlos cuando no los necesite. Si utiliza Addressables, asegúrese de liberar los activos no utilizados.
- Al cargar/descargar escenas, los activos deben eliminarse correctamente de la memoria. Unity no descarga automáticamente los activos cuando se descarga un nivel, por lo que es importante asegurarse de eliminar cualquier acceso de la memoria. La API Resources.UnloadUnusedAssets puede ayudar a limpiar los activos. Sin embargo, puede provocar picos de CPU, ya que devuelve un objeto que cede hasta que se completa la operación, por lo que debe utilizarse en lugares no sensibles al rendimiento.
- Evite el uso frecuente de Instantiate y Destroy GameObjects. Hacerlo puede dar lugar a asignaciones gestionadas innecesarias, además de ser una operación costosa para la CPU. Sin embargo, en los casos en los que sea necesario utilizar Destruir, asegúrese de eliminar todas las referencias al objeto para evitar Objetos Shell Filtrados. Cuando un objeto o sus padres son destruidos mediante Destroy, un código C# mantiene una referencia a un objeto Unity, conservando el objeto envoltorio gestionado -su Managed Shell- en memoria. Su Memoria Nativa será descargada una vez que la Escena en la que reside sea descargada, o el GameObject al que está unido o sus padres sean destruidos vía Destruir. Por lo tanto, si otra cosa que no fue descargada todavía hace referencia a ella, la memoria gestionada puede seguir viviendo como un Objeto Shell Filtrado.
- Tenga cuidado cuando implemente eventos utilizando Singletons. Las instancias Singleton mantienen referencias a todos los objetos que se han suscrito a sus eventos. Si esos objetos no viven tanto como la instancia singleton, y no se dan de baja de estos eventos, permanecerán en memoria provocando una fuga de memoria. Si el origen del evento se elimina antes que los oyentes, la referencia se borrará, y si los oyentes se anulan correctamente tampoco quedará ninguna referencia. Para solucionar y prevenir este problema, recomendamos implementar el patrón de eventos débiles o IDisposable en todos los objetos que escuchen eventos singleton, y asegurarse de que se eliminan adecuadamente en su código. El patrón de eventos débiles es un patrón de diseño que le ayuda a gestionar la memoria y la recogida de basura en la programación basada en eventos, sobre todo cuando se trata de objetos de larga duración. Es especialmente útil cuando tiene suscriptores que duran poco, pero el editor es longevo. Por favor, tenga en cuenta que estas son soluciones específicas de C# y funcionan sólo con eventos de C# y no están soportadas directamente por UnityEvents o el Unity UI Toolkit. Por ello, recomendamos implementar estas soluciones sólo en sus scripts que no sean MonoBehaviour.
Por último, la creación de perfiles, la realización de pruebas CI/CD y las pruebas de estrés desde las primeras fases de desarrollo pueden suponer un verdadero ahorro de tiempo, ya que la detección de fugas a medida que surgen le permitirá abordar el problema con prontitud, ahorrando tiempo de depuración y garantizando un rendimiento óptimo.