Número 6 - Marzo 2004 ZX Certified webmaster speccy.org
 

CÁLCULO DE DIRECCIONES DE PANTALLA A PARTIR DE COORDENADAS

Cuando tenemos la coordenada de cualquier sprite almacenada en registros o variables, necesitaremos saber donde hay que empezar a ponerlo cuando vayamos a pintarlo. Esto, dada la extraña disposición de la memoria de la pantalla en el Spectrum, no es tan sencillo como pueda serlo en otros sistemas (por ejemplo en el modo VGA 13h del PC, donde el cálculo consiste en hacer (320*y)+x ). Así pues, vamos a dedicar este artículo a ver varias formas distintas de realizar esta tarea, comentando sus ventajas e inconvenientes.

Para poder comprender las distintas rutinas hay que tener muy en cuenta la disposición de la memoria de video, así que vamos a recordarla por si a alguien se le ha olvidado.

Básicamente, el aparente caos que supone el orden de la pantalla se entiende una vez que se descomponen las direcciones en campos de bits bien diferenciados:

byte alto byte bajo
010 tt yyy YYY XXXXX

tt tercio de la pantalla, de 00 a 10
YYY fila dentro del tercio (en caracteres)
yyy fila dentro del caracter (en pixels)
XXXXX columna (en caracteres)

Los datos de los que partimos son un byte con la coordenada Y en el formato: ttYYYyyy , y otro byte para la coordenada X en el formato: 000XXXXX.

Así pues nuestro cometido principal consiste en reordenar todos los campos de la coordenada Y al insertarlos en la dirección, para terminar añadiendo al resultado la coordenada X, lo cual es bastante trivial.

Para realizar el calculo de la coordenada Y tenemos dos posibilidades básicas: hacer todo el cálculo por nuestra cuenta, o usar una tabla que almacene todos los resultados. También tenemos la opción intermedia de usar las tablas sólo para la parte mas compleja y calcular el resto.

Realizando el cálculo, la primera rutina que se me ocurrió fue ésta:

				; entrada x=L, y=A; salida en DE
CALC1:		LD      D,2     ; D = 00000010  2 / 7    2 /   7
                RLCA            ; A = tYYYyyyt  1 / 4    3 /  11
                RL      D       ; D = 0000010t  2 / 8    5 /  19
                RLCA            ; A = YYYyyytt  1 / 4    6 /  23
                RL      D       ; D = 000010tt  2 / 8    8 /  31
                PUSH    AF      ;               1 /11    9 /  42
                AND     224     ; A = YYY00000  2 / 7   11 /  49
                ADD	A,L	; A = YYYXXXXX  1 / 4   12 /  53
                LD      E,A     ; E = YYY00000  1 / 4   13 /  57
                POP     AF      ; A = YYYyyytt  1 /11   14 /  68
                RLCA            ; A = YYyyyttY  1 / 4   15 /  72
                RLCA            ; A = YyyyttYY  1 / 4   16 /  76
                RLCA            ; A = yyyttYYY  1 / 4   17 /  80
                RLCA            ; A = yyttYYYy  1 / 4   18 /  84
                RL      D       ; D = 00010tty  2 / 8   20 /  92
                RLCA            ; A = yttYYYyy  1 / 4   21 /  96
                RL      D       ; D = 0010ttyy  2 / 8   23 / 104
                RLCA            ; A = ttYYYyyy  1 / 4   24 / 108
                RL      D       ; D = 010ttyyy  2 / 8   26 / 116
                RET             ;               1 /10   27 / 126

En los comentarios podeis ver el resultado de la última operación (para que se entienda lo que va haciendo el programa), los bytes y estados de la instrucción actual y por último la suma de bytes / estados hasta el momento. En los siguientes listados prescindiré de algunos de estos campos.

En total esta primera rutina ocupa 27 bytes, y tarda 126 estados en realizar el cálculo.

¿Es mucho, es poco? Veamos si podemos mejorarla.

Si nos damos cuenta, existen algunas instrucciones que se repiten, por lo que podemos ahorrar algunos bytes haciendo:

         			  ; entrada x=L, y=A; salida en DE
CALC2:    	LD      D,74      ;     2 / 7            2 /   7
ROT1:           RLCA              ;     1 / 4,4          3 /  15
                RL      D         ;     2 / 8,8          5 /  31
                JR      NC, ROT1  ;     2 / 12,7         7 /  50
                PUSH    AF        ;     1 / 11           8 /  61
                AND     224       ;     2 / 7           10 /  68
                ADD	A,L	  ;	1 / 4		11 /  72
                LD      E,A       ;     1 / 4           12 /  76
                POP     AF        ;     1 / 11          13 /  87
                RLCA              ;     1 / 4           14 /  91
                RLCA              ;     1 / 4           15 /  95
                RLCA              ;     1 / 4           16 /  99
ROT2:           RLCA              ;     1 / 4,4,4       17 / 111
                RL      D         ;     2 / 8,8,8       19 / 135
                JR      NC, ROT2  ;     2 / 12,12,7     21 / 166
                RET               ;     1 / 10          22 / 176

Como podéis observar, el algoritmo es identico al caso anterior, pero hemos introducido un par de bits extra en el registro D que actúan de bits marcadores (los bit markers que vimos en el artículo anterior) para salir de los bucles.

A cambio de ahorrar 5 bytes, estamos tardando 50 estados más en realizar el cálculo.

Si nos fijamos en la primera rutina, vemos que estamos haciendo una tontería con el campo yyy, ya que estamos rotando el byte 8 veces, para que el campo vuelva otra vez al mismo sitio al final. Además, el uso de instrucciones PUSH AF y POP AF resulta costoso en estados, así que podemos refinar la rutina un poquito:

        			  	; entrada x=L, y=D; salida en HL
CALC3:          LD	A,D	; A = ttYYYyyy	1 / 4    1 /   4
		LD      H,2     ; H = 00000010  2 / 7    3 /  11
                RLCA            ; A = tYYYyyyt  1 / 4    4 /  15
                RL      H       ; H = 0000010t  2 / 8    6 /  23
                RLCA            ; A = YYYyyytt  1 / 4    7 /  27
                RL      H       ; H = 000010tt  2 / 8    9 /  35
                AND     224     ; A = YYY00000  2 / 7   11 /  42
                ADD	A,L     ; A = YYYXXXXX  1 / 4   12 /  46
                LD	L,A	; L = YYYXXXXX  1 / 4	13 /  50
                LD	A,D	; A = ttYYYyyy  1 / 4   14 /  54
                AND	7	; A = 00000yyy  2 / 7   16 /  61
                RLC     H       ; D = 00010tt0  2 / 8   18 /  69
                RLC     H       ; D = 0010tt00  2 / 8   20 /  77
                RLC     H       ; D = 010tt000  2 / 8   22 /  85
                ADD	A,H	; A = 010ttyyy  1 / 4   23 /  89
                LD	H,A	; H = 010ttyyy  1 / 4   24 /  93
                RET             ;               1 /10   25 / 103

Ahora ocupa 25 bytes, y tarda sólo 103 estados en realizar el cálculo.

Haciendo lo mismo de antes, la versión pequeña saldría así:

       			  	; entrada x=L, y=D; salida en HL
CALC4:    	LD	A,D	; 1 / 4    	 1 /   4
		LD      H,74    ; 2 / 7    	 3 /  11
ROT1:           RLCA            ; 1 / 4,4    	 4 /  19
                RL      H       ; 2 / 8,8   	 6 /  35
                JR	NC,ROT1 ; 2 / 12,7 	 8 /  54
                AND     224     ; 2 / 7   	10 /  61
                ADD	A,L     ; 1 / 4   	11 /  65
                LD	L,A	; 1 / 4		12 /  69
                LD	A,D	; 1 / 4   	13 /  73
                AND	7	; 2 / 7   	15 /  80
ROT2:           RLC     H       ; 2 / 8,8,8   	17 / 104
		JR	NC,ROT2	; 2 / 12,12,7	19 / 135
                ADD	A,H	; 1 / 4   	20 / 139
                LD	H,A	; 1 / 4   	21 / 143
                RET             ; 1 /10   	22 / 153

De nuevo perdemos 50 estados, pero en esta ocasión tan solo ganamos 3 bytes en lugar de 5, por lo que no bajamos de los 22 bytes.

Parece como si no pudiésemos hacer el cálculo en menos de 100 estados, pero sí que es posible si volvemos a cambiar de método.

En lugar de utilizar 5 rotaciones para poner el campo del tercio en su sitio, podemos detectar en que tercio estamos al efectuar las dos primeras rotaciones (las que ajustan la posición del campo YYY) gracias al bit de acarreo, y en caso de que sea necesario, "inyectar" el bit directamente.

       			  	; entrada x=C, y=B; salida en HL
CALC5:          LD      A,B	; A = ttYYYyyy   1 / 4
                AND     A,7     ; A = 00000yyy   2 / 7
                OR      64      ; A = 01000yyy   2 / 7
                LD      H,A     ; H = 01000yyy   1 / 4
                LD      A,B     ; A = ttYYYyyy   1 / 4
                AND     248     ; A = ttYYY000	 2 / 7
                ADD     A,A     ; A = tYYY0000	 1 / 4
                JR      NC,noter_3  ; 		 2 / 7,12
                SET     4,H     ; H = 01010yyy	 2 / 8
noter_3: 	ADD     A,A     ; A = YYY00000	 1 / 4
                JR      NC,noter_2 ; 		 2 / 7,12
                SET     3,H     ; H = 01001yyy   2 / 8
noter_2: 	ADD     A,C     ; A = YYYXXXXX	 1 / 4
                LD      L,A     ; L = YYYXXXXX   1 / 4
                RET             ; 		 1 / 10

El cálculo para la parte alta ya está terminado en la cuarta instrucción en el caso de que estemos en el primer tercio. Los otros dos tercios necesitan uno de los SETs para añadir el bit que les falta. Esta rutina también ocupa 22 bytes, y tarda 83 estados en el primer tercio y 86 para los otros dos.

Parece que hemos llegado a la conclusión de que no es posible hacerlo más pequeño, pero es posible hacerlo más rápido si tenemos en cuenta que no existe el caso 11 para el campo tt, por lo que si detectamos el tercer tercio, no es necesario probar si estamos en el segundo. Así que separando los caminos un poquito y repitiendo por tanto algo de código obtenemos:

       			  	; entrada x=C, y=B; salida en HL
CALC6:     	LD      A,B	; A = ttYYYyyy   1 / 4
                AND     7       ; A = 00000yyy   2 / 7
                OR      64      ; A = 01000yyy   2 / 7
                LD      H,A     ; H = 01000yyy   1 / 4
                LD      A,B     ; A = ttYYYyyy   1 / 4
                AND     248     ; A = ttYYY000	 2 / 7
                ADD     A,A     ; A = tYYY0000	 1 / 4
                JR      C,ter_3 ; 		 2 / 7,12
                ADD	A,A	; A = YYY00000   1 / 4
                JR      NC,ter_1 ; 		 2 / 7,12
	        SET     3,H     ; H = 01001yyy   2 / 8
ter_1:		ADD     A,C     ; A = YYYXXXXX	 1 / 4
                LD      L,A     ; L = YYYXXXXX   1 / 4
                RET             ; 		 1 / 10
ter_3:          SET     4,H     ; H = 01010yyy	 2 / 8
 		ADD     A,A     ; A = YYY00000	 1 / 4
		ADD     A,C     ; A = YYYXXXXX	 1 / 4
                LD      L,A     ; L = YYYXXXXX   1 / 4
                RET             ; 		 1 / 10

26 bytes, los tiempos para cada tercio son respectivamente: 78, 81, 79. Aparte de ganar 7 estados para el tercer tercio, hemos ganado otros 5 para los otros dos casos porque el primer salto que antes se daba ahora no se produce (y los saltos condicionales son mas rápidos cuando no se producen (7 estados) que cuando se producen (12)).

Si el segundo salto lo hacemos de forma que salte en caso de acarreo hacia otra zona donde se realiza el proceso del segundo tercio, separando el código un poco más, optimizamos el camino del primer tercio a costa del camino del segundo, con lo que obtendríamos 73, 86, 79 (y ocuparía algunos bytes mas).

Y eso es todo en cuanto a los métodos de cálculo directo. Vamos a ver ahora los métodos que utilizan tablas.

Las tablas no son más que listas de resultados de ciertos cálculos que dejamos apuntados en memoria para no tener que recalcularlos cada vez que los necesitamos. Por lo tanto permiten ganar tiempo a cambio de usar memoria.

En un principio, para cada posible valor de Y, necesitaremos dos valores en las tablas, uno para el byte alto y otro para la primera mitad del byte bajo, a la cual le sumaremos el campo X para obtener la dirección completa. Como tenemos 192 posibles valores de Y, obtenemos un total de 192*2=384 bytes de datos.

Tan solo necesitamos que la rutina lea los datos de la tabla. Eso se puede hacer por ejemplo así:

	       			  	; entrada x=A, y=L; salida en DE
TABLA1:         LD      DE,TABLA        ; 3 / 10	 3 / 10
                LD      H,0             ; 2 /  7	 5 / 17
                ADD     HL,HL           ; 1 / 11	 6 / 28
                ADD     HL,DE           ; 1 / 11	 7 / 39
                ADD     A,(HL)          ; 1 /  7	 8 / 46
                LD	E,A		; 1 /  4	 9 / 50
                INC     HL              ; 1 /  6 	10 / 56
                LD      D,(HL)          ; 1 /  7	11 / 63
                RET                     ; 1 / 10	12 / 73

La tabla se construye pasando ttYYYyyy -> YYY00000, 010ttyyy para cada valor del 0 al 191, y puede estar situada en cualquier parte de la memoria.

En un principio parece que no ganamos mucho sólo por el hecho de utilizar una tabla, ya que tenemos que calcular la posición de los datos que buscamos, y para esto tenemos que usar instrucciones de 16 bits que resultan muy costosas en tiempo. Para mejorar esto tenemos que colocar alguna restricción en la tabla, y aprovecharla para tener que realizar menos cálculos. Por ejemplo, si hacemos que la tabla comience en una dirección fija que sea múltiplo de 512, en lugar de un 0 colocamos en H el valor TABLA/512, con lo cual al hacer la suma ADD HL,HL tenemos ya la dirección de la tabla calculada por completo, y no necesitamos utilizar para nada el registro DE.

	       			  	; entrada x=A, y=L; salida en HL
TABLA2:         LD      H,TABLA/512     ; 2 /  7	 2 /  7
                ADD     HL,HL           ; 1 / 11	 3 / 18
                ADD     A,(HL)          ; 1 /  7	 4 / 25
                INC     L               ; 1 /  4	 5 / 29
                LD      H,(HL)          ; 1 /  7	 6 / 36
                LD	L,A		; 1 /  4	 7 / 40
                RET                     ; 1 / 10	 8 / 50

Ahora podemos utilizar INC L en lugar de INC HL porque el ADD HL,HL dejó un valor par, y por lo tanto al incrementar L nunca va a incrementarse H, y así nos ahorramos 2 estados mas. En el caso anterior, cabía la posibilidad de que la etiqueta TABLA cayera en un lugar impar.

Esto ya tiene mejor pinta, ¿verdad?. Pues la cosa puede mejorar mas aún. Si en lugar de una tabla con los 384 valores juntos creamos dos tablas de 192 bytes separadas entre si 256 bytes (osea, dejando un hueco de 64), podemos pasar de un valor al siguiente usando INC H en lugar de INC L (o INC HL), y nos podemos ahorrar la otra suma de 16 bits.

Sin restricción en la colocación de la pareja de tablas quedaría:

	       			  	; entrada x=A, y=L; salida en DE
TABLA3:         LD      DE,TABLA1       ; 3 / 10
                LD      H,0             ; 2 /  7
                ADD     HL,DE           ; 1 / 11
                ADD     A,(HL)          ; 1 /  7
                LD	E,A		; 1 /  4
                INC     H               ; 1 /  4
                LD      D,(HL)          ; 1 /  7
                RET                     ; 1 / 10     11+384 = 395 / 60

Ahora se hacen dos tablas ttYYYyyy -> YYY00000 por una parte y ttYYYyyy -> 010ttyyy por otra, para cada valor del 0 al 191.

Y juntando los dos trucos (aunque ahora la pareja de tablas basta con alinearlas a un múltiplo de 256, no hace falta 512), obtenemos la versión más rápida:

	       			  	; entrada x=A, y=L; salida en HL
TABLA4:         LD      H,TABLA/256	; 2 / 7
                ADD     A,(HL)    	; 1 / 7
                INC     H         	; 1 / 4
                LD      H,(HL)    	; 1 / 7
                LD      L,A       	; 1 / 4
                RET               	; 1 / 10    7+384 = 391 / 39

Como podemos ver, las tablas pueden ayudarnos a reducir el tiempo a la mitad, lo cual resulta muy útil cuando tenemos que llamar a una rutina muy a menudo.

Por último vamos a ver un par de ejemplos de rutinas "híbridas", en el sentido de que realizan parte del cálculo y miran otra parte en alguna tabla, obteniendose mayor velocidad que con el cálculo pero ocupando menos espacio que las soluciones con tabla que hemos visto hasta ahora.

La primera que vamos a ver se aprovecha del hecho de que el cálculo de la parte baja es muy sencillo, así que tan solo usamos una tabla de 192 bytes para la parte alta. La versión directamente optimizada (con la tabla alineada a un múltiplo de 256) podría ser algo así:

	       			  	; entrada x=B, y=A; salida en HL
HIBRI1:       	LD      H,TABLA/256 	; 2 / 7
	        LD      L,A       	; 1 / 4
	        LD      H,(HL)    	; 1 / 7
	        RLCA              	; 1 / 4
	        RLCA              	; 1 / 4
	        AND     224       	; 2 / 7
	        ADD     A,B       	; 1 / 4
	        LD      L,A       	; 1 / 4
	        RET               	; 1 / 10   11+192=203 / 51

En este caso tan solo necesitamos una tabla recorriendo ttYYYyyy -> 010ttyyy de 0 a 191.

Para la última rutina que vamos a ver nos aprovecharemos del hecho de que los valores que aparecen en la tabla de 192 bytes se repiten 8 veces, dado que el campo YYY no pinta nada en esta tabla. Así pues rotamos el campo Y para acumular los bits que nos interesan en las posiciones mas bajas y borramos los de las posiciones altas antes de mirar en la tabla. La tabla resultante es de 32 bytes, de los cuales 8 no se usan (dado que el tercio 11 no existe), por lo que son 24 bytes ocupando un espacio de 31.

	       			  	; entrada x=B, y=A; salida en HL
HIBRI2:	        LD      H,TABLITA/256  	; 2 / 7
	        RLCA                	; 1 / 4
	        RLCA                	; 1 / 4
	        LD      C,A         	; 1 / 4
	        AND     31          	; 2 / 7
	        LD      L,A         	; 1 / 4
	        LD      H,(HL)      	; 1 / 7
	        LD      A,C         	; 1 / 4
	        AND     224         	; 2 / 7
	        ADD     A,B         	; 1 / 4
	        LD      L,A         	; 1 / 4
	        RET                 	; 1 / 10    15+24 = 39 / 66

La tablita en este caso se construye recorriendo 000yyytt -> 010ttyyy de 0 a 30 (ignorando los casos con tt = 11).

Visto todo esto, ¿cual es la mejor rutina? Si nos quedamos sólo con las mejores rutinas, podemos escoger la relación velocidad/espacio que mejor convenga a nuestro programa, entre las siguientes:

  bytes / estados
TABLA4: 391 / 39
HIBRI1: 203 / 51
HIBRI2: 39(*) / 66
CALC6: 26 / 81
CALC5: 22 / 86

* > 46 si no aprovechásemos los huecos.

¿Pueden optimizarse más? Pues depende. Como rutinas de uso general, creo que no dan más de si (aparte de la obviedad que supone usarlas en línea y evitar por lo tanto el RET), pero una vez aplicadas a un juego determinado, las características del juego pueden favorecer nuevas optimizaciones. Por ejemplo, si un juego tan solo usa los dos primeros tercios, dejando el tercio de abajo para puntuaciones, barras de energía, etc... podemos reducir los tamaños de las tablas a 256,128 o 16 bytes (en esta ocasión seguidos) respectivamente, y las rutinas de cálculo quedarían como una sóla de 18 bytes, tardando 71/74 estados.

Con esto concluye el artículo, si tenéis dudas o queréis proponer temas para futuros artículos, podeis apuntaros a la lista de correo de programación en ensamblador [z80asm] de speccy.org.

La mayoría de las rutinas vistas en este artículo ya fueron discutidas hace tiempo en dicha lista, y quiero agradecer especialmente la colaboración de Truevideo (que planteó su rutina usando tabla) y Z80user (autor original de CALC6, de la cual se deriva fácilmente CALC5).

Hasta otra.



METALBRAIN
 
Volver arriba
(c) 2003-2004 Magazine ZX