Archivo por días: 17 enero, 2021

Pintando en el Spectrum (6)

En la entrada anterior expliqué cómo crear un sprite, y puse una demostración que muestra cómo funciona, moviendo un sprite de un círculo en vertical. Y aunque en apariencia funciona bien cuando el fondo está vacío, si ponemos alguna imagen de fondo (en este ejemplo pongo «basura» copiada desde la zona de la ROM) y movemos el sprite por encima, vemos que hay un problema: aunque el sprite es un círculo, los límites del cuadrado que delimitan los caracteres son visibles.

Lo lógico sería que en las zonas externas del círculo pudiésemos ver el fondo, y que éste sólo estuviese tapado en donde está el círculo en sí.

Pues bien: este efecto es relativamente sencillo de conseguir, y para eso sólo necesitamos utilizar máscaras. Se trata de dividir el sprite en dos imágenes independientes: una contiene el sprite en sí, tal y como hasta ahora, y la otra es una máscara de transparencia, donde un bit puesto a 1 indica que ese pixel del sprite es transparente y se debe ver el fondo, y un bit a 0 especifica que el pixel correspondiente del sprite es opaco y debe pintarse.

En esta imagen vemos el sprite, la máscara, y cómo queda la transparencia al final:

Como vemos, en la máscara, la zona alrededor del círculo es de color negro (bit a 1), lo que significa que es transparente, mientras que la zona interna es blanca (bit a 0), lo que significa que es opaca. A la derecha podemos ver las zonas transparentes con un damero de ajedrez. Vemos que hemos dejado un margen de un píxel alrededor del círculo para que se vea mejor.

Todo esto está muy bien, pero ¿cómo hacemos para pintarlo? La clave está en utilizar operaciones lógicas: antes de copiar un byte del sprite en el buffer de la pantalla, tenemos que leer el byte que ya hay y hacer un AND lógico con la máscara. De esta manera, los bits del fondo que coincidan con un bit a 1 de la máscara se quedarán tal y como están, mientras que los bits que coincidan con un 0 en la máscara se pondrán a 0, «dejando un hueco» en donde podremos pintar luego el sprite. Por supuesto, para ello no podemos tampoco copiar directamente el byte del sprite, sino que tenemos que hacer un OR lógico de lo que haya en pantalla y el sprite, de manera que se mezclen.

Veámoslo de manera gráfica: supongamos que tenemos como fondo un damero de ajedrez, y queremos pintar encima nuestro sprite círculo. Primero aplicamos la máscara usando el operador AND entre cada byte de ella y el que hay en pantalla en la posición correspondiente, y almacenamos el resultado de nuevo en pantalla. Esto borrará sólo las partes que la máscara indica que son opacas, dejando intactas aquellas zonas marcadas como transparentes.

Hecho esto, copiamos los bytes del sprite en la misma zona pero utilizando esta vez la operación OR, de manera que «se mezcle» con lo que había. Dado que previamente habíamos «hecho un agujero» con la máscara, los píxeles del sprite se mezclarán exclusivamente donde nos interesa, dejando inalterados el resto de zonas.

Sin embargo, a la hora de implementarlo nos encontramos con el problema de que es un proceso muy ineficiente, pues tenemos que, literalmente, imprimir dos sprites realmente: primero la máscara, y luego los píxeles. Y por si fuera poco tenemos que hacer tres operaciones en memoria por cada sprite en lugar de dos, como hasta ahora: leer el dato de la pantalla, leer el byte del sprite (ya sea pixels o máscara), y escribir el nuevo valor.

La solución consiste en aplicar ambas operaciones a la vez. Para ello empezamos leyendo el primer byte de la zona de pantalla, hacemos AND con el primer byte de la máscara, luego OR con el primer byte de los píxeles, y finalmente escribimos el valor resultante en la memoria de pantalla. Hecho esto, incrementamos en uno los punteros de los píxeles y de la máscara, pasamos a la siguiente dirección de pantalla, y repetimos el proceso.

Este sistema permite aumentar mucho el rendimiento, pues no sólo reducimos los accesos a memoria a sólo dos tercios, sino que, además, se pueden compartir muchas operaciones comunes, como la inicialización de los bucles (e incluso la sobrecarga que supone ejecutar el bucle en sí). Sin embargo, todavía podemos optimizarlo un poquito más. Para entenderlo, pensemos en cómo almacenamos los píxeles y la máscara del sprite: la opción más inmediata es colocar la máscara justo a continuación de los píxeles, de manera que si conocemos la dirección inicial y el tamaño del sprite en caracteres, podemos obtener la dirección de la máscara simplemente con la operación MASCARA = PÍXELES + ANCHO * ALTO * 8. El resultado sería como tener un sprite del doble de tamaño, y almacenado así:

Es sencillo de describir, pero recordemos que el Z80 no tiene instrucción de multiplicación, lo que supone que hacer ese cálculo sea lento y ocupe bastante código. Además, nos obliga a tener tres punteros: uno para los píxeles, otro para la máscara, y otro para el buffer de pantalla.

Sin embargo, existe una manera de evitar todo esto, y es entrelazar los datos de la máscara y de los píxeles; esto es, almacenar en memoria un byte de máscara, un byte de píxeles, un byte de máscara, un byte de píxeles… así:

La ventaja de este sistema es que sólo necesitamos dos punteros: uno que apunte al sprite, y otro al buffer de pantalla. La mecánica es sencilla: leemos byte del puntero del buffer de pantalla y hacemos un AND con el byte del puntero del sprite. Incrementamos en uno el puntero del sprite y hacemos un OR del byte al que apunta con el resultado anterior. Hecho esto, incrementamos de nuevo el puntero del sprite y pasamos a la siguiente dirección de memoria del buffer de pantalla (según estemos todavía en el mismo scanline o tengamos que pasar al siguiente). Lo curioso es que pocos juegos utilizan este sistema, a pesar de sus claras ventajas. Un ejemplo de juego que lo usa es Knight Lore, uno de los primeros juegos en utilizar máscaras en sus sprites.

El código para hacer esto sería el siguiente:

pintar_sprite:
    push HL
    exx
    pop HL      ; metemos la dirección del sprite en HL'
    exx
    set 7, D    ; DE ya casi tiene la dirección, le falta el bit 7 de D
    ex de, hl   ; metemos la dirección de la pantalla en HL
    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 E, A     ; siguiente scanline, y lo almacenamos en DE
    ld D, 0
bucle:
    push BC     ; guardamos B (pues es el contador de scanlines) y C
    ld B, C     ; preparamos el bucle interno
bucle2:
    ld a, (hl)  ; leemos el byte actual de la pantalla
    exx
    and (hl)    ; aplicamos la máscara
    inc hl
    or (hl)     ; aplicamos los píxeles
    inc hl
    exx
    ld (hl), a  ; almacenamos el resultado en la pantalla
    inc hl      ; y pasamos a la siguiente posición de la pantalla
    djnz bucle2 ; terminamos el scanline
    add HL, DE  ; y pasamos al siguiente scanline
    pop BC      ; recuperamos el número de scanlines que nos quedan
    djnz bucle  ; y repetimos hasta hacer todos los scanlines

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

y reemplazaría al código que escribimos en la entrada anterior. Existe un cambio extra a mayores: dado que ahora cada scanline ocupa el doble (pues contiene píxeles y máscara), en la parte donde comprobamos si el sprite está fuera de la pantalla por arriba tenemos que multiplicar por 16 en lugar de por 8, lo que se consigue añadiendo un add A, A extra.

Y este es el resultado: como se ve, es muchísimo mejor que el original, pues ya no hay ese cuadrado tan feo alrededor del sprite que hace que parezca pegado encima, sino que éste realmente aparece integrado con el fondo.

Como cabe suponer, mi editor de sprites ZXSpriter almacena los sprites precisamente en este formato. Pero no sólo eso, sino que si se incluyen atributos de color, puede también incluirlos entrelazados (aunque en este caso, es opcional). De esta manera, el sprite tendrá ocho scanlines con los bytes de máscara y píxeles entrelazados, tal y como hemos visto ya, y justo a continuación los bytes con los atributos de color para ese conjunto de scanlines; a continuación otros ocho scanlines, y otra vez los atributos de color para ese conjunto de scanlines. Y así sucesivamente. Esto permite pintar sprites de colores de manera óptima.

El código del ejemplo con máscaras está disponible aquí: ejemplo de sprites con máscaras. En él vuelvo a hacer uso de los registros alternativos (BC’, DE’ y HL’), y del hecho de que el registro A no se intercambia con A’ cuando se ejecuta la instrucción EXX, para tener en HL la dirección de la pantalla y en HL’ la del sprite.