Développement GameBoy #4 : Afficher des images

Après trois premiers articles qui nous ont permis de nous familiariser avec l'écriture de programmes pour la GameBoy, on va enfin passer aux choses sérieuses : les graphismes. C'était bien sympa de faire des jeux en mode texte, mais ça nous limite vite dans nos réalisations.

Étant donné que le système vidéo de la GameBoy est un sujet assez vaste et central pour la création de jeu, je ne vais pas pouvoir en faire le tour en un seul article. Ce premier article sera donc une entrée en matière plus théorique histoire de bien poser les bases et les suivants seront un peu plus orientés pratique, mais promis je montrerai quand même comment afficher une image à la fin de l'article, il ne faudrait pas que le titre soit mensonger non plus. 😉️

Note

Cet article fait partie d'une série sur le développement GameBoy en C avec le compilateur SDCC et la bibliothèque gbdk-n. Cette série est toujours en cours et de nouveaux articles paraissent de temps à autre.

Articles de la série :

L'écran de la GameBoy

Avant toute chose, on va jeter un coup d'œil à l'écran de la GameBoy. Il s'agit d'un écran d'une définition de 160×144 pixels pouvant afficher 4 niveaux de « gris » (enfin niveau de jaune / vert / bleu serait plus juste dans le cas de la toute première GameBoy).

Jusque-là, rien de bien perturbant par rapport à ce que l'on a l'habitude de voir sur nos machines modernes (à part que l'on dispose de plus de couleurs et de pixels)... mais en fait ce n'est pas tout à fait aussi simple : les pixels de l'écran ne sont pas adressables. Cela signifie que l'on ne peut pas juste changer la couleur d'un seul pixel de l'écran, on n'y a tout simplement pas accès. « Mais alors comment qu'on affiche des images sur l'écran si on ne peut pas toucher aux pixels » me direz-vous ? Eh bien c'est très simple, l'écran est divisé en un quadrillage de 20×18 « cases » que l'on appelle des tuiles (ou tiles en anglais). Chaque tuile est un carré de 8×8 pixels :

Détail de l'écran de la GameBoy

Pour afficher une image, il faudra donc la découper en petits morceaux que l'on enverra dans la mémoire vidéo de la console, et il faudra ensuite expliquer à la GameBoy où afficher chacun de ces morceaux. Ce qui est pratique avec cette manière de faire, c'est qu'on peut réutiliser une tuile à plusieurs endroits à la fois, ce qui nous fait économiser de la mémoire.

Un affichage composé de plusieurs couches

L'image affichée sur l'écran de la GameBoy est issue de la composition de plusieurs couches :

  • La couche Background est une couche composée d'une grille de 32×32 tuiles (oui c'est plus grand que l'écran), et est scrollable.
  • La couche Window est affichée par-dessus la couche background, et est également composé d'une grille de tuiles. Cette couche n'est pas scrollable mais peut être déplacée à l'écran afin de ne recouvrir que partiellement la couche background.
  • Enfin, il y a les sprites. Les sprites sont des objets composés de 1 ou 2 tuiles (8×8 ou 8×16 pixels) et peuvent être positionnés librement à l'écran (au pixel près, en dehors de toute grille). Leur utilisation est cependant soumise à un certain nombre de restrictions dont nous reparlerons dans l'article qui leur sera dédié.
Les différentes couches d'affichage de la GameBoy

Note

NOTE : il est également possible d'afficher des sprites sous la couche background, mais on verra également ça le moment venu. 😉️

La mémoire vidéo

La mémoire vidéo (VRAM) de la GameBoy est divisée essentiellement en 3 plages contenant chacune une partie des informations nécessaires à l'affichage :

  • la zone que l'on nomme Tile Data sert à stocker les données des tuiles, c'est à dire les images bitmap en elles-mêmes.
  • la zone Background Maps sert à stocker le placement des tuiles à l'écran (par exemple, la tuile numéro 01 doit être affichée dans la case ayant pour coordonnées (0, 0)).
  • enfin la zone OAM, pour Object Attribute Memory (parfois appelée Sprite Attribute Table), stocke toutes les informations liées à chaque sprite (de quelle(s) tuile(s) il est composé, sa position, la palette de couleur à utiliser, etc).

La partie qui nous intéresse le plus pour le moment est la plage de la mémoire vidéo contenant les données des tuiles (Tiles Data), car c'est avec elle qu'on va travailler le plus directement et qui va nous apporter le plus de contraintes. Voici donc une représentation de cette zone mémoire (chaque case correspond à 16 octets, soit la taille des données d'une tuile de 8×8 pixels) :

Apperçu de la mémoire vidéo (VRAM) de la GameBoy

On peut voir dans le schéma ci-dessus que j'ai divisé cette zone de la mémoire en 3 parties :

  • une zone en bas qui stocke les tuiles pouvant être utilisés par les couches Background et Window,
  • une zone en haut qui stocke les tuiles pouvant être utilisées par les sprites,
  • et une zone au milieu permettant de stocker des tuiles utilisables à la fois par les sprites et les couches Background et Window.

On peut donc utiliser au maximum 255 tuiles différents sur chacune des couches, et si on utilise 255 tuiles pour les couches Background et Window, il nous reste seulement 128 tuiles utilisables pour les sprites.

On oublie le mode texte

Tant qu'on parle de mémoire, je vous informe qu'il va falloir oublier l'idée d'utiliser le mode texte dans un programme graphique. Fini donc la fonction printf(), les bibliothèques <stdio.h>, <gb/console.h> et compagnie : elles ne feront pas bon ménage avec votre programme.

Pour afficher du texte, les bibliothèques susnommées stockent en effet les caractères affichables sous forme de tuiles dans la mémoire vidéo. Voici une représentation de la mémoire vidéo lorsque ces bibliothèques sont chargées :

Occupation de la mémoire vidéo par la police d'écriture

On se rend vite compte que toute la place est occupée par les caractères et qu'on ne peut plus trop rajouter les images de notre propre jeu. Alors bien sur, il y a plein de caractères qu'on ne va pas utiliser et qu'on pourrait remplacer, mais c'est plus simple dans ce cas de placer seulement ceux dont on a besoin en mémoire et d'y faire appel nous-même plutôt que de jongler avec la bibliothèque <stdio.h> (en plus vous gagnerez pas mal de place dans votre ROM).

Afficher une image

Après tout ce que l'on vient de voir, on pourrait se dire que c'est quand même assez compliqué d'afficher une image... mais il ne faut pas se décourager, lisez l'exemple ci-dessous, vous verrez que c'est plutôt simple en réalité. ;)

Pour afficher des tuiles à l'écran, il nous faut commencer par les dessiner. N'importe quel éditeur d'image peut faire l'affaire. Comme j'ai la flemme de dessiner de nouvelles tuiles, je vais reprendre celles que j'avais faites pour mon Snake il y a quelques années :

Tileset issue d'un de mes vieux projets

Il faut ensuite convertir cette image sous forme de code C, dans un format spécifique à la GameBoy... Comme on abordera ce sujet dans le prochain article, je vous donne directement le résultat ci-dessous :

const UINT8 TILESET[] = {
    // Tile 00: Blank
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    // Tile 01: Block
    0xff, 0x01, 0x81, 0x7f, 0xbd, 0x7f, 0xa5, 0x7b,
    0xa5, 0x7b, 0xbd, 0x63, 0x81, 0x7f, 0xff, 0xff,
    // Tile 02: Snake Body
    0x7e, 0x00, 0x81, 0x7f, 0x81, 0x7f, 0x81, 0x7f,
    0x81, 0x7f, 0x81, 0x7f, 0x81, 0x7f, 0x7e, 0x7e,
    // Tile 03: Boulder
    0x3c, 0x00, 0x54, 0x2a, 0xa3, 0x5f, 0xc1, 0x3f,
    0x83, 0x7f, 0xc5, 0x3f, 0x2a, 0x7e, 0x3c, 0x3c,
    // Tile 04: Cherry
    0x04, 0x04, 0x04, 0x04, 0x0a, 0x0a, 0x12, 0x12,
    0x66, 0x00, 0x99, 0x77, 0x99, 0x77, 0x66, 0x66,
};

Il nous faut maintenant charger ces tuiles dans la mémoire vidéo :

set_bkg_data(0, 5, TILESET);
  • Le premier paramètre correspond à la case mémoire à partir de laquelle on va placer les tuiles (ici on a mis 0, donc notre première tuile sera dans la case 0, la seconde dans la case 1, et ainsi de suite).
  • Le second paramètre est le nombre de tuiles à copier dans la mémoire vidéo. On met donc 5 comme c'est le nombre de tuiles présentes dans notre tileset.
  • Enfin, le dernier paramètre est un pointeur vers les données des tuiles.

Après cette étape, notre mémoire vidéo ressemble à ça :

Représentation des tiles dans la mémoire vidéo

Il ne nous reste plus qu'à indiquer à la GameBoy où afficher les tuiles... Si on veut par exemple représenter une scène du célèbre jeu Snake (je prends cet exemple totalement au hasard hein, pas parce que c'est les seules tiles que j'ai sous la main 🤫️), on fait un grand tableau avec les numéros des tuiles à afficher :

Tilemap de la scène de Snake

Une fois converti sous forme de code, notre tableau devient :

const UINT8 TILEMAP[] = {
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 2, 2, 2, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
};

Il ne nous reste plus qu'à copier tout ça dans la mémoire vidéo :

set_bkg_tiles(0, 0, 20, 18, TILEMAP);
  • Les deux premiers paramètres sont les coordonnées (x, y) où sera collée notre tilemap. Ici, (0, 0) correspond au coin en haut à gauche de l'écran.
  • Les deux paramètres suivants correspondent aux dimensions de notre tilemap, dans le cas présent, la tilemap fait 20×18 tiles.
  • Enfin, le dernier paramètre est un pointeur vers les données de la tilemap.

Il ne nous reste plus qu'une dernière étape afin de rendre notre image visible à l'écran : il faut dire à la GameBoy qu'elle doit afficher la couche Background (elle est masquée par défaut). Pour ce faire, on appelle la macro suivante :

SHOW_BKG;

Voici donc le code final pour afficher notre magnifique image de serpent croqueur de cerises :

#include <gb/gb.h>

const UINT8 TILESET[] = {
    // Tile 00: Blank
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    // Tile 01: Block
    0xff, 0x01, 0x81, 0x7f, 0xbd, 0x7f, 0xa5, 0x7b,
    0xa5, 0x7b, 0xbd, 0x63, 0x81, 0x7f, 0xff, 0xff,
    // Tile 02: Snake Body
    0x7e, 0x00, 0x81, 0x7f, 0x81, 0x7f, 0x81, 0x7f,
    0x81, 0x7f, 0x81, 0x7f, 0x81, 0x7f, 0x7e, 0x7e,
    // Tile 03: Boulder
    0x3c, 0x00, 0x54, 0x2a, 0xa3, 0x5f, 0xc1, 0x3f,
    0x83, 0x7f, 0xc5, 0x3f, 0x2a, 0x7e, 0x3c, 0x3c,
    // Tile 04: Cherry
    0x04, 0x04, 0x04, 0x04, 0x0a, 0x0a, 0x12, 0x12,
    0x66, 0x00, 0x99, 0x77, 0x99, 0x77, 0x66, 0x66,
};

const UINT8 TILEMAP[] = {
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 2, 2, 2, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
};

void main(void) {
    set_bkg_data(0, 5, TILESET);
    set_bkg_tiles(0, 0, 20, 18, TILEMAP);
    SHOW_BKG;
}

Et voici le résultat une fois ce code compilé et exécuté dans un émulateur :

Capture d'écran du Snake dans un émulateur GameBoy

Vous retrouverez cet exemple sur Github à l'adresse suivante :

Ceci conclut notre première immersion dans le système vidéo de la GameBoy, et je vous retrouve très bientôt pour un prochain article qui détaillera le format d'image utilisé par la GameBoy, et comment convertir ses propres images en tuiles utilisables par vos futurs jeux.