Pintando en el Spectrum (5)

Editado: el código completo del ejemplo de esta entrada está disponible en un enlace al final.

Llega la hora de pintar cosas en pantalla. La idea consiste en modificar los valores de la memoria RAM de vídeo para que, cuando la ULA (que es el chip encargado de generar la imagen) lea esos bytes, éstos muestren la imagen que queremos. Hay dos maneras de hacerlo: vectorial, y por sprites. En el caso de gráficos vectoriales, se tiene almacenada en memoria una lista de segmentos y vértices que deben pintarse sobre la pantalla para generar la imagen. Son ideales para gráficos 3D, y son los utilizados en juegos como Elite o Driller, sin embargo, en una máquina de 8 bits son muy limitados, por lo que de momento lo dejaremos de lado. Es verdad que hace tiempo estuve trabajando en una rutina para pintar triángulos vectoriales a alta velocidad en Spectrum, por lo que puede que retome el tema algún día.

Los sprites, por su parte, son mucho más sencillos, pues consisten en tener los bytes necesarios para pintar algo en pantalla ya preparados en otra zona de memoria, y lo único que necesitaremos será copiarlos en la dirección adecuada de pantalla. Además, cambiando la dirección de memoria en la que empezamos a pintar podremos cambiar la posición, con lo que podemos colocarlos en cualquier punto de la pantalla o, incluso, moverlos y animarlos.

Para pintarlo en pantalla, primero necesitamos encontrar la dirección de memoria en la que irá el primer byte, que, como recordaremos de la entrada anterior, se podía hacer de manera sencilla:

Obtención de la dirección en pantalla a partir de las coordenadas de un carácter trabajando con el buffer lineal. En verde va la coordenada Y, y en azul la coordenada X. En magenta va el número de scanline dentro del carácter.

Así, si recordamos, la pantalla del Spectrum está dividida en una matriz de 32×24 caracteres, con ocho scanlines cada uno, y si utilizamos la rutina de copia de buffer que vimos en las tres primeras entradas, obtener la dirección de un carácter a partir de sus coordenadas X e Y es tan sencillo como multiplicar la Y por 256, sumarle la X, y ese valor sumarlo a la dirección inicial del buffer. Alguno dirá que las multiplicaciones en ensamblador son muy costosas, pero es aquí donde viene lo divertido: digamos que queremos tener en el par de registros HL la dirección inicial del carácter situado en las coordenadas X, Y. Pues sólo tenemos que copiar el valor de Y en el registro H, el de X en el registro L, y sumarle a HL la dirección base del buffer. Pero si hemos sido cuidadosos y hemos puesto el buffer en la dirección 0x8000, ni siquiera necesitaremos una suma, sino simplemente poner a 1 el último bit del registro H.

Por supuesto, esto nos da la dirección del primer scanline del carácter, pero movernos al siguiente scanline es tan sencillo como sumar 32 a la dirección. Esto nos permite pintar sprites del tamaño de un carácter en pantalla, pero ¿como hacemos para pintar sprites de mayor tamaño?

La solución más inmediata consiste en dividir el sprite en bloques de 8×8 píxeles, almacenar cada bloque por separado, y luego pintarlos uno a uno en las posiciones correspondientes. Así, si tuviésemos por ejemplo el siguiente sprite de 16×16 píxeles:

Lo que haríamos sería dividirlo en bloques de 8×8 píxeles, de manera que cada uno se podría pintar como un carácter en pantalla, y almacenaríamos los bytes en este orden en memoria:

Vemos que primero viene el primer byte del carácter superior izquierdo, luego el inmediatamente inferior, y así hasta hacer ocho bytes del primer carácter. A continuación vendrían los ocho bytes del carácter de la parte superior derecha, luego los de la parte inferior izquierda, y por último los de la parte inferior derecha.

Digamos que queremos pintarlo en las coordenadas X=5 e Y=12. Lo primero sería calcular la dirección de 5,12, la cual vamos a almacenarla en el par de registros DE (para tener la regla mnemotéctica de DE = DEstino), y eso lo haremos escribiendo 12 en D y 5 en E, y sumándole 128 a D para activar el bit 7 y que apunte al inicio de nuestro buffer. Ahora ya tenemos la dirección de memoria donde tenemos que empezar a copiar los datos. Digamos, además, que en HL tenemos la dirección de memoria del primer byte de nuestro sprite. Bien, ahora sólo tenemos que leer el byte de la dirección de memoria apuntada por HL, y escribirlo en la dirección de memoria apuntada por DE. El siguiente paso es saltar al siguiente byte, y para ello sólo tenemos que incrementar HL en 1, y sumar 32 a DE para pasar al siguiente scanline, con lo que ya podemos copiar el segundo byte. Este proceso lo repetimos ocho veces para copiar los ocho bytes del carácter.

Ahora que ya tenemos el primer carácter copiado, pasamos al siguiente. Para ello tenemos que calcular la dirección de las coordenadas 6,12 y meter el resultado en DE. HL no debemos tocarlo pues ya está apuntando al segundo carácter. Una vez hecho esto, copiamos los bytes igual que hicimos arriba y volvemos a repetirlo para las coordenadas 5, 13 y 6, 13.

El método es directo, pero tiene el problema de que no es demasiado eficiente: tenemos que mantener en algún lado las coordenadas del carácter actual, así como el ancho y el alto para saber cuando hemos terminado, y recalcular la dirección de memoria de cada carácter. Todo esto consume mucho tiempo de proceso, y por eso pocos juegos lo usan (uno que sí lo hace, por ejemplo, es The trapdoor). Es por esto que, normalmente, se prefiere almacenar los sprites en formato scanline. La diferencia está en que, en lugar de almacenarlo carácter a carácter, se almacena por filas completas. Así, el orden para scanline en el ejemplo anterior sería el siguiente:

En este caso, la manera de trabajar es ligeramente diferente: partimos una vez más de las coordenadas X e Y en las que queremos pintar nuestro sprite, y procedemos a calcular la dirección de memoria de dichas coordenadas. Ahora sólo necesitamos hacer dos bucles, uno que cuente de cero a ANCHO-1, que será el interno, y otro que cuente de 0 a (ALTO*8)-1. En el bucle interno leeremos un byte del sprite y lo copiaremos en la dirección de pantalla, tras lo cual incrementaremos ambas direcciones en 1 y repetiremos la operación tantas veces como ancho sea el sprite. Con eso ya hemos copiado un scanline. Ahora sólo tenemos que sumar a la dirección de destino, la que apunta al buffer de pantalla, 32 – ANCHO para pasar al siguiente scanline de la pantalla y volver a repetir la copia anterior, y así tantas veces como scanlines tenga el sprite, que será ALTO * 8. Además, el valor de 32 – ANCHO lo podemos tener almacenado en un registro, por lo que sólo lo tendremos que calcular una vez, fuera de los bucles. Un ejemplo de código sería éste:

; B contiene el alto, C el ancho, ambos en caractéres
; HL contiene la dirección del primer byte del sprite
; D contiene la coordenada Y, E la coordenada X
; Asumimos que el buffer lineal de pantalla está en 0x8000

pintar_sprite:
    set 7, D ; DE ya casi tiene la dirección, le falta el bit 7 de D
    sla B
    sla B ; rotamos tres veces B, que es igual que multiplicar por 8
    sla B ; así tenemos en B la altura en scanlines
    ld A, 32
    sub C     ; calculamos cuanto tenemos que sumar para pasar al
    ld IXl, A ; siguiente scanline (32-ANCHO), y lo guardamos en IXl
bucle:
    push BC ; guardamos B (pues es el contador de scanlines) y C
    ld B, 0 ; preparamos el bucle interno
    ldir ; copiamos C bytes (C contiene el ancho)
    ld C, IXl ; sumamos IXl a DE para pasar al siguiente scanline
    ex DE, HL ; funciona porque B vale cero después de LDIR
    add HL, BC
    ex DE, HL 
    pop BC ; recuperamos el número de scanlines que nos quedan
    djnz bucle2 ; y repetimos por cada scanline

    call paint_screen ; llamamos a la función que vuelca el buffer
                      ; en la pantalla (si hemos pintado todos los
                      ; sprites)

Con esta función podemos pintar fácilmente un sprite de cualquier tamaño en cualquier parte de la pantalla (a nivel de carácter). Sin embargo, hay una condición inexcusable: el sprite tiene que entrar completamente en la pantalla, no puede «estar medio fuera». Si no lo tenemos en cuenta ocurrirán «cosas raras». Así, si se sale por un lado aparecerá por el opuesto (parecido a Pacman), lo cual es erróneo pero no peligros; pero si se sale «por arriba» o «por abajo» escribiremos en zonas de memoria fuera del buffer de pantalla, por lo que lo más probable es que nuestro código se cuelgue. Afortunadamente este problema se puede solucionar.

En el caso vertical (que el sprite «se salga por arriba o por abajo») resolverlo es muy sencillo: supongamos que se sale «por abajo» porque la coordenada Y es tan grande que Y + ALTURA es mayor que la máxima coordenada «pintable» (24 en el caso del Spectrum). En ese caso simplemente tenemos que cambiar la altura del sprite (la que le pasamos a la función) por ALTURA – Y, con lo que el bucle externo pintará justo hasta el final de la pantalla, pero ni un solo byte más. Algo así:

y_no_negativa:
    ld A, D
    cp 24
    ret NC ; si está completamente fuera de la pantalla, no pintamos nada
    add A, B ; comprobamos Y + ALTURA
    cp 24
    ; si hay acarreo, el sprite está completamente dentro de la pantalla
    jr C, pintar_sprite ; lo pintamos normalmente
    ld A, 24
    sub D
    ld B, A ; sustituimos la altura del sprite por 24 - Y

pintar_sprite:
    ...

Y con esto, colocando este código justo antes de la función de pintar sprites, nuestro sprite ya se puede salir «por abajo» que no pasará nada. Para permitir que se pueda salir «por arriba» la solución es la misma, pero a mayores tenemos que cambiar la dirección de inicio del sprite, saltándonos tantos scanlines como se queden fuera. Dado que Y será negativa en este caso, el número de scanlines que nos tenemos que saltar es de (-Y * 8) (ojo al signo menos), y como cada scanline tiene tantos bytes como ancho sea el sprite, tenemos que hacer una multiplicación. Este será el código:

    bit 7, D ; comprueba si Y es negativa
    jr Z, y_no_negativa
    ld A, D
    add A, B; sumando Y al alto nos da la nueva altura (no olvidar que Y es negativo, por lo que realmente es una resta)
    ret Z
    ret NC ; si no hay acarreo, o es cero, significa que el sprite está completamente fuera de la pantalla (B < -Y)
    ld B, A ; sustituimos la altura
    push BC
    push DE
    ld A, C  ; necesitamos calcular (-Y) * ANCHO * 8 para saber
    add A, A ; cuantos bytes saltar
    add A, A
    add A, A ; multiplicamos ANCHO por 8
    ld E, A
    ld A, D
    neg ; A contiene -Y, que es el número de filas a saltar (en caracteres)
    ld B, A
    ld D, 0
bucle1:
    add HL, DE ; HL + 8 * ANCHO * (-Y)
    djnz bucle1
    pop DE
    pop BC
    ld D, 0 ; pintamos a partir de la coordenada 0
    jr pintar_sprite ; pintamos el sprite

y_no_negativa:

Así, colocando este código justo antes del anterior, ya podrá salirse también «por arriba» sin que pase nada.

Para tener en cuenta el que se salga «por los lados» el proceso es el mismo, pero con la diferencia de que hay que modificar la función de pintado para que, al terminar de pintar un scanline del sprite (que será más corto que el ancho real), se salte tantos bytes como haya de diferencia.

Así se ve este ejemplo corriendo en un emulador:

El código del ejemplo se puede descargar aquí: ejemplo de sprite.

En el próximo capítulo hablaré de máscaras y transparencia.

CC BY-SA 4.0 Pintando en el Spectrum (5) por A cuadros está licenciado bajo una Licencia Creative Commons Atribución-CompartirIgual 4.0 Internacional.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.