Cada sección siguiente describe una situación que puede afectar al rendimiento de tus aplicaciones y proporciona pautas para enfoques alternativos y soluciones alternativas que pueden ayudarte a solucionar cualquier problema que puedas estar experimentando.
Antes de continuar
Si no estás familiarizado con la creación de perfiles de rendimiento en Unreal Engine, te recomendamos encarecidamente que leas Introducción a la creación de perfiles de rendimiento y configuración para adquirir unos conocimientos básicos sobre este tema antes de seguir leyendo las siguientes secciones.
Objetos gestionados, recolección de basura y picos de procesamiento
En Unreal Engine, los UObjects y las clases derivadas de ellos (como los actores y los recursos de datos) son gestionados por el recolector de basura del motor. El recolector de basura limpia periódicamente los UObjects que se eliminan en el mundo y limpia cualquier referencias existente a ese objeto.
En cambio, los objetos estándar de C++ no están gestionados. Esto significa que cuando eliminas o anulas una copia de un objeto, tienes que limpiar manualmente las referencias a él. Esto introduce un riesgo si no se maneja con cuidado, ya que cualquier hueco en su lógica de limpieza puede resultar en fugas de memoria (si los objetos no se limpian) y referencias inválidas (si se eliminan los objetos pero las referencias persisten).
La compatibilidad con objetos gestionados supone un uso adicional de la memoria. Los UObjects contienen metadatos adicionales, como un FName y una referencia Externa, que consumen más memoria. El recolector de basura tiene que ejecutarse de vez en cuando para limpiar automáticamente los objetos, por lo que un sistema backend tiene que ser capaz de monitorizar todos los lugares en los que se hace referencia a los objetos. Los picos de procesamiento suelen producirse durante los fotogramas en los que se ejecuta el recolector de basura, sobre todo si tu aplicación ha destruido un gran número de objetos recientemente.
Puedes configurar la recolección de basura en Configuración del proyecto > Motor > Recolección de basura, incluido el intervalo de recolección de basura, la cantidad máxima de objetos que puede limpiar en un momento dado y otros ajustes sobre cómo se procesa la recolección de basura. Aunque es poco probable que tengas que realizar ajustes al principio del proyecto, esto proporciona opciones para adaptar el comportamiento del recolector de basura de Unreal Engine a las necesidades únicas de tu proyecto.
Se recomienda confiar en el recolector de basura automático. Si es necesario, también puedes llamar manualmente al recolector de basura usando el nodo Recolección de basura en blueprint o la función UObjectGlobals::CollectGarbage en C++.
Esto provocará un pico de procesamiento, pero puede haber ocasiones en las que llamar manualmente a la recolección de basura evitará que se acumule basura en segundo plano, lo que provocaría un pico mayor cuando se ejecute automáticamente más adelante.
La recolección de basura manual es apropiada en las siguientes situaciones:
Cuando el programa se encuentra en un estado en el que, desde la perspectiva de la experiencia de usuario, los picos de fotograma son más tolerables, como durante una pantalla de carga. Esto reduce la posibilidad de que ocurra durante un estado en el que sería más notorio o intolerable.
Antes de llevar a cabo una operación que asigne mucha memoria, si durante las prueba descubres que la operación puede provocar bloqueos por falta de memoria o problemas al pasar de página a menos que se haya realizado un recolector de basura inmediatamente antes.
Creación y destrucción de objetos contra Agrupación de Objeto
Para crear un objeto, tu ordenador debe asignar un nuevo bloque de memoria para almacenar una copia del objeto y luego inicializarlo junto con cualquier subobjeto que necesite. Al destruir un objeto, debes eliminar esa información, desasignarla y borrar cualquier referencia a ese objeto que pueda existir en otras partes del código de tu aplicación.
Ambas operaciones pueden ser bastante costosas, especialmente cuando su inicialización implica la coordinación con otros sistemas. En la mayoría de los casos, Unreal Engine es lo suficientemente eficiente gestionando estas operaciones como para que puedas usarlas de forma segura en muchos contextos, tanto en PC como en consolas, pero en proyectos con un margen limitado para el procesamiento en la CPU, te puede interesar usar la agrupación de objetos en su lugar. La agrupación de objeto es el acto de crear todas las copias de un objeto que necesitas por adelantado, asignarlas en la memoria y luego mantenerlas desactivado u ocultas hasta que se necesiten.
Cuanto más alto sea el nivel de un objeto, más caro será crearlo y destruirlo. La agrupación suele ser más útil para actores que para componentes, y es más probable que sea más útil para componentes que para otros UObjects. Esto se debe a que el coste de crear un actor también implica insertarlo en la lista de actores del mundo, crear sus componentes y registrarse a sí mismo y a sus componentes con infraestructura adicional como el Renderización y las física. Cuando se trata de estructuras C++ que no interactúan con clases adicionales al crearse y destruirse, si intentas agruparlas, puede acabar siendo menos eficaz que permitir que el asignador del sistema recicle su memoria sin procesar.
Por ejemplo, imagina un arma que dispare proyectiles. Es bastante común que un arma genere un proyectil al disparar y que luego se autodestruya al impactar contra otro objeto.
Con la agrupación de objetos, en lugar de generar un nuevo proyectil cada vez que necesites disparar uno, tu arma generaría previamente la cantidad máxima de proyectiles que podría tener activos en un momento dado, luego los ocultaría y desactivaría. Este grupo de proyectiles desactivado es la reserva de objetos. Cuando tu arma dispara un proyectil, lo saca de la reserva, lo mueve hasta el extremo del arma, lo muestra, lo activa y lo inicializa en la dirección adecuada. Así, cuando el proyectil impacte en un objetivo, se ocultará, se desactivará y retornará a la reserva para reutilizarse más tarde.
La ventaja de los grupos de objetos es que no es necesario crearlos ni destruirlos, lo que ahorra mucho tiempo de procesamiento al inicializarlos y limpiarlos. A cambio, consumen memoria que, de otro modo, estaría desocupada, incluso cuando los objetos de la agrupación estén inactivos. Sin embargo, en muchas situaciones sería necesario dejar espacio para el máximo de objetos que necesite el grupo. Además, la memoria para estos objetos también se mantiene más estable, ya que se asigna y se limpia en fragmentos grandes en lugar de en fragmentos más pequeños, lo que reduce la posibilidad de fragmentación de la memoria.
Lógica al hacer clic en comparación con devoluciones de llamadas, temporizadores y lógica programada
El evento tic de los UObjects y los actores en los que se pueden hacer tics proporciona una forma de crear lógica que se repite en cada fotograma. Esto es útil para controlar el movimiento en tiempo real. Sin embargo, usar tic para rutinas ocasionales en lugar de continuas puede suponer un consumo excesivo de la CPU.
En concreto, a menudo no es el mejor método para usar una lógica que compruebe si una variable ha cambiado en cada fotograma, como en el siguiente ejemplo. Una clase usa el tic para comprobar repetidamente cuándo cambia la variable de otra clase.
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AChildActor* ChildActor;
protected:
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
int32 getMyInt(){
return MyInt;
}
private:
int32 myInt = 0;
En lugar de usar los tics para monitorizar el valor, puedes crear una función de configuración personalizada que envuelva la operación para cambiar la variable y que, a continuación, llame a otra función o evento que ejecute la lógica necesaria solo cuando cambies el valor.
El siguiente ejemplo incluye las clases del ejemplo anterior, pero ahora usa una devolución de llamada para realizar una acción solo cuando una variable ha cambiado:
UCLASS()
class AMyActor : public AActor
{
GENERATED_BODY()
public:
void OnChildIntChanged(int32 NewValue)
{
if (newValue > 3)
{
UCLASS()
class AChildActor : public AActor
{
GENERATED_BODY()
public:
//Set this reference in the details panel.
UPROPERTY(BlueprintReadOnly, EditAnywhere)
AMyActor* ParentActor;
Esto garantiza que la lógica solo se ejecute cuando cambie la variable en lugar de consultar un valor en cada fotograma.
Sin embargo, el enfoque basado en eventos puede ser menos óptimo dependiendo de la frecuencia con la que cambien las condiciones. Si un evento se activa varias veces por fotograma, o si una función está asociada a muchos eventos que podrían cambiar en el mismo fotograma, puede resultar más eficaz usar tic, o usar el "patrón de comandos". Esto evita que los resultados de cálculo terminen sobrescribiéndose antes de renderizarse.
Cuando quieras establecer un evento para que se produzca después de un periodo de tiempo determinado, puedes iniciar un temporizador, que rastrea temporalmente el tiempo transcurrido hasta que finalice y luego se limpiará. También puedes usar el nodo retraso en un grafo de eventos de blueprint.
Si necesitas que la lógica se repita con frecuencia, pero no en cada fotograma, plantéate hacer que se produzca en un determinado intervalo de fotogramas o segundos. Puedes hacerlo en objetos individuales y Componentes del actor ajustando su intervalo de tic a un número determinado de segundos. También puedes crear intervalos para subconjuntos de la lógica en tu función de tic. Aunque para ello es necesario acumular y restablecer una variable, sigue siendo más económico que ejecutar la lógica en cada fotograma.
Asíncrono en comparación con lógica sincrónica
La lógica sincrónica consiste en completar acciones de principio a fin en secuencia. La mayor parte de la lógica que escribas en blueprint o C++ será síncrona por defecto. Por ejemplo, si creas un evento en blueprint, pero no añades ningún nodo de retraso, temporizadores o tareas de jugabilidad, toda la lógica derivada de ese evento de blueprint se ejecutará a la vez en el mismo fotograma. El fotograma no puede terminar de procesarse hasta que la lógica termine de ejecutarse. Cuando se ejecutan operaciones extensas, especialmente en conjuntos de datos grandes u objetos grandes que deben cargarse o descargarse de la memoria, esto puede generar picos de procesamiento significativos.
La lógica asincrónica se refiere a la realización de acciones en paralelo, ya sea literalmente al mismo tiempo (en núcleos de CPU separados) o lógicamente al mismo tiempo (intercaladas en fragmentos más pequeños que técnicamente se ejecutan sincrónicamente a bajo nivel). Una operación asincrónica se ejecuta hasta que se completa, mientras que el programa principal sigue ejecutándose sin esperar a que la operación se ponga al día. Normalmente, las operaciones asincrónicas usan devoluciones de llamada para indicar cuándo se han completado.
Varios marcos de Unreal Engine, como el sistema de Partición del entorno y varios sistemas de distribución de contenido a la carta, ya son asincrónicos. Para tus propios proyectos, plantéate implementar lógica asincrónica para distribuir las operaciones durante un periodo de tiempo y así evitar poner demasiado peso en una sola operación o fotograma.
Por ejemplo, es posible que necesites cargar e instanciar una gran cantidad de enemigos (digamos, 30 o más ) en un juego de defensa basado en oleadas. Dado que crear un nuevo actor en tiempo de ejecución ya es algo costoso, tratar de procesarlos todos en el mismo fotograma es muy engorroso. En su lugar, podrías crear una operación asincrónica para generar solo hasta 5 enemigos por fotograma hasta que se alcance el límite especificado o se agoten todas las posiciones de aparición especificadas. Esto daría como resultado que los 30 enemigos aparezcan en el transcurso de 6 fotogramas, momento en el que indicarías que la operación de generación en masa está completa. Si bien esto alivia drásticamente la carga de trabajo de generar tantos enemigos, la mayoría de los jugadores no notarán la duración de la generación, ya que ocurre en una décima o una quinta parte de segundo.
Procesamiento paralelo en Unreal Engine
El procesamiento paralelo es un tipo de procesamiento asíncrono en el que las acciones se procesan en el mismo ordenador, pero en distintos subprocesos o núcleos de CPU. Estos son algunos ejemplos de procesamiento en paralelo en Unreal Engine :
Resolver punteros blandos.
Cargar niveles y recursos en segundo plano.
Carga asincrónica de recursos desde un sistema de distribución de contenido en línea.
Un subproceso es una ruta específica para el procesamiento de instrucciones, tanto en la CPU como en la GPU. La mayoría de las CPU tienen varios núcleos, que son a su vez procesadores individuales, y cada núcleo puede tener varios subprocesos. Aprovechar el procesamiento paralelo es garantizar que un programa no se convierta en un cuello de botella en los procesos de la CPU, especialmente cuando se encarga de tareas más complejas y grandes cantidades de datos.
Subprocesos de procesamiento destacados
Unreal Engine cuenta con subprocesos dedicados a lo siguiente:
| Nombre del subproceso | Descripción |
|---|---|
Juego | Gestiona la lógica del actor y el objeto en común en C++ y blueprint, así como la lógica de la IU. La mayor parte de la programación tendrá lugar en este subproceso. |
Renderizado | Convierte la estructura de la escena en comandos de dibujo. |
RHI | Envía comandos de trazado a la GPU. |
Grupos de tareas | Gestiona varias tareas en subprocesos reutilizables. |
Audio | Procesamiento de sonido y música. |
Cargando | Se encarga de la carga y descarga de datos. |
Puedes ver estos subprocesos en la ventana Información de sincronización de Unreal Insights.
Dado que el subproceso del juego maneja una gran cantidad de lógica, es importante perfilar y optimizar el código con cuidado.
Creación de tu propia lógica de subprocesos
UE cuenta con varios recursos para añadir tu propia lógica de procesamiento paralelo:
Sistema de tareas proporciona un marco robusto y relativamente ligero para dividir la lógica en tareas que pueden ejecutarse en paralelo en subprocesos independientes.
FRunnable proporciona la interfaz de bajo nivel más directa para ejecutar una función en un subproceso arbitrario. Esto debería evitarse a menos que sepas lo que haces y estés justificado para usar un subproceso dedicado en lugar del grupo de subprocesos.
Ten cuidado al crear tu propia lógica de subprocesos, ya que puede generar condiciones de carrera, en las que se producirán errores si las operaciones se realizan en un orden inesperado.
Además, la página renderización multihilo proporciona información sobre la lógica de subprocesos específica del renderizado.
Compilación de sombreador, enganches de tasa de fotogramas y almacenamiento en caché de PSO
Unreal Engine compila las instrucciones de material en sombreadores para prepararlos para su ejecución en la GPU. Aunque el rendimiento general de los materiales mejora considerablemente una vez terminada la compilación, el acto de compilar sombreadores puede provocar picos significativos en el procesamiento, lo que a su vez provoca bajadas momentáneas pero notorias de la velocidad de fotogramas.
Para contrarrestar esto, UE implementa el almacenamiento en caché de PSO. Puedes recopilar PSO manualmente reproduciendo y prueba tu aplicaciones, o usar el precaché de PSO para generarlos automáticamente. En cualquier caso, la idea es hacer un registro de todos los estados posibles de modo que se renderice tu tarjeta gráfica cuando la aplicación se esté ejecutando. Luego, almacena esos datos en la caché y empaquétalos para usarlos en compilaciones posteriores. Esto reduce drásticamente la cantidad de compilación de sombreadores que tiene que ocurrir durante el tiempo de ejecución, ya que puedes cargar la mayoría de ellos por adelantado y, por lo tanto, reduce la cantidad de tirones que experimentan los usuarios al cargar nuevas áreas y materiales.