WAIT, SKIP et COPJMPx : un usage avancé du Copper sur Amiga (1/2)

La programmation du Copper sur Amiga est très particulière. Tout d'abord, ce coprocesseur graphique ne comprend que trois instructions, MOVE, WAIT et SKIP, auxquelles il est toutefois possible de rajouter JUMP, quoiqu'elle ne se code pas comme les autres. Ensuite, on n'écrit pas du code pour le Copper comme on écrit du code pour le 68000 : point de mnémoniques, il faut écrire directement en opcodes. Enfin, WAIT et SKIP présentent certaines limitations dont la prise en compte conduit à complexifier le code dès que ce dernier doit pouvoir fonctionner quelle que soit la ligne que le faisceau d'électrons - le raster - est en train de balayer à l'écran.
Paradoxalement, ces limitations n'entravent généralement pas la programmation d'effets visuels complexes comme le plasma ou l'affichage d'une image true color en plein écran - oui, l'Amiga 500 peut afficher en true color, et sans utiliser aucun bitplane par ailleurs -, c'est-à-dire sur toute la largeur des 320 pixels et toute la hauteur des 256 lignes de pixels d'une surface classiquement utilisée dans une démo en PAL. La surprise du codeur n'en est que plus grande quand il s'y heurte. Pour les dépasser, il faut exploiter l'information qui réside dans une section assez cryptique de l'Amiga Hardware Reference Manual. Explications.
Un effet plasma (un plasma RGB pour être plus exact)
Mise à jour du 14/01/2019 : J'ai oublié de mentionner qu'ayant la flegme de terminer la rédaction de la seconde partie, j'en ai inclu l'essentiel dans ici. En effet, le rotozoom s'appuie sur une solution au problème évoqué à la fin du présent article.
NB : Cet article se lit mieux en écoutant l'excellent module Skidtro composé par Sun / Dreamdealers pour la cracktro de Road Rash, mais c'est affaire de goût personnel...

Un bref rappel des bases du Copper

Avec le Copper, il est possible de modifier la valeur de certains registres à l'aide d'une instruction MOVE. Comme toute instruction du Copper, MOVE se code en composant son opcode sous la forme de deux mots successifs dans la liste des instructions qui constitue le programme que le Copper exécute à chaque trame, la Copper list.
Le premier mot d'un MOVE fournit l'offset du registre hardware dans lequel le Copper doit écrire une valeur - cet offset est donné à partir de l'adresse $DFF000, soit par exemple $0180 pour le registre COLOR00 qui de spécifier les valeurs des composantes rouge, vert et bleu de la couleur 0, dit couleur de fond. Le second mot fournit la valeur à écrire dans ce registre. Ce qui donne :
Le codage de l'instruction MOVE
Ainsi le MOVE suivant passe COLOR00 à $0F00 (rouge) :
DC.W $0180,$0F00
Le Copper prend un certain temps pour exécuter un MOVE, celui qu'il faut au faisceau d'électrons pour balayer 8 pixels en basse résolution. Par conséquent, une série de MOVE dans COLOR00 va produire à l'écran une séquence de segments de 8 pixels, chacun d'une certaine couleur.
Par exemple, la série de MOVE suivante produit 3 segments, respectivement rouge, vert et bleu :
DC.W $0180,$0F00
DC.W $0180,$00F0
DC.W $0180,$000F
Au maximum, il est donc possible de produire le résultat de 40 MOVE à la suite, soit 40 segments de couleurs différentes, sur une ligne d'un écran en basse résolution (320 pixels de large). C'est qui permet de produire l'effet dit de Copper line, où les valeurs des MOVE successifs proviennent d'un dégradé parcouru comme un barillet à partir d'un offset incrémenté à chaque trame pour produire une animation :
Une Copper line
Cliquez ici pour télécharger le source du code minimal pour une Copper line.
Noter que pour produire cet effet à une certaine hauteur de l'écran, il faut précéder les MOVE d'un WAIT.
Le premier mot d'un WAIT fournit les positions horizontale et verticale auxquelles le Copper doit comparer la position courante du raster tandis que ce dernier balaie l'écran pour afficher l'image à chaque trame. Le second mot fournit des masques à appliquer aux positions attendue et courante, ce qui détermine donc les bits de ces dernières sur lesquelles porte la comparaison. Ce qui donne :
Le codage de l'instruction WAIT
La comparaison est de type "supérieur ou égal" : pour que le Copper passe à l'instruction WAIT, il faut que la position courante soit supérieure ou égale à la position attendue. Autrement dit, et c'est un point important, si le raster a déjà passé la position attendue quand le Copper tombe sur le WAIT dans la Copper list, le Copper ne reste pas planté devant le WAIT en attendant que le raster repasse à cette position lors la trame suivante : il passe à l'instruction suivante dans la Copper list.
Il n'aura pas échappé au lecteur que la position horizontale et son masque sont codés sur 7 bits à partir du bit 1, tandis que la position verticale est codée sur 8 bits sauf pour son masque, dont le bit 7 apparaît exclu. Comme nous le verrons, ces particularités sont autant de sources de complexification. Pour l'heure, retenons que le bit BFD doit être à 1 (il sert à indiquer au Copper qu'il doit attendre le Blitter, ce qui ne présente aucun intérêt ici) et que les bits 0 du premier et second mot doivent être à 0 et 1 respectivement - le Copper se base sur la combinaisons des bits 0 des deux mots d'un opcode pour déterminer à quelle instruction il a affaire.
Par exemple, il est possible d'attendre la position horizontale X à la position verticale Y ainsi (les masques ne sont pas utilisés, tous leurs bits étant à 1) :
DC.W (Y<<8)!X!$0001
DC.W $8000!($7F<<8)!$FE
Dans le source de la Copper line, la position horizontale attendue pour débuter les MOVE est $3E. Cette position coïncide avec le début d'un bitplane quand la position horizontale spécifiée dans DIWSTRT est classiquement $81. Elle se retrouve donc souvent.
La possibilité de pouvoir attendre le début de chaque ligne pour modifier la couleur de fond - ou tout autre couleur - en série et produire une Copper line est mise à profit pour produire l'effet plasma présenté plus tôt. Dans un tel effet, ce sont 41 MOVE qui sont effectués par ligne. En effet, une petite subtilité consiste à exploiter la précision de la position horizontale d'un WAIT, qui est de 4 pixels en basse résolution, pour décaler une ligne par rapport à la précédente et la suivante et éviter ainsi un effet trop pixelisé :
Le même effet plasma, sans décalage de 4 pixels une ligne sur deux
Dans ces conditions, une ligne sur deux permet d'afficher les 8 pixels produits par 39 MOVE plus les 4 pixels produits par 2 MOVE supplémentaires, soit un total de 41 MOVE. Pour simplifier le code qui modifie les couleurs dans la Copper list, ce nombre de MOVE par ligne est généralisé à l'ensemble des lignes, décalées ou non.
Cliquez ici pour télécharger le source du code minimal pour un plasma RGB. Si le code est intégralement original, précisons qu'il exploite la technique de séparation des composantes R, G et B décrite par Stéphane Rubinstein dans Amiga News Tech n°31 (mars 1992).

De l'intérêt de boucler au Copper

Le Copper peut servir à bien plus qu'attendre le raster en début de ligne pour procéder à une série de MOVE. L'Amiga Hardware Reference Manual documente deux usages avancés du Copper : pour activer le Blitter et pour réaliser des boucles. C'est sur ce dernier usage que nous allons nous pencher.
Avant de rentrer dans le vif du sujet, précisons qu'il n'est pas évident de trouver un intérêt aux boucles au Copper, et de là à rentrer dans les particularités des instructions WAIT et SKIP, sources comme déjà dit de complexification. La raison en sera exposée plus tard. Pour l'heure, contentons-nous de dire que paradoxalement, il est parfaitement possible de réaliser des effets complexes au Copper sans vraiment savoir comment il fonctionne - c'est cette leçon qui donnera à cet article une portée propre à susciter l'intérêt du lecteur qui n'est pas vintage, du moins nous l'espérons.
Pour illustrer le propos, nous pouvons tout de même nous appuyer sur un exemple concret, celui de l'affichage d'une image en true color au Copper. Eh ! somme toute, la technique présentée à l'instant pour produire un plasma ne permet-elle pas tout simplement de disposer d'un fond d'écran de 40x32 "pixels" en PAL (chaque "pixel" étant représenté à l'écran par un segment de 8 pixels de large et 1 pixel de haut) ? Dès lors, pourquoi donc ne pas chercher y afficher une image dont les valeurs des composantes R, G et B de la couleur de chaque pixel sont données sans renvoi à une palette, donc en true color ?
Pour cela, il suffit de convertir une image normale en séries de MOVE. Prenons une image en RAW Blitter (RAWB) de PICTURE_DX sur PICTURE_DY pixels sur PICTURE_DEPTH bitplanes, c'est-à-dire en 1 << PICTURE_DEPTH couleurs. Pour rappel, on désigne par RAWB un format trivial de fichier image, puisque le contenu du fichier est organisé ainsi :
Ligne 0 dans le bitplane 1
...
Ligne 0 dans le bitplane N
...
Ligne PICTURE_DY-1 dans le bitplane 1
...
Ligne PICTURE_DY-1 dans le bitplane N
Palette (suite de mots)
Définissons quelques constantes... :
PICTURE_DX=320
PICTURE_DY=256
PICTURE_DEPTH=5
...intégrons le fichier de l'image à l'exécutable... :
picture:	incbin "picture.rawb"
...et convertissons l'image :
	lea picture,a0
	movea.l a0,a1
	addi.l #PICTURE_DEPTH*PICTURE_DY*(PICTURE_DX>>3),a1
	movea.l moves,a2
	move.w #PICTURE_DY-1,d1
_convertY:
	move.w #(PICTURE_DX>>3)-1,d0
_convertX:
	moveq #7,d2
_convertByte:
	clr.w d4
	clr.w d5
	moveq #1,d6
	moveq #PICTURE_DEPTH-1,d3
_convertByteBitplanes:
	btst d2,(a0,d4.w)
	beq _convertBit0
	or.b d6,d5
_convertBit0:
	add.b d6,d6
	add.w #PICTURE_DX>>3,d4
	dbf d3,_convertByteBitplanes
	add.w d5,d5
	move.w (a1,d5.w),(a2)+
	dbf d2,_convertByte
	lea 1(a0),a0
	dbf d0,_convertX
	lea (PICTURE_DEPTH-1)*(PICTURE_DX>>3)(a0),a0
	dbf d1,_convertY
Le convertisseur doit retrouver l'indice de la couleur pour chaque pixel, ce qui génère un nombre outrancier de tests, mais le résultat est tout de même atteint assez rapidement. De toute manière, il ne s'agit que d'une phase d'initialisation. Peu importe donc d'optimiser, car les performances de la boucle principale ne seront pas péjorées.
Ces MOVE servent à constituer une Copper list : pour afficher 40 pixels d'une ligne sous la forme de "pixels", il suffit de reprendre la série de MOVE correspondante. Toutefois, cela ne produira à l'écran qu'une série de segments de 8 pixels sur 1 pixels. Pour donner au résultat l'apparence d'un écran certes de très basse résolution, mais un écran tout de même, nous devons en toute logique répéter chaque ligne 8 fois. Ainsi, un "pixel" apparaitra à l'écran sous la forme d'un bloc de 8x8 pixels.
Par exemple, reprenons un échantillon de la somptueuse (les mots manquent...) image Dragonsun de ce Michel-Ange du graphisme sur Amiga, je veux nommer Cougar du groupe Sanity, vainqueur par K.O. lors de la compétition The Party en 1993 :
Une image true color sur Amiga 500 (échantillon de Dragonsun par Cougar / Sanity)
Il serait tout à fait possible de nous contenter de bourrer la Copper list de séries de 40 MOVE en répétant une série huit fois d'affilée, chaque série étant précédées d'un WAIT qui indique au Copper qu'il doit commencer à l'exécuter au début d'une ligne de l'écran.
Toutefois, il semble que cela présenterait deux inconvénients :
  • ce serait consommateur de mémoire ;
  • cela ferait beaucoup de MOVE à modifier pour modifier l'image dans le fond d'écran.
Pour réduire le nombre de MOVE, il est possible d'utiliser l'instruction SKIP du Copper.

WAIT et SKIP pour faire boucler le Copper

Considérons qu'il faut constituer une Copper list dont l'adresse figure dans le registre A0. Chaque bloc de 8 lignes est composé d'une série COPPER_DX MOVE (40 pour couvrir les 320 pixels d'un écran en basse résolution) qu'il faut répéter à chacune des COPPER_DY lignes de l'écran (32 pour couvrir les 256 lignes de ce même écran en PAL). Commençons donc par attendre la première ligne à laquelle afficher la première ligne de blocs, à savoir COPPER_Y, qui correspond généralement $2C pour désigner le haut de l'écran :
	move.w #((COPPER_Y&$00FF)<<8)!$0001,d0		;D0 X=0 Y=y
	move.w d0,(a0)+
	move.w #$8000!($7F<<8)!$FE,(a0)+			;WAIT X=0 Y=COPPER_Y
C'est une instruction WAIT classique, où le masque de position verticale est $7F et celui de position horizontale est $FE, ce qui indique au Copper qu'il doit comparer la position du raster avec tous les bits de la position verticale et de la position horizontale que nous lui indiquons.
Modifions la position à laquelle le Copper attendra désormais le raster, à moins que nous en masquions certains bits. Par défaut, le Copper attendra en (COPPER_X, COPPER_Y), COPPER_X correspondant généralement à $81 pour désigner le côté gauche de l'écran :
	or.w #(((COPPER_X-4)>>2)<<1)&$00FE,d0		;D0 X=x Y=y
Mémorisons l'adresse à laquelle nous en sommes, car cela va servir plus loin :
_copperListRows:
	move.l a0,d2
Attendons le raster à la position horizontale à laquelle doit figurer le premier "pixel". Nous l'attendons sur une ligne quelconque, en utilisant pour cela la possibilité de masquer les bits de la position verticale :
	move.w d0,(a0)+
	move.w #($00<<8)!$FE,(a0)+					;WAIT X=x Y=?
C'est alors que nous pouvons fournir la série de MOVE pour représenter à l'écran la première ligne de la série de COPPER_DX "pixels". En considérant que nous avons stocké l'adresse des MOVE résultant de la conversion de l'image dans A1, cela donne :
	move.w #COPPER_DX-1,d3
_copperListCols:
	move.w #COLOR00,(a0)+
	move.w (a1)+,(a0)+
	dbf d3,_copperListCols
Les choses deviennent alors subtiles. En effet, nous allons demander au Copper de reboucler sur le WAIT précédent.
Comment demander au Copper de commencer à exécuter une liste d'instructions données ? En modifiant l'adresse que le Copper lit dans les registres COP1LCH et COP1LCL quand une valeur quelconque est écrite dans le registre COPJMP1. C'est l'équivalent d'un JUMP du 68000, d'où le nom de ce registre, et c'est en cela l'"instruction" JUMP du Copper.
Or ces registres font partie de ceux dans lesquels le Copper peut écrire. Il est donc possible de demander au Copper d'écrire l'adresse sauvegardée plus tôt dans COP1LCH et COP1LCL :
	move.w #COP1LCL,(a0)+
	move.w d2,(a0)+								;MOVE COP1LCL
	swap d2
	move.w #COP1LCH,(a0)+
	move.w d2,(a0)+								;MOVE COP1LCH
La suite naturelle serait rajouter un MOVE pour écrire une valeur quelconque, par exemple 0, dans COPJMP1, provoquant le saut du Copper.
De fait, s'il retombe sur le second WAIT, le Copper va attendre le raster à la position horizontale COPPER_X sur une ligne quelconque. Et comme il va retomber sur ce WAIT alors que le tracé des 320 pixels représentant les COPPER_DX MOVE vient de s'achever sur la ligne N, le Copper va attendre le raster à la position horizontale COPPER_X de la ligne suivante, la ligne N+1. C'est ainsi que le Copper va exécuter de nouveau les COPPER_DX MOVE sur cette ligne.
Ainsi, nous n'avons pas à répéter ces MOVE sur 7 lignes, ce qui nous fait économiser de la mémoire et ce qui limite l'ampleur de MOVE à modifier pour modifier l'image affichée sous la forme de "pixels".
Toutefois, c'est uniquement 8 fois que le Copper doit exécuter la série de MOVE. Avant d'écrire dans COPJMP1, il faut donc qu'il vérifie si le raster n'a pas dépassé la position à partir de laquelle il a tracé la dernière ligne. Si tel est le cas, le Copper doit ignorer le COPJMP1. C'est exactement ce que permet un SKIP.
Un SKIP se code exactement comme un WAIT. Son premier mot fournit les positions horizontale et verticale auxquelles le Copper doit comparer la position courante du raster. Le second mot fournit des masques à appliquer aux positions attendue et courante, ce qui détermine donc les bits de ces dernières sur lesquelles porte la comparaison. Ce qui donne :
Le codage de l'instruction SKIP
Il suffit donc de réutiliser la valeur du premier mot utilisé pour coder le WAIT, après avoir incrémenté la position verticale de 7 :
	addi.w #$0700,d0
	move.w d0,(a0)+
	move.w #$8000!($7F<<8)!$FE!$0001,(a0)+		;SKIP X=x Y=y+7
Si le SKIP n'est pas validé, le Copper doit donc reboucler sur la série de MOVE en écrivant dans COPJMP1 :
	move.w #COPJMP1,(a0)+
	move.w #$0000,(a0)+							;MOVE COPJMP1
Il faut alors qu’il tombe sur un WAIT lui indiquant d’attendre le raster à la position horizontale COPPER_X sur la ligne d’après :
	addi.w #$0100,d0
Pour finir, nous pouvons passer à la ligne suivante dans l'image, et reboucler sur la production des instructions correspondant à la série suivante de 40 "pixels" :
	lea (PICTURE_DX-40)<<1(a1),a1
	dbf d1,_copperListRows
Avant _copperListRows, D1 aura été initialisé avec le nombre de lignes de "pixels" à afficher moins une, soit COPPER_DY-1 :
	move.w #COPPER_DY-1,d1
Au total, cela donne :
	movea.l moves,a1
	move.w #((COPPER_Y&$00FF)<<8)!$0001,d0		;D0 X=0 Y=y
	move.w d0,(a0)+
	or.w #(((COPPER_X-4)>>2)<<1)&$00FE,d0		;D0 X=x Y=y
	move.w #$8000!($7F<<8)!$FE,(a0)+			;WAIT X=0 Y=COPPER_Y
	move.w #COPPER_DY-1,d1
_copperListRows:
	move.l a0,d2
	move.w d0,(a0)+
	move.w #$8000!($00<<8)!$FE,(a0)+			;WAIT X=x Y=?
	move.w #COPPER_DX-1,d3
_copperListCols:
	move.w #COLOR01,(a0)+
	move.w (a1)+,(a0)+
	dbf d3,_copperListCols
	lea (PICTURE_DX-COPPER_DX)<<1(a1),a1
	move.w #COP1LCL,(a0)+
	move.w d2,(a0)+								;MOVE COP1LCL
	swap d2
	move.w #COP1LCH,(a0)+
	move.w d2,(a0)+								;MOVE COP1LCH
	addi.w #$0700,d0
	move.w d0,(a0)+
	move.w #$8000!($7F<<8)!$FE!$0001,(a0)+		;SKIP X=x Y=y+7
	move.w #COPJMP1,(a0)+
	move.w #$0000,(a0)+							;MOVE COPJMP1
	addi.w #$0100,d0
	dbf d1,_copperListRows
Toutefois, le Copper nous réserve une surprise...
WAIT, SKIP et COPJMPx : un usage avancé du Copper sur Amiga (1/2)