Compute: migración del emblemático videojuego <em>DOOM</em>
La accesibilidad de la base de código y la nitidez de las abstracciones han hecho de DOOM, de id Software, uno de los títulos que más migraciones ha registrado en la historia de los videojuegos. Así que migrarlo a Compute, diseñado en nuestro entorno informático sin servidores, para hacer pruebas con diversas aplicaciones del producto me pareció un proyecto perfecto.
La demostración de que DOOM se puede ejecutar en Compute supondría superar los límites de rendimiento del producto y contar con una demostración tangible con la que dar a conocer las fascinantes posibilidades que ofrece Compute. Os voy a contar cómo lo logramos.
Breve historia de DOOM
El videojuego DOOM fue desarrollado en 1993 por la empresa id Software, que lo publicó en diciembre de ese mismo año. Aunque id Software ya había publicado videojuegos 2D de alta calidad, fue con los títulos Wolfenstein en 1992 y con DOOM el siguiente año con los que dio el trascendental salto al 3D, aprovechando la rapidísima evolución del hardware de los PC para expandir los límites del sector.
En 1997, el código de DOOM se abrió al público, con el siguiente mensaje en el archivo «README»: «Mígralo a tu sistema operativo favorito». Así que infinidad de seguidores hicieron lo propio, y DOOM llegó a centenares de plataformas, desde las más conocidas a las de dudoso origen. Como fan de DOOM a la vez que empleado de Fastly, me apetecía probar el potencial de Compute. A continuación, os explicaré cómo migré este emblemático videojuego a Compute.
Conviene resaltar que el libro Game Engine Black Book, de Fabien Sanglard, es una valiosa fuente de información a la que recurrí regularmente en este proyecto. Tanto esta obra como la relativa a Wolfenstein, también del mismo autor, están bien documentadas y analizan en profundidad los momentos clave de la historia del desarrollo de videojuegos en clave amena y educativa.
Migración
La estrategia que adopté para migrar DOOM constaba de dos pasos:
Hacerme con el código agnóstico en cuanto a plataforma (es decir, código que no estuviera sujeto a las llamadas del sistema o a los SDK de una arquitectura o plataforma concretas) para compilarlo y ejecutarlo; este vendría a ser el grueso de lo que muchos consideran la «mecánica de juego».
Reemplazar las llamadas a la API específicas de plataforma según sea necesario para la plataforma de destino; este es el código que gestiona sobre todo la entrada y la salida, incluidos la presentación y el audio.
Al no haber una interfaz pública oficial para los enlaces en C, en casa tendréis que deducir las API de C a partir del cajón fastly-sys.
Código común
Conseguir que DOOM se ejecutara sin presentación ni audio en Compute no fue complicado: dado que la base de código contiene prefijos en todos los nombres de función y usa «I_» para todas las funciones de implementación, solo tuve que repasar la base y eliminarlas de la compilación. Una vez acabado esto, utilicé wasi-sdk para apuntar a un archivo binario Wasm, cambio que, al estar WebAssembly diseñada para compilar código nativo sin mayores complicaciones, no resultó complicado.
Tuve que hacer algunas correcciones para conseguir que el videojuego se ejecutara como archivo binario de WebAssembly, pero todas se debieron al hecho de que DOOM se desarrolló en una época en la que imperaba la programación de 32 bits. Por ejemplo, en varias ubicaciones había código que presuponía que los punteros tenían 4 bytes, lo cual era una elección totalmente razonable por aquel entonces. Los datos de DOOM se cargan a partir de un archivo que contiene todos los activos que creó y empaquetó el equipo de desarrollo en el momento de publicación. Estos datos se cargan directamente en la memoria y se convierten a la estructura en C integrada en el videojuego que representan. Si estas estructuras presentan algún puntero, la carga de los datos en un entorno de 64 bits se traduciría en la superposición incorrecta de los datos sobre la estructura, con la consiguiente imprevisibilidad del comportamiento. Fue bastante sencillo localizar dichas estructuras, que en un principio hubieran provocado errores claros en el juego.
Cambios en el bucle de partida
Para lograr que el código común se ejecutara en Compute, tuve que refactorizar el bucle de partida tradicional que usaba DOOM. Las partidas convencionales suelen iniciarse para ejecutarse seguidamente en un bucle infinito, por el que se genera un tick interminable de entrada-simulación-salida a la frecuencia deseada. Las entradas proceden de los dispositivos de entrada locales (como teclados, ratones o mandos) y las salidas se generan en forma vídeo y audio. Sin embargo, en Compute, la misma plataforma acabaría por expulsar un proceso como este, ya que el objetivo es que la instancia se inicie, realice alguna tarea y después sea devuelta al solicitante. Así que suprimí la totalidad del bucle y cambié la instancia de modo que solo ejecutara un fotograma de la partida.
El resultado general se parecería a la ejecución en bucle de lo siguiente:
En las secciones siguientes, analizaré en detalle cada uno de estos pasos.
Salida
En los videojuegos, la memoria que alberga la imagen final que se muestra al jugador se denomina framebuffer o búfer de fotograma. Aunque en la actualidad el búfer de fotograma se suele diseñar mediante hardware especializado de procesador gráfico (GPU) —ya que la imagen final suele ser el producto de ejecutar varios pasos de canalización en el GPU—, en 1993 la presentación se hacía con software y, en el caso de DOOM, el programador disponía del búfer final en forma de matriz básica en C. Este tipo de diseño suponía que la migración a otras plataformas no tuviera apenas complicaciones: proporcionaba a los desarrolladores un punto de partida sencillo y comprensible.
En el caso de Compute, mi intención era devolver el búfer de fotograma al navegador del jugador, donde se podría mostrar. Se trataba de una tarea tan sencilla como usar la API en C para programar el búfer en el cuerpo de una respuesta y, seguidamente, enviar dicho cuerpo en sentido descendente:
// gets a pointer to the framebuffer
byte* framebuffer = GetFramebuffer(&framebuffer_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size,...);
SendDownStream(handle, bodyhandle, 0);
Una vez que el cliente que se ejecuta en el navegador recibe la respuesta HTTP de parte de Compute, analiza el búfer de fotograma y lo representa en el navegador.
Estado
La reproducción del bucle de partida en este nuevo modelo exige que almacenemos el estado en otra ubicación, de modo que, cuando invoquemos a Compute para fotogramas subsiguientes, podamos indicar a la nueva instancia en qué momento de la partida nos encontrábamos. Para ello, conseguí aprovechar la funcionalidad de almacenamiento-carga del videojuego, que en su momento permitía al jugador guardar en disco el estado de la partida, y posteriormente volver a cargar esta y continuar jugando allí donde se quedó.
Empleé el mismo mecanismo con el estado que con el búfer de fotograma: al final del fotograma de la partida, invoqué al sistema de almacenamiento para obtener un búfer que representara el estado de la partida y, a continuación, lo superpuse al búfer de fotograma a la hora de devolver la respuesta HTTP al solicitante.
// gets a pointer to the framebuffer
byte* resp = GetFramebuffer(&framebuffer_size);
// gets the gamestate, appends it to the framebuffer
resp+fb_size = GetGameState(&state_size);
BodyWrite(bodyhandle, framebuffer, framebuffer_size + state_size,...);
SendDownStream(handle, bodyhandle, 0);
Junto con este cambio, se modificó el cliente de modo que el búfer de fotograma y el estado quedaran separados, manteniendo al segundo almacenado en local y mostrando el primero en el navegador. La siguiente ocasión que dirija una petición a Compute, el cliente comunicará el estado en el cuerpo de la petición, que la instancia de Compute podrá leer y enviar a la partida del siguiente modo:
BodyRead(bodyhandle, buffer,...);
LoadGameFromBuffer(buffer);
A partir de aquí, si ejecutamos el fotograma de la partida, este se reproducirá como si hubiera habido un tick después de haber guardado la partida.
Entrada
El siguiente elemento necesario es la entrada del usuario: el jugador debe poder jugar la partida. El sistema de entradas de DOOM se sintetiza a partir del concepto de eventos de entrada; por ejemplo, «el jugador ha pulsado la tecla W» o «el jugador ha movido el ratón una distancia X». Se pueden generar en el navegador eventos de entrada con bastante facilidad que se correlacionen con las expectativas de DOOM mediante agentes de escucha de eventos Javascript:
document.addEventListener(‘keydown’, (event) => {
// save event.keyCode in a form we can send later
});
Estos eventos de entrada los envío junto con el estado al formular la petición HTTP que va dirigida a Compute. A continuación, la instancia los analiza de forma que se puedan transmitir al motor del videojuego antes de ejecutar el fotograma.
Optimizaciones
La primera versión operativa de esta demo funcionaba a unos 200 ms por trayecto de ida y vuelta, latencia que no es aceptable para un videojuego interactivo: los videojuegos convencionales se ejecutan a 33 ms, a razón de 30 fotogramas/seg., o a 16 ms, a razón de 60 fotogramas/seg. Puesto que la latencia se podría considerar una parte relevante de nuestra frecuencia de actualización, decidí que 50 ms sería un objetivo aceptable; o sea, una mejora del 400 % con respecto a la versión base.
Varias de las optimizaciones que conseguí implementar giraban en torno al cambio que suponía pasar de ejecutar un bucle de partida continuo a ejecutar un solo fotograma. El diseño de multitud de sistemas de partidas gravita alrededor del concepto de que cada tick es una variación del fotograma anterior; la partida conserva un estado que no se captura en la propia partida guardada, sino que se usa en cada fotograma para la toma de decisiones. Varios de estos sistemas precisaban correcciones que garantizaran el buen funcionamiento de las características y que solventaran problemas de rendimiento. De hecho, muchos de estos sistemas funcionaban mejor si no lo hacían en el primer fotograma, momento en el cual se inicializaban bastantes variables y estados.
Al iniciarse, el videojuego hacía varios cálculos previos que en su mayoría consistían en trigonometría para calcular el espacio de vista respecto al espacio del escenario de la partida. Además, estas tablas de cálculos previos precisaban conocer la resolución de pantalla de la partida, motivo por el cual se hacían en el tiempo de ejecución. Con vistas al objetivo planteado, procuré mantener fija la resolución del renderizado, de modo que pude incrustar las tablas en el archivo binario compilado y ahorrar la ejecución de este cálculo inicial con cada fotograma.
Conseguí que la ejecución del videojuego oscilara entre 50 y 75 ms por tick. Aunque se podría haber avanzado más para que estas prestaciones se acercaran a las que ofrecía el DOOM original, la migración que analizamos en esta entrada de blog prueba que es posible hacer iteraciones con Compute a partir de proyectos parecidos a este.
Conclusiones
Esta ha sido mi primera incursión en Compute y no tenía claro qué podía esperar en cuanto a depuración e iteración. Al estar la plataforma sujeta a mejoras activas y continuas, a lo largo de las tres semanas dedicadas a esta migración he tenido la oportunidad de probarlas y de ser testigo de cómo potencian la fiabilidad y la capacidad de depuración. En concreto, quisiera subrayar Log Tailing, novedad gracias a la cual pude visualizar la salida de impresiones de DOOM casi en tiempo real. Al estar iterando en un programa en C bastante opaco, sobre todo antes de lograr que la presentación funcionara, era imprescindible poder ver estas impresiones para depurar problemas. En líneas generales, se podría decir que el despliegue en Compute se asemejó al trabajo realizado con una consola de videojuegos tradicional.
También conviene aclarar que esta no es una solución perfecta para ejecutar un videojuego en tiempo real que exija actualizaciones a intervalos determinados; en realidad, la ejecución de un videojuego de este tipo con este enfoque no comporta ventajas reales. La finalidad de este experimento era poner a prueba los límites de la plataforma, crear una demo atractiva que permitiera descubrir y mostrar sus posibilidades, y servir de fuente de inspiración y de interés por la plataforma. A buen seguro existen casos de uso que respalden el aprovechamiento de esta plataforma en el campo de los videojuegos, y seguiremos investigando maneras de demostrar el atractivo de Compute para más sectores.
Si quieres probar la demo, podrás ejecutarla en nuestro Developer Hub.Échale un vistazo.