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).
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.
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".
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.
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.
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.
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...
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 ); }
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.
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 } }
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-1Este 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.
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 ); }
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.
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.
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