Rediseño del renderizador

Ahora que voy a pasar a otra etapa en el desarrollo de lo que algún día podría convertirse en un juego, no quería dejar algunos ámbitos vitales del motor como habían quedado. Durante el desarrollo, la implementación de la renderización se había dividido de manera que para dibujar algunos elementos se usaba una clase y para otros otra. Además, el hecho de que el dibujo se realizaba en capas tampoco se reflejaba en la arquitectura. Para ello he revisado todas las piezas funcionales y reducido la implementación a sus conceptos principales.

  • ¿En base a qué se divide una fase de renderización de otras? Por ejemplo, primero se dibuja el terreno, que include el suelo y el agua. El terreno lleva asociada una textura específica; cambiar de textura es una operación que cuesta bastante para la tarjeta gráfica, lo que ha hecho que la mayoría de juegos intenten incluir los gráficos en la menor cantidad de archivos de imagen posibles. Sin embargo, la misma textura se usa en otras fases del dibujo; cuando el usuario mira desde una altura considerable, sobre las casillas de terreno debe dibujarse una casilla traslúcida, y esas casillas leen de la misma textura. Algo similar pasa con la textura de donde se sacan los dibujos de las criaturas. Se lee de ellas durante la fase en la que se dibujan las criaturas situadas en la misma altura desde la que el usuario ve el mapa, y durante la fase en la que se dibujan las criaturas situadas bajo el nivel de altitud actual.
  • Lo que parece único de este proceso es el concepto de capas, o layers en inglés. Primero se dibuja la capa de terreno, luego la de actores bajo el nivel de altitud actual (si existen), luego las casillas traslúcidas, luego los actores en el mismo nivel de altitud, y sobre esas capas obligatorias también se utilizan, en algunos programas, una capa para mostrar las regiones (que no usa una textura, además), y por último una capa para dibujar los overlays, o iconos; por ejemplo, para indicar al usuario la casilla situada bajo el cursor del ratón.
  • No todas esas capas están activas en todos los programas. Es la responsabilidad del usuario activarlas.

1.png

Unas funciones aisladas se encargan de gestionar esa activación, lo que ha permitido refactorizar todo ese código para aislar lo poco que cambia.

2.png

  • Idealmente, la renderización de todas las capas debería poder realizarse con una sola llamada, en vez de que otras clases intercalaran renderizaciones cuando lo consideraran necesario. Por fortuna existen dos fases claras a la hora de interactuar con los renderizadores basados en OpenGL. En la primera fase se introducen los valores que componen los cuatro vértices de un cuadrado. Cada vértice puede tener hasta nueve valores en el sistema actual: tres valores para la posición, cuatro para el color y dos para la coordenada de la textura. Pero esos valores introducidos en el renderizador sólo se dibujan cuando se le exige que lo haga. Eso facilita dibujar las capas en sucesión.

3.png

  • Por desgracia uno de los programas, el que genera imágenes, ha evidenciado que no todo el renderizado puede reducirse al concepto de capas. Para ello se le permite registrar “órdenes de dibujo”, funciones que se ejecutarán durante la fase de dibujo, aunque se desconozcan los detalles de los renderizadores utilizados.

La única otra mejora que se me ocurre ahora mismo es limitar los cambios en los datos de los vértices a cuando el mapa se desplace, pero en el futuro algunas de las casillas podrían estar animadas, y para sólo cambiar los datos de esas casillas implicaría crear estructuras nuevas y registrar esas coordenadas específicas. Demasiada complicación para lo que me solucionaría ahora mismo.

 

Generación de imágenes mediante un algoritmo neuroevolutivo

Gracias a mi recientemente adquirido conocimiento sobre cómo mostrar cosas en la pantalla mediante OpenGL, he vuelto a implementar en Python un experimento que hace años implementé en Java: un algoritmo que genera imágenes mediante el método de neuroevolución NEAT inventado a mediados de los 2000s. Cuando lo implementé en Java, tuve que programar el método NEAT desde cero tirando de los artículos científicos, porque las librerías de Java existentes en ese momento no me inspiraban mucha confianza. Por fortuna hoy en día y en Python existen un par de paquetes muy sólidos que me libran de esa responsabilidad.

Empiezo enseñando un vídeo de seis minutos que muestra imágenes generadas a lo largo de varios procesos evolutivos independientes:

Un fenómeno curioso, aunque lógico, de los resultados de la neuroevolución programada se asemeja a lo que pasa en la natural: cuando algún patrón surge cerca del comienzo de la evolución, y por un motivo u otro beneficia al genoma, tiende a perpetuarse durante el resto de la evolución en diferentes formas (por ejemplo, la columna vertebral en los seres vivos).

El experimento funciona de la siguiente manera: se genera una población de unos 100 genomas que contienen los nodos y las conexiones de una red neural. Cuando se necesite activarla, se le pasarán dos valores: la coordenada x dividida entre la anchura de la resolución que la imagen tendrá, y la coordenada y dividida entre la altura de la resolución que la imagen tendrá. Tras el cálculo interno, la red neural produce los cuatro componentes de un color RGBA: el valor para el rojo, para el verde, para el azul, y para la transparencia.

Cuando implementé este experimento por primera vez en Java hace años, yo programé que cada genoma de cada generación produjera una imagen de 32 por 32 píxeles que guardaba en el disco duro. Yo tendría que elegir a mano cuáles me interesaran y añadirlos a otra carpeta, de los que el programa leería al iniciarse la siguiente vez, y sólo consideraría esos genomas elegidos para comenzar otra evolución. Sin embargo, aun entonces yo sabía que estaba sacrificando información vital para que la segunda evolución con genomas anteriores funcionara de la manera adecuada: los genomas guardan información sobre cuándo surgieron por primera vez sus nodos y conexiones, además de a qué especie pertenecen. Ambas informaciones son vitales cuando los genomas se reproducen. Por aquel entonces a mí no se me ocurría cómo implementarlo adecuadamente. En esta ocasión me planteé solucionarlo lo antes posible para la nueva versión en Python, pero acabé llegando a la conclusión de que no sólo habría que guardar los genomas queridos, sino también todas las especies y la generación a la que pertenecen, así que he optado por permitir que el usuario salve una generación entera, todos los genomas y las especies a los que pertenecen. Es el equivalente de salvar la partida y volver a cargarla.

Además, el código dibuja en la pantalla la imagen en 32×32 píxeles que cada genoma produce. Al revés que en mi pasada implementación en Java, en la que los genomas se promovían dependiendo de lo novedosos que fueran, ahora se le da la responsabilidad al usuario de seleccionar los genomas que quiere promover en la evolución. Cuando decida pasar a la siguiente generación, el algoritmo puntua de manera descendiente dependiendo del orden en el que el usuario ha seleccionado los genomas.

He grabado 100 generaciones de este proceso en el siguiente vídeo:

Los genomas empiezan o completamente desconectados o parcialmente conectados a los nodos de salida; lo he configurado para que exista una posibilidad del 10% de que cada conexión esté presente nada más empezar. Eso hace que algunos genomas no produzcan ningún color, o que salgan completamente negros. Sin embargo, en la primera generación ya se presentan patrones diferentes: barras verticales, diagonales con distorsiones, y gradientes. Sólo transcurren cinco generaciones hasta que tres genomas conectan con el nodo de salida verde. Sin embargo, de manera curiosa, es sólo en la generación 26 cuando un genoma conecta con el nodo de salida azul. Una mezcla de ambos acaba dominando la evolución, y faltan por entero, o casi, genomas que contemplen la salida roja.

Dado que la red neural que un genoma forma debe ejecutarse 32 * 32 veces (1024 veces) sólo para generar la textura que luego plasmo en la pantalla, no era factible generar durante la grabación las imágenes grandes que incluyo en los otros vídeos, ya que la resolución de 1080 por 1080 píxeles implica ejecutar la red neural de cada genoma 1.166.400 veces, lo que tarda un buen rato, y de momento no he conseguido paralelizarlo.

Aunque las imágenes generadas durante esa evolución no me han interesado demasiado, he recogido unas cuántas de ellas en el siguiente vídeo:

How to render text using OpenGL 4 and Python 3

The first time I implemented the following simulation, and the architecture of the entire application, following Python’s Pygame library. However, that library already struggled showing the entire map and the actors. Searching how to improve the performance of that library I understood that I needed to restructure the architecture to base it on native OpenGL, so all the data about what needed to be rendered ran in the graphics card. I took my time understanding how to store data in the GPU and how to organize the calls, because it works as a state machine. But I didn’t expect to get blocked rendering text, something that in 2018 should be as easy as making a single call saying what you want to write and on what position of the screen.

Before I talk about the issues rendering text, the next video shows the pathfinding experiment rendered entirely with OpenGL.

Each cell of terrain, as well as the images that represent the actors, are drawings of 32×32 pixels. That uniformity makes it easy to store them in textures even by hand, and rendering them. However, each character of a text could have a different size, and its position could vary depending on the previous character (something called kerning, apparently). I assumed some library would manage that much data for all the characters in a font, which led me to the library freetype-py. Given a font with a ttf extension, the library provides the necessary information, but doesn’t produce an image. I searched for around three to four weeks without success (to be fair, I was working full time). All the examples, about how to translate the information that the library provided into textures that could be rendered, were either based on obsolete OpenGL 2 techniques, or used Python 2 code that featured complicated maths that didn’t work in 3, and I couldn’t figure out how to make them.

The alternative to using that library and creating the image with the characters in the graphics card would be a bitmap font. It involves stuffing all the wanted characters of a font in a jpg, png, etc. image, and then rendering each character according to the UV coordinates associated to that texture. However, how would I organize all the characters in a texture by hand, given that they have different sizes? And how would I handle those widths and heights and divergent positions of the characters?

During my search I tried a few programs that turned a font into bitmaps. I ended up settling for Font Builder because it exported the information about each of those characters to a XML file.

fontBuilder.PNG

fontXML.png

Writing the code to turn that XML file into Python variables was easy. Afterwards I structured the code so that, from the upper layer of the application, rendering a text on the screen needed a single call and a few arguments.

drawZLevel.PNG

That call only needs the chosen font, the text that will be rendered, the screen coordinates, the height and width of the screen, the color the text will be rendered with, the relative scale to its original size, and the notion of whether that line of text will stick around. Although it seems like plenty of arguments, they are few in comparison to how much is handled in the lower layers.

In the second layer, a renderer object that holds the data structure that OpenGL understands (VAOs, VBOs, IBOs and other structures specific to working with graphics in the GPU), and that has already been initialized with the data of the combined text glyphs into an image, renders it in a similar way to the other elements on the screen.

mainMessageDraw.PNG

In the third layer, the program creates the renderer object. You need to take certain decisions with each element you are going to render on the screen. If you intend to draw an image that is not going to change, you better establish its data once and lock the internal structures as static, because otherwise you are sacrificing performance. A text gets created once and doesn’t change, so the code produces the image once when the renderer object gets initialized.

createTextRenderer.png

Here, the passed arguments to produce an image from the text get complicated. You need to define the max amount of quads that you believe the image of the text will occupy, the structure of the data that will get sent to the graphics card to render each vertex, and how many floats represent a vertex. In this case it’s nine floats: three for the position (x, y, z), four for the color (r, g, b, a), and two for the UV coordinates that represent in what part of the texture it will find the character that the vertex relates to.

The color of the text doesn’t change, so it would have been easier to send the color once to the internal program that runs in the GPU (called shader, a sort of “legacy name” that these days doesn’t necessarily have anything to do with rendering shadows), but maybe in the future I might want to render some characters with a different color, so it seemed like a good idea to send the data this way.

Optimization is vital when you need to update the screen 60 times a second. Every renderer object gets created once and stored, and when it isn’t needed, it gets discarded.

In the fourth layer of that initial call to draw the text you can find the most complicated part: how to translate the information of each character to form the wanted text.

calculateBufferData.PNG

For each character in the text, the code does the following:

  • Retrieves the character object formed from the data contained in the XML file, to know its height, width and other concrete information.
  • The UV coordinates get calculated, to know in which part of the texture of that font you can locate a particular character. Floating-point arithmetic that requires precision to avoid mixing the characters shown on the texture.
  • Adjusts the height and width of the character according to the passed scale, as well as to the natural offset of that character (for example, a p must get drawn lower than the other characters).
  • Checks the previous character and adjusts the cursor of the text depending on whether the current character must move closer to the previous one or further apart.
  • Creates 9 * 4 floats corresponding to the position, color and UV coordinates that form the four vertices of a quad.
  • Increases the cursor of the text with the width of the character we just handled.

I hope this guide helps someone, because I wish I had found something like this instead of cobbling the knowledge together by trial and error.

Afterwards I will restructure the code that draws a map so it can handle an experiment on neuroevolution: instead of drawing the cells from a texture, dozens of neural networks will generate 32×32 images, and they will keep evolving depending on which ones the user prefers.

Cómo dibujar texto mediante OpenGL 4 y Python 3

En un primer momento implementé la simulación siguiente, y la arquitectura de la aplicación entera, mediante la librería Pygame de Python. Sin embargo, a Pygame ya le costaba demasiado sólo mostrar el mapa y los actores. Mientras yo investigaba cómo mejorar el rendimiento de esa librería se hizo evidente que yo necesitaría reescribir la arquitectura para basarme en OpenGL nativo, y que toda la información sobre lo que debiera dibujarse en la pantalla la gestionara la tarjeta gráfica. Tardé bastante ya en entender cómo almacenar los datos en la tarjeta y cómo organizar las llamadas a OpenGL para que dibujara lo necesario. Donde no había esperado bloquearme era en dibujar texto, algo que en 2018 yo pienso que debería ser tan sencillo como hacer una única llamada diciendo qué quieres escribir y en qué coordenadas de la pantalla.

Antes de hablar sobre los problemas con el texto, este siguiente vídeo muestra el experimento de búsqueda de ruta dibujado por entero con OpenGL.

Cada casilla de terreno, y las imágenes de los actores, son dibujos de 32 por 32 píxeles. La uniformidad hace muy fácil almacenarlos en texturas y dibujarlos. Sin embargo, cada carácter de un texto puede tener un tamaño diferente, y además su posición puede variar dependiendo del carácter previo (algo llamado kerning, aparentemente). Asumí que alguna librería gestionaría esos múltiples datos individuales de los caracteres, lo que me llevó a la librería freetype-py. Dada una fuente con extensión ttf, la librería te da la información necesaria, pero no incorpora su imagen. Buscando en internet cómo implementarlo encallé durante unas semanas, porque todos los ejemplos sobre cómo convertir esa información de freetype-py en texturas y dibujarlas o bien se basaban en técnicas obsoletas de OpenGL 2, o en código de Python 2 que contenía partes matemáticas que no compilaban bien con Python 3 por motivos que no acabé de solucionar.

La alternativa a usar la librería y crear la imagen con los caracteres en la tarjeta gráfica sería una fuente bitmap, que consiste en meter los caracteres queridos de una fuente concreta en una imagen jpg, png, etc., y luego dibujar cada carácter mediante las coordenadas UV asociadas para esa textura. Sin embargo, ¿cómo metería yo todos los caracteres en una sola textura a mano, cuando tienen tamaños diferentes? ¿Y cómo gestionaría yo esas alturas y anchuras y posicionamientos divergentes de los caracteres?

Durante la búsqueda probé varias utilidades que convertían una fuente en bitmaps. Me acabé quedando con Font Builder porque exportaba la información sobre cada uno de esos caracteres a un archivo XML.

fontBuilder.PNG

fontXML.png

Escribir el código para convertir ese archivo XML resultante en variables de Python fue sencillo. Después estructuré el código de manera que desde la capa superior de la aplicación, dibujar un texto en la pantalla se redujera a una simple llamada y unos pocos argumentos:

drawZLevel.PNG

Esa llamada sólo necesita la fuente con la que quieres escribir el texto, el texto que mostrará, sus coordenadas en la pantalla, la altura y anchura de la pantalla, el color con el que se dibujará el texto, la escala relativa a su tamaño original, y la noción de si el texto es de usar y tirar o si reaparecerá. Aunque parecen muchos argumentos, son muy pocos en comparación con lo que se gestiona en las capas inferiores.

En la segunda capa, un objecto dibujante, que contiene la estructura de datos que OpenGL entiende (VAOs, VBOs, IBOs y demás estructuras específicas a trabajar con gráficos en la tarjeta), y que se ha construido ya con los datos que componen el texto completo en una imagen, la dibuja de una manera similar al resto de elementos de la pantalla.

mainMessageDraw.PNG

En la tercera capa, el programa crea el objeto dibujante. Hay que tomar ciertas decisiones con cada elemento que se va a dibujar en la pantalla. Si pretendes dibujar una imagen que no vas a alterar, más vale que establezcas los datos una vez y que definas las estructuras internas como estáticas, porque de lo contrario estás sacrificando rendimiento. Un texto se crea una vez y no se modifica, así que toda la imagen se compone una única vez cuando se inicializa el objeto dibujante.

createTextRenderer.png

Aquí, los argumentos que se pasan para definir la imagen del texto se complican. Necesitas declarar la cantidad máxima de recuadros que crees que el texto ocupará, la estructura de los datos que se enviarán a la tarjeta gráfica para dibujar cada vértice, y cuántos números reales representan un vértice. En este caso son nueve números reales: tres para la posición (x, y, z), cuatro para el color (r, g, b, a), y dos para las coordenadas UV que representan en qué punto de la textura se encuentra el carácter de texto al que corresponde ese vértice.

Dado que el color del texto no cambia, habría sido más sencillo enviar el color una vez al programa interno que corre en la tarjeta gráfica (llamado shader), pero en vistas a que en el futuro algunas letras pudieran tener otros colores, me pareció una buena idea enviar los datos así.

La optimización es vital cuando necesitas mostrar imágenes 60 veces por segundo. Cada objeto dibujante se crea una vez y se almacena, y cuando sobra se elimina.

En la cuarta capa de esta llamada se encuentra lo más complicado: cómo traducir la información de cada carácter al texto que se pretende mostrar:

calculateBufferData.PNG

Por cada carácter en el texto, el código hace lo siguiente:

  • Pide el objeto carácter que se ha extraído del archivo XML, para conocer su altura, anchura y demás datos concretos.
  • Se calculan las coordenadas UV, para saber en qué posición de la textura de la fuente se encuentra ese carácter. Calcula divisiones con números reales, y necesitan tener una precisión perfecta para que los caracteres no se mezclen.
  • Ajusta la altura y anchura del carácter en función de la escala que se le haya pasado, además de del desplazamiento natural de ese carácter en concreto (por ejemplo, una p debe dibujarse más abajo que las demás letras).
  • Comprueba el carácter previo y cambia el cursor del texto dependiendo de si el carácter actual debe acercarse al previo o distanciarse de él.
  • Crea 9 * 4 números reales correspondientes a la posición, color y coordenadas UV correspondientes a los cuatro vértices de un cuadrado.
  • Añade al cursor del texto la anchura del carácter cuyos datos acabamos de calcular.

Espero que esta guía ayude a alguien, ya que me hubiera gustado encontrarme una similar en vez de averiguar todo esto desde cero.

Después de esto reestructuraré el código que dibuja un mapa para hacer un experimento de neuroevolución: en vez de casillas leídas de una textura, unas docenas de redes neurales generarán imágenes de 32 por 32 píxeles, que seguirán evolucionando dependiendo de si el usuario las prefiere o no.