Écran ILI9488 & bibliothèque graphique
Le bus SPI et le pilote bas niveau de l'écran, puis la bibliothèque GFX bâtie par-dessus : tracé de lignes, cercles, texte bitmap et principe effacer / déplacer / redessiner.
L'écran ILI9488 et son pilote bas niveau
L'affichage est le cœur visible du jeu : c'est sur lui que se dessinent le vaisseau, les astéroïdes, les projectiles et le score. Pour le piloter, le programme s'appuie sur une bibliothèque bas niveau, ili9488.c/ili9488.h, qui parle directement au contrôleur de la dalle. Cette section décrit le matériel, le bus de communication, l'origine du code et les fonctions essentielles, en mettant en regard chaque choix avec l'idée directrice du projet : ménager un microcontrôleur lent et dépourvu de calcul flottant matériel.
L'écran ILI9488 et ses 8 bornes utilisées
L'écran retenu est une dalle TFT de 480 x 320 pixels en mode paysage, pilotée par le contrôleur ILI9488. La résolution native du module est 320 x 480 en portrait ; le programme la fait basculer en paysage par logiciel. La couleur est transmise au contrôleur en 18 bits, soit trois octets correspondant aux composantes Rouge, Vert et Bleu. La liaison entre le PIC16F18877 et l'écran est un bus SPI à sens unique : le microcontrôleur envoie des données vers l'écran (ligne MOSI), mais ne lit jamais en retour.
Au dos du module, treize bornes sont disponibles ; seules les huit premières sont utilisées ici, dans l'ordre : VCC (alimentation), GND (masse), SCLK (horloge SPI), MOSI (données), MISO (retour, présent mais non câblé côté programme), CS (sélection du composant), RES (reset) et DC (Data/Command). Les bornes BKL, SCL, SDA, INT et SDCS, situées au-delà de la huitième, ne servent pas dans ce projet : la dalle est commandée exclusivement par son interface SPI à quatre fils, sans recourir à l'éventuelle interface I2C ni au lecteur de carte micro-SD intégré.
Le câblage effectif entre les bornes de l'écran et le PIC est le suivant : CS sur RC0 (patte 15), RES sur RC1 (patte 16), DC sur RC2 (patte 17), SCLK sur RC3 (patte 18) et MOSI sur RC5 (patte 20). La ligne MISO de l'écran existe mais reste inutilisée, puisque le programme n'interroge jamais le contrôleur.
Le bus SPI et le rôle de chaque ligne
La communication s'effectue par le périphérique SPI1 (MSSP1) du PIC, configuré en mode Maître, SPI Mode 0. Le SPI est un bus synchrone : une horloge cadence l'échange et chaque front transfère un bit. Cinq lignes interviennent.
- SCK (horloge) : générée par le PIC, elle rythme la transmission. Chaque coup d'horloge fait avancer un bit. En mode 0, la donnée est échantillonnée sur le front montant, l'horloge étant au repos à l'état bas.
- MOSI (Master Out, Slave In) : transporte les données du PIC vers l'écran. C'est l'unique sens utile ici.
- CS (Chip Select) : sélectionne l'écran. Maintenue à l'état bas pendant un échange, elle indique au contrôleur que les bits qui défilent lui sont destinés.
- DC (Data/Command) : distingue une commande (DC = 0) d'une donnée (DC = 1). Le même octet sur MOSI sera interprété comme un registre à configurer ou comme un pixel à afficher selon l'état de DC.
- RST (Reset) : réinitialise le contrôleur au démarrage.
On ne relit jamais le contrôleur : cela simplifie le code et évite des temporisations coûteuses, au prix de l'impossibilité de vérifier l'état interne de la dalle. C'est un compromis assumé, cohérent avec le fil rouge du projet, qui privilégie la rapidité et la simplicité.
Origine de la bibliothèque
Le pilote bas niveau n'a pas été écrit intégralement par le binôme. Il provient d'une bibliothèque libre publiée par l'auteur timagr615, prévue à l'origine pour un microcontrôleur STM32 utilisant la couche d'abstraction matérielle HAL. Le professeur E. Nativel l'a portée vers l'environnement PIC/MCC : les appels HAL de STM32 ont été remplacés par les fonctions générées par MCC (MPLAB Code Configurator), notamment l'échange SPI et le pilotage des broches. Cette base, fournie par l'encadrant, constitue la couche ili9488.c/.h ; le travail du binôme a consisté à l'utiliser et à bâtir au-dessus la logique du jeu.
Les fonctions clés du pilote
Envoyer une commande ou une donnée
Tout repose sur deux briques : envoyer un octet en mode commande, ou en mode donnée. La différence tient uniquement à l'état de la ligne DC, le reste de la séquence étant identique : abaisser CS, transmettre l'octet par SPI, relever CS.
void ILI9488_SendCommand(uint8_t cmd) {
DC_SetLow(); // DC = 0 : l'octet est une commande
CS_SetLow(); // selection de l'ecran
SPI1_ExchangeByte(cmd); // emission de l'octet sur MOSI
CS_SetHigh(); // fin de l'echange
}
void ILI9488_SendData(uint8_t data) {
DC_SetHigh(); // DC = 1 : l'octet est une donnee
CS_SetLow();
SPI1_ExchangeByte(data);
CS_SetHigh();
}Ligne par ligne : DC_SetLow() / DC_SetHigh() positionnent la ligne Data/Command, ce qui dicte l'interprétation côté contrôleur. CS_SetLow() ouvre la transaction ; SPI1_ExchangeByte() pousse les huit bits sur MOSI (le PIC étant maître, c'est lui qui cadence l'horloge) ; CS_SetHigh() referme la transaction. On notera que SPI1_ExchangeByte est nominalement bidirectionnelle (elle renvoie aussi un octet reçu), mais sa valeur de retour est ici ignorée : le sens PIC vers écran est le seul exploité. Le choix d'encadrer chaque octet par CS rend les appels indépendants et robustes, au prix de quelques basculements de broche supplémentaires.
Initialiser le contrôleur
ILI9488_Init envoie la séquence d'initialisation recommandée par le fabricant. Elle enchaîne, via ILI9488_SendCommand puis ILI9488_SendData, plusieurs registres : les courbes de gamma positive et négative (0xE0 et 0xE1) qui ajustent le rendu des niveaux, les contrôles d'alimentation (Power Control 0xC0, 0xC1 et VCOM 0xC5), l'orientation mémoire MADCTL (0x36), et surtout le format de pixel (0x3A) réglé à 0x66 pour sélectionner le mode 18 bits par pixel. La séquence se termine par la sortie de veille SLPOUT puis l'allumage de l'affichage DISPON. Sans cette séquence, le contrôleur reste muet ou affiche du bruit : c'est elle qui amène la dalle dans un état connu et exploitable.
Définir la fenêtre d'adressage
Avant d'écrire des pixels, il faut indiquer au contrôleur dans quel rectangle de sa mémoire ils vont aller. C'est le rôle de setAddrWindow :
void setAddrWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1){
ILI9488_SendCommand(ILI9488_CASET);
ILI9488_SendData(x0 >> 8);
ILI9488_SendData(x0 & 0xFF);
ILI9488_SendData((x0+x1-1) >> 8);
ILI9488_SendData((x0+x1-1) & 0xFF);
ILI9488_SendCommand(ILI9488_PASET);
ILI9488_SendData(y0 >> 8);
ILI9488_SendData(y0 & 0xff);
ILI9488_SendData((y0+y1-1) >> 8);
ILI9488_SendData((y0+y1-1) & 0xff);
ILI9488_SendCommand(ILI9488_RAMWR);
}Le registre CASET (0x2A, Column Address Set) fixe la plage de colonnes ; PASET (0x2B, Page Address Set) celle des lignes. Comme les adresses tiennent sur 16 bits mais que le bus transmet des octets, chaque coordonnée est coupée en deux : x0 >> 8 extrait l'octet de poids fort par décalage de huit bits vers la droite, et x0 & 0xFF isole l'octet de poids faible par un masque (ET binaire avec 0xFF). On envoie donc poids fort puis poids faible. L'adresse de fin vaut x0 + x1 - 1, ce qui revient à interpréter le quatrième paramètre x1 comme une largeur (idem y1 comme une hauteur) : le pixel de fin est inclus, d'où le « -1 ». Enfin RAMWR (0x2C, Memory Write) signale que les octets suivants seront des données de pixels : toute donnée envoyée ensuite remplit la fenêtre, colonne après colonne, le contrôleur incrémentant tout seul l'adresse interne. Ce mécanisme de fenêtre est central pour l'optimisation du jeu : il permet de ne réécrire qu'un petit rectangle (un projectile, un astéroïde) sans toucher au reste de l'écran.
Convertir une couleur 16 bits en 18 bits
Le code de plus haut niveau manipule des couleurs au format RGB565 (16 bits : 5 bits de rouge, 6 de vert, 5 de bleu). Or le contrôleur attend ici 18 bits, soit trois octets pleins R, G, B (six bits utiles par composante). write16BitColor réalise la conversion par une règle de trois sur les plages respectives 31, 63, 31 :
void write16BitColor(uint16_t color){
uint8_t r = (color >> 11) & 0x1F; // 5 bits de rouge (0..31)
uint8_t g = (color >> 5) & 0x3F; // 6 bits de vert (0..63)
uint8_t b = color & 0x1F; // 5 bits de bleu (0..31)
ILI9488_SendData((r * 255) / 31); // remise a l'echelle sur 0..255
ILI9488_SendData((g * 255) / 63);
ILI9488_SendData((b * 255) / 31);
}Les décalages et masques extraient chaque composante à sa place dans le mot de 16 bits : le rouge occupe les bits 11 à 15, le vert les bits 5 à 10, le bleu les bits 0 à 4. La multiplication par 255 suivie d'une division par la plage d'origine (31 pour le rouge et le bleu, 63 pour le vert) étire chaque valeur sur un octet complet 0..255, le contrôleur n'en conservant que les six bits de poids fort. On garde des entiers du début à la fin : aucune opération flottante, conformément au fil rouge du projet.
setRotation(3) : passer en paysage 480 x 320
L'écran étant nativement portrait (320 x 480), le jeu force le mode paysage par setRotation(3). Cette fonction écrit dans le registre MADCTL (0x36, Memory Access Control), qui contrôle le sens de balayage et l'échange des axes X/Y. La valeur correspondant à l'orientation 3 inverse et échange les axes de sorte que l'origine se place dans le coin attendu et que l'espace utile devienne 480 x 320. Tout le reste du programme raisonne ensuite avec SCREEN_W = 480 et SCREEN_H = 320, place la bande de score sur une hauteur HUD_H = 24 en haut, et fixe le centre du jeu en (CX = 240, CY = 172), c'est-à-dire au milieu de la zone de jeu située sous le HUD.
Pourquoi 18 bits = 3 octets, et l'impact sur le débit SPI
En mode 18 bits, chaque pixel est décrit par six bits de rouge, six de vert et six de bleu. Comme le bus SPI transmet par paquets de huit bits, le contrôleur attend un octet par composante, soit trois octets par pixel (les deux bits inutiles de chaque octet sont ignorés). C'est plus simple à transmettre que de tasser 18 bits à cheval sur des octets, mais cela gaspille six bits sur vingt-quatre transmis.
Remplir l'écran entier représente 480 x 320 = 153 600 pixels, donc 153 600 x 3 = 460 800 octets à faire transiter sur MOSI, chacun nécessitant au moins huit coups d'horloge SPI, soit plus de 3,6 millions de bits pour un seul rafraîchissement complet. Sur un microcontrôleur cadencé à FOSC = 32 MHz (cycle instruction de 125 ns), repeindre tout l'écran à chaque tour de boucle serait prohibitif. C'est précisément ce qui justifie la stratégie de rendu du jeu : ne jamais repeindre tout l'écran en boucle, mais effacer puis redessiner uniquement les petits objets qui bougent, en s'appuyant sur la fenêtre d'adressage de setAddrWindow pour ne streamer que les quelques octets d'un projectile ou d'un astéroïde.
La bibliothèque graphique (GFX) et les fonctions de tracé
La partie précédente a décrit le pilote bas niveau ili9488.c, qui sait piloter l'écran ILI9488 sur le bus SPI : envoyer une commande (DC=0), envoyer une donnée (DC=1), ouvrir une fenêtre d'adressage (CASET 0x2A, PASET 0x2B, RAMWR 0x2C) et allumer un pixel. Ce pilote est cependant trop primitif pour écrire directement le jeu : il ne sait dessiner ni un cercle, ni un triangle, ni du texte. C'est le rôle de la couche intermédiaire GFX_Library.c/.h, portage de la célèbre bibliothèque libre Adafruit GFX, qui fournit les primitives géométriques de plus haut niveau.
Rôle de GFX_Library par-dessus le pilote ILI9488
La bibliothèque graphique se place exactement entre le code du jeu (main.c) et le pilote matériel (ili9488.c). Elle expose un jeu de fonctions display_* indépendantes du matériel : display_drawLine, display_drawRect, display_drawCircle et display_fillCircle, display_drawTriangle et display_fillTriangle, display_drawRoundRect, display_printText et display_drawChar, ou encore display_drawBitmap. L'idée fondamentale est la séparation des responsabilités : GFX raisonne en géométrie (un cercle de rayon r centré en (x0, y0)), puis traduit cette géométrie en une suite d'allumages de pixels, et délègue chaque pixel au pilote. Si l'on changeait un jour d'écran, il suffirait de réécrire le pilote ; la couche GFX et le jeu resteraient inchangés.
Cette traduction repose sur quelques macros qui font le pont vers le pilote : display_drawPixel renvoie vers drawPixel, display_fillScreen vers fillScreen, display_fillRect vers fillRect et display_setRotation vers setRotation. Toutes les primitives de GFX finissent donc, directement ou indirectement, par appeler drawPixel (allumage unitaire) ou fillRect (remplissage rapide d'un rectangle en streamant les octets RGB sur le SPI). Cette organisation est cruciale pour un microcontrôleur lent comme le PIC16F18877 : l'oscillateur interne HFINTOSC délivre FOSC = 32 MHz, soit une horloge d'instruction FOSC/4 = 8 MHz (Tcy = 125 ns). Chaque pixel coûte cher en transmission SPI (la couleur est envoyée sur 18 bits, soit 3 octets R, G, B), donc les algorithmes doivent être frugaux et n'allumer que les pixels strictement utiles.
Le tracé de ligne par l'algorithme de Bresenham
Tracer une droite entre deux points entiers (x0, y0) et (x1, y1) paraît trivial avec un calcul de pente y = a*x + b, mais cette approche imposerait une division et des multiplications flottantes pour chaque colonne. Sur un PIC16 dépourvu d'unité de calcul flottant matérielle, ce serait rédhibitoire. La fonction writeLine emploie donc l'algorithme de Bresenham, qui ne manipule que des entiers et une simple addition par pixel.
Le principe est le suivant. On suppose d'abord que la pente est faible (la ligne avance surtout horizontalement). On parcourt alors x de x0 à x1 en allumant un pixel par colonne. À chaque pas, on accumule l'écart vertical dans un terme d'erreur entier ; lorsque cet écart dépasse une demi-colonne, on incrémente y d'une unité et on retranche la largeur totale de l'erreur. Formellement, en posant dx = |x1 - x0| et dy = |y1 - y0|, l'erreur est initialisée à dx/2 puis décrémentée de dy à chaque colonne ; lorsqu'elle devient négative, on avance y et on lui rajoute dx. Aucun flottant n'intervient : la décision se prend uniquement sur le signe d'un entier.
Pour traiter une ligne de forte pente (presque verticale), l'algorithme utilise un indicateur de pente raide, le drapeau steep. Si |y1 - y0| dépasse |x1 - x0|, on échange mentalement les rôles de x et de y (on échange x0 avec y0, x1 avec y1), de sorte que la boucle principale itère toujours sur l'axe le plus long. Au moment d'allumer le pixel, on inverse de nouveau les coordonnées pour steep, ce qui revient à refléter la ligne par la première bissectrice. On garantit ainsi qu'il y a exactement un pixel allumé par valeur de l'axe dominant, sans trou ni doublon. Cet algorithme est exactement celui qui trace les trois arêtes du vaisseau triangulaire (display_drawTriangle) et les huit côtés de l'octogone des astéroïdes.
Deux cas particuliers très fréquents bénéficient d'un chemin rapide dédié : les lignes parfaitement horizontales et verticales. drawFastHLine et drawFastVLine court-circuitent Bresenham car la coordonnée constante ne demande aucun calcul d'erreur ; elles remplissent un segment d'un coup, ce qui est nettement plus efficace. Ce sont elles qui dessinent par exemple la bande de score (HUD, de hauteur HUD_H = 24 pixels) en haut de l'écran et, comme on va le voir, le remplissage des cercles.
Le cercle plein par l'algorithme du point milieu
Les projectiles tirés par le vaisseau (touche TIR, bouton B sur RD6, patte 29) sont de petits disques de rayon BULLET_RADIUS = 2 pixels. Ils sont dessinés par display_fillCircle, dont voici le code :
void display_fillCircle(uint16_t x0, uint16_t y0, uint16_t r, uint16_t color){
drawFastVLine(x0, y0-r, 2*r+1, color);
display_fillCircleHelper(x0, y0, r, 3, 0, color);
}Analysons ce code ligne par ligne. La première ligne, drawFastVLine(x0, y0-r, 2*r+1, color), trace le diamètre vertical du cercle : un segment vertical qui part du sommet (y0 - r) et descend sur 2*r + 1 pixels de haut, c'est-à-dire le diamètre complet (r pixels au-dessus du centre, r en dessous, plus le pixel du centre). On profite ici du chemin rapide vertical de la section précédente pour ce trait central, plutôt que de l'allumer pixel par pixel. La seconde ligne, display_fillCircleHelper(x0, y0, r, 3, 0, color), remplit les deux moitiés latérales du disque autour de ce diamètre. Le paramètre 3 est un masque de quadrants : ses deux bits actifs (binaire 11) désignent simultanément les côtés droit et gauche, ce qui produit un disque complet. Le 0 final est un décalage (delta) ajouté aux segments, inutile ici pour un cercle plein mais essentiel pour dessiner les coins de rectangles arrondis (display_drawRoundRect), où le même helper sert à tracer un quart de disque écarté du centre.
Le remplissage repose sur l'algorithme du point milieu, qui calcule le contour du cercle uniquement avec des entiers, sans aucune racine carrée ni sinus. Il s'appuie sur trois variables entières : un terme de décision f, et deux incréments ddF_x et ddF_y. On part du sommet du cercle et l'on avance d'un pixel à la fois sur l'axe x ; le signe de f indique s'il faut aussi descendre d'un pixel sur l'axe y pour rester au plus près du cercle idéal d'équation x^2 + y^2 = r^2. À chaque itération, f est mis à jour par de simples additions de ddF_x et ddF_y (typiquement ddF_x augmente de 2 et ddF_y de 2 à chaque pas). Ce schéma évite totalement l'évaluation de sqrt et de fonctions trigonométriques, conformément au fil rouge du projet : on ménage un microcontrôleur lent en bannissant le flottant.
L'autre astuce de l'algorithme est l'exploitation de la symétrie en huit octants : un cercle est symétrique par rapport à ses deux axes et à ses deux diagonales. Il suffit donc de calculer un seul huitième du contour, puis de reporter chaque point calculé dans les sept autres octants par simple changement de signe et échange de coordonnées. Pour le remplissage, le helper ne dessine pas des points isolés mais des segments verticaux (à l'aide de drawFastVLine) reliant les points symétriques de haut en bas : on balaie ainsi la surface du disque colonne par colonne. C'est ce mécanisme qui produit les projectiles à l'écran. L'octogone des astéroïdes, lui, n'utilise pas ce cercle plein : il est formé de huit sommets précalculés astX[8] = {10,7,0,-7,-10,-7,0,7} et astY[8] = {0,7,10,7,0,-7,-10,-7} (rayon 10, mis à l'échelle par AST_RADIUS/10 = 12/10), reliés par les segments de Bresenham.
Le texte : police bitmap 5x7
L'affichage du score (+10 par astéroïde détruit), des libellés du menu et du compte à rebours passe par display_printText, qui appelle display_drawChar pour chaque caractère de la chaîne. La police est une police bitmap stockée dans le tableau font[256][5], indexé par le code ASCII du caractère. Chaque caractère occupe 5 colonnes ; chaque colonne est un octet de 8 bits dont les bits à 1 indiquent les pixels à allumer de haut en bas. On obtient une matrice de 5 pixels de large sur 7 utiles (la police est dite 5x7), la huitième ligne servant d'espacement vertical.
display_drawChar lit donc les 5 octets du caractère voulu, puis, pour chaque colonne et chaque bit, allume ou non le pixel correspondant. L'agrandissement est géré par un paramètre de taille size : au lieu d'allumer un pixel unitaire, la fonction dessine un petit carré plein de size x size pixels par display_fillRect. Ainsi, un texte de taille 2 occupe quatre fois plus de surface qu'un texte de taille 1, sans qu'il faille stocker une seconde police : un seul jeu de données 5x7 suffit pour toutes les tailles, ce qui économise la mémoire programme du PIC.
Les bibliothèques C standard utilisées par le jeu
Le fichier main.c inclut un ensemble restreint de bibliothèques standard, chacune choisie pour un usage précis et compatible avec les contraintes du microcontrôleur :
<stdint.h>fournit les types entiers de taille fixe (uint8_t,uint16_t,int8_t). Les utiliser plutôt queintpermet de dimensionner au plus juste chaque variable et donc d'économiser la RAM, ressource rare sur un PIC16 8 bits : un compteur qui ne dépasse jamais 255 tient dans un seul octet.<stdbool.h>apporte le typeboolet les valeurstrue/false, qui rendent le code lisible pour les drapeaux d'état (objet actif ou non, touche pressée ou non).<stdlib.h>est inclus pourrand(), générateur de nombres pseudo-aléatoires qui sert à faire surgir les astéroïdes depuis des bords et avec des trajectoires variables.<stdio.h>est inclus poursprintf, qui fabrique les chaînes de caractères du score (conversion d'un entier en texte avant l'affichage pardisplay_printText).<math.h>fournitsqrtfet la constanteM_PI. Conformément au fil rouge, ces outils flottants sont évités autant que possible pendant le jeu ; lorsqu'une distance doit être testée (collision projectile-astéroïde ou astéroïde-vaisseau), on compare des distances au carré pour ne pas appelersqrtf.- enfin
mcc_generated_files/mcc.hrassemble les fonctions générées par MCC (MPLAB Code Configurator) :SYSTEM_Initialize, les fonctionsADCC_*pour la lecture du joystick,SPI1_Openpour ouvrir le bus de l'écran, ainsi que les macros d'accès aux broches. C'est l'interface vers les périphériques matériels.
Le principe effacer / déplacer / redessiner
Toutes ces primitives ne servent leur but que si le rendu reste fluide sur un matériel lent. Comme l'écran TFT n'a pas de double tampon et que chaque pixel envoyé coûte trois octets sur le SPI, le jeu n'efface jamais l'écran entier à chaque image. Il applique au contraire un cycle local pour chaque objet mobile (vaisseau, projectiles, astéroïdes, étoiles de fond) : on efface l'objet à son ancienne position (en le redessinant avec la couleur du fond), on met à jour ses coordonnées, puis on le redessine à sa nouvelle position. Seuls les quelques pixels réellement modifiés transitent sur le bus, ce qui maintient une cadence acceptable.
Ce pipeline incrémental boucle la présentation de la couche graphique : la bibliothèque GFX fournit des primitives géométriques économes (lignes de Bresenham, cercles par le point milieu, texte bitmap réutilisant une seule police), le pilote ILI9488 les transforme en octets SPI, et le code du jeu orchestre le tout en ne redessinant que ce qui change. L'ensemble illustre la philosophie constante du projet : produire un jeu réactif tout en ménageant un microcontrôleur 8 bits sans calcul flottant matériel.