Universitat Oberta de Catalunya

Tilengine: el motor gráfico con efectos raster para videojuegos retro

Introducción

Tilengine es un motor gráfico orientado a la programación de gráficos similares a los de los sistemas de videojuego de los años 80/90, hoy en día englobados en la categoría retro. Su funcionamiento interno está basado en los mismos principios que los chips gráficos utilizados en dichos sistemas. De esta forma no se imita el resultado final de los gráficos, sino que su modelo de uso invita al programador a utilizar las mismas técnicas que se emplean en los juegos arcade clásicos.

Desarrollado en Barcelona, Tilengine es un proyecto sin ánimo de lucro, distribuido bajo licencia MIT, por lo que puede usarse gratuitamente en cualquier tipo de aplicación. Está disponible para los principales sistemas operativos de escritorio: Windows, Linux (incluida Raspberry Pi) y Mac OS X.

Objetivos

  1. Contribuir a la creación de videojuegos de estilo clásico, especialmente dirigido a pequeños estudios. Si bien hay otras herramientas, Tilengine está específicamente diseñado para este fin.
  2. Ser una herramienta educativa que facilite la comprensión del modelo de gráficos de scanlines, donde el uso creativo de los recursos limitados es clave para conseguir efectos visuales aparentemente fuera de las capacidades del sistema.
  3. Reivindicar el uso de tecnología comercialmente obsoleta como medio válido de creación.

Gráficos de ordenador

Para explicar qué hace Tilengine diferente a otros motores gráficos, hay que adentrarse un poco en la naturaleza de los gráficos de ordenador.

El sistema framebuffer

El primer gran método para generar imágenes, que es a la vez el más flexible y utilizado hoy en día, es el de la memoria de imagen o framebuffer. En este sistema, hay una memoria con capacidad para alojar una imagen completa, como un lienzo en blanco, y donde cada píxel ocupa una posición determinada. A grandes rasgos el proceso de trabajo consiste en escribir en esta memoria, alterando los píxeles individuales, y una vez la imagen está completa se vuelca entera al monitor. Para mejorar el rendimiento lo habitual es tener en memoria dos pantallas completas: mientras una se está generando, la otra está siendo volcada al monitor. Durante el tiempo del retrazado vertical, estas se intercambian, con lo que la que estaba siendo generada y ya se ha completado pasa a volcarse el monitor, y la otra queda disponible para generar una nueva imagen mientras tanto. De esta forma se consigue una animación suave sin fisuras ni parpadeos. Este modelo de gráficos es muy flexible, pero presenta dos inconvenientes:

  • Requiere mucha memoria, ya que debe alojar al menos dos imágenes completas
  • Requiere mucha capacidad de proceso, ya que cada píxel debe ser establecido individualmente.

Hoy en día este trabajo lo realizan las modernas aceleradoras gráficas o GPU, Graphics Processing Unit, procesadores gráficos dedicados que disponen de grandes cantidades de memoria y capacidad de procesamiento para manipular centenares de millones de píxeles por segundo. Y aún sin una GPU dedicada, las CPU actuales con velocidades medidas en GHz tienen capacidad para generar gráficos fluidos a pantalla completa por sí solas, aunque sin la complejidad ni definición que se puede alcanzar con una GPU. Pero, ¿cómo se pueden conseguir animaciones fluidas a pantalla completa sin todos estos recursos técnicos? ¿Cómo puede funcionar el clásico Sonic a 60 imágenes por segundo en un sistema de sólo 7.6 Mhz y 64 Kb de RAM, por ejemplo? La respuesta es: por medio de chipsets VDP.

El sistema VDP

Este segundo método de gráficos es el que se emplea en todos los sistemas de videojuego arcade de 8 y 16 bits: desde máquinas recreativas a consolas domésticas. Este método requiere un chip especializado en gráficos 2D llamado Video Display Processor (o VDP). No existe una memoria framebuffer que almacene una pantalla completa. Las imágenes se generan de forma secuencial, línea a línea en tiempo real, sincronizadas con el barrido del monitor. Un VDP funciona componiendo capas de fondo, formadas por tiles (pequeñas imágenes cuadradas a modo de baldosas), y sprites, que son los personajes y objetos que se mueven libremente por la pantalla.

Un VDP gestiona por sí sólo un determinado número de capas de fondo y de sprites, por ejemplo el de la consola Mega Drive de Sega maneja 2 planos de fondo y 80 sprites. Para programarlo, hay que escribir en unos registros, que son unas direcciones de memoria con un significado especial, donde cada atributo de cualquier capa y sprite ocupa una posición determinada. Los atributos de una capa pueden ser por ejemplo:

  • dirección de memoria RAM donde están los gráficos de las baldosas (tileset)
  • dirección de memoria del mapa de distribución de las baldosas (tilemap)
  • posición x,y inicial del mapa que se visualiza en pantalla

De forma similar, los atributos de un sprite pueden ser:

  • tamaño
  • dirección de memoria que contiene el gráfico
  • paleta de colores
  • posición x,y de pantalla

Para hacer un scroll (desplazamiento suave de pantalla) sólo hay que modificar la posición x,y del mapa en cada fotograma. Igualmente, para mover un sprite por la pantalla sólo hay que escribir el valor deseado en el registro correspondiente y el VDP se encarga de generar la representación final en pantalla. De esta forma, es posible generar animaciones rápidas a pantalla completa con muy poco tiempo de proceso, pues escribir unos pocos registros es un proceso muy rápido, y utilizando muy poca memoria gráfica, pues las diferentes baldosas que componen los fondos ocupan poco y se reutilizan a lo largo del escenario. A grandes rasgos lo que se hace es sacrificar flexibilidad a cambio de velocidad. En el sistema framebuffer las pantallas pueden contener cualquier imagen, pues cada píxel es totalmente independiente del resto, mientras que el sistema VDP los gráficos están más limitados, se componen de una cuadrícula de patrones que se repiten y reutilizan y de unos pequeños objetos que se superponen.

Los efectos raster

¿Qué tiene de especial el trabajo con VDP, dadas sus limitaciones? La respuesta está en la forma de generar la imagen hacia la pantalla. El VDP funciona de forma secuencial, línea por línea a la misma velocidad que el barrido del monitor. En los sistemas clásicos, el chip VDP es capaz de interrumpir a la CPU justo en el momento en que se produce el retrazado horizontal, y permitir la modificación de varios registros durante este breve intervalo de tiempo, antes de que se inicie la generación de la siguiente línea. El uso creativo de esta característica permite conseguir una gran variedad de efectos visuales:

  • Simular más planos de profundidad, dividiendo una capa en diferentes tiras horizontales que se mueven a diferente velocidad, por ejemplo el clásico juego de Amiga Shadow of the Beast.
  • Linescroll es el caso extremo del anterior, donde cada línea horizontal se mueve a una velocidad diferente. Puede verse en cualquier juego con carreteras pseudo-3d, como el Pole Position de Namco, y también el el efecto de suelo en perspectiva en Street Fighter II de Capcom.
  • Más sprites: pueden reutilizarse sprites para objetos que se encuentran a diferentes alturas de la pantalla. Por ejemplo el clásico juego Space Invaders los enemigos se mueven en hileras horizontales. Cada vez que termina una línea, los sprites se vuelven a utilizar para representar la siguiente hilera de enemigos. Así pueden verse en pantalla muchos más objetos a la vez.
  • Zonas inundadas: en un nivel parcialmente cubierto por agua, se empieza el fotograma con una paleta que representa el terreno seco, y se cambia por otra más azulada a la altura del agua.
  • Efectos de distorsión y ondulaciones horizontales para simular fuego, agua, etc
  • Escalado vertical: modificando el orden en que se dibujan las líneas de un escenario, es posible simular que ésta se comprime o expande verticalmente.

La propuesta de Tilengine

Ya existen diversos motores gráficos que trabajan con fondos de patrones y sprites, de forma similar a como hacen los chips VDP, pero todos tienen la misma limitación: funcionan mediante el modelo de framebuffer, generando toda la imagen completa y luego mostrándola ya terminada en la pantalla mientras preparan otra. Como no diferencian scanlines individuales, no se pueden modificar los parámetros en mitad de un fotograma. No es posible ninguna clase de efecto raster, con lo que el resultado final queda muy limitado con respecto a los sistemas con chipsets VDP reales. Algunos efectos concretos se pueden intentar emular a nivel de resultado final, pero no hay una forma genérica de conseguirlo.

Tilengine sí funciona de forma nativa mediante scanlines por lo que los efectos raster son parte integral de este motor. Al tener el modelo de un VDP virtual, el programador no ordena dibujar objetos directamente, sino que establece los atributos de los elementos gestionados por el motor. A diferencia de un VDP tradicional, el acceso no se realiza mediante escrituras en registros, sino mediante una API moderna en lenguaje de alto nivel, pero el concepto básico es el mismo. Mediante un mecanismo de interrupción horizontal virtual, el programa recibe una notificación cada vez que se completa una línea del fotograma, momento en el que se pueden cambiar los atributos de cualquier elemento antes de seguir generando la imagen.

En la web de Tilengine y en su canal de Youtube pueden verse numerosos ejemplos de uso del motor, código fuente y vídeos de demostración.

Un ejemplo paso a paso

Tilengine ha sido diseñado para ser muy fácil de usar y poder conseguir resultados con poco código. La API principal está en lenguaje C, pero existen puentes para otros lenguajes como C# o Python. En este caso usaremos el lenguaje Python, ya que tiene una sintaxis muy intuitiva, es interpretado -no requiere compilador- y es multiplataforma.

Instalación

Descargar el proyecto completo desde [la página oficial en GitHub](https://github.com/megamarc/Tilengine), o uno de los binarios que se pueden encontrar en [itch.io](https://megamarc.itch.io/tilengine) En ambos casos hay un archivo README en la raíz que indica los pasos a seguir para instalar el motor.

Documentación

La referencia completa del módulo Python para Tilengine está disponible en línea y puede ser interesante tenerla abierta para ir complementando al tutorial:

http://www.tilengine.org/doc_python/classes.html

Importar el puente python

extensión .py y añadir la siguiente línea:

python
from tilengine import *

Toda la API está englobada dentro del espacio de nombres `tilengine`, que importamos completa para no tener que ir haciendo referencia explícita.

Inicialización

El siguiente paso consiste en iniciar el motor gráfico y la ventana. Son dos pasos diferentes, ya que Tilengine permite ser integrado en otros entornos y por lo tanto no crea una ventana propia por defecto.

Para inicializar el motor se utiliza el método estático [`Engine.create()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Engine.create), al que hay que indicarle la resolución en píxeles, el nº de capas, de sprites y de animaciones simultáneas. Por ejemplo para inicializar a 640×360 píxeles, 2 capas de fondo, 64 sprites y 4 animaciones:

python
engine = Engine.create(640, 360, 2, 64, 4)

Para mostrar la ventana se utiliza el método estático [`Window.create()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Window.create), podemos usar los parámetros por defecto:

python
window = Window.create()

Establecer el color de fondo

Tilengine realiza una composición de imagen a partir de diferentes capas y sprites, que normalmente contienen zonas transparentes a través de las que se muestran los elementos que quedan por detrás. En las zonas donde no hay ningún elemento, por defecto se muestra el color negro. Para cambiar el color de fondo de las zonas vacías se utiliza el método [`set_background_color()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Engine.set_background_color) sobre objeto `engine` creado anteriormente, al que se le pasa un color definido con sus valores rojo, verde y azul. Por ejemplo para establecer el azul claro: 

python
engine.set_background_color(Color(120, 215, 242))

Cargar y visualizar capas de fondo

Para cargar una capa de fondo (tilemap) se utiliza el método estático [`Tilemap.fromfile()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Tilemap.fromfile) al que hay que indicar el archivo tmx a leer:

python
tilemap = Tilemap.fromfile(“layer_foreground.tmx”)

A continuación hay que asociar el tilemap cargado a una de las capas del motor. En este caso hemos creado dos capas, numeradas 0 y 1. La capa 0 es la de delante y la 1 la de detrás. El objeto `engine` creado anteriormente contiene una lista de objetos de la clase [`Layer`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Layer) llamado `layers[]`, con tantas posiciones como capas se han creado. Para asociar un tilemap a una capa, se utiliza el método [`setup()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Layer.setup) sobre el elemento de `layers[]` correspondiente a la capa que queremos modificar, a la que se le indica el objeto tilemap a asociar. Así, si queremos asociar nuestro tilemap a la capa 0:

python
engine.layers[0].setup(tilemap)

Para cargar la capa de fondo (capa 1), se hace de la misma forma:

python
tilemap = Tilemap.fromfile(“layer_background.tmx”)
engine.layers[1].setup(tilemap)

Cargar y visualizar sprites

Los recursos gráficos para los sprites están contenidos en spritesets. Un spriteset consta de dos archivos: una imagen con todos los fotogramas de animación relativos a un objeto, y un archivo de texto asociado con las coordenadas de las diferentes imágenes dentro del set.

Fragmento del archivo de texto `hero.txt` asociado:

idle1,0,0,24,36

idle2,24,0,24,36

idle3,48,0,24,36

jump1,72,36,24,36

jump2,96,36,24,36

run1,0,72,24,36

run2,24,72,24,36

run3,48,72,24,36

Para cargar un spriteset se utiliza el método estático [`Spriteset.fromfile()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Spriteset.fromfile), al que hay que indicar el nombre base del par de archivos png/txt, sin la extensión:

python
spriteset = Spriteset.fromfile(“hero”)

De forma similar al trabajo con capas de fondo, el objeto `engine` contiene una lista de objetos [`Sprite`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Sprite) con tantas posiciones como sprites hemos inicializado, llamada `sprites[]`. Para establecer el sprite, se utiliza el método [`setup()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Sprite.setup) sobre el elemento correspondiente indicando el spriteset a asociar. Para asociar nuestro spriteset al sprite 0:

python
engine.sprites[0].setup(spriteset)

Por defecto aparecerá en la posición 0,0, es decir la coordenada superior izquierda. Podemos situarlo en cualquier logar de la pantalla con el método [`set_position()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Sprite.set_position) al que se indica las coordenadas x,y de la esquina superior izquierda:

python
engine.sprites[0].set_position(60, 156)

Por defecto se muestra la primera imagen del spriteset. Para cambiar la imagen a otra distinta dentro del mismo spriteset, se utiliza el método [`set_picture()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Sprite.set_picture) al que se le puede pasar o bien el índice de la imagen, empezando por 0, o bien el nombre tal como aparece dentro del archivo de texto del spriteset. Así para establecer la imagen llamada `run3` al sprite 0:

python
engine.sprites[0].set_picture(“run3”)

Secuencias y animaciones

Aunque se puede animar un sprite manualmente mediante continuas llamadas al método `set_pitcure()` visto anteriormente, es mucho más conveniente usar la característica de animaciones incluida en Tilengine. Es posible aplicar secuencias de animación a sprites, tilesets y paletas de color para conseguir diferentes efectos. En este ejemplo sólo veremos animación de sprites.

Las secuencias están contenidas en archivos .xml con extensión *sqx*, donde cada archivo es un *pack de secuencias* ya que puede contener diversas secuencias diferentes. Por ejemplo el siguiente fragmento del archivo `hero.sqx`:

xml
<sequence name=”seq_idle” delay=”4″ loop=”0″>
0,1,2,3,4,5,6,7,8,9,10
</sequence>

Describe una secuencia llamada `seq_idle` para cuando el personaje está parado, con un retardo de 4 fotogramas entre cada cambio de imagen y que repite desde el principio al terminar. La secuencia está formada por las imágenes 0 a la 10 del spriteset.

Para cargar un pack de animaciones se utiliza el método [`SequencePack.fromfile()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.SequencePack.fromfile) , al que hay que indicar el nombre del archivo con las secuencias:

python
sequence_pack = SequencePack.fromfile(“hero.sqx”)

Para obtener una secuencia concreta, el objeto [`SequencePack`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.SequencePack) contiene un diccionario llamado `sequences` con todas las secuencias indicadas por el nombre que tienen en el archivo *sqx*. Por ejemplo para obtener la secuencia llamada “seq_idle”:

python
sequence_idle = sequence_pack.sequences[“seq_idle”]

Finalmente, para establecer la secuencia, el objeto `engine` contiene una lista de objetos [`Animation`](http://www.tilengine.org/doc_python/classes.html#animation) con tantos elementos como animaciones diferentes hemos declarado al iniciar, de forma similar al manejo de *layers* y *sprites*. Para iniciar una animación de sprite, se utiliza el método [`set_sprite_animation()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Animation.set_sprite_animation) sobre el elemento correspondiente a la animación a iniciar, a la que hay que indicar el índice del sprite, la secuencia a iniciar, y un entero indicando el nº de veces que se va a repetir, o 0 si repite indefinidamente. Para asociar la animación 0 al sprite 0 y la sequencia `seq_idle` para que se repita indefinidamente:

python
engine.animations[0].set_sprite_animation(0, sequence_idle, 0)

El bucle principal

Una vez inicializado todo, hay que ir refrescando la ventana y atendiendo la entrada de usuario de forma continua. Para ello se utiliza el método [`process()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Window.process) del objeto `window` creado previamente. Este método debe ejecutarse una vez por cada fotograma a generar. Devuelve `True` mientras la ventana permanece activa, y cuando el usuario la cierra devuelve `False` indicando que el programa debe terminar.

python
window = Window.create()
while window.process():
# atender entrada y actualizar objetos del juego

Entrada de usuario

La ventana de Tilengine simula una entrada sencilla de sistema de juego clásico, con cuatro flechas direccionales y seis botones de acción. Para conocer el estado de una determinada entrada, se utiliza el método [`get_input()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Window.get_input) sobre el objeto `window` creado previamente, al que se indica el nombre de la entrada a consultar. El siguiente fragmento atiende a las direcciones izquierda y derecha:

python
while window.process():

if window.get_input(Input.RIGHT):
# realizar acciones si se está pulsando derecha

elif window.get_input(Input.LEFT):
# realizar acciones si se está pulsando izquierda

Desplazamiento de pantalla (scroll)

El scroll es una técnica que consiste en desplazar el escenario progresivamente, en lugar de una pantalla estática entera cada vez. El scroll parallax es una variante común, en la que se utilizan dos o más capas superpuestas para simular profundidad. La capa más frontal es la que está más cerca del jugador, y por lo tanto la que se mueve más deprisa, mientras que la capa trasera representa objetos más lejanos y se mueve más despacio. Vamos a simular un scroll parallax con nuestras dos capas de ejemplo. Al pulsar derecha, desplazamos la capa frontal el doble de píxeles que la de fondo:

python
xpos = 0

while window.process():
if window.get_input(Input.RIGHT):
engine.layers[0].set_position(xpos, 0)
engine.layers[1].set_position(xpos/2, 0)
xpos += 2

Un efecto raster

Por último vamos a ver un ejemplo de efecto raster, en qué consiste y cómo puede servir para aplicar efectos visuales. Los efectos de raster se consiguen modificando los parámetros gráficos en mitad de la generación de un fotograma, en líneas determinadas. En este caso crearemos dos efectos:

-Pintar el color de fondo del cielo con un degradado suave, en vez de un color plano

-Mover la zona de agua de forma que cada línea se mueva un poco más deprisa que la inmediatamente superior, creando un efecto pseudo-3D. También se conoce como linescroll.

Para ambos efectos necesitaremos interpolar diferentes valores a lo largo de un recorrido, para ello utilizaremos la interpolación lineal (o *lerp* para abreviar):

python
def lerp(x, x0, x1, fx0, fx1):
return fx0 + (fx1 – fx0) * (x – x0) // (x1 – x0)

Para el degradado del cielo necesitamos dos colores: el inicial, correspondiente a la parte superior de la pantalla, y el final cuando llega a la zona de agua:

python
sky_color1 = Color(120, 215, 242)
sky_color2 = Color(226, 236, 242)

Es necesario interpolar estos dos colores de forma que varíe progresivamente del inicial al final dependiendo de la línea en la que se encuentra:

python
def interpolar_color(linea, linea1, linea2, color1, color2):
r = lerp(linea, linea1, linea2, color1.r, color2.r)
g = lerp(linea, linea1, linea2, color1.g, color2.g)
b = lerp(linea, linea1, linea2, color1.b, color2.b)
return Color(r, g, b)

* El efecto degradado del cielo estará entre las líneas 0 y 96

* La zona con las nubes se moverá a 1/8 de la velocidad de la capa frontal

* El efecto linescroll del agua estará entre las líneas 144, que se moverá a 1/8 de la velocidad, y la línea 192 que se moverá a 1/4 de velocidad. El resto de las líneas lo harán a un valor intermedio proporcional.

* La parte inferior con la colina, que empieza en la línea 192, se moverá a 1/2 de la velocidad.

Ahora hay que definir la función de raster callback, que es aquella que se ejecuta cada vez que se genera una línea del fotograma, indicándonos cuál es la línea que se va a pintar:

python
def efecto_raster(linea):
if 0 <= linea <= 96:
color = interpolar_color(line, 0, 96, sky_color1, sky_color2)
engine.set_background_color(color)

if linea == 0:
engine.layers[1].set_position(world.x//8, 0)
elif 144 <= linea < 192:
pos1 = xpos//8
pos2 = xpos//4
pos3 = lerp(linea, 144, 192, pos1, pos2)
engine.layers[1].set_position(pos3, 0)
elif linea == 192:
engine.layers[1].set_position(xpos//2, 0)

Finalmente hay que indicar que queremos habilitar los efectos raster, ejecutando el método [`set_raster_callback()`](http://www.tilengine.org/doc_python/tilengine.html#tilengine.Engine.set_raster_callback) sobre nuestro objeto `engine`:

python
engine.set_raster_callback(efecto_raster)

Descargar el ejemplo completo

El siguiente enlace contiene un código fuente base completo con el contenido del ejemplo paso a paso: [tutorial.py](tutorial.py). Puede descargarse el código fuente y los recursos de este ejemplo en el siguiente proyecto GitHub de código abierto: [TilenginePythonPlatformer](https://github.com/megamarc/TilenginePythonPlatformer). Este proyecto toma como base el ejemplo aquí desarrollado, pero añade muchas más características jugables.

Conclusión

En este tutorial de introducción hemos visto las características básicas de Tilengine y cómo utilizarlas desde Python: puesta en marcha, trabajar con escenarios, sprites y animaciones, y unos efectos raster. No obstante, queda mucho más por descubrir, como escalado, rotación, transparencias, deformaciones, colisiones de sprites, o modificar recursos en tiempo real.

Enlaces de interés

Página web:
http://www.tilengine.org/

Canal de actualizaciones en Facebook:
https://es-es.facebook.com/Tilengine-858312034204745/

Canal Youtube con vídeos de ejemplo:
https://www.youtube.com/channel/UCaINjGQpQut4MW30rSQjlXg

Acerca del autor

Marc Palacios Domènech

Analista-programador desde 2001 en el mundo de la consultoría TI de Barcelona, actualmente responsable del departamento de software en proyectos de electrónica aplicada. Estudiante del Grado de Informática de la UOC, y gran aficionado a los ordenadores y los videojuegos desde los tiempos del Spectrum.

Deja un comentario