CURSO DE PROGRAMACIÓN GRÁFICA

Artículo 6: BITMAPS, SPRITES Y PANTALLAS VIRTUALES

Autor: (c) Santiago Romero.
Revista: Programación Actual (Prensa Técnica) nº 6, Septiembre-1997


Los Bitmaps y Sprites son elementos fundamentales en los gráficos por ordenador, y combinando su uso con el de las pantallas virtuales podemos obtener fluidas animaciones y complejos gráficos en cualquier tipo de programa.

Si los pixels son la primitiva básica de los gráficos por ordenador, la asociación de estos proporciona otra unidad mínima de las composiciones gráficas. Esta asociación constituye el mapa de bits o bitmap, donde agrupamos estos pixels para formar una figura gráfica mayor, un conjunto de pixels gráficos.

En principio es normal confundir los sprites con los bitmaps, pero no son exactamente lo mismo. Un sprite (duende en castellano) es un bitmap con una característica que lo identifica: el movimiento. Para iniciar la comprensión de estas estructuras lo primero es ver algunas definiciones generales básicas.


BITMAPS Y SPRITES

Un bitmap (mapa de bits en castellano) es la representación de una imagen o bloque gráfico dentro de la videomemoria de la tarjeta gráfica (o, en general, dentro de la memoria). La imagen visual que se suele tener al iniciarse en este tema es la de ver el bitmap como un "bloque cuadrado" que contiene todos los puntos que definen una imagen con una anchura y altura determinadas, tal y como puede apreciarse en la figura 1.

Bitmaps

Ese pequeño recuadro (con la anchura y altura que sea necesaria para contener la imagen completa) que encierra la figura está constituido por ScanLines o líneas de escaneado, es decir, está constituido por una serie de líneas horizontales (o verticales preferentemente en modos unchained o modos x) que toman forma a la hora de dibujar el bitmap, como veremos más adelante.

Un sprite, por contra, es un bitmap dotado de estado, posición, movimiento, etc. que necesita ser controlado, es decir, es un mapa de bits con características propias, como puede verse también en la figura 1.

Deberemos pues decidir qué velocidad tiene cada sprite, su posición inicial, qué tipo de eventos hacen modificar su estado, etc. Son por lo tanto bitmaps con una serie de variables que determinan su existencia, como pudiera ser el siguiente ejemplo de definición de Sprite:


 struct Sprite
 {
    char estado;
    int x, y;
    int ancho, alto;
    long energia;
    char *bitmap;
 };

Su anchura y altura son el número de pixels de ancho y alto del bitmap que lleva asociado. Las variables (x,y) en este ejemplo designarían su posición en pantalla, sus coordenadas. La variable energia se ha definido para dotarle de una característica distintiva de otros sprites (es tan sólo a modo de ejemplo), y estado es un flag o indicador cuyo valor puede usarse en el código para indicar el estado del sprite, como por ejemplo 0=inactivo, 1=activo, 2=móvil, etc.

Los sprites pueden tener cualquier tamaño, ya que nada nos impide utilizar sprites más grandes que la pantalla en sí, pero debe quedar claro que a mayor tamaño, mayor número de pixels y por tanto menor velocidad de representación.

De una manera más intuitiva, un bitmap es cualquier gráfico fijo de una aplicación o juego que no permite interacción con otros elementos del programa ni con el usuario mientras que un sprite es un botón (que puede ser pulsado, estar activo, inactivo, etc.) o el personaje de cualquier juego (con sus coordenadas propias y sus interacciones con el usuario y el resto de sprites del juego).

Lo que vamos a ver en el presente artículo es la manera de guardarlos en memoria, de imprimirlos en pantalla, de gestionar su estado y movimiento y la manera de almacenarlos y cargarlos desde archivos de disco, además de la utilización de pantallas virtuales.


DEFINICION DE UN BITMAP

Al igual que hacíamos con las fuentes, podemos guardar los sprites en memoria en arrays conteniendo los diferentes puntos. Si estamos en un modo de 2 colores, nos basta con utilizar un bit por cada pixel (como en las fuentes monocolor), indicando 1 color (bit=0) o el otro (bit=1). El caso más sencillo son los modos de 1 byte por pixel (como el modo 13h), donde al representarse cada pixel con un byte, para almacenar un sprite de 16*16 pixels, por ejemplo, bastaría con un array tipo char de 16*16=256 elementos (similar a como hacíamos en las fuentes multicolor). Únicamente hemos de guardar en este array todos los puntos que lo definen, de manera consecutiva.


char Sprite[5*4] =
{
      3,1,1,1,3,
      2,0,0,0,2,
      2,0,0,0,2,
      3,1,1,1,3
};

Dado que los sprites son simplemente variables de estado, posición, etc. con un bitmap asociado, es el dibujo de estos mapas de bits lo que nos interesa en primera instancia.


DIBUJO DE BITMAPS

En sistemas como el Amiga, por ejemplo, el hardware proporciona funciones para la impresión de mapas de bits con un tamaño prefijado, pero (mala suerte la nuestra) el PC se hizo exclusivamente "para trabajar" así que hemos de crear nuestras propias rutinas de impresión de bitmaps por software. Una forma de hacer esto consiste en utilizar la primitiva gráfica <pixel> para, utilizando bucles anidados, dibujar cada uno de los pixels que conforman el bloque gráfico.


void DrawSprite( int x, int y, int ancho, int alto )
{
 int vx, vy;

 for( vy=0; vy<alto; vy++ )
   for( vx=0; vx<ancho; vx++ )
     PutPixel(x+vx, y+vy, Sprite[(vy*ancho) + vx] );
}

Esta forma de dibujar un mapa de bits es muy general y sirve para cualquier modo gráfico del que dispongamos de su función PutPixel().


DIBUJO POR SCANLINES

Dibujar los mapas de bits es por ScanLines consiste en aprovecharse de la linealidad de modos como el 13h o los modos SVGA VBE 2.0 para dibujar los sprites como un conjunto de líneas horizontales.

Simplemente nos posicionamos en la posición correcta de pantalla (seg:offs) y comenzamos a dibujar pixels horizontalmente, que es como los tenemos organizados en el array y como mejor podemos traspasarlos a la VideoMemoria. Para pasar al siguiente punto de pantalla bastaría con incrementar el puntero de memoria (generalmente ES:DI). Al llegar al final de cada ScanLine incrementamos el puntero de memoria en <AnchoPantalla> unidades (para pasar a la línea siguiente) y le restamos el ancho del sprite para posicionarnos en el pixel correcto (para colocar el puntero de escritura en el primer pixel de la siguiente línea). Este proceso queda reflejado en el listado 1, con el siguiente pseudocódigo:

   DS:SI = Sprite
   ES:DI = (x,y) de pantalla
   Repetir ALTO veces
      Copiar ANCHO bytes desde DS:SI a ES:DI
      DI += (AnchoPantalla-ANCHO)
   Fin Repetir

Es con este sistema con el que se consigue una mayor velocidad de representación, ya que el bucle se repite sólo ALTO veces (y no ANCHO*ALTO como en el otro método), y además nos permite aprovechar la linealidad de la VRAM.

 LISTADO 1: Trazado de sprites por scanlines.

 void DrawSprite(int x, int y, int ancho,
                int alto, char far *sprite )
{
 unsigned int offs;
 offs = (y*320)+x;

 asm {
   push ds
   lds si, sprite
   mov ax, 0xA000
   mov es, ax
   mov di, [offs]
   mov dx, [alto]
     }
 BucleY:
 asm {
    mov cx, [ancho]
    rep movsb
    add di, 320
    sub di, [ancho]
    dec dx
    jnz BucleY
  
    pop ds
     }
}


LOS PIXELS TRANSPARENTES

Al definir los bitmaps como bloques rectangulares y dibujarlos en pantalla tratándolos como sprites nos encontramos con el problema de que el relleno rectangular (el clásico fondo negro de los bitmaps) es también dibujado sobre la pantalla, sobreescribiendo el fondo. Esto significa que si dibujamos un sprite sin tener en cuenta qué pixels son transparentes (no deben ser dibujados) y cuales no, en pantalla aparecerá el sprite con su recuadro negro en vez de ver el sprite recortado sobre el fondo (figura 2).

Bitmaps

Hay 2 formas de evitar esto. La primera de ellas es la más rápida y cómoda en términos de memoria y es por ello la más sencilla de aplicar.

1). Utilizar un color máscara. Esto significa designar un color que no se use en el sprite en sí y con el que rellenamos las partes del bitmap que no han de dibujarse en pantalla, el fondo del bitmap. En la rutina de dibujado comprobamos el color de cada pixel del bitmap. Sólo en caso de que el pixel leido sea distinto del color máscara lo dibujamos en pantalla. El color más utilizado en estos casos es el color 0 (generalmente negro), con el que se rellenan las partes del sprite que deseamos que sean transparentes. Dentro del bucle de dibujado con una simple condición podemos saber si el pixel que estamos tratando debe ser dibujado o no:


  for( vy=0; vy<alto; vy++ )
    for( vx=0; vx<ancho; vx++ )
      if( Sprite[(vy*ancho) + vx] != 0 )
         PutPixel(x+vx, y+vy, Sprite[(vy*ancho) + vx] );

2). Utilizar bitmaps y máscaras. Consiste en utilizar un bitmap invertido o máscara (como puede verse en la figura 2), con lo cual mediante operaciones lógicas OR y AND puede evitarse la escritura de los pixels transparentes sobre la pantalla.

Para crear las máscaras y los sprites hemos de rellenar la parte transparente del sprite (el fondo) del color negro en cualquier programa de dibujo. A continuación copiamos el sprite al lado del anterior y rellenamos su fondo con el color 255. Estamos creando la máscara del sprite, tan sólo falta vaciar el interior de la máscara (lo que queremos que aparezca en pantalla) con el color negro. En resumen, necesitamos el dibujo original con fondo negro (0) y el mismo dibujo vaciado de negro con fondo blanco (255).

El proceso consiste en tener para cada bitmap una máscara de manera que podamos realizar los siguientes pasos (para cada pixel del sprite):

   - Se lee el pixel sobre el que vamos a dibujar en pantalla.
   - Se realiza un AND lógico (operador & en C y and en assembler)
     entre el byte leido y la máscara del dibujo.
   - Con el byte resultante se hace un OR lógico (operador |) con el
     byte correspondiente al bitmap original.
   - El resultado de la última operación se almacena en VideoMemoria.

Con un par de pruebas (de operaciones lógicas AND y OR) y observando la figura 2 el uso de máscaras resulta muy sencillo pero costoso en términos de memoria, pues para cada bitmap hemos de almacenar además su máscara. Es por ello que resulta mucho más cómodo, rápido y sencillo el primer método, realizable con una simple comprobación.


AHORRO DE MEMORIA

Cada Sprite en realidad no es más que un conjunto de variables de estado con un bitmap asociado. Esto quiere decir que con un sólo bitmap podemos crear multitud de sprites, no siendo necesario almacenar el cuerpo gráfico o imagen para cada uno de ellos. Esto puede verse en uno de los ejemplos incluidos en el CD que acompaña a la revista, donde se utiliza el mismo bitmap para 10 sprites diferentes que rebotan por la pantalla.

Los sprites pueden agruparse para dar lugar a un sprite mayor, algo que suele hacerse para reutilizar bitmaps. Si al crear un juego disponemos de un sprite cuyo mapa de bits sea el de una pequeña nave, por ejemplo, añadiéndole a los lados de esta 2 pequeños sprites que representen 2 escudos tendremos un sprite mayor y distinto del anterior que nos ahorra el almacenamiento en memoria de esta mayor estructura.


CONTROL DE LOS SPRITES

Una vez declaradas las variables para cada sprite, el siguiente paso consiste en dotarles de un valor inicial y preparar la interacción que este sprite va a tener con el usuario y con el resto de sprites del programa.

En el caso por ejemplo del sprite de un juego, habremos de detectar colisiones con otros sprites (cuando cualquiera de los puntos del sprite toma contacto con un punto de otro sprite), de controlar su movimiento con el joystick, ratón o teclado, o si no es controlable por el usuario deberemos tener precalculada una tabla de movimientos de manera que el sprite posea trayectoria propia.

Hay muchos ejemplos de lo que acabo de nombrar. Si el usuario pulsa, por ej., la tecla <DERECHA>, tras algunas comprobaciones incrementaremos la coordenada X del sprite en tantas unidades como velocidad tenga en esa dirección. Si colisiona con otro sprite, tendremos que tener una función capaz de discernir qué ha de ocurrir tras ese choque (el personaje muere, recogemos un objeto del juego, el cursor está sobre el botón, etc).

Las tablas de movimientos, por otra parte, son arrays con incrementos del tipo (+1, 0, -1) que se añaden a las coordenadas (x,y) del sprite, y estando precalculados de manera que los sprites sigan una trayectoria determinada. Un ejemplo de esto son las trayectorias de los enemigos en un juego de naves (que suelen realizar complejas curvas u oscilaciones), y que nos permiten a la 2ª ó 3ª partida saber por qué lado van a aparecer los enemigos y disparar allí antes de que entren en pantalla. Podemos hacer que sigan trayectorias circulares, lineales, curvas, etc.

Por otra parte, una vez almacenados en memoria los bitmaps, al representarlos en pantalla podemos aplicarles diferentes tratamientos tales como el escalado (ampliación o reducción de los sprites a un tamaño determinado, tal y como se hace en las aventuras gráficas cuando el personaje se aleja de la pantalla, reduciéndose), la rotación, la inversión, la impresión basandose en tablas sinusoidales, etc. Los tratamientos más sencillos son por ejemplo el borrado (escribiendo el fondo encima o un bloque vacío si no hay fondo) y el movimiento (borrandolo y dibujándolo en otra posición de forma continua).


COMO GUARDAR LOS SPRITES

Los sprites se guardan en disco de manera similar a como hicimos con las fuentes de texto. Abrimos un fichero, guardamos allí todas las variables que lo definen (generalmente la anchura, la altura y la paleta) y guardamos después el cuerpo gráfico (el bitmap) que lo representa. La lectura es igual de sencilla.

En ocasiones nos interesará guardar muchos sprites dentro de un sólo archivo, y lo que se suele hacer en esos casos es utilizar una cabecera (en el inicio del fichero) que indique cuántos sprites contiene el archivo. La cabecera va seguida por los datos individuales de cada sprite (ancho, alto y cuerpo gráfico) desde el primer sprite hasta el último.

Para mayor comodidad en la creación de sprites se ha incluido una pequeña utilidad propia (SPROFF.EXE) mediante la cual podemos extraer bitmaps, sprites y paletas de ficheros de dibujo PCX, RAW y SCR realizados con cualquier programa de dibujo, para crear nuestros propios sprites o utilizar los de otros programas, grabándolos en ficheros SPR, pudiendo incluir anchura, altura, paleta y otros datos gráficos, además de realizar conversiones entre diferentes formatos gráficos simples.


PANTALLAS VIRTUALES

Una Pantalla Virtual (en inglés Virtual Screen o VScreen) es un buffer de memoria el cual tratamos igual que si fuera la VideoRAM, escribiendo en esta memoria como si lo hiciéramos en videomemoria. Esto quiere decir que de la misma forma que escribimos bytes en la VRAM podemos escribirlos en nuestra pantalla virtual sabiendo que no van a aparecer en pantalla, pero que están almacenados ahí, en ese buffer de RAM convencional.

Muchos se preguntarán qué utilidad tiene esto, y la respuesta es muy sencilla. Al dibujar directamente en pantalla el usuario va viendo (aunque en un tiempo mínimo) como construimos la pantalla, un sprite tras otro, un bitmap tras otro. Esto ya de por sí es un efecto desagradable, pero si además al mover un sprite lo borramos para dibujar el siguiente fotograma de la animación, durante un tiempo también mínimo no habrá nada en pantalla (entre el borrado y el dibujado), de tal forma que el Sprite parecerá parpadear (por la repetición del proceso <sprite, fondo, sprite, fondo...>).

Para evitar estos 2 problemas (y muchos otros como las cortinillas de los scrolles, o el Sprite Doubling, por ejemplo), se dibuja en una pantalla virtual, con lo que el usuario ya no puede ver el proceso de construcción de la pantalla, se espera a un retrazo vertical y se "vuelca" el contenido de la VScreen a la RAM de Video. El proceso de volcado consiste en copiar cada punto de la pantalla virtual en el punto que le corresponde de la VRAM.


PANTALLAS VIRTUALES EN 320x200

Como ejemplo de uso de pantallas virtuales vamos a ver el caso concreto del modo 13h. Como cada pixel está representado por un byte y hay 320x200 pixels, cada pantalla está representada por 64.000 bytes (igual que en la VideoRAM). Si le pedimos al DOS un segmento de memoria (un segmento son 65.536 bytes), podemos escribir en ese segmento y tratarlo como una pantalla virtual, reduciéndose luego el proceso de volcado a un simple REP STOSB (o STOSD). Veamos los distintos pasos con más detenimiento:

1). Asignamos la memoria necesaria para la pantalla virtual. Esto lo podemos hacer de diversas formas: bien con punteros y utilizando funciones C como calloc() o similares; o bien mediante la subfunción 48h de la interrupción 21h de la BIOS (lo más recomendable si se trabaja en MSDOS). Esta subfunción recibe como parámetros AH=48h, BX=nº de párrafos (memoria deseada/16) y devuelve en AX el segmento de memoria asignado. Veamos un ejemplo de esto:


 unsigned int vscreen;

 asm {
     mov ah, 48h
     mov bx, 4000
     int 21h
     mov [vscreen], ax
     }

2). Escribimos en el segmento de memoria tal y como lo hacíamos en la VRAM. Para ello tan sólo hay que modificar las rutinas para que acepten el segmento en el que escribir. En vez de mover en el registro ES el valor 0xA000, movemos el valor especificado. De esta manera este nuevo PutPixel() vale tanto para pantallas virtuales (indicando el segmento donde escribir) como para VRAM (segmento 0xA000). Esto podemos verlo ilustrado en el ejemplo 2, donde movemos en ES el valor del segmento especificado en vez de 0xA000 como hacíamos anteriormente. Para nosotros, el byte 0000h del segmento liberado ha de significar lo mismo que el byte 0000h de la VRAM: el punto (0,0). Es tras el proceso de volcado cuando obtendremos en pantalla la imagen que hemos creado en la VScreen.

 LISTADO 2: Pixels en pantallas virtuales y VRAM.

void PutPixel( int x, int y, char color,
               unsigned int segmento )
{
 unsigned int offs;

 offs = (320*y)+x;

 asm {
   mov es, [segmento]
   mov di, [offs]
   mov al, [color]
   mov es:[di], al
    }
}

3).Volcamos (Flip en inglés) la VScreen sobre la VRAM. Como en 13h cada byte es un pixel y el pixel (0,0) es en ambos buffers de memoria el offset 0, el volcado se reduce al uso del comando REP STOSB o STOSD como puede verse en el ejemplo 3. En este listado hacemos DS:SI=Segmento origen y ES:DI=Segmento destino, tras lo cual copiamos los 64000 bytes desde DS:SI a ES:DI con 64.000 MOVSB, con 32.000 MOVSW o con 16.000 MOVSD. La instrucción MOVSB lo que hace es copiar el byte contenido en DS:SI a ES:DI e incrementar en uno SI y DI.

  LISTADO 3: Volcado de pantallas virtuales.

void FlipScreen(unsigned int origen, unsigned int destino)
{
asm {
   push ds
   mov ds, [origen]
   xor si, si
   mov es, [destino]
   xor di, di
   mov cx, 64000
   rep movsb
   pop ds
    }
}

Con esta función (debido a que la VRAM es un segmento de RAM en sí mismo) podemos realizar indistintamente copias de VScreen a VRAM y el proceso inverso, es decir, las 2 órdenes siguientes son totalmente válidas:


 FlipScreen( VScreen, 0xA000 );
 FlipScreen( 0xA000, VScreen );

En el caso de los Pentium podemos aprovechar la potente caché para realizar un volcado y borrado más rápido (hasta 8 veces más) tal y como se comentó en la sección de Trucos de Programación Actual nº 1.

4). Liberamos la memoria asignada antes de salir del programa bien con la función free() (en el caso de usar punteros) o mediante la subfunción 49h de la int 21h (AH=49h, ES=Segmento a liberar).

Las pantallas virtuales proporcionan múltiples ventajas. En una de ellas podemos guardar el fondo mientras en otra construimos la imagen (marcadores, sprites, cursores), de manera que para borrar los sprites bastaría con copiar desde la 1ª VScreen porciones de fondo sobre la 2ª, y luego copiar esta sobre la VRAM. Podemos tener tantas VScreens como la memoria libre de nuestro ordenador lo permita.

Además, la RAM normal es de mucho más rápido acceso que la RAM de video, de manera que utilizar pantallas virtuales a la larga acaba resultando beneficioso. Recordemos también que los modos SVGA con VESA VBE 2.0 son lineales y podemos usar pues el mismo sistema de volcado de pantallas virtuales que en 13h, pero utilizando registros de 32 bits en modo protegido, ya que una pantalla en SVGA de, por ejemplo, 640x480 requeriría un buffer de 640*480=307.200 bytes, que sobrepasa el límite de los segmentos (65.536 bytes) en modo real.


OPTIMIZACIONES

Cuando trabajamos utilizando en nuestros programas instrucciones en ensamblador, es posible optimizar el código aprovechando características concretas de cada micro como el truco que utilizamos para multiplicar mediante desplazamientos en números anteriores. Es al ejecutar nuestro programa en máquinas con procesadores inferiores (como los micros 386 ó 486), donde se notaría una más cuidada creación de las funciones gráficas. En muchos casos optimizar nos permitirá además acelerar también la velocidad en procesadores Pentium y superiores, aprovechandonos de algunos trucos básicos:

 - Utilizar tablas de precálculo siempre que sea posible y que
   dispongamos de la memoria necesaria, ya sean de offsets, de
   raices cuadradas, etc.
 - Cuando queramos mover un valor (un sólo valor y no varios con
   el comando REP) a una posición de memoria, resulta más rápido
   ejecutar <mov es:[di], al> que <stosb>.
 - En cambio, si hemos de utilizar el comando REP, es recomendable
   usar STOSD siempre que sea posible antes que STOSB y STOSW, debido
   a que STOSD mueve 4 bytes por cada repetición, resultando en
   un significativo aumento de velocidad.

Programar en modo protegido no es una optimización en sí misma pero disponer de toda la memoria en modo lineal es algo que puede ayudar en la creación de código. Además es un requisito indispensable para la programación con el estándar VESA VBE 2.0 para disponer de acceso a la VRAM en SVGA de igual manera que en 320x200: con un buffer lineal de memoria, y bastaría con cambiar la multiplicación por 320 en nuestras rutinas para adaptarlas a cualquier modo SVGA. Por ahora estamos trabajando en modo real y viendo los conceptos básicos de la programación gráfica, cubriendo los aspectos más necesarios antes de entrar a contemplar los registros de la tarjeta gráfica, la comunicación directa vía hardware con los gráficos en el PC, algo que veremos en posteriores entregas.


EN LA PROXIMA ENTREGA

El lector debe practicar ahora con el tratamiento de los sprites y bitmaps y aplicarle diferentes efectos de prueba, tales como el recorte (controlar si parte del sprite queda fuera de la pantalla e imprimir sólo la parte que queda dentro), la animación, etc.

El próximo artículo abordaremos los formatos gráficos más sencillos como el RAW, el SCR y el PCX para poder cargar estos tipos de ficheros en nuestros programas y conocer su estructura completa, y es que los bitmaps pueden ser comprimidos ocupando un menor espacio y descomprimidos posteriormente para su utilización. De los principios básicos del almacenamiento de imágenes con y sin compresión nos ocuparemos el próximo número.

Pulse aquí para bajarse los ejemplos y listados del artículo (45 Kb).

Figura 1: "Sprites, Bitmaps y Scanlines."
Figura 2: "Control de los pixels de fondo."

Santiago Romero


Volver a la tabla de contenidos.