Programmer un contrôleur pour écran VGA avec une carte de développement FPGA

Étude du protocole et premiers exercices

f-leb

VGA désigne à la fois un protocole d’affichage vidéo, un format d’affichage (comme le format 640x480 pixels) et le fameux connecteur 15 broches à trois rangées que tout le monde connaît.

Certes, le pauvre VGA et ses composantes analogiques RGB (Red, Green, Blue) est supplanté progressivement par de nouvelles interfaces vidéo (et audio) numériques comme HDMI ou DisplayPort, avec des flux vidéo/audio toujours plus rapides pour un meilleur confort utilisateur. Cependant, VGA reste le standard historique et on le trouve encore sur de nombreux équipements vidéo comme les moniteurs, téléviseurs, vidéoprojecteurs, mini-PC, etc. pour garder la compatibilité avec des équipements plus anciens.

Dans les avantages restant au VGA, sa simplicité : trois broches pour les signaux RGB analogiques et deux broches de synchronisation horizontale et verticale seront utilisées seulement. Pour autant, afin de rafraîchir un écran 60 fois par seconde à la résolution standard de 640x480 pixels, il faut piloter en parallèle tous ces signaux à une fréquence proche de 25 MHz, ce qui justifie l’emploi d’un FPGA comme contrôleur.

L’objectif de ce tutoriel est donc de vous montrer comment programmer une cible FPGA en langage Verilog et transformer ainsi votre carte FPGA en contrôleur VGA, capable de produire des images et des animations (60 images par seconde) au format 640x480 pixels.

Commentez

- Titre : Programmer un contrôleur pour écran VGA avec une carte de développement FPGA

- Auteur : f-leb

- Parution : 16 septembre 2022

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2022 f-leb. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Voir également :

- FPGA : contributions personnelles

- Débuter avec les FPGA dans l’environnement Intel Quartus Prime

- FPGA : tutoriel pour s’initier au langage Verilog

- Description d'un afficheur 7-segments en langage Verilog

- Découvrir le langage Lucid avec une carte de développement FPGA d’Alchitry

- Introduction au langage VHDL

Sommaire

- I. Les signaux du port VGA

- I-A. Génération des signaux analogiques

- I-B. Processus de balayage (scan) et signaux de synchronisation

- II. Ressources utilisées

- III. Programmation FPGA

- III-A. Module de génération des signaux

- III-B. Un premier dessin

- III-C. Exercice 1 : dessiner une mire

- III-D. Une première animation

- III-E. Animation d’un sprite stocké en ROM

- III-E-1. Préparation du fichier d’initialisation de la ROM

- III-E-2. Configuration de la ROM dans Quartus Prime

- III-E-3. Description du projet

- III-E-4. Résultat

- III-F. Exercice 2 : sprite formé de deux images

- IV. Conclusion

- V. Annexe : archives Quartus Prime

I - Les signaux du port VGA

Le rôle d’un contrôleur VGA est de générer les signaux nécessaires qui, à l’époque des écrans et moniteurs à tube cathodique (ou CRT), permettaient de gérer les mouvements de balayage du spot en sortie du canon à électrons ainsi que l’intensité des faisceaux rouge, vert et bleu déterminant la couleur du point. Les écrans et moniteurs modernes n’utilisent évidemment plus cette technologie et sont passés à l’ère du numérique, mais on y trouve encore des ports vidéo VGA pour émuler ce balayage.

Port VGA DE-15 - domaine public

Il y a au moins quatre versions de ce connecteur 15 broches DE-15 avec des signaux maintenant obsolètes. Les versions les plus récentes (DDC2 et ultérieures) permettent même la communication bidirectionnelle avec un ordinateur.

Câble et connecteur femelle VGA (DE-15)

Dans ce tutoriel, nous détaillerons uniquement les 5 signaux utiles a minima pour générer des images (avec un brochage commun à toutes les versions), mais vous trouverez le brochage avec les explications détaillées dans le document VGA connectors pinouts.

- Les broches (1), (2) et (3) permettent de définir respectivement les composantes rouge (RED), verte (GREEN) et bleue (BLUE) du pixel en cours. Les signaux sur ces broches sont analogiques et doivent évoluer entre 0 V (composante de couleur éteinte) et 0,7 V (composante de couleur illuminée au maximum). Pour éviter le bruit sur les signaux analogiques, ceux-ci sont transmis au moyen de câbles coaxiaux dans les câbles VGA de qualité. Pour la composante rouge par exemple, le signal utile est égal à la différence de tension entre les conducteurs (1) et (6) (RED Ground). Ce sera entre (2) et (7) (GREEN Ground) pour la composante verte, puis entre (3) et (8) (BLUE Ground) pour la composante bleue.

- Les broches (13) (synchronisation horizontale HSYNC) et (14) (synchronisation verticale VSYNC) sont pilotées avec des signaux numériques (à l’état logique haut ou bas). Il s’agit avec ces signaux d’organiser le balayage (scan) des pixels (de gauche à droite, puis du haut vers le bas de l’écran) en le synchronisant avec un signal d’horloge.

I-A - Génération des signaux analogiques

Pour générer les signaux analogiques à partir d’un microcontrôleur ou d’un FPGA, il est plus simple de passer par trois convertisseurs numérique-analogique (ou DAC pour Digital-Analog Converter) fonctionnant en parallèle. Pour interfacer le FPGA avec le moniteur VGA, un module comme celui de Digilent fait très bien l’affaire :

Module Digilent PmodVGA

Ce module comporte 4 broches numériques par composante de couleur R, G ou B :

- R3, R2, R1 et R0 pour la composante rouge (Red) ;

- G3, G2, G1 et G0 pour la composante verte (Green) ;

- B3, B2, B1 et B0 pour la composante bleue (Blue).

Chaque composante de couleur est donc définie avec 4 bits et le convertisseur numérique-analogique associé (un simple réseau de résistances R-2R gravé en surface du module PmodVGA) pourra générer 2^4 = 16 niveaux de tension entre 0 et 0,7 V, soit une profondeur de couleurs 12 bits (RGB444). On est loin des performances à haute résolution des convertisseurs des RAMDAC 24 bits qui équipaient les cartes VGA, mais vous avez là un composant à moins de 20 € et suffisant pour du prototypage rapide (et les adeptes du fer à souder pourront aussi bien faire leur petite carte, et pour moins cher encore).

Simulation sur circuitlab.com du fonctionnement du convertisseur numérique-analogique du module PmodVGA avec un réseau de résistances R-2R.

Sur la copie d’écran de la simulation ci-dessus, la composante de couleur sur 4 bits est définie par la position des interrupteurs SWx. Avec les quatre interrupteurs fermés (soit la valeur binaire 1111), la tension produite est de 0,674 V en théorie, soit un peu moins du 0,7 V requis pour la composante illuminée au maximum.

I-B - Processus de balayage (scan) et signaux de synchronisation

Deux broches supplémentaires HS et VS du module PmodVGA permettent de diriger les signaux de synchronisation horizontale et verticale vers le port VGA.

Ces signaux organisent le processus de balayage du spot, pixel par pixel, pour rafraîchir l’image de l’écran. Le balayage commence depuis le coin supérieur gauche de la surface en suivant la ligne de gauche à droite. En fin de ligne, on continue le balayage en reprenant au début de la ligne suivante, et ainsi de suite jusqu’en bas de la surface. Une fois toute la surface parcourue, le processus reprend à nouveau pour rafraîchir l’image. Pour une animation vidéo fluide, le balayage est cadencé par une horloge à une fréquence suffisamment rapide pour rafraîchir une image complète soixante fois par seconde.

Mouvements de balayage - Image Wikimedia Commons (domaine public)

Mais à l’époque des écrans à tube cathodique CRT, il fallait aussi laisser le temps nécessaire à l’électronique embarquée pour repositionner le spot du faisceau en début de ligne suivante ou de le retourner à sa position initiale à la fin du balayage (les flèches en pointillés Horizontal retrace et Vertical retrace sur la figure précédente).

Dans le protocole VGA (défini par la norme VESA(1)), on peut prendre en compte ces délais matériels en étendant la zone d’affichage avec des pixels fictifs (qui ne seront pas allumés). Ainsi, pour une surface vidéo active de 640 x 480 pixels, tout se passe au niveau du minutage comme si l’on balayait continuellement une surface équivalente étendue à 800 x 525 pixels. Les zones fictives balayées au taux de rafraîchissement de 60 Hz autour de la zone active donnaient alors du temps au système matériel pour repositionner le spot du faisceau.

Ce protocole est toujours conservé sur les écrans modernes, même si ce temps supplémentaire peut maintenant servir à d’autres choses comme transporter des données.

Pour illustrer le protocole, on donne la représentation ci-dessous :

On y voit :

- au centre, la zone effective d’affichage de l’image (Active Pixels, drawing area) de 640 x 480 pixels. Dans cette zone, il faut présenter les signaux RGB suivant un minutage bien précis pour allumer le pixel en cours durant la phase de balayage ;

- des zones tampons Front porch et Back porch, qui entourent la zone effective d’affichage, où les signaux RGB sont à zéro. Comme vu précédemment, ces zones permettaient de temporiser pour gérer les mouvements de retour du spot. Ces temporisations doivent avoir une durée définie par la norme, qui peut être exprimée de façon équivalente en nombre de pixels balayés ;

- des zones de synchronisation horizontale et verticale dans lesquelles doivent être générés les signaux HSYNC et VSYNC. Une impulsion du signal HSYNC indique la fin d’une ligne et prépare au balayage de la ligne suivante. Une impulsion du signal VSYNC indique la fin du balayage de toute la zone et prépare à un nouveau balayage en reprenant au coin supérieur gauche. Ces impulsions ont aussi une durée bien définie par la norme, qui peut également être exprimée de façon équivalente en nombre de pixels balayés.

Si le taux de rafraîchissement de l’écran est de 60 Hz, avec une surface équivalente étendue à 800 x 525 pixels, chaque pixel doit être traité en 1 / (60 x 800 x 525) ≈ 39,7 ns (nanosecondes). Une horloge à une fréquence de 1 / 39,7.10-9 ≈ 25,2 MHz est donc nécessaire pour cadencer le processus de balayage pixel par pixel.

Dans le tableau ci-dessous(2), on indique les largeurs et hauteurs des différentes zones en nombre équivalent de pixels parcourus (verticalement, en nombre de lignes avec 1 ligne = 800 pixels).

Parameter

Horizontal

Vertical

Active Pixels

640

480

Front Porch

16

10

Back Porch

48

33

Sync Width

96

2

Total Pixels

800

525

Par exemple :

- la largeur de la bande Horizontal Back Porch définie par la norme est de 48 pixels, soit une temporisation de 48 x 39,7 ns = 1,91 μs avant de commencer à dessiner la ligne ;

- la hauteur de la bande Vertical Back Porch définie par la norme est de 33 lignes, soit une temporisation de 33 x 800 x 39,7 ns = 1,05 ms avant d’arriver au niveau de la première ligne de l’écran ;

- la largeur de la bande Horyzontal Sync définie par la norme est de 96 pixels, ce qui détermine la largeur de l’impulsion du signal de synchronisation : 96 x 39,7 ns = 3,81 μs ;

- etc.

La figure ci-dessous illustre les chronogrammes, en rouge, des signaux de synchronisation horizontale et verticale suivant le timing défini par la norme (en logique négative : signal à l’état haut par défaut, et qui bascule à l’état bas durant l’impulsion). Prenez garde à bien vérifier les unités de temps, le dessin n’est pas à l’échelle :

En pratique, on décale cette représentation de sorte à faire coïncider les coins de la zone d’affichage active avec les coordonnées (0, 0) et (639, 479) :

II - Ressources utilisées

On récapitule ici l’environnement matériel et logiciel utilisé dans ce tutoriel :

- Kit Terasic DE0-Nano (SoC FPGA Intel Cyclone IV). Ce kit a déjà été présenté dans le précédent tutoriel : Débuter avec les FPGA dans l’environnement Intel Quartus Prime.

- Les différents projets de ce tutoriel ont été développés dans l’environnement Quartus Prime d’Intel (version Lite).

Les fichiers archivés des démonstrations qui suivent, à importer directement dans Quartus Prime, sont donnés en annexe.

- Le module PmodVGA de Digilent fait l’interface entre la carte FPGA et le port VGA de votre écran.
Il comprend 14 broches d’entrée, dont 4 broches pour chaque composante Rouge-Vert-Bleu, soit 4 bits par composante ce qui confère au module une profondeur de 16 x 16 x 16 = 4096 couleurs. Il reste 2 broches HS et VS pour les signaux de synchronisation horizontale et verticale. La conversion numérique-analogique est effectuée avec un simple réseau de résistances R-2R en conjonction avec la résistance de terminaison standard de 75 Ω de l’écran VGA. Ce module peut transporter les signaux jusqu’à une fréquence de balayage pixel par pixel de 150 MHz.

- Quelques fils de connexion femelle-femelle pour relier les sorties de la carte FPGA aux entrées du module PmodVGA.

- Un câble VGA mâle-mâle.

- Un écran avec une entrée VGA, cela va de soi.

III - Programmation FPGA

III-A - Module de génération des signaux

On commence par programmer le module vga_sync qui va générer les signaux nécessaires au balayage de l’écran :

Les entrées de ce module sont :

- un signal d’horloge clk25 à 25,2 MHz, fréquence de balayage de l’écran pixel par pixel ;

- un signal rst Reset de réinitialisation, actif à l’état bas, et qui pourra être activé sur appui d’un bouton-poussoir intégré en surface de la carte.

En sortie :

- les signaux logiques de synchronisation horizontale et verticale hsync et vsync ;

- les coordonnées du point en cours de la surface balayée x (entre 0 et 799) et y (entre 0 et 524) sur 10 bits évoluant selon le sens de balayage à la fréquence de l’horloge 25,2 MHz ;

- un signal logique inDisplayArea à l’état haut lorsque les coordonnées du pixel en cours sont dans la zone active d’affichage (x entre 0 et 639, et y entre 0 et 479) et à l’état bas sinon.

Le code commenté du module Verilog ci-dessous s’appuie sur le protocole VGA décrit plus haut :

vga_sync.v

module vga_sync
#(
parameter hpixels = 800,     // nombre de pixels par ligne
parameter vlines = 525,     // nombre de lignes image
parameter hpulse = 96,         // largeur d'impulsion du signal HSYNC
parameter vpulse = 2,         // largeur d'impulsion du signal VSYNC
parameter hbp = 48,             // horizontal back porch
parameter hfp = 16,             // horizontal front porch
parameter vbp = 33,             // vertical back porch
parameter vfp = 10            // vertical front porch
)

(
input clk25, rst,            // signal horloge 25MHz, signal reset
output wire [9:0] x, y,    // coordonnées écran pixel en cours
output inDisplayArea,     //    inDisplayArea = 1 si le pixel en cours est dans la zone d'affichage, = 0 sinon
output hsync, vsync        // signaux de synchronisation horizontale et verticale
);

// compteurs 10 bits horizontal et vertical
// counterX : compteur de pixels sur une ligne
// counterY : compteur de lignes
reg[9:0] counterX, counterY;

always @(posedge clk25 or negedge rst) // sur front montant de l'horloge 25MHz, ou front descendant du signal Reset
begin
if (rst == 0) begin // Remise à zéro des compteurs sur Reset
counterX <= 0;
counterY <= 0;
end else
begin
// compter les pixels jusqu'en bout de ligne
if (counterX < hpixels - 1)
counterX <= counterX + 1;
else
// En fin de ligne, remettre le compteur de pixels à zéro,
// et incrémenter le compteur de lignes.
// Quand toutes les lignes ont été balayées,
// remettre le compteur de lignes à zéro.
begin
counterX <= 0;
if (counterY < vlines - 1)
counterY <= counterY + 1;
else
counterY <= 0;
end
end
end

// Génération des signaux de synchronisations (logique négative)
// <expression 1> ? <expression 2> : <expression 3>, opérateur ternaire comme en C
assign hsync = ((counterX >= hpixels - hbp - hpulse) && (counterX < hpixels - hbp)) ? 1'b0 : 1'b1;
assign vsync = ((counterY >= vlines - vbp - vpulse) && (counterY < vlines - vbp)) ? 1'b0 : 1'b1;

// inDisplayArea = 1 si le pixel en cours est dans la zone d'affichage, = 0 sinon
assign inDisplayArea = (counterX < hpixels - hbp - hfp - hpulse)
&&
(counterY < vlines - vbp - vfp - vpulse);

// Coordonnées écran du pixel en cours
// (x, y) = (0, 0) à l'origine de la zone affichable
assign x = counterX;
assign y = counterY;

endmodule

L’environnement de Quartus Prime propose un raccourci pour lancer le simulateur ModelSim. Ce logiciel permet de visualiser sous forme de chronogrammes les sorties produites en fonction des entrées forcées, le tout par simulation.

Simulation ModelSim

Sur les chronogrammes ci-dessus, le signal Reset rst est inactif (forcé à l’état haut), et le signal d’horloge clk25 est un signal carré forcé à 25,2 MHz.

On voit que :

- chaque impulsion du signal hsync prépare au passage à la ligne suivante (ligne 488, 489…) ;

- la largeur de l’impulsion du signal vsync a une durée équivalente au balayage de 2 lignes (lignes 490 et 491), ce qui correspond bien au paramètre Vertical Sync Width ;

- la fin de l’impulsion du signal vsync se produit à la fin de la ligne 491. Il reste donc 33 lignes à balayer pour atteindre la dernière ligne (ligne 524), ce qui correspond bien à la hauteur de la zone Vertical Back Porch.

Sur le diagramme ci-dessous, on mesure la durée de la zone Horizontal Back Porch en plaçant des curseurs (en jaune) calés sur les fronts des signaux :

Simulation ModelSim

La durée simulée obtenue est de 1905,6 ns, soit 48 x 39,7 ns. Il y a bien 48 pixels balayés dans cette zone.

La simulation fonctionnelle semble montrer un comportement conforme du module vga_sync. Il reste à passer à la pratique…

III-B - Un premier dessin

Pour une première réalisation, on restera sur une image dessinée très modeste : un carré jaune de 200 pixels de côté et centré sur un fond cyan .

La synthèse finale de ce premier projet devrait ressembler au schéma ci-dessous (menu Tools → Netlist Viewers → RTL Viewer) :

- le bloc pll_Clock est un circuit spécialisé disponible dans la bibliothèque de composants de Quartus Prime (IP Catalog → Library → Basic Functions → Clocks, PLLs and Resets → PLL → ALTPLL) : une boucle à verrouillage de phase ou PLL (phase-locked loop) pour asservir la fréquence de sortie sur un multiple de la fréquence d’entrée. L’entrée du bloc est raccordée à l’horloge principale 50 MHz de la carte FPGA (entrée CLOCK_50). Le ratio de fréquence est fixé à 63 / 125 pour réduire la fréquence de l’horloge principale : (63 / 125) x 50 = 25,2 MHz ;

- le bloc du module vga_sync est celui détaillé au chapitre précédent. En entrée, il est raccordé à l’horloge 25,2 MHz et au signal Reset généré par l’appui d’un bouton-poussoir KEY0 en surface de la carte FPGA. Les signaux gérant le mouvement de balayage sont produits sur ses sorties.

Le module drawing se charge de générer les signaux synchronisés des composantes Rouge-Vert-Bleu pendant le balayage des pixels. Pour une mise en œuvre plus rapide (et économiser de la filasse sur le câblage du module PmodVGA avec la carte FPGA), sur les 4 bits par composante, seul le bit de poids fort sera utilisé. Chaque composante de couleur sera soit allumée, soit éteinte. Avec une profondeur de couleur limitée à 3 bits, on peut tout de même restituer de nouvelles couleurs par synthèse additive des couleurs primaires :

Synthèse additive des couleurs primaires

- Cyan = Bleu + Vert

- Jaune = Rouge + Vert

- Magenta = Rouge + Bleu

- Blanc = Rouge +Vert + Bleu

Produire un dessin de rectangle ou carré rempli est ce qu’il y a en effet de plus simple. Au cours du balayage de l’écran, les pixels du rectangle sont ceux dont les coordonnées horizontales et verticales sont comprises entre un mini et un maxi qui dépendent chacun de la position et la taille du rectangle.

On en déduit le code du module Verilog drawing :

drawing.v

module drawing
#(
parameter square_width = 200,     // taille du carré en pixels
parameter screen_width = 640,
parameter screen_height = 480
)

(   input inDisplayArea,
input wire [9:0] x, y,
output reg r, g, b
);

assign inSquare = (x > (screen_width - square_width) / 2) && (x < (screen_width  + square_width) / 2)
&&
(y > (screen_height - square_width) / 2) && (y < (screen_height + square_width) / 2);

always @(*)
begin
if (inDisplayArea) // si coordonnées dans l'aire d'affichage
begin
// <expression 1> ? <expression 2> : <expression 3>, opérateur ternaire comme en C
r <= inSquare ? 1'b1 : 1'b0;
g <= 1'b1;
b <= inSquare ? 1'b0 : 1'b1 ;
end
else
begin
r <= 1'b0;
g <= 1'b0;
b <= 1'b0;
end
end

endmodule

Le projet étant conçu de façon modulaire, il reste à raccorder les entrées-sorties. L’EDI Quartus Prime permet la description de votre projet sous la forme de schéma-blocs (voir Description graphique par schéma-blocs). Après avoir déposé les instances des différents composants dans la fenêtre de travail, les connexions sont réalisées à la souris :

Description par schéma-blocs

Les entrées à gauche du schéma doivent être dirigées vers l’horloge principale 50 MHz de la carte FPGA et un des boutons-poussoirs. Les sorties à droite du schéma seront dirigées vers le port GPIO où sera connecté le module PmodVGA.

Les affectations des broches d’entrées-sorties sont accessibles depuis le menu Assignments → Pin planner de Quartus Prime :

Il faudra vous référer à la documentation de la carte FPGA pour trouver l’emplacement physique des broches où connecter les sorties vers le module PmodVGA. Avec la configuration choisie dans ce tutoriel, la localisation des broches est précisée sur le schéma ci-dessous :

Localisation des sorties vers le module PmodVGA

Après génération du projet et configuration du FPGA, vous pouvez constater le résultat sur votre écran VGA. Une fraction de seconde après le démarrage, le temps que l’écran se cale sur les signaux, et le résultat est magnifique !

__________________________

« Ce n’est pas une défaillance de votre téléviseur. N’essayez donc pas de régler l’image. Nous maîtrisons, à présent, toute retransmission. Nous contrôlons les horizontales et les verticales… ». D’après la série télévisée Au-delà du réel : l'aventure continue.

__________________________

III-C - Exercice 1 : dessiner une mire

Modifiez le code du module drawing.v pour dessiner une mire à partir de rectangles :

Corrigé en annexe.

III-D - Une première animation

Dans ce chapitre, on se propose de réaliser une première animation vidéo. Une balle jaune se déplace en diagonale sur un fond rouge et rebondit indéfiniment sur les bords. Dans la continuité du chapitre précédent et pour rester simple en pratique, la balle sera en fait modélisée par un petit carré.

L’architecture du système va maintenant ressembler à ceci :

- le bloc pll_Clock produit le signal d’horloge à 25,2 MHz comme précédemment ;

- le bloc du module vga_sync est celui détaillé dans un chapitre précédent, à une nuance près. Un signal supplémentaire frame est produit en sortie. Il s’agit d’une impulsion sur un cycle d’horloge au moment de commencer le balayage de la 481e ligne, la ligne qui suit la zone d’affichage active. Cette impulsion qui va donc se répéter 60 fois par seconde déclenche le calcul de la nouvelle position de la balle pour l’image suivante ;

- le bloc du module square_move calcule la nouvelle position de la balle à chaque impulsion du signal d’entrée frame en tenant compte de la vitesse de la balle (supposée constante) et des rebonds sur les bords de l’écran (chocs élastiques). Comme les coordonnées x et y des pixels balayés alimentent le bloc en entrée, le module élabore en sortie un signal inBall à l’état haut si le pixel en cours de balayage est à l’intérieur du contour de la balle (c’est facile à déterminer si la balle est carrée !) ;

- suivant le même principe que précédemment, le dernier bloc du module drawing se charge de générer les signaux synchronisés des composantes Rouge-Vert-Bleu pendant le balayage des pixels. Dans la zone d’affichage (inDisplayArea à l’état haut), si l’entrée inBall est à l’état haut, le pixel en cours est en jaune (couleur de la balle), sinon le pixel fait partie de l’arrière-plan en rouge.

La fenêtre de construction du schéma-blocs dans Quartus Prime s’étoffe un peu :

On donne les codes commentés des différents modules :

vga_sync.v

module vga_sync
#(
parameter hpixels = 800,     // nombre de pixels par ligne
parameter vlines = 525,     // nombre de lignes image
parameter hpulse = 96,         // largeur d'impulsion du signal HSYNC
parameter vpulse = 2,         // largeur d'impulsion du signal VSYNC
parameter hbp = 48,             // horizontal back porch
parameter hfp = 16,             // horizontal front porch
parameter vbp = 33,             // vertical back porch
parameter vfp = 10            // vertical front porch
)

(
input clk25, rst,            // signal horloge 25MHz, signal reset
output wire [9:0] x, y,    // coordonnées écran pixel en cours
output inDisplayArea,     //    inDisplayArea = 1 si le pixel en cours est dans la zone d'affichage, = 0 sinon
output hsync, vsync,        // signaux de synchronisation horizontal et vertical
output frame                // impulsion en début de phase inactive
);

// compteurs 10 bits horizontal et vertical
// counterX : compteur de pixels sur une ligne
// counterY : compteur de lignes
reg[9:0] counterX, counterY;

always @(posedge clk25 or negedge rst) // sur front montant de l'horloge 25MHz, ou front descendant du signal Reset
begin
if (rst == 0) begin // Remise à zéro des compteurs sur Reset
counterX <= 0;
counterY <= 0;
end else
begin
// compter les pixels jusqu'en bout de ligne
if (counterX < hpixels - 1)
counterX <= counterX + 1;
else
// En fin de ligne, remettre le compteur de pixels à zéro,
// et incrémenter le compteur de lignes.
// Quand toutes les lignes ont été balayées,
// remettre le compteur de lignes à zéro.
begin
counterX <= 0;
if (counterY < vlines - 1)
counterY <= counterY + 1;
else
counterY <= 0;
end
end
end

// Génération des signaux de synchronisations (logique négative)
assign hsync = ((counterX >= hpixels - hbp - hpulse) && (counterX < hpixels - hbp)) ? 1'b0 : 1'b1;
assign vsync = ((counterY >= vlines - vbp - vpulse) && (counterY < vlines - vbp)) ? 1'b0 : 1'b1;

// inDisplayArea = 1 si le pixel en cours est dans la zone d'affichage, = 0 sinon
assign inDisplayArea = (counterX < hpixels - hbp - hfp - hpulse)
&&
(counterY < vlines - vbp - vfp - vpulse);

// Coordonnées écran du pixel en cours
// (x, y) = (0, 0) à l'origine de la zone affichable
assign x = counterX;
assign y = counterY;

// frame=1 au début de la 481è ligne, lorsque la zone d'affichage active a été balayée.
assign frame = (counterX == 0) && (counterY == vlines - vpulse - vfp - vbp);

endmodule

square_move.v

module square_move
#(
parameter ball_width = 20,      // taille du carré (balle) en pixels
parameter hscreen_width = 640,  // largeur de l'écran en pixels
parameter vscreen_width = 480   // hauteur de l'écran en pixels
)

(
input clk25, rst, frame,
input wire [9:0] x, y,
output wire inBall
);

reg [9:0] x_ball = hscreen_width / 2, y_ball = vscreen_width / 2; // balle centrée initialement
integer dir_x_ball = 2, dir_y_ball = 2; // déplacement en diagonale vers le bas à droite de l'écran

// inBall=1 si le pixel (x, y) en cours de balayage est à l'intérieur du carré (balle), inBall=0 sinon
assign inBall = (x > x_ball - ball_width / 2) && (x < x_ball + ball_width / 2)
&&
(y > y_ball - ball_width / 2) && (y < y_ball + ball_width / 2);

always @(posedge clk25 or negedge rst)
begin
if (rst==0) begin   // Réinitialisation si appui sur bouton Reset
x_ball <= hscreen_width / 2;
y_ball <= vscreen_width / 2;
dir_x_ball <= 2;
dir_y_ball <= 2;
end else
begin
if (frame) begin // calcul de la position de la balle en dehors de la zone d'affichage active
x_ball <= x_ball + dir_x_ball;
y_ball <= y_ball + dir_y_ball;

if (x_ball > hscreen_width - ball_width / 2) begin  // rebond sur bord droit
dir_x_ball = dir_x_ball * (-1);
x_ball <= hscreen_width - ball_width / 2;
end
else if (x_ball < ball_width / 2) begin             // rebond sur bord gauche
dir_x_ball = dir_x_ball * (-1);
x_ball <= ball_width / 2;
end

if (y_ball > vscreen_width - ball_width / 2) begin  // rebond sur bord bas
dir_y_ball = dir_y_ball * (-1);
y_ball <= vscreen_width - ball_width / 2;
end
else if (y_ball < ball_width / 2) begin             // rebond sur bord haut
dir_y_ball = dir_y_ball * (-1);
y_ball <= ball_width / 2;
end
end
end
end

endmodule

drawing.v

module drawing
(   input clk25, inDisplayArea, inBall,
output reg r, g, b
);

always @(posedge clk25)
begin
if (inDisplayArea) // si coordonnées dans l'aire d'affichage
begin
r <= 1'b1;
g <= inBall ? 1'b1 : 1'b0;  // si coordonnées dans l'aire d'affichage de la balle, rouge+vert=jaune
b <= 1'b0 ;
end
else
begin
r <= 1'b0;
g <= 1'b0;
b <= 1'b0;
end
end

endmodule

Et voici une vidéo filmée du résultat sur un écran VGA :

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/square-move.mp4

Une fois de plus, ce que vous voyez à l’écran n’est pas une défaillance de votre téléviseur. N’essayez donc pas de régler l’image…

III-E - Animation d’un sprite stocké en ROM

Vous aurez remarqué dans ces premières démonstrations que le système n’utilise pas de mémoire pour le stockage de l’image à afficher. Au lieu de cela, les informations de couleur des pixels sont « calculées » et envoyées en continu à la fréquence de 25,2 MHz pour obtenir une image stable rafraîchie 60 fois par seconde. Cette situation où le FPGA sert à la fois de contrôleur VGA, mais aussi de générateur d’images calculées « à la volée » en fonction des coordonnées du pixel en cours, convient donc pour des images mathématiques (tant que les calculs mathématiques peuvent être synthétisés pour la puce FPGA).

Si vous vous lancez dans le jeu vidéo sur FPGA, avec des sprites(3) animés sur des décors qui défilent en arrière-plan, il reste à mettre en œuvre les techniques d’animation 2D avec les sprites ou les décors stockés en mémoire tampon, RAM ou ROM (voir entre autres les feuilles de sprites).

Dans ce chapitre, on se propose de stocker un sprite dans une zone adressable de mémoire ROM de la puce FPGA. Le but est de configurer un circuit qui, au cours du balayage des pixels, récupère en mémoire « à la volée » la composante Rouge-Vert-Bleu du pixel du sprite.

III-E-1 - Préparation du fichier d’initialisation de la ROM

La ROM (bloc de mémoire intégré M9K configuré en ROM) sera préchargée à partir d’un fichier texte au format ASCII avec l’extension .mif (memory intialization file). Le fichier spécifie les valeurs initiales pour chaque case mémoire ROM adressée. Ce fichier sera lu par Quartus Prime pendant la génération du projet.

Le sprite utilisé pour cette démonstration a une taille de 36 x 54 = 1944 pixels.

Sprite 36x54 pixels

On montre ci-dessous un extrait du fichier d’initialisation spaceinvaders.mif qui va précharger la ROM :

spaceinvaders.mif (extrait)

DEPTH = 1944;                 -- The size of memory in words
WIDTH = 3;                    -- The size of data in bits
ADDRESS_RADIX = HEX;          -- The radix for address values
DATA_RADIX = BIN;             -- The radix for data values
CONTENT                       -- start of (address : data pairs)
BEGIN
000 : 000;
001 : 000;
002 : 000;
003 : 000;
004 : 000;
005 : 000;
006 : 000;
007 : 000;
008 : 000;
009 : 000;
00A : 000;
00B : 000;
00C : 100;
00D : 100;
00E : 100;
00F : 100;
010 : 100;
011 : 100;
012 : 100;
013 : 100;
014 : 100;
015 : 100;

…

78D : 000;
78E : 000;
78F : 000;
790 : 000;
791 : 000;
792 : 000;
793 : 000;
794 : 000;
795 : 000;
796 : 000;
797 : 000;
END;

Comme indiqué dans les premières lignes d’entête, les adresses sont au format hexadécimal et les données sont au format binaire sur 3 bits.

Après le BEGIN, chaque ligne du fichier est au format adresse (hexadécimal) : donnée (binaire 3 bits). La première adresse mémoire (000) concerne le pixel du sprite en haut à gauche, et on parcourt les 1944 pixels de gauche à droite en descendant ligne par ligne. Les 3 bits donnent la couleur du pixel du sprite avec un codage Rouge-Vert-Bleu. Par exemple, pour un pixel vert, la donnée sera 010.

Fort heureusement, la génération de ce fichier peut-être automatisée grâce à un script Python avec les modules :

- Pillow pour récupérer les composantes Rouge-Vert-Bleu des pixels du sprite ;

- mif pour travailler en lecture ou écriture avec ces fichiers .mif.

On donne un exemple de script Python ≥ 3.5 (à adapter) pour créer le fichier .mif à partir de l’image du sprite au format .gif :

write_mif.py

from PIL import Image # https://pypi.org/project/Pillow/
import os
import mif # https://pypi.org/project/mif/
import numpy as np

image_source = 'spaceinvaders.gif' # image source .gif
mif_file = 'spaceinvaders.mif'           # fichier mif

dir_path = os.path.dirname(os.path.abspath(__file__)) # chemin complet du répertoire de travail
image_source = dir_path + '\\' + image_source
mif_file = dir_path + '\\' + mif_file

RED, GREEN, BLUE = 4, 2, 1

im = Image.open(image_source)
im = im.convert('RGB')
#im.show()
L, H = im.size # largeur, hauteur de l’image

tab = np.empty((0,1), dtype=np.uint8) # tableau 2D vide

for y in range(H):
for x in range(L):
r, g, b = im.getpixel((x,y))
color_pixel = (RED if r > 128 else 0) + (GREEN if g > 128 else 0) + (BLUE if b > 128 else 0)
tab = np.append(tab, np.uint8([[color_pixel]]), axis=0)

mem = np.unpackbits(tab, axis = 1, bitorder = 'little')

#print(mif.dumps(mem, packed = False, width = 3))
fp = open(mif_file, 'w')
mif.dump(mem, fp, packed = False, width = 3)

Quartus Prime accepte aussi les fichiers au format Intel .hex pour précharger la ROM. Le module Python intelhex peut alors vous aider à gérer les fichiers dans ce format.

III-E-2 - Configuration de la ROM dans Quartus Prime

La bibliothèque de composants réutilisables (IP Catalog) de Quartus Prime propose des interfaces pour gérer les mémoires ROM ou RAM intégrées. Ici, on sélectionnera le composant ROM:1-PORT :

Quand on insère une instance dans le projet, on suit les indications de l’assistant (MegaWizard) pour paramétrer le composant :

C’est en parcourant les différents onglets de l’assistant que l’on renseigne la taille des données et le lien vers le fichier d’initialisation avec l’extension .mif.

Le schéma-bloc du composant ressemblera finalement à ceci :

- en entrée, on doit fournir l’adresse de la case mémoire (sur 11 bits) que l’on veut accéder. L’adresse est prise en compte sur front montant de l’horloge clock ;

- en sortie, dans le même cycle d’horloge, on récupère la donnée (sur 3 bits) contenue à l’adresse pointée.

Simulation fonctionnelle de la ROM : sur front montant de l'horloge CLK, on récupère la donnée préchargée avec le fichier .mif à l'adresse 64A.

III-E-3 - Description du projet

La nouvelle configuration à synthétiser est la suivante :

- le bloc pll_Clock produit le signal d’horloge à 25,2 MHz ;

- le bloc du module vga_sync reste inchangé et s’occupe du balayage des pixels synchronisé avec l’horloge ;

- le bloc du module rom1 représente la mémoire ROM où est stockée l’image du sprite.

Le bloc du module sprite_generator comprend les calculs de déplacement du sprite selon les mêmes principes que la balle jaune qui rebondit sur les bords de l’écran lors de l’étude précédente. Il englobe également la génération des signaux synchronisés des composantes Rouge-Vert-Bleu des pixels au cours du balayage pixel par pixel. Quand c’est au tour du sprite d’être affiché, le bloc génère en sortie une adresse adr_sprite[10..0] de données en ROM et récupère à l’entrée color_pixel_sprite[2..0] le code couleur du pixel du sprite à l’adresse pointée.

Dans les passages importants du code de ce module, on trouve l’assignation d’un signal inSprite qui passe à l’état haut si le pixel de coordonnées (x, y) en cours de balayage coïncide avec la position du sprite :

sprite_generator.v (extrait)

// inSprite=1 si le pixel (x, y) en cours de balayage est à l'intérieur du sprite, inSprite=0 sinon
assign inSprite = (x >= x_sprite) && (x < x_sprite + sprite_width)
&&
(y >= y_sprite) && (y < y_sprite + sprite_height);

Un autre signal (de largeur 11 bits) adr_sprite transporte le calcul de l’adresse en cours du pixel du sprite stocké en ROM :

sprite_generator.v (extrait)

assign adr_sprite = (y - y_sprite) * sprite_width + (x - x_sprite);

À ce moment-là, les composantes Rouge-Vert-Bleu en sortie du bloc rom1 peuvent être récupérées et les signaux envoyés au module PmodVGA :

sprite_generator.v (extrait)

if (inDisplayArea) // si coordonnées dans l'aire d'affichage
begin
if (inSprite)
begin
r <= color_pixel_sprite[2];
g <= color_pixel_sprite[1];
b <= color_pixel_sprite[0];

end else
begin
r <= 1'b0;
g <= 1'b0;
b <= 1'b0;
end
end

On donne une copie du schéma-blocs du projet ainsi que le code complet du module sprite_generator.v :

sprite_generator.v

module sprite_generator
#(
parameter sprite_width = 36,        // largeur du sprite en pixels
parameter sprite_height = 54,       // hauteur du sprite en pixels
parameter screen_width = 640,       // largeur de l'écran en pixels
parameter screen_height = 480       // hauteur de l'écran en pixels
)

(
input clk25, rst, frame, inDisplayArea,
input wire [9:0] x, y,
input wire [2:0] color_pixel_sprite,
output reg r, g, b,
output wire [10:0] adr_sprite
);

integer x_sprite = (screen_width - sprite_width) / 2;
integer y_sprite = (screen_height - sprite_height) / 2;

integer dir_x_sprite = 1, dir_y_sprite = 1; // déplacement en diagonale vers le bas à droite de l'écran

// inSprite=1 si le pixel (x, y) en cours de balayage est à l'intérieur du sprite, inSprite=0 sinon
assign inSprite = (x >= x_sprite) && (x < x_sprite + sprite_width)
&&
(y >= y_sprite) && (y < y_sprite + sprite_height);

assign adr_sprite = (y - y_sprite) * sprite_width + (x - x_sprite);

always @(posedge clk25 or negedge rst)
begin
if (rst==0) begin   // Réinitialisation si appui sur bouton Reset
x_sprite = (screen_width - sprite_width) /2;
y_sprite = (screen_height - sprite_height) / 2;
dir_x_sprite <= 1;
dir_y_sprite <= 1;
end else
begin
if (frame) begin // calcul de la position du sprite en dehors de la zone d'affichage active
x_sprite <= x_sprite + dir_x_sprite;
y_sprite <= y_sprite + dir_y_sprite;

if (x_sprite > screen_width - sprite_width) begin   // rebond sur bord droit
dir_x_sprite <= dir_x_sprite * (-1);
x_sprite <= screen_width - sprite_width;
end
else if (x_sprite < 0) begin                // rebond sur bord gauche
dir_x_sprite <= dir_x_sprite * (-1);
x_sprite <= 0;
end

if (y_sprite > screen_height - sprite_height) begin // rebond sur bord bas
dir_y_sprite <= dir_y_sprite * (-1);
y_sprite <= screen_height - sprite_height;
end
else if (y_sprite < 0) begin                // rebond sur bord haut
dir_y_sprite <= dir_y_sprite * (-1);
y_sprite <= 0;
end
end
end
end

always @(*)
begin
if (inDisplayArea) // si coordonnées dans l'aire d'affichage
begin
if (inSprite)
begin
r <= color_pixel_sprite[2];
g <= color_pixel_sprite[1];
b <= color_pixel_sprite[0];
end else
begin
r <= 1'b0;
g <= 1'b0;
b <= 1'b0;
end
end
else
begin
r <= 1'b0;
g <= 1'b0;
b <= 1'b0;
end
end

endmodule

III-E-4 - Résultat

La vidéo de l’écran filmé ci-dessous (de piètre qualité, certes…) montre un résultat probant d’envahisseur parachuté et errant dans l’espace :

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/spriteFPGA.mp4

Photo de l'écran avec agrandissement

Vous aurez noté que l’accès synchronisé au bloc mémoire à la fréquence de balayage des pixels permet une fois de plus de récupérer « à la volée » la couleur du pixel, juste au moment de son affichage. Dans les cas où le processus d’affichage doit collaborer avec des matériels travaillant à des fréquences différentes pour générer ou stocker les images, sans doute utiliserez-vous les techniques bien connues avec double buffering. L’image est d’abord calculée et stockée dans une mémoire tampon, puis un autre processus parcourt l’image préparée à la fréquence de balayage des pixels pour restitution à l’écran.

III-F - Exercice 2 : sprite formé de deux images

Suspendu dans l’espace, même un envahisseur peut se mettre à gigoter…

On reprendra la démonstration précédente avec cette fois un sprite formé de deux images. On alternera les deux images du sprite deux fois par seconde pour donner vie à l’envahisseur et simuler ses mouvements :

Animation en format "mp4" non supportée.
Contenu uniquement consultable en ligne à cette adresse :
./videos/FPGAanimatedSprite.mp4

Avec un script Python, il faudra générer un nouveau fichier d’initialisation (extension .mif) à partir du sprite suivant avec les deux images de même taille alignées verticalement :

Sprite 36 x 108 pixels

Pour alterner entre les deux images (toutes les demi-secondes ou toutes les 30 frames puisque l’image est rafraîchie 60 fois par seconde), il suffira de lire en ROM soit la partie haute des adresses (les 54 premières lignes du sprite) soit la partie basse (les 54 dernières lignes) en faisant un simple décalage dans l’adresse.

En reprenant le projet précédent, faire les modifications nécessaires pour réaliser cette animation 2D.

Corrigé en annexe.

IV - Conclusion

Dans ce tutoriel, vous avez pu découvrir le protocole VGA et sa mise en œuvre dans différents exercices simples de configuration d’une puce FPGA d’Intel à la fois en tant que contrôleur VGA et générateur d’images. L’environnement Quartus Prime d’Intel procure de nombreux outils et assistants de description de votre projet, et les modules personnalisés sont écrits en Verilog standard.

Les principes et les codes exposés sont d’une apparente simplicité. Il faut vous méfier, la simplicité n’est qu’apparente !

La principale difficulté provient des raisonnements auxquels sont habitués les programmeurs C, C++, Java, Python, etc., c’est-à-dire probablement 99 % des lecteurs qui passeront par ici. En programmation traditionnelle, un programme compilé deviendra une suite d’instructions exécutées séquentiellement par un microprocesseur. Même si par abus de langage on parle encore de « programmation » et de « compilation » dans un projet FPGA, les langages HDL sont conçus pour faire de la « description » d’un circuit hardware, et si vous décrivez plusieurs processus, chacun d’entre eux peut mener à des circuits différents et qui fonctionneront en concurrence. C’est un gros avantage des FPGA, mais aussi une difficulté de représentation pour le développeur qui s’y aventure.

Même quand vous rencontrez des « nœuds » (wire) ou des « registres » (reg) qui semblent être manipulés en Verilog comme nos variables traditionnelles, avec des processus rédigés de façon algorithmique (et Verilog a une syntaxe qui ressemble à des langages connus), n’oubliez pas que cette abstraction de haut niveau ne vise qu’à décrire le « comportement » de votre futur circuit et non à produire des instructions exécutées séquentiellement. Vous rencontrerez ainsi des messages d’avertissement ou d’erreurs, des comportements inattendus à cause de soucis de synchronisation, de chemins mal définis dans les circuits et autres problèmes du monde physique. Le débogage passe alors par les test benches et les simulations fonctionnelles ou temporelles pour inspecter vos signaux sans recourir au matériel. Développer sur FPGA, ce n’est pas seulement une question de maîtrise d’un langage comme Verilog, SystemVerilog ou VHDL, c’est un autre métier…

Mon but n’est évidemment pas de vous décourager, au contraire… Il n’y a rien d’insurmontable, mais il faudra se débarrasser de certains réflexes de programmeurs, s’approprier une nouvelle culture et comprendre que des notions de logique séquentielle et combinatoire sont indispensables pour comprendre ce qui se passe dans vos circuits. Mes modestes contributions (voir sur mon site) vous aideront peut-être à y voir plus clair et mettre le pied à l’étrier, elles sont aussi le reflet de ma progression dans ce domaine (et il reste encore beaucoup à faire…)

De nos jours, sans doute devrait-on plutôt s’intéresser aux nouvelles interfaces vidéo numériques comme HDMI ou DisplayPort, et laisser VGA s’éteindre (mais finira-t-il vraiment par disparaître complètement ?). Beaucoup de cartes de développement FPGA intègrent d’ailleurs ces interfaces modernes. Voyez alors dans ce tutoriel un intérêt pédagogique, avec un protocole VGA relativement simple à assimiler, pour découvrir de façon ludique le monde des FPGA et des langages de description de matériel (ou HDL).

Ou peut-être êtes-vous nostalgique des jeux vidéo sur les bornes d’arcade d’antan que vous pourriez ressusciter avec une carte FPGA. Des développeurs se sont déjà penchés sur le problème, voir par exemple le projet MISTer dans le domaine du retrogaming, où les processeurs des consoles ou ordinateurs se voient leurs fonctionnalités répliquées matériellement grâce à la flexibilité des FPGA (voir réplication de matériel vs émulation logicielle). On en est encore loin dans ce tutoriel, mais vous avez déjà acquis quelques principes qu’il reste à développer. Le travail mérite d’être poursuivi…

Pour terminer, je remercie les membres de Developpez pour leur travail de relecture de cet article et leurs propositions d’amélioration, en particulier : Auteur, LittleWhite, Chrtophe, Delias et escartefigue.

V - Annexe : archives Quartus Prime

Archives des différents projets de ce tutoriel au format Quartus Archive (fichiers avec extension .qar), à importer directement depuis Quartus Prime.

Version de Quartus Prime : 20.1.1 Lite Edition

Archives Quartus Prime

- Premier essai : un carré jaune sur un fond cyan.

VGA-yellowSquare.qar

- Exercice 1 : dessiner une mire.

Le module drawing.v est modifié comme suit:

drawing.v

module drawing
(    input inDisplayArea,
input wire [9:0] x, y,
output reg r, g, b
);

reg [2:0] color [0:7];
integer i;

initial begin // RGB
color[0] = 3'b111; // blanc
color[1] = 3'b110; // jaune
color[2] = 3'b011; // cyan
color[3] = 3'b010; // vert
color[4] = 3'b101; // magenta
color[5] = 3'b100; // rouge
color[6] = 3'b001; // bleu
color[7] = 3'b000; // noir
end

always @(*)
begin
if (inDisplayArea) // si coordonnées dans l'aire d'affichage
begin
if (x < 80) i <= 0;
else if (x >= 80  && x < 160) i <= 1;
else if (x >= 160 && x < 240) i <= 2;
else if (x >= 240 && x < 320) i <= 3;
else if (x >= 320 && x < 400) i <= 4;
else if (x >= 400 && x < 480) i <= 5;
else if (x >= 480 && x < 560) i <= 6;
else i <= 7;
end
else i <= 7;
end

always @(i)
begin
r <= color[i][2];
g <= color[i][1];
b <= color[i][0];
end

endmodule

VGA-dvp-mire.qar

- Une première animation : une balle (carrée !) se déplace et rebondit sur les bords de l’écran.

VGA-dvp-ballBouncing.qar

- Animation d’un sprite : un joli sprite stocké en ROM se déplace et rebondit sur les bords de l’écran.

VGA-dvp-sprite.qar

- Exercice 2 : idem avec le sprite formé de deux images pour lui donner un peu de vie.

Il faut rajouter un compteur qui s’incrémente à chaque nouvelle frame, c’est-à-dire tous les 1/60e de seconde :

sprite_generator.v (extrait)

…
reg [5:0] counter_frame; // compteur 6 bits

sprite_generator.v (extrait)

…
begin
if (frame) begin
counter_frame <= counter_frame + 1;

L’avantage de déclarer un compteur 6 bits, c’est qu’après avoir franchi la valeur 2^6-1 = 63, il repart à zéro (débordement).
Pour obtenir la deuxième image du sprite, il faut décaler l’adresse en ROM, lorsque le compteur évolue entre 32 et 63 :

sprite_generator.v (extrait)

// calcul de l'adresse en ROM
assign adr_sprite = (y - y_sprite) * sprite_width + (x - x_sprite)
+ ((counter_frame < 32) ? 0 : sprite_width * sprite_height); // décalage de l'adresse

Note : l’énoncé exigeait d’« alterner entre les deux images toutes les demi-secondes ou toutes les 30 frames puisque l’image est rafraîchie 60 fois par seconde… ». Hé bien, je triche un peu… ce sera toutes les 32 frames, c’est-à-dire toutes les 0,53 s environ.

VGA-dvp-animatedSprite.qar

(1)Voir Coordinated Video Timings (CVT ; VESA-2013-3 v1.2)

(2)Les caractéristiques de minutage et de synchronisation des signaux VGA pour toutes les résolutions d’écran sont disponibles gratuitement sur le site officiel de l’organisation vesa.org… à condition de s’y inscrire comme membre. Sinon, on trouve quelques références non officielles, comme sur tinyvga.com.

(3)Sprite : un sprite, ou lutin, est dans le jeu vidéo un élément graphique qui peut se déplacer sur l'écran. Voir Sprite (jeu vidéo) sur Wikipédia.