Lección 09 – Scroll (3) Más allá de los 512px

Aventuras en Megadrive:
Más allá de los 512px

EL PROBLEMA

Si sois observadores, habréis visto un pequeño problema en los ejemplos de scroll:

scroll problem

No es ningún error, es así como funciona la Mega.

EL MOTIVO

Por defecto el SGDK dedica 512x256px (64×32 tiles) a la memoria de los planos (A y B).
Así que aunque la imagen original tiene un tamaño de 640x224px

bga640

…sólo se carga en la memoria del VDP del pixel 0 al pixel 511 (total 512px, hablando siempre en la horizontal). El resto de la imagen, del pixel 512 al 639, no cabe en memoria y por tanto no se muestra.  Por eso se cortan la palmera y el «500».

Ya hemos hablado del rolling otras veces. A continuación del pixel 511 la Mega vuelve a pintar la columna de pixels de la posición 0, luego la del 1, etc. De ahí la sensación de continuidad en el suelo en la imagen superior, pura casualidad que rompe la palmera.

 

¿Y si necesito planos de scroll más grandes?

El SGDK nos permite configurar la memoria para tener varios tamaños de plano. Debido a las restricciones de memoria, no pueden existir planos más grandes. No obstante, juegos como Sonic gozan de pantallas enormes con scroll en todas direcciones. ¿Cómo?

Desde los tiempos antiguos, los programadores han desarrollado técnicas para paliar la falta de memoria. La mega tiene un plano más grande que la pantalla visible, 64×32 tiles en su configuración habitual. La idea básica es, al hacer scroll, añadir en la zona no visible los tiles que sean necesarios. A medida que se hace scroll, esos nuevos tiles aparecen en la pantalla visible.

De esta forma podemos tener planos tan grandes como queramos, pero hay que controlar la generación de tiles manualmente. De la misma forma, si es scroll se hace en dirección contraria, tenemos que tenerlo en cuenta. Y si lo hacemos de forma vertical y horizontal, aún será más complicado.

Vamos a centrarnos en el caso más simple, scroll horizontal. Fíjate en este diagrama:

Captura024

  • VISIBLE TILE SPACE: Se refiere a la parte que veremos en pantalla. En el momento inicial serán 40 tiles horizontales (de la columna 0 a la 39).
  • VDP TILE SPACE: En el VDP caben los tiles del 0 al 64, la zona visible (40 tiles) + la zona no visible, que son 23 tiles horizontales (de la columna 40 a la 63).
  • WORLD TILE SPACE: El mapa completo (en tiles) de un nivel determinado. El tamaño puede ser tan grande como queramos. Se guarda en la ROM del cartucho.

 

 

CARGANDO TILES AL VUELO

Es ahora de aprender a cargar tiles bajo demanda. El plano completo estará en la ROM del cartucho, por tanto aunque no cabe entero en memoria del VDP, podemos cargar los tiles que necesitemos en cada momento.

Una posible técnica (seguramente no la mejor), podría ser la siguiente:

  • Cargar el plano en memoria, tal y como hemos hecho hasta ahora, por tanto inicialmente tenemos los primeros 512px/64 tiles del plano en la mem del VDP.
    Inicialmente vemos del tile 0 al tile 39, en la zona no visible del tile 40 al 63:

Captura026

Un detalle importante que mencionaremos después. Hay 23 tiles de distancia entre el tile 40º ( que es el tile 39, recordemos que empezamos con el tile 0 ) y el tile 63.

  • Al desplazarse el escenario, la primera columna de tiles, la columna 0, va desapareciendo por la izquierda de la pantalla. La consola hace rolling y pixel a pixel la pone de nuevo al principio del plano (por la derecha, parte no visible de la pantalla). La columna 40 va entrando poco a poco en la zona visible.Captura027
  • Al desplazar 8 pixels, la columna 0 está completamente a la derecha del plano, en la zona no visible. Por otro lado, la columna 1 es la primera columna de la zona visible. Además la columna 40 ha entrado completamente en la zona visible.Captura028
  • En ese momento vamos a cargar la columna de tiles 64 (del plano completo, World Tile Space), y la pintaremos encima de la columna 0. Pintar = escribir en memoria del VDP. La columna 0 ha sido machacada por la 64, por tanto la columna 0 ya no existe en memoria del VDP.Captura029

La nueva columna, la 64, la hemos actualizado fuera de la pantalla visible.De esta forma el usuario no «ve» el cambio de tiles. Nosotros podemos verlo con la herramienta Plane explorer  de GensKmod (lo veremos más abajo).

 

  • Este proceso lo repetimos tantas veces como sea necesario. En cuanto desaparezca la columna 1 por la izquierda y la tengamos por completo a la derecha, cargaremos la columna 65 donde la 1, y así sucesivamente.
  • Cuando lleguemos al final del plano, tocará bien detener la gestión del scroll o, si es infinito, hacer nosotros manualmente el rolling cargando el tile 0 cuando corresponda.

Esta técnica es muy simple, sólo sirve para scroll horizontal, y seguramente no es eficiente, pero es didáctica, espero haberme explicado bien.

 

UN POCO DE CÓDIGO

Veamos un ejemplo sencillo:

  • Trabajaremos sólo con 1 plano.
  • Resolución 320x224px (40×28 tiles).
  • Scroll horizontal. No hay scroll vertical.
  • Tamaño del PNG 1024x224px (127×28 tiles).

Esta es la imagen de fondo que voy a usar (archivo 1024x224px.png):

fondo1024x224px

Date cuenta que en la parte superior tienes numerados los tiles, en la inferior los pixels:

  • La zona visible (en verde) son las columnas de tiles del 0 al 39. El resto del plano está en la zona no visible (en naranja) y son las columnas de tiles del 40 al 63.
  • El resto del plano (de la columna 64 a la columna 127) no está en memoria. No obstante estará en la ROM del cartucho (el plano completo).

 

Comenzamos. Recuerda que puedes encontrar este ejemplo en mi github.

Cargamos el planos de fondo tal y como hemos hasta ahora.

En GensKmod abre Plane Explorer (GensKmod / CPU / debug / Plane Explorer) para ver lo que se ha cargado en la memoria del VDP (escoge el plano B):

Captura023

Al iniciar, la primera columna de tiles es la 0 y la última la 63, tal y como esperábamos. Con Plane explorer vamos a ir viendo como sustituimos columnas de tiles

Fíjate en las 3 variables que pintamos en pantalla:

s16 offset=0;            //scroll en pixels, de 0 a 1023 px
s16 column_to_update = 0;//scroll en TILES,  de 0 a 127 tiles
s16 cuentaPixels = 0;    //detecta cambios de tile,se resetea cada 8px (de 0 a 7)

offset y column_to_update miden el desplazamiento del plano en píxels y tiles respectivamente. Si pulsamos derecha (Xorder=1) significa que vamos a mover 1 pixel el plano (offset++) y además vamos a incrementar el contador (cuentaPixels++) :

static void handleInput()
{
    u16 value = JOY_readJoypad(JOY_1);

   [...]

    if (value & BUTTON_LEFT) Xorder = -1;
    else if (value & BUTTON_RIGHT) Xorder = +1;
        else Xorder = 0;
}

static void updatePhysic()
{
  if (Xorder > 0) //PULSO DERECHA
  {
    offset++; cuentaPixels++; 
    if(cuentaPixels>7) cuentaPixels=0;
  [...]
  }
  [...]
updateCamera();
}

Date cuenta que cuentaPixels se va reseteando cuando hemos avanzado 8 pixels (en ese momento se cumple que es >7). Cada 8 px = 1 TILE: nos marca cuando hay un cambio de columna de tile. Esto tiene importancia para la función que realiza la gestión de la cámara updateCamera():

static void updateCamera()
{
  //cada vez que nos 'salimos' de la imagen, volvemos al principio
  if(offset>1023) offset=0;

  //solo actualizamos el tile que va a mostrarse a continuación,
  //justo ANTES de mostrarse (de zona no visible a zona visible)
  if(cuentaPixels==0)
  {
    column_to_update = (((offset + 320) >> 3)+ 23) & 127; 
    // 320=screen_width
    // valor_px >> 3 = valor_tiles ===> ">>3" equivale a dividir entre 8
    // valor_tiles + 23 = nuevo_valor_tiles ===> apunta al tile correcto
    // nuevo_valor_tiles & 127 ===> devuelve un numero de 0 a 127
    VDP_setMapEx(PLAN_B, bgd_image.map, TILE_ATTR_FULL(PAL2, FALSE, FALSE, FALSE, TILE_USERINDEX),
                 column_to_update, 0, column_to_update, 0, 1, 28);
  }
  [...]
  //hacemos scroll
  VDP_setHorizontalScroll(PLAN_B, -offset);
}

updateCamera()  va actualizando el scroll del fondo pixel a pixel ( es lo que va a hacer la última instrucción VDP_setHorizontalScroll ). Ahora bien, para cada ciclo y ANTES de actualizar el scroll del plano:

  • Si el offset se ‘sale’ de la imagen, volvemos a cargar la imagen como en el inicio:
    if(offset>1023) offset=0;
    Significa que hemos dado la «vuelta al plano». Es un rolling manual. Recordemos que la imagen mide 1024px.

 

  • Si cuentaPixels es cero, vamos a actualizar una columna completa de tiles:
    • Para calcular la nueva columna de tiles a leer usamos la sig. fórmula: column_to_update = (((offset + 320) >> 3) +23) & 127;
      donde:

      •   offset + 320 : es la resolución horizontal+desplazamiento.
      •   >>3 : Equivale a dividir por 8. Pasa de px a tiles.
      •   +23 :  Apunta al tile correcto a actualizar (*).
      •   &127 : Asegura que el núm. resultante está entre 0 y 127.
    • A continuación VDP_setMapEx() pinta=escribe una serie de tiles en memoria, en otras palabras hace copiar del plano completo y pegar en memoria del VDP (más abajo amplio info).

(*):  Inicialmente, offset=0 y el resultado de la fórmula es:  ((offset + 320) >> 3) = 40.
Si avanzamos 8 pixels, offset=8 y el resultado de la fórmula es:  ((offset + 320) >> 3) = 41. En ese momento y según la técnica que explicamos antes, ahora tendríamos que copiar la columna de tiles 64 en la 0. Por ese motivo hemos de sumar 23, porque 41+23 = 64.
Con offset = 16, sería 42+23 = 65.
Y así sucesivamente.

Vamos a verlo en un vídeo donde damos la «vuelta completa» al plano, fíjate bien en el Plane Explorer (te recomiendo ponerlo a pantalla completa / cámara lenta):

Inicialmente tenemos en memoria la parte verde (visible) y la parte naranja (no visible). Cuando column_to_update supera 63, se empieza a machacar la memoria. El tile 0 del VDP se machaca con el tile 64 de la ROM, luego el tile 1 con el 64, etc.

No obstante como al mismo tiempo que machacamos, hacemos el scroll, no lo vemos aparecer hasta que VDP_setHorizontalScroll() ha avanzado el plano lo suficiente.

Cuando offset es mayor que 1023, pasa a valer 0 y, por tanto, column_to_update pasa a valer de 127 a 0, machacando de nuevo la memoria del VDP y pintando el principio del plano.

 

VDP_setMapEx()

VDP_setMapEx( plan, map, TILE_ATTR_FULL(....), x,y, xm, ym, wm, hm)
  • map = Mapa de imagen (imagen fuente), cargada previamente en memoria.
  • x,y = posición en TILES donde vamos a PEGAR la captura de TILES.
  • xm,ym = posición en TILES del plano MAP de donde vamos a COPIAR TILES.
  • wm,hm = ancho x alto en TILES de la captura.

 

¿Cómo se hace el «copiar+pegar»? Cogemos un conjunto de tiles (wm x hm) de la posición (xm, ym) de la imagen fuente y dichas tiles se pegan en la posición (x, y) del plano elegido. En el caso que nos ocupa;

VDP_setMapEx(PLAN_B, bgd_image.map, TILE_ATTR_FULL(....), column_to_update, 0, column_to_update, 0, 1, 28);

Copia una columna entera (1,28) -> (224px = 28 tiles),  de la imagen fuente en la posición (column_to_update, 0), y la pega en la posición (column_to_update, 0) del plano B -> en la memoria del VDP.

VDP_setMapEx() nos permite pintar parte o toda una imagen, en el caso que nos ocupa un pequeño trozo, una columna de tiles. Lo ideal es hacerlo en la parte no visible, ya que la función tarda un pequeño tiempo en terminar (tiene que leer de la ROM) y de verse en pantalla visible, quedará mal al no ser instantáneo.

Antes de usar VDP_setMapEx() hemos de cargar la imagen fuente, ¿cómo lo hacemos?

VDP_setMapEx(PLAN_B, bgd_image.map, TILE_ATTR_FULL(PAL2, FALSE, FALSE, FALSE, tile_ind_imagen), column_to_update, 0, column_to_update, 0, 1, 28);
  • El sufijo .map se refiere a un MAPA de imagen, y no a la imagen en sí misma.
  • tile_ind_imagen se refiere a la zona de memoria donde hemos cargado la imagen.

Un mapa de imagen es una estructura del SGDK que guarda las dimensiones de una imagen, un puntero a toda la información de la imagen, la paleta y la imagen en sí misma.

El SGDK crea el mapa de imagen de forma automática en tiempo de compilación. Normalmente lo haremos así, pero esta manera no nos permite jugar con el mapa de memoria y sacarle el jugo al 100%. Podríamos por ejemplo cambiar el tileset referenciando a otra zona de memoria, con otros tiles, cambiando tile_ind_imagen.

No obstante al no ser algo relacionado con el scroll lo explicaré en una futura entrada.

 

EN DIRECCIÓN CONTRARIA

¿Y si en vez de pulsar derecha, pulsamos izquierda? Sonic se dará la vuelta pero … ¿y el scroll? ¿cómo lo hacemos funcionar?

Vamos a ver el código:

static void updatePhysic()
{
  [...]
  if (Xorder < 0) //PULSO IZQUIERDA
  {
    offset--; cuentaPixels--; if(cuentaPixels<-7) cuentaPixels=0;
    [...]
  }
    [...]

updateCamera();
}

Obviamente al pulsar izquierda hemos de disminuir el offset (offset–) y cuentaPixels debería ir bajando, la idea es replicar el cambio de tile en dirección contraria.

Por tanto cuando el valor sea (<-7), hay cambio de tile y reseteo cuentaPixels.

Ahora veamos como funciona updateCamera():

static void updateCamera()
{
  [...]
if(cuentaPixels==-1)
{
column_to_update = (((offset + 320) >> 3)+ 88) & 127;
// valor_tiles + 88 = nuevo_valor_tiles ===> para apuntar a la tile correcta a actualizar
VDP_setMapEx(PLAN_B, bg_image.map, TILE_ATTR_FULL(PAL0, FALSE, FALSE, FALSE, TILE_USERINDEX),
column_to_update, 0, column_to_update, 0, 1, 28);
}

//hacemos scroll
VDP_setHorizontalScroll(PLAN_B, -offset);

}

El único cambio reseñable está en la fórmula :
column_to_update = (((offset + 320) >> 3)+ 88) & 127;

¿Por qué 88? Recordamos que inicialmente (offset + 320) >> 3 = 40. Recordemos que la imagen tiene 128 tiles de ancho.

Inicialmente offset = 0 (se aplica la fórmula de pulsar derecha del apartado anterior), ahora si pulsamos izquierda, offset = -1, y la fórmula será la de pulsar izquierda:

(((-1 + 320) >> 3)+ 88) & 127 = (((319) >> 3)+ 88) & 127 = ((39+ 88) & 127 = 127

Es decir, cargaremos la última columna de tiles del plano completo, y lo pegaremos en el VDP en el tile 127, que no existe, pero el VDP hace rolling y lo traduce por 63.

A continuación VDP_setHorizontalScroll() moverá el plano de izquierda a derecha y mostrará el primer pixel de la columna 63 por la izquierda… pero esta columna de tiles ya no existe, ha sido machacada por la columna 127 del plano completo.

Y así sucesivamente. Lo vemos:

video25

 

Es un poco WTF pero funciona perfectamente. En realidad, es lógico, al movernos hacia la izquierda no tenemos margen para cargar tiles ya que deben aparecer inmediatamente en la zona visible, por tanto hemos de cargar dichos tiles primero y mover el scroll para verlos en ese mismo momento.

Espero no haberos confundido demasiado.

 

 

GITHUB

El código de esta lección, y de todas las demás, lo podrás encontrar en mi github:

https://github.com/danibusvlc/aventuras-en-megadrive