CURSO DE PROGRAMACIÓN GRÁFICA

Artículo 3: PROGRAMACIÓN DE 320x200x256 (II)

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


El desarrollo de la función PutPixel() por parte del programador constituye en las 2D la base de cualquier forma gráfica. Veamos la manera de dibujar y leer pixels en modo 13h y, mediante la asociación de pixels, cualquier otro tipo de figura gráfica.

En la anterior entrega se comentó la ROM-BIOS del PC y su uso para la inicialización de modos de vídeo, entre ellos el modo 13h. Ya inicializado este modo de vídeo disponemos de acceso lineal a la VideoRAM, donde cada byte corresponde a un pixel en pantalla. De esta manera es muy fácil implementar funciones de borrado de pantalla (escribiendo en los 64.000 bytes de la VRAM) y otras operaciones básicas.

Como vimos en el número anterior, cada pixel puede ser activado o leído con un simple acceso al segmento de memoria. La escritura de un pixel de color negro (color 0 por defecto) en pantalla se reduciría a las simples instrucciones:


  pokeb( 0xA000, Offset, 0 );

o usando assembler:

asm  {
      mov ax, 0xA000
      mov es, ax
      mov di, Offset
      mov al, 0
      mov es:[di], al
     }

Lo que hacemos en este bloque de código es escribir el valor 0 en la dirección apuntada por ES:DI (inicializado como 0xA000:Offset). Cambiando "Offset" por el offset del pixel que queremos dibujar obtenemos el color trazado en la pantalla. El offset del pixel (0,0) es (mirando la figura 1 se deduce) 0000h (el offset del primer pixel es el offset del primer byte de la VideoMemoria).

VMem en modo 13h

Pero, ¿cuál es el offset correspondiente a cualquier punto de la pantalla, por ejemplo el (159,45)?

Para crear una rutina PutPixel() necesitamos calcular el offset de cualquier punto (x,y). Ésta es una operación muy sencilla gracias al direccionamiento lineal que nos ofrece el modo 13h.


CÁLCULO DEL OFFSET PARA UN PUNTO

Hemos visto pues que cada byte de la VideoRAM (segmento 0A000h) corresponde a un pixel concreto en pantalla, y que es posible dibujar y leer pixels tan sólo escribiendo valores en este segmento.

Supongamos que queremos poner un pixel blanco en la posición (0,0) de la pantalla. El pixel (0,0) corresponde a la dirección 0A000:0000h. Si queremos poner este punto con el color blanco (15 en la paleta por defecto), bastaría con ejecutar la orden C:


  pokeb( 0xA000, 0x0000, 15 );

Por supuesto, el equivalente ensamblador es más rápido, porque no necesita ser llamado como una función y por lo tanto evita saltos e innecesarios accesos a la pila.


asm {
     mov ax, 0xA000
     mov es, ax
     xor di, di
     mov es:[di], 15
    }

Para los que empiezan ahora con el assembler, la operación lógica "xor di, di", (hacer un XOR de 2 elementos iguales) resulta el equivalente a hacer "mov di, 0", pero mucho más rápido, ya que son simples operaciones lógicas de bits.

Por otra parte, los corchetes de mov es:[di], 15 significan acceso a memoria, y en esta orden forzamos al micro a escribir el valor 15 en la dirección ES:DI, que apunta a 0A000:0000 gracias las órdenes mov anteriores. Esta línea podría haber sido reemplazada por el par de órdenes "mov al, 15" y "stosb" pero es aconsejable (por cuestiones de velocidad) guardar las instrucciones de cadena (stosb/stosw/stosd) para su uso con el prefijo rep (bloques completos), porque en la mayoría de los casos resulta más rápido la orden "mov" (hasta 3 ciclos más rápido en un 486).

Aunque parezca que este bloque assembler tiene más líneas que el bloque C y esto induzca a pensar que puede ser más lento que el pokeb() o que una simple llamada al servicio 0Ch de la int 10h, lo cierto es que es, con diferencia, más rápido que la primera acción y muchísimo más que la segunda.

El acceso directo con assembler a la VideoRAM es la manera más directa posible de representar gráficos en pantalla, único camino a seguir al desarrollar aplicaciones y juegos de tiempo crítico (aquellos donde es necesaria mucha velocidad de representación) al igual que hacen los videojuegos de las compañías profesionales, aunque sí es preciso hacer notar que la VideoRAM es bastante más lenta que la RAM normal, pero esto es algo que solucionaremos más adelante mediante el uso de "pantallas virtuales".


OFFSET DE UN PIXEL (X,Y)

Resulta bastante intuitivo ver que el offset 0000h corresponde al pixel (0,0), y que el offset 0001h corresponde al pixel (1,0), pero, si queremos modificar o leer el pixel (160, 100), por ejemplo, ¿qué offset tendremos que mover en DI para el acceso a memoria?

De nuevo la solución nos la proporciona la figura 1. Cada 320 bytes de memoria estamos dentro de una nueva línea de pantalla. Esto quiere decir que el byte 320 corresponde al pixel (0,1), el 640 al (0,2), y así hasta el pixel (0,199), que corresponde al offset 199*320 = 63.380. Analicemos este último valor.

Si el pixel (0,199) está situado en el offset 63.380, el pixel (1,199) será el siguiente ( (199*320)+1 = 63.380 + 1= 63.381). De ello se deduce que el offset de cualquier pixel (x,y) será:


  offset = (320*y) + x;

Por lo tanto, el pixel (160,100) estará situado en el segmento 0A000h, en el offset (320*100) + 160 = 32160. Si escribimos el valor 1 (azul en la paleta por defecto) en esta posición de memoria, en el centro de la pantalla aparecerá un pequeño pixel de color azul.

Así de sencillo es el cálculo del offset correspondiente a cualquier pixel (x,y). Únicamente hemos de saltarnos 320 bytes por cada línea desde el principio del segmento (pixel (0,0) = offset 0) hasta llegar al offset deseado.

Con estos datos ya podemos crear nuestra propia función PutPixel(), tal y como podemos ver en el listado 1.

 LISTADO 1: Función PutPixel en C.

/*----------------------------------------------------
 PutPixel(); Dibujo de pixels en VideoRam.

 Pseudocódigo:
	OFFSET = (320*y)+x;
    [A000:OFFSET] = Color
------------------------------------------------------*/

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

  offset = (320*y)+x;
  pokeb( 0xA000, offset, color );
}

En el listado 2 podemos ver la misma función con ensamblador.

 LISTADO 2: Función PutPixel en ASM.

/*------------------------------------------------------
 PutPixel(); Dibujo de pixels en VideoRam.

 Pseudocódigo:
        OFFSET = (320*y)+x
        ES:DI = A000:OFFSET
        ES:[DI] = Color
------------------------------------------------------*/

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

offset_pixel = (320*y)+x;

asm {
        mov ax, 0xA000
        mov es, ax
        mov di, [offset_pixel]
        mov al, [color]
        mov es:[di], al
    }
}

El primer listado está en C puro mientras que el segundo ya contiene código assembler. Al ser pokeb() una función y el bloque assembler no, el segundo listado es definitivamente más rápido que el primero. Recordemos que la llamada a una función en cualquier lenguaje de medio/alto nivel equivale a:

  1). Introducción a la pila de los parámetros que se le pasan a
      la función, así como la dirección de retorno para el "ret".
  2). Introducción en la pila de los registros que se modifican en
      el código de la función.
  3). Salto al código de la función.
  4). Recuperación de los valores de los registros y de la dirección
      de retorno desde la pila.
  5). Salto a la dirección de retorno (ret = pop de CS:IP).
En el caso de nuestros bloques asm{}, lo único que sucede es que C ejecuta el código sin saltos ni retornos, pulsando a la pila aquellos registros de propósito general cuyos valores necesite recordar. En una función PutPixel(), este código únicamente carga ES:DI apuntando a 0A000:OFFSET, calculando primero este offset, y escribe AL (el color) en es posición de memoria, obteniendo instantáneamente en pantalla el pixel especificado, tras unas rápidas (1 ciclo de reloj cada una) instrucciones MOV.

Así que con un sencillo cálculo y el posterior acceso a la VideoMemoria podemos implementar operaciones de escritura y lectura de pixels, la base del dibujo en las 2 dimensiones.


OPTIMIZACIONES A PUTPIXEL

Vamos a suponer que ya han sido asimilados todos los conocimientos explicados hasta ahora y que el lector, a base de experimentación y algo de estudio, ya ha comprendido la organización del modo 13h. Esto quiere decir comprender cada uno de los pasos que ejecuta PutPixel() y la organización lineal de la VideoMemoria en 320x200x256.

En ese caso podemos pasar a optimizar nuestro PutPixel() para incrementar su eficiencia.

Optimizar una rutina significa eliminar operaciones innecesarias y sustituir las operaciones lentas por otras más rápidas con el objetivo de conseguir otra rutina que realice el mismo trabajo pero de manera más rápida, y la velocidad es fundamental en el mundo de los gráficos.

Por una parte ya sabemos: (comentado en un párrafo anterior) que la orden "mov es:[di], al" es más rápida y eficiente que "stosb", a menos que se trate de escrituras masivas de datos con el prefijo REP (como en la función ClearScreen() del número anterior, donde escribíamos 64.000 veces el valor de AL en ES:[DI] e incrementábamos DI).

Por otra parte tenemos la optimización de las multiplicaciones. Cualquier programador de ensamblador sabe que la multiplicación es una de las operaciones más lentas para el microprocesador (entre 139 y 13 ciclos de reloj, según los operandos y el tipo de procesador), y una rutina tan crítica como el cálculo del offset de un punto (que puede ser repetida miles de veces por segundo) es importantísimo que esté lo más optimizada posible. Para ello, vamos a reescribir nuestra rutina PutPixel() usando assembler, eliminando la multiplicación aprovechándonos de un pequeño truco matemático.


MULTIPLICAR MEDIANTE DESPLAZAMIENTOS

Para los no iniciados, recordemos que los bits de cualquier byte pueden ser desplazados a izquierda y derecha mediante las instrucciones asm shl y shr (Shift Logical Left y Shift Logical Right), o usando los operadores de C << y >>. Veamos el siguiente ejemplo para comprender su utilidad, desplazando los bits de AX 1 y 2 veces a la izquierda (shl 1 y shl 2):

 AX = 00010110b (22 decimal)

 SHL AX, 1 = 0101100b (44 decimal)
 SHL AX, 2 = 1011000b (88 decimal)
¿Qué ha ocurrido? Desplazar un número una posición a la izquierda es equivalente a multiplicarlo por 2. Desplazarlo 2 veces es equivalente a multiplicarlo por 4, y así sucesivamente con las potencias de 2 (desplazarlo 4 veces es el equivalente a multiplicar por 2 elevado a 4: 16). Exactamente lo mismo ocurre con SHR, que equivale a dividir por 2, 4, 8, 16, 32, 64, 128, 256, 512, etc... según el número de bits que desplacemos. Además, estas operaciones de multiplicación y división mediante desplazamientos son casi instantáneas (no olvidemos que son simples operaciones lógicas de bits, realizándose en 1 ó 2 ciclos de reloj), resultando mucho más rápidas que la multiplicación normal (hasta 139 ciclos).

Pero por desgracia, 320 no es una potencia de 2, por lo que no podemos multiplicar directamente usando desplazamientos... excepto aprovechándonos de la propiedad distributiva del producto según la cual una multiplicación puede dividirse en sumas de multiplicaciones (por ejemplo: x*4 = x*2 + x*2, ya que 2+2=4).

La multiplicación por 320 es el equivalente a multiplicar por 256 más multiplicar por 64 (256+64=320):

   num*320 = (num*256) + (num*64)

Ahora sí, 256 y 64 son potencias de 2 y podemos utilizar desplazamientos para multiplicar, como en la función PutPixel() optimizada del listado 3:


   offset = (y<<8) + (y<<6) + x;

 LISTADO 3: Pixels en VideoRam mediante shifts

/*----------------------------------------------------
 PutPixel(); Dibujo de pixels en VideoRam
             multiplicando con desplazamientos.

 Pseudocódigo:
	OFFSET = (y*256)+(y*64)+x
	ES:DI = A000:OFFSET
	ES:[DI] = Color
------------------------------------------------------*/

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

offset_pixel = (y<<8)+(y<<6)+x;

asm {
      mov ax, 0xA000
      mov es, ax
      mov di, [offset_pixel]
      mov al, [color]
      mov es:[di], al
    }
}

Esta rutina es mucho más rápida que la primera que desarrollamos y por tanto podremos poner más pixels en pantalla en menos tiempo. La optimización de código es otro punto importante de la programación gráfica, ya que arañar algún ciclo de reloj puede parecer poco importante, pero si es una rutina que se ejecuta miles de veces por segundo, el incremento de velocidad puede ser notable.

La única desventaja de los desplazamientos radica en que las instrucciones "SHL reg, n" y "SHR reg, n", requieren un procesador 286 o superior, por lo que habremos de compilar nuestros programas con la opción de generar código 286 y ya no funcionarán en los antiguos 80086 (que por otra parte prácticamente han desaparecido). Para compilar en Borland C con código 286 se debe usar la opción -1 (bcc -1 nombre.c) o activarlo dentro de los menús del entorno IDE de Borland.


USO DE TABLAS PRECALCULADAS

Como alternativa para programadores más expertos, también podemos precalcular tablas con las multiplicaciones de y*320 y de manera que el cálculo del offset se limitaría a una simple línea como esta:


 offset = tabla[y] + x;

siendo tabla[] un array de 320 elementos conteniendo desde 0 a 320 todos los valores de 320*y precalculados al inicio del programa (0, 320, 640, etc... hasta 63.680, que sería lo que contendría tabla[199]).

Pero la elección de un sistema u otro (cálculo o precálculo) ya es un tema referente a la manera de programar (y optimizar) de cada uno, y que ya se estudiará más adelante (optimización más profesional) cuando apliquemos nuestras funciones a los modos SVGA, donde construiremos una de estas tablas debido a la gran cantidad de resoluciones de que dispondremos y para evitar las multiplicaciones por 320, 640, 800, 1024, etc...


APLICANDO LOS CONOCIMIENTOS

Sabiendo calcular el offset de cualquier punto de pantalla ya estamos preparados para realizar gran cantidad de funciones basándonos en la primitiva punto. Veamos algunas de estas derivadas del punto.


LECTURA DE PIXELS

La lectura, como ya se ha comentado, es prácticamente igual que la función PutPixel() pero leyendo de la VideoRAM en lugar de almacenar valores en ella. Bastaría con reemplazar "mov es:[di], al" por "mov al, es:[di]" y devolver el valor de AL, tal y como se hace en el listado 4.

 LISTADO 4: lectura de pixels de VideoRAM.

/*----------------------------------------------------
 GetPixel(); Lectura de pixels en VideoRam.

 Pseudocódigo:
	OFFSET = (y*256)+(y*64)+x
	ES:DI = A000:OFFSET
	Color = ES:[DI]
------------------------------------------------------*/

char GetPixel( int x, int y, char color )
{
 unsigned int offset_pixel;

 offset_pixel = (y<<8)+(y<<6)+x;

 asm {
      mov ax, 0xA000
      mov es, ax
      mov di, [offset_pixel]
      mov al, es:[di]
     }
 return( _AL );
}


DIBUJO DE LÍNEAS HORIZONTALES

Una línea horizontal no es más que una serie de puntos todos ellos consecutivos (y debido a la linealidad del modo 13h, también consecutivos en memoria), que podemos dibujar bien con un bucle C o utilizando órdenes de cadena de assembler, siendo esta última la opción más recomendable. Considerando el dibujo de una línea desde (x1,y) hasta (x2,y), el bucle C podría tener el siguiente aspecto:


void DrawHLineC( int x1, int x2, int y, char color )
{
int var;

  for( var=x1; var<x2; var++ )
     PutPixel(x1+var, y1, color );
}

No obstante, podemos aplicar nuestros conocimientos de assembler y evitar (x2-x1) llamadas a PutPixel() reemplazando este bucle por un REP STOSB, ya que todos los pixels que forman una línea horizontal son contiguos, como puede verse en la figura 2 y en el listado 5.

Primitivas a partir del pixel

 LISTADO 5: Dibujo de Líneas horizontales

/*-----------------------------------------------------
 DrawHLine(); Dibujo de lineas horizontales. C+ASM.

 Pseudocódigo:
	OFFSET = (y*256)+(y*64)+x
	ES:DI = A000:OFFSET
	CX = Ancho de la línea
	Repetir CX veces 
		ES:[DI++] = Color
------------------------------------------------------*/

void DrawHLine( int x, int y, int ancho, char color )
{
unsigned int offset_pixel1;

offset_pixel1 = (y<<8)+(y<<6)+x;

asm {
      mov ax, 0xA000
      mov es, ax
      mov di, [offset_pixel1]
      mov al, [color]
      mov cx, [ancho]
      rep stosb
    }
}


DIBUJO DE LÍNEAS VERTICALES

El dibujo de líneas verticales se corresponde con un sencillo código C, similar al de líneas horizontales:


void DrawVLineC( int x, int y1, int y2, char color )
{
int var;

  for( var=y1; var<y2; var++ )
    PutPixel(x, y1+var, color );
}

De nuevo una función en ensamblador es capaz de desarrollar más velocidad que el bucle C. Es este caso no podemos valernos de REP STOSB porque los distintos pixels que forman una línea vertical no son pixels consecutivos en memoria (figura 2), sino que cada pixel está 320 bytes por debajo del anterior, lo que nos obliga a usar el siguiente pseudocódigo:

  Offset = (320*y1)+x
  ES:DI = 0A000:Offset
  Repetir ALTO veces
     ES:[DI] = color
     DI = DI + 320
     Alto = Alto-1

Este pseudocódigo se corresponde con el bloque assembler contenido en el listado 6. Podemos ver que el lenguaje assembler resulta en un código más complejo, extenso, e "ilegible" que C pero con un mayor control sobre los distintos registros del procesador, evitando operaciones innecesarias y resultando por tanto más rápido.

 LISTADO 6: Dibujo de líneas verticales.

/*-----------------------------------------------------
 DrawVLine(); Dibujo de lineas verticales. C+ASM.

 Pseudocódigo:
	OFFSET = (y*256)+(y*64)+x
	ES:DI = A000:OFFSET
	CX = Alto de la línea
	Repetir CX veces 
		ES:[DI] = Color
          DI = DI + 320
------------------------------------------------------*/

void DrawVLine( int x, int y, int alto, char color )
{
unsigned int offset_pixel1;

offset_pixel1 = (y<<8)+(y<<6)+x;

asm {
      mov ax, 0xA000
      mov es, ax
      mov di, [offset_pixel1]
      mov al, [color]
      mov cx, [alto]
    }
BucleVLine:;
asm {
      mov es:[di], al
      add di, 320
      dec cx
      jnz BucleVLine
    }
}

El par de instrucciones "dec cx" y "jnz BucleVLine" resultan en decrementar CX (que en principio vale el alto de la línea) y saltar al bucle mientras sea mayor que 0 (o sea, repetir CX veces el código situado entre la etiqueta BucleVLine y el testeo de CX).

Para los amantes de la optimización, aunque la instrucción "LOOP label" parece más compacta y realiza en mismo trabajo pero en una sola instrucción, el par "dec/jnz" resulta más rápido.


CAJAS Y RECTÁNGULOS

Desde la perspectiva lineal del modo 13h, las cajas (o rectángulos rellenos) no son más que un conjunto de líneas horizontales como puede verse en la figura 2 (que resultan muy rápidas de dibujar):


void FillBox( int x1, int y1, int x2, int y2, char color )
{
  int var;

  for( var=y1; var<y2; var++ )
    DrawHLine(x1, x2, y1+var, color );
}

Por otra parte, un rectángulo vacío simplemente está formado por 4 líneas (2 líneas verticales y 2 horizontales):


void DrawBox( int x1, int y1, int x2, int y2, char color )
{

  DrawHLine(x1, x2, y1, color );
  DrawHLine(x1, x2, y2, color );
  DrawVLine(x1, y1, y2, color );
  DrawVLine(x2, y1, y2, color );
}


DIBUJO DE BITMAPS Y SPRITES

Un bitmap (mapa de bits en castellano) no es más que un conjunto de pixels guardados en un array de (ancho*alto) elementos de manera que dibujando todos esos pixels obtenemos la imagen que representa.

No vamos a extendernos ahora sobre eso porque el tema de dibujo de Bitmaps y Sprites requiere un artículo por sí mismo, pero baste con saber que podemos dibujar cualquiera de estos arrays en pantalla con un código tan simple como anidar 2 bucles en C:


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


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*5) + vx] );
}

Por supuesto, no vamos a dibujar los bitmaps de esta manera, pero resulta muy intuitivo como introducción ir viendo las aplicaciones de PutPixel() como primitiva gráfica. Poco a poco iremos profundizando y desarrollando rutinas específicas para Sprites, Bitmaps, Fuentes, etc...

Además el sprite que hemos utilizado en el ejemplo anterior es muy simple y representa un pequeño cuadrado de distintos colores, pero mediante cualquier programa de dibujo podemos crear dibujos más complejos (de más resolución y colores) y luego transformarlos a arrays para usarlos en nuestros programas.


USANDO LA PRIMITIVA PUNTO

Con lo comentado en este artículo ya podemos crear gran variedad de formas gráficas en la pantalla de nuestro PC. Bastaba para ello con conocer las interioridades del modo 13h, y a partir de la función PutPixel dibujar cualquier imagen, compuesta por un número determinado de unidades gráficas o pixels.

Conviene practicar lo aprendido y repasarlo para comprender todos sus aspectos, sobre todo en lo relativo a la programación assembler y la organización de la VideoMemoria.


EN LA PRÓXIMA ENTREGA

Ya finalizada la explicación del modo 13h (completada en el presente número) podemos pasar a la programación del DAC de la VGA para modificar la paleta por defecto y adaptar los 256 colores disponibles a cualquier tonalidad de rojo, verde y azul, para disponer de un total de 262.144 colores.

También realizaremos efectos de paleta, como los fundidos de pantalla y rotaciones de colores, muy útiles para presentaciones de imágenes.

Hasta el próximo número, se recomienda al lector realizar pruebas con las rutinas desarrolladas para coger soltura en la programación de los modos lineales, y ejecutar los ejemplos incluidos en el CD de la revista.

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

Figura 1: "Organización de la VRAM en 13h."
Figura 2: "Derivadas gráficas de la primitiva punto."

Santiago Romero


Volver a la tabla de contenidos.