Archivo por días: 16 enero, 2021

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.

Pintando en el Spectrum (4)

Ha llegado el momento de empezar a pintar cosas en la pantalla. Obviamente lo que queremos pintar serán Sprites. Como explica la wikipedia, se trata de gráficos 2D que se integran con una escena de fondo. Como sabemos, el Spectrum no tiene soporte de sprites por hardware, lo que significa que nos toca a nosotros hacer todo el trabajo de pintarlos. Afortunadamente, en las entradas anteriores vimos cómo hacer una rutina que copie a la pantalla una imagen completa desde un buffer organizado de manera secuencial, y esto nos va a simplificar la tarea, como veremos.

Lo primero que necesitamos saber para pintar algo en pantalla es a partir de qué dirección de memoria tenemos que hacerlo. Si recordamos, la pantalla del Spectrum está formada por una matriz de 256 píxels de ancho por 192 de alto, y cada píxel puede tener dos colores, por lo que cada uno ocupa un bit. Esto significa que cada fila o scanline de la pantalla ocupa 32 bytes, y la parte de píxeles de la pantalla ocupa en total 6 144 bytes o 6 Kbytes exactos.

Sin embargo, justo a continuación viene una segunda zona denominada atributos, que define una matriz de 32 por 24 atributos y asigna un byte a cada uno. Este byte de atributos especifica qué colores tendrán un grupo de píxeles. En concreto, cada byte define dos colores para cada grupo de 8×8 píxeles de la pantalla: uno será el color que se mostrará cuando el bit correspondiente al píxel esté a cero, y el otro cuando esté a uno. Esta zona empieza en la dirección de memoria 22 528 y ocupa un total de 768 bytes. Este sistema permite al Spectrum mostrar hasta 16 colores simultáneos en pantalla pero consumiendo muy poca memoria.

Como ya vimos en la primera entrada, la organización de la pantalla es algo caótica. Veámoslo en un gráfico:

Vemos que los bytes situados en las direcciones de memoria en hexadecimal 0x4000, 0x4100, 0x4200, 0x4300, 0x4400, 0x4500, 0x4600 y 0x4700 se corresponden con el bloque de 8×8 píxeles superior izquierdo de la pantalla, y que los atributos de dicho bloque están almacenados en la dirección 0x5800, que especifica que los dos colores de ese grupo de píxeles son blanco y negro. El siguiente bloque de 8×8 píxeles está en las direcciones 0x4001, 0x4101, 0x4201, 0x4301, 0x4401, 0x4501, 0x4601 y 0x4701, y sus atributos, que indican que los dos colores mostrados serán celeste y rojo, están en la dirección 0x5801. Y así sucesivamente. Esta disposición puede parecer absurda, pero si nos fijamos, vemos que la dirección de memoria de un scanline de píxeles y la de los atributos que le corresponden tienen el mismo valor en los ocho bits inferiores. Gracias a esta característica, es posible leer los dos bytes necesarios para mostrar los ocho píxeles en sólo tres ciclos de reloj, en lugar de los cuatro que se necesitarían si ambas direcciones no tuviesen siempre un byte idéntico (lo que se denomina fast-page). Y si tenemos en cuenta que cada bloque de 8 píxeles tarda cuatro ciclos de reloj en ser pintado, salta a la vista de donde sale el ciclo de contención de memoria que vimos en el primer artículo: la circuitería «compacta» la lectura de dos grupos de 8 píxeles consecutivos, de manera que en lugar de bloquear al procesador durante tres ciclos y liberarlo uno, lo bloquea durante seis ciclos y lo libera durante dos consecutivos. Además, de esta manera se garantiza también que los siete bits bajos se recorren completos aproximadamente cada milisegundo, lo que es más que de sobra para garantizar el refresco de la RAM.

Obviamente este sistema se implementó porque permite abaratar y simplificar el hardware, pero tiene el inconveniente de que, a la hora de programar para él, es bastante engorroso calcular la dirección de memoria que se corresponde con cada coordenada. Así, si dividimos la pantalla en caracteres (bloques de 8×8 píxeles) y queremos encontrar las ocho direcciones de memoria de sus ocho bytes, la operación que hay que hacer es la siguiente (gráficamente):

En azul tenemos la coordenada X, que puede valer entre 0 y 31 para las 32 columnas del Spectrum. En verde y magenta tenemos la coordenada Y en líneas: en la parte verde estaría la coordenada Y en caracteres (de 0 a 23), y en magenta sería el scanline dentro de ese carácter, todo de arriba a abajo y de izquierda a derecha. A mayores es necesario poner los tres bits superiores a 010 para que apunte al bloque de memoria concreto. Como vemos, la cosa es bastante complicada y requiere rotaciones y máscaras. Sin embargo, gracias a las rutinas que vimos en las entradas anteriores esta complicación desaparece completamente. Si las utilizamos, la conversión es tan sencilla como:

Ponemos los tres bits superiores a 100 porque, para simplificar, colocamos el buffer en la dirección 0x8000 (32768). De esta manera, si ponemos la coordenada X en el byte inferior y la coordenada Y en la superior, sólo tendremos que poner a uno el bit 7 del byte inferior y ya tendremos la dirección de memoria del scanline superior del carácter con dichas coordenadas; para pasar al siguiente carácter sólo tenemos que sumar uno; para pasar al anterior, restar uno; para pasar al que está encima, restar 32, y para pasar al que está debajo, sumar 32. Tan simple como esto. ¿Y si tenemos una dirección de píxeles, cómo calculamos la de sus atributos? Pues sólo tenemos que hacer esto:

Sencillo ¿no? Aunque para los que no se quieran complicar, aquí está un trozo de código que lo hace:

; Asumimos que HL contiene la dirección de memoria
; de un byte de pantalla
    ld a, l
    and 0x1F
    ld l, a
    ld a, h
    rrca
    rrca
    rrca
    ld h, a
    and 0xE0
    or l
    ld l, a
    ld a, h
    and 3
    or 0x98
    ld h, a
; Ahora HL contiene la dirección de memoria del atributo
; que corresponde con el byte de pantalla inicial

Y como esta entrada quedó ya algo larga, seguiré en la siguiente.