Motores de juego que sufren microcortes por los shaders: la solución de Unreal Engine al problema
Hola, somos Kenzo ter Elst, Daniele Vettorel, Allan Bentham y Mihnea Balta, algunos de los ingenieros que han trabajado en el sistema de precaché de PSO para Unreal Engine.
Recientemente, se han producido algunas conversaciones en la comunidad de Epic en torno a los shaders que provocan microcortes en los juegos, el llamado «stuttering» en inglés, y su impacto en los proyectos de los desarrolladores de videojuegos.
Hoy vamos a profundizar en el origen de este fenómeno, a explicar cómo la precaché de PSO puede ayudar a resolver el problema y a explorar algunas prácticas recomendadas de desarrollo que os ayudarán a minimizar los microcortes por los shaders. También os contaremos nuestros futuros planes para el sistema de precaché de PSO.
Para más información al respecto, no os perdáis nuestra transmisión en directo de Inside Unreal este jueves 6 de febrero a través de Twitch o YouTube a las 20:00 CET.
Los microcortes en la compilación de los shaders se producen cuando un motor de renderizado descubre que necesita compilar un nuevo shader justo antes de usarlo para dibujar algo, por lo que todo se detiene mientras se espera a que el controlador termine de compilar. Para comprender por qué pasa esto, tenemos que examinar con más detalle cómo los shaders se traducen al código que opera en la GPU.
Los shaders son programas que se ejecutan en la GPU para llevar a cabo los distintos pasos que intervienen en la renderización de las imágenes en 3D: transformación, deformación, sombreado, iluminación, posprocesamiento, etc. Por lo general, están escritos en un lenguaje de alto nivel como HLSL, que debe compilarse en código de máquina que pueda ejecutar la GPU. Este proceso es similar para las CPU, donde el código escrito en un lenguaje de alto nivel como C++ se introduce en un compilador con el objetivo de producir instrucciones para una determinada arquitectura: x64, ARM, etc.
Sin embargo, existe una diferencia fundamental: cada plataforma (PC, Mac, Android, etc.) suele ir dirigida a uno o dos conjuntos de instrucciones de CPU. Sin embargo, hay muchas GPU distintas, con conjuntos de instrucciones tremendamente diferentes. Un ejecutable compilado hace 10 años para PC y x64 puede ejecutarse en chips producidos hoy por AMD e Intel porque ambos proveedores usan el mismo conjunto de instrucciones y porque ofrecen unas grandes garantías de compatibilidad con versiones anteriores. Por el contrario, un binario de GPU compilado para AMD no funcionará en NVIDIA o viceversa, y los conjuntos de instrucciones pueden incluso cambiar entre distintas generaciones de hardware del mismo proveedor.
Por lo tanto, aunque es posible compilar programas de CPU directamente en código de máquina ejecutable y distribuirlo, se debe usar un enfoque distinto para los programas de GPU. El código para shaders de alto nivel se compila en una representación intermedia, o código de bytes («bytecode»), que usa un conjunto de instrucciones abstractas que define la API 3D: DXBC para Direct3D 11, DXIL para Direct3D 12, SPIR-V para Vulkan, etc.
Los juegos se publican con estos archivos binarios de código de bytes de modo que tengan una sola biblioteca de shaders, en lugar de una para cada arquitectura de GPU posible. En tiempo de ejecución, el controlador traduce el código de bytes a código ejecutable para la GPU instalada en la máquina. Este método también se usa a veces para programas de CPU. Por ejemplo, el código fuente de Java se compila en código de bytes para que el mismo binario pueda ejecutarse en todas las plataformas que tengan un entorno Java, independientemente de su CPU.
Cuando se introdujo este sistema, los pocos shaders que tenían los juegos eran relativamente sencillos. Además, la transformación de código de bytes en código ejecutable era sencilla, por lo que el coste de hacerlo durante el tiempo de ejecución era insignificante. A medida que las GPU se han ido volviendo más potentes, hemos empezado a tener más y más código de shaders, y los controladores también han empezado a realizar transformaciones sofisticadas para producir un código de máquina más eficiente, lo que ha significado que el coste de compilación en tiempo de ejecución se haya convertido en un problema. Esta situación ha llegado al límite en Direct3D 11, por lo que las API modernas como Direct3D 12 o Vulkan se han propuesto solucionarlo introduciendo el concepto de Pipeline State Objects (PSO).
Normalmente, en el renderizado de un objeto intervienen varios shaders (p. ej., un shader de vértices y otro de píxeles trabajando en conjunto), así como otros ajustes para la GPU: modo de cribado, modo de mezcla, modos de comparación de plantillas y profundidad, etc. Juntos, estos elementos describen la configuración (o estado) de la canalización («pipeline») de la GPU.
Las API de gráficos más antiguas, como Direct3D 11 y OpenGL, permiten cambiar partes del estado individualmente y en momentos arbitrarios, lo que significa que el controlador solo ve la configuración completa cuando el juego envía una solicitud de dibujo. Algunos ajustes influyen en el código ejecutable de los shaders, por lo que hay casos en los que el controlador solo puede empezar a compilar shaders cuando se procesa el comando de dibujo. Esto puede tardar decenas de milisegundos o más para un solo comando de dibujo, lo que se traduce en fotogramas muy largos la primera vez que se usa un shader, un fenómeno conocido por la mayoría de los jugadores como microcortes o parones.
Las API modernas requieren que los desarrolladores empaqueten todos los shaders y ajustes que usarán para una solicitud de dibujo en un Pipeline State Object y lo definan como una sola unidad. Básicamente, los PSO se pueden construir en cualquier momento, por lo que en teoría los motores pueden crear todo lo que necesitan con suficiente antelación (por ejemplo, durante la carga) para que la compilación tenga tiempo de terminar antes de renderizar.
Unreal Engine cuenta con un potente sistema de creación de materiales que usan los artistas para crear mundos atractivos y ricos visualmente. Muchos juegos contienen miles de materiales. Cada uno de ellos puede producir muchos shaders distintos. Por ejemplo, hay un shader de vértices para renderizar un material en mallas estáticas, otro distinto para las mallas con piel o skinning y otro para las de spline. El mismo shader de vértices se puede utilizar con varios shaders de píxeles y esto se vuelve a multiplicar por los distintos conjuntos de ajustes de canalización. Esto puede dar lugar a millones de PSO distintos que tendrían que compilarse por adelantado para cubrir todas las posibilidades, lo que por supuesto resultaría inviable tanto por el tiempo que conllevaría como por la memoria que requeriría (cargar un solo nivel llevaría horas).
En el tiempo de ejecución se usa un subconjunto muy pequeño de estos posibles PSO, pero no podemos determinar cuál es ese subconjunto tan solo observando un material de forma aislada. El subconjunto también puede cambiar entre distintas sesiones de juego: al modificar los ajustes de vídeo, se activan o desactivan ciertas funciones de renderización, lo que hace que el motor use shaders o estados de canalización («pipeline states») distintos. Las primeras implementaciones del motor de Direct3D 12 se basaban en pruebas de juego, recorridos automáticos de niveles y otros métodos de detección similares para registrar qué PSO se encuentran en la práctica. Estos datos se incluían en el videojuego final y se usaban para crear los PSO conocidos al arrancar el juego o al cargar un nivel. Unreal Engine llama a este método una caché de PSO empaquetada («Bundled PSO Cache»), y fue nuestra mejor práctica recomendada hasta UE 5.2.
La caché empaquetada es suficiente para algunos juegos, pero tiene muchas limitaciones. Recopilarla requiere muchos recursos y debe mantenerse actualizada cuando cambia el contenido. Es posible que el proceso de registro no sea capaz de descubrir todos los PSO en juegos con mundos muy dinámicos (por ejemplo, si los objetos cambian de material en función de las acciones del jugador).
La caché puede volverse mucho más grande de lo necesario durante una sesión de juego si hay mucha variación entre sesiones. Por ejemplo, si hay muchos mapas o si los jugadores pueden elegir su aspecto entre muchas posibilidades. Fortnite es un claro ejemplo de título en el que usar una caché empaquetada sería mala idea, ya que se dan todas estas limitaciones. Además, ofrece contenido generado por los usuarios, por lo que tendría que usar una caché de PSO en base a la experiencia y hacer responsables a los creadores de contenido de recopilar estas cachés.
Para poder emplearse con contenido generado por los usuarios y mundos de juego grandes y variados, Unreal Engine 5.2 introdujo las precachés de PSO, una técnica que determina los potenciales PSO mientras se carga el juego. Cuando se carga un objeto, el sistema examina sus materiales y usa información de la malla (p. ej., si es estática o animada), así como el estado global (p. ej., los ajustes de calidad de vídeo), para calcular un subconjunto de posibles PSO que podrían usarse para renderizar el objeto.
Este subconjunto sigue siendo más grande que lo que se usa al final, pero mucho más pequeño que el rango completo de posibilidades, de ahí que resulte factible compilarlo durante la carga. Por ejemplo, Fortnite Battle Royale compila alrededor de 30 000 PSO para una partida y usa alrededor de 10 000 de ellos, pero es una porción muy pequeña del espacio total de combinaciones, que contiene millones.
Los objetos que se crean mientras se carga el mapa almacenan con antelación en la caché sus PSO mientras se muestra la pantalla de carga. Los que se transmiten o aparecen durante la partida pueden esperar a que sus PSO estén listos antes de renderizarse o usar un material predeterminado que ya esté compilado. En la mayoría de casos, esto solo retrasa la transmisión varios fotogramas, algo imperceptible. Este sistema logra eliminar los microcortes en la compilación de los PSO para materiales y funciona sin interrupciones con el contenido generado por los usuarios.
Cambiar el material de una malla ya visible es un caso más complicado, porque no queremos ocultarlo o renderizarlo con un material predeterminado mientras se compila el nuevo PSO. Estamos trabajando en una API que permita que el código del juego y los blueprints avisen al sistema con antelación, de modo que los PSO adicionales también puedan prealmacenarse en la caché. También queremos modificar el motor para que siga renderizando el material anterior mientras se compila el nuevo.
Unreal Engine cuenta con una clase independiente de shaders que no están relacionados con los materiales. Se trata de los shaders globales, programas que usa el renderizador para implementar diversos algoritmos y efectos, como el desenfoque de movimiento, el escalado, la reducción de ruido, etc. El mecanismo de precaché también cubre los shaders computacionales globales, pero a partir de UE 5.5 ya no se encarga de los shaders gráficos globales. Es poco habitual, pero estos tipos de PSO aún pueden provocar que el juego sufra microcortes la primera vez que se usan. Se está trabajando en solucionar esta carencia aún no cubierta por las precachés.
La caché empaquetada se puede usar en combinación con la precaché, lo que puede resultar útil en determinados juegos. Algunos materiales comunes se pueden incluir en la caché empaquetada para que se compilen durante el arranque, en lugar de mientras se juega. También puede venir bien con los shaders gráficos globales, ya que el proceso de descubrimiento se topará con ellos y los registrará.
Los controladores guardan los PSO compilados en el disco para que puedan cargarse directamente cuando se vuelvan a encontrar en sesiones de juego posteriores. Esto ayuda a los juegos, independientemente del motor o de la estrategia de compilación de PSO que usen. En el caso de los títulos de Unreal Engine que emplean las precachés de PSO, la pantalla de carga será notablemente más breve la segunda vez que se pase por ella. Fortnite tarda entre 20 y 30 segundos más en cargar una partida de Battle Royale cuando la caché de los controladores está vacía. La caché se borra cada vez que se instala un nuevo controlador, por lo que es normal ver pantallas de carga más largas la primera vez que se ejecuta un juego después de que se actualice un controlador.
Unreal Engine aprovecha la caché de los controladores para PSO durante la carga y descartándolos inmediatamente cuando terminan de compilarse: de ahí que esta técnica se llame «precaché». Cuando más adelante se necesita un PSO para renderizar, el motor envía una solicitud de compilación, pero el controlador simplemente la devuelve desde la caché, ya que el sistema de precaché se ha asegurado de que ya estuviera ahí. Cuando se usa un PSO para dibujar, permanecerá cargado hasta que se eliminen de la escena todos los elementos primarios que lo usen, por lo que no seguiremos pidiéndolo al controlador en cada fotograma.
Descartarlos después del proceso de precaché tiene la ventaja de que los PSO que no se usan no se guardan en memoria. La desventaja es que recuperar un PSO de la caché de los controladores justo cuando se necesita puede llevar algo de tiempo y, aunque es mucho más rápido que compilarlo, puede provocar microcortes la primera vez que se renderiza un material.
Una solución sencilla sería mantener los PSO en precaché en lugar de descartarlos, pero eso puede aumentar el uso de memoria en más de un 1 GB, por lo que solo debería llevarse a cabo en dispositivos con suficiente RAM. Estamos trabajando en soluciones que reduzcan el impacto en la memoria y decidan automáticamente cuándo mantener los PSO en precaché.
Solo algunos de los estados afectan al código ejecutable de los PSO. Eso significa que cuando creamos dos PSO que tengan los mismos shaders pero con distintos ajustes de canalización, es posible que solo el primero pase por el costoso proceso de compilación y que el segundo se devuelva inmediatamente desde la caché.
Por desgracia, el conjunto de estados que importan para la generación de código varía en función de la GPU y puede cambiar incluso de una versión a otra del controlador. Unreal Engine aprovecha conocimientos prácticos que nos permiten omitir algunas permutaciones durante el proceso de precaché. Gracias a la caché de los controladores, las solicitudes redundantes son más breves, pero el motor aún tiene que trabajar para generarlas. Este trabajo se va acumulando, por lo que el proceso de descarte es útil para reducir los tiempos de carga y el uso de memoria.
Las plataformas móviles usan el mismo modelo de compilación de shaders en el dispositivo, por lo que el sistema de precaché de Unreal Engine también resulta efectivo en ellas. En términos generales, el renderizador para móviles usa menos shaders que para un ordenador, pero la compilación de PSO lleva mucho más tiempo debido a que las CPU son más lentas, por lo que hemos tenido que hacer algunos ajustes en el proceso para que sea viable.
Nos saltamos algunas permutaciones que raramente se usan, lo que significa que el conjunto de precachés ya no incluye todos los casos, por lo que en algunos casos pueden producirse algunos microcortes si se acaba necesitando que se renderice uno de esos estados poco comunes. También hay un tiempo de espera destinado al proceso de precaché mientras se carga el mapa para evitar que se muestre la pantalla de carga durante demasiado tiempo. Esto se traduce en que la partida puede empezar cuando aún haya tareas de compilación pendientes, por lo que el juego sufrirá microcortes si necesita enseguida alguno de los PSO que aún se están almacenando. Usamos un sistema de aumento de prioridad para que cuando se requiera un PSO, se muevan las tareas relacionadas al principio de la cola, lo que minimiza dichos microcortes.
En consolas no se da este problema, ya que solo tienen una posible GPU. Los shaders individuales se compilan directamente en código ejecutable y van incluidos en el juego. No se producen montones de combinaciones posibles por usar el mismo shader de vértices con múltiples shaders de píxeles distintos, ni debido a los estados de canalización, porque estos factores no provocan una recompilación. Los shaders y los estados se pueden ensamblar en PSO durante el tiempo de ejecución sin incurrir en un coste significativo, por lo que se producen problemas de microcortes debido a los PSO en estas plataformas.
Hay quien piensa que Direct3D 11 no experimentaba estos problemas, lo que no es del todo cierto. Aun así, a veces nos piden volver al modelo de compilación antiguo o incluso a las API de gráficos antiguas. Como hemos explicado antes, en aquella época había ocasiones en las que los juegos también sufrían microcortes y, debido a la forma en que se diseñó la API, los motores no tenían forma de evitarlos. Eran menos frecuentes o más breves, sobre todo porque los juegos contaban con menos shaders, estos eran más sencillos y algunas funciones, como el trazado de rayos, ni siquiera existían.
Los controladores también tenían sus propios trucos para minimizar estos microcortes al mínimo, pero no podían evitarlos del todo. Direct3D 12 intentó solucionar el problema antes de que empeorara introduciendo los PSO, pero los motores tardaron un tiempo en usarlos de manera eficaz. Por una parte, eso se debía a la dificultad de readaptar los sistemas de materiales existentes. Por otra, la API tenía deficiencias que solo se hicieron evidentes cuando los juegos fueron volviéndose más complejos.
Unreal Engine es un motor de uso general con muchos casos de uso y mucho contenido y procesos de trabajo existentes, por lo que este problema era especialmente difícil de resolver. Por fin estamos llegando a un punto en el que tenemos una solución viable y también hay buenas iniciativas para corregir las deficiencias de la API, como la extensión Vulkan de la biblioteca del proceso gráfico.
El sistema de precaché ha evolucionado mucho desde su introducción experimental en la versión 5.2 y ya evita la mayoría de tipos de microcortes debidos a la compilación de shaders. Sin embargo, todavía no funciona a la perfección en todos los casos y sufre otras limitaciones, por lo que seguiremos esforzándonos por mejorarlo aún más. También estamos trabajando con proveedores de hardware y software para adaptar los controladores y las API de gráficos a la forma en que los juegos usan estos sistemas en la práctica.
Nuestro objetivo final es que la precaché se gestione automáticamente de la mejor manera posible para que los desarrolladores de juegos no tengan que hacer nada para evitar los microcortes. Hasta que el sistema esté terminado, los licenciatarios pueden realizar algunas acciones para garantizar que se pueda jugar a sus títulos con fluidez:
Recordad que, para obtener más información sobre este tema, podéis acompañarnos en la transmisión de Inside Unreal este jueves 6 de febrero en Twitch o YouTube a las 20:00 CET.
Cómo instalar Unreal Engine
Descargar el iniciador
Antes de instalar y ejecutar Unreal Editor, tendrás que descargar e instalar el iniciador de Epic Games.
Instalación del iniciador de Epic Games
Cuando lo hayas descargado e instalado, abre el iniciador y crea o inicia sesión con tu cuenta de Epic Games.
Solicita asistencia o reinicia la descarga del iniciador de Epic Games del paso 1.
Instalación de Unreal Engine
Cuando hayas iniciado sesión, dirígete a la pestaña «Unreal Engine» y haz clic en el botón «Instalar» para descargar la versión más reciente.