ASMtrad CPC

Apprenez l'assembleur Z80

Optimisation de l’affichage des sprites

Vos routines sont trop lentes. Il faut impérativement régler le problème si vous ne voulez pas perdre du temps machine et avoir un jeu qui RAM.

Il est temps maintenant de parler un peu d'optimisation.

Ce cours va donc être un peu long et va regrouper plusieurs choses. Voici un apperçu du programme:

  • Entrelacer les données.
  • Diverses astuces.
  • Changer l'ordre des lignes pour ne plus avoir à calculer l'adresse inférieure.
  • Récupérer les adresses des blocs avec la pile.
  • Afficher en zigzag pour ne pas avoir à soustraire la largeur.
  • Auto-générer l'affichage.
  • Affichage à la pile

Entrelacer les données.

Vous avez sans doute fait d'un coté une routine pour masquer et une autre pour afficher...
Soit une routine qui fait un AND avec le masque sur l'écran et une autre qui fait un OR sur l'écran avec les gfx.

La première optimisation consistera à mixer les deux dans la même routine.
Pour cela le mieux est d'entrelacer masque et sprite. Le masque servant en premier ce sera donc le premier octet.

Voila ce à quoi devra ressembler vos données entrelacées:

sprite entrelacé avec son masque

Votre routine d'affichage sera donc plus simple.

Si DE est la destination et HL l'adresse du sprite alors:

                            LD            A,(DE)
                            AND           (HL)
                            INC           HL
                            OR            (HL)
                            INC           HL
                            LD            (DE),A

Et le tour est joué pour 1 octet.

Diverses astuces.

Si votre sprite fait moins de 256 octets de long, placez-le à une adresse dont le poids faible=#00 et remplacez les INC HL par des INC L.
En effet, vous n'aurez pas à incrémenter le poids fort et gagnerez donc 1 NOP à chaque octet !!!

Supprimez les boucles. Les boucles prennent du temps à être gérées. Rien qu'un DJNZ prend 4 NOPS.
4 NOPS*le nombre de fois ou cela boucle... Ca fait vite beaucoup.
Mieux vaut recopier plusieurs fois une routine que de faire une boucle.

Preférez aussi LDI à LDIR. Par exemple si vous envoyez un sprite de 10 octets de large, placez: LDI:LDI:LDI:LDI:LDI:LDI:LDI:LDI:LDI:LDI
Un LDIR prend 6NOPS*BC(sauf quand BC arrive à 0 dans quel cas il prend 5NOPS) alors qu'un LDI en prend 5.

Dans notre exemple, avec LDIR: (6*9)+5=59 NOPS.
Avec LDI: 50 NOPS.
C'est peut être pas grand chose mais multiplié par les 32 lignes de votre sprite, vous économisez 288 NOPS !!!

Changer l'ordre des lignes pour ne plus avoir à calculer l'adresse inférieure.

Parmi ce qui consomme pas mal de cpu dans l'affichage, il y a ce fameux calcul de l'adresse de la ligne inférieure.
Oui souvenez-vous celui la même sur lequel vous vous êtes cassé le nez au tout début de ces cours...

Hors, justement, on va pouvoir s'abstenir de faire ce calcul !!!

Explication:

Prenons nos adresses pour le premier bloc (je mets à coté l'octet de poids fort en binaire):

  • L0: #C000 => %1100 0000
  • L1: #C800 => %1100 1000
  • L2: #D000 => %1101 0000
  • L3: #D800 => %1101 1000
  • L4: #E000 => %1110 0000
  • L5: #E800 => %1110 1000
  • L6: #F000 => %1111 0000
  • L7: #F800 => %1111 1000

Ce qu'on remarque tout de suite, c'est que pour passer de la ligne 0 à la ligne 1, seul un bit change...
Et c'est justement ce qui va nous intéresser: qu'il n'y ait qu'un bit à changer...

Nous avons sur Z80 deux instructions bien utiles: SET bit,r8 et RES bit,r8.

SET bit,r8 vous permet de mettre à 1 le bit numéro "bit" du registre 8 bits "r8".
RES bit,r8 fais le contraire en mettant le bit à 0.

Pour le coup pour passer de la L0 à la L1 on peut le faire simplement en faisant un SET 3,H (en supposant qu'on pointe sur l'adresse avec HL).
Nous sommes maintenant sur la L1. regardons comment en changeant un seul bit on pourrait passer à une autre ligne.

Passer de la ligne L1 à la L2 nous obligerait à modifier 2 bits... En revanche passer de L1 à L3 ne nous fait modifier que 1 seul bit: le bit 4 :)
Du coup nous faisons un Set 4,H.

Sur le même principe pour passer de L3 à L2, seul le bit 3 peut être mis à 0. On utilise cette fois RES 3,H.

Et ainsi de suite ce qui nous donne pour un bloc complet:

  • SET 3,H
  • SET 4,H
  • RES 3,H
  • SET 5,H
  • SET 3,H
  • RES 4,H
  • RES 3,H

Il va de soit que les lignes de votre sprite devront aussi être dans cet ordre.
Cela vous impose donc un post traitement du graph d'origine pour mettre les lignes dans le bon ordre à savoir:

L0,L1,L3,L2,L6,L7,L5,L4

Reste le changement de bloc...
Pour le moment nous avons supprimé le calcul de la ligne inférieur, mais arrivé à la fin d'un bloc il va bien falloir savoir l'adresse du bloc suivant.
Je propose pour cela d'utiliser la pile (mais ce n'est pas une obligation et vous pouvez aussi utiliser d'autres moyens.

Récupérer les adresses des blocs avec la pile.

Un des gros avantages de la pile c'est de sauvegarder ou récupérer des registres 16 bits.
En plus ca ne prend pas grand chose puisqu'un POP vaut 3 NOPs.

L'idée est la suivante: Pour chaque adresse de début de bloc on va stocker celle-ci dans une table.

Rappelez-vous. La pile fonctionne comme une pile d'assiettes. La dernière valeur sauvegardée est la première récupérée.

Dans le fonctionnement, SP pointe sur l'adresse de la pile.
Quand nous faisons un PUSH, SP est décrémenté 2 fois et la valeur est sauvegardée.
Quand nous faisons un POP c'est le contraire, SP est incrémenté 2 fois et on récupère la valeur.

Notre table devra donc contenir l'adresse de chaque début de bloc.
Il suffira donc de placer la pile en début de notre table avec un LD SP,table.
On aura donc juste à faire un POP HL pour récupérer l'adresse du bloc suivant.

Attention cependant: Coupez les interruptions pendant votre routine.
En effet, n'oubliez pas que le moindre saut avec retour posera une valeur dans la pile.
Et c'est donc le cas lors d'une interruption. Avant de sauter, l'adresse PC est sauvegardée dans la pile.
Vous devrez donc obligatoirement couper les ints si vous utilisez la pile.
Quelqu'en soit l'usage d'ailleurs...

Afficher en zigzag pour ne pas avoir à soustraire la largeur.

Et on arrive à une autre astuce qui vous fera gagner encore du cpu.
Puisque notre première ligne affichée est envoyée de la gauche vers la droite, lorsque nous passons à la ligne suivante, nous nous retrouvons en HL+largeur.

Notre astuce de changer l'ordre de lignes tombe plus ou moins à l'eau puisqu'avant de le faire nous devons donc soustraire la largeur...
Soustractraire la largeur c'est une perte de temps et nous pouvons économiser la dessus !

L'astuce va donc consister à afficher la première ligne de la gauche vers la droite, mais la suivante de la droite vers la gauche.
Et on fera ceci en boucle (mais sans boucle ;) )

Ainsi, on économise toutes les soustractions et on économise éventuellement en plus un registre 16 bits...

Auto-générer l'affichage.

La on attaque l'optimisation de façon un peu plus brutale.

Jusque la on stockait d'un coté notre gfx et de l'autre coté nous avions le code pour l'afficher.
En code Auto-généré, notre gfx sera dans le code !!!

Le principe est simple:

En temps normal on utilisait le LDI pour envoyer notre octet de gfx vers l'écran. Un LDI prend 5 Nops.
Nous allons maintenant utiliser l'instruction LD (HL),data (3 NOPs), mais aussi les instructions LD(HL),r8 (2 NOPS).

Prenons le cas juste du LD (HL),data.

Dans le cas ou notre sprite fait 64 octets, l'afficher au LDI, sans parler des calculs d'adresses inférieur et autre, il nous faudrait 64*5 Nops (temps du LDI) pour l'afficher.
Ce qui nous donne: 320 NOPS.

Avec LD (HL),data, il nous faudra tout de même incrémenter l'adresse pour l'octet suivant avec l'instruction INC L (1 NOP). Ce qui nous fait 4 NOPs au total.
64*4=256 NOPs... Nous avons déjà gagné pas mal...

Cela peut paraitre peu mais imaginez sur un écran complet de tiles... Disons pour 192 Tiles...
Vous économisez 12 288 Nops, soit 192 lignes de temps machine !!! Quasiment un écran standard !!!

Le but va être donc de lire chaque octet de notre gfx et de le transformer en code de type LD (HL),data.

Mais attention... Sur un écran de taille standard nous aurons un soucis: Les changement de poids fort pour l'adresse d'affichage.

En effet, si l'on marque tous les changements de poids faible sur l'écran voila ce que nous obtenons:

changement de poids fort à l'écran standard

Et ceci va poser problème... En effet, nous ne pourrons pas nous contenter d'un INC L pour passer à l'octet suivant car par exemple quand nous passons de #C0FF à #C100, un INC L donnerait #C000...

Il faut donc forcement un INC HL à ce moment.

Une première astuce consiste à alterner INC L et INC HL (dans cet ordre là).
Effectivement, le changement de poids fort n'arrivant que sur une adresse impaire, le INC HL résoudra toujours le problème.
C'est la solution qu'il faudra utiliser si vous choisissez d'avoir un écran de largeur supérieure à 64 octets.

On y perd un peu puisque l'envoie d'un octet à une adresse impaire prendra alors 5 NOPs (3 pour le LD (HL),data et 2 pour le INC HL) ce qui vaut autant qu'un LDI.

L'autre astuce vous expliquera pourquoi beaucoup de jeux sur cpc ont un écran de 64 octets de large... et aussi pourquoi cette dimension a été choisie sur spectrum

En effet, en réduisant la taille de l'écran à 64 octets, toutes les adresses de nouveau poids fort se retrouvent alignés à gauche de l'écran comme sur le screenshot suivant:

changement de poids fort sur un ecran de 64 nops

S'en est terminé des INC HL, vous pourrez alors utiliser tout le temps des INC L

L'analyse de votre gfx peut aussi vous permettre d'économiser.

Par exemple si un octet y est plusieurs fois répété, vous pourrez stocker sa valeur dans un registre 8 bits libre et utiliser l'instruction LD (HL),r8.

Mais ce n'est pas tout...

Parlons du cas de sprites masqués.
Vous avez donc d'un coté le sprite et de l'autre le masque.

En temps normal, pour chaque octet vous faites donc:

                            LD            A,(DE)                    ;DE contenant l'adresse du masque
                            AND           (HL)                      ;Suppression de la forme sur le fond
                            LD            C,A                       ;on sauvegarde le résultat
                            INC           E                         ;on passe à l'octet de gfx
                            LD            A,(DE)                    ;on récupère l'octet de gfx
                            OR            C                         ;on l'ajoute au fond
                            LD            (HL),A                    ;on envoie le résultat

C'est assez long et surtout le faire pour chaque octet est inutile...

En effet, réfléchissez un peu...
Pour tout ce qui est autour du sprite, quand le masque est vide, alors tout ceci est inutile puisque l'octet de fond ne sera pas modifié
Pour un octet de masque de valeur 0 on se contentera donc d'incrémenter l'adresse (ou de la décrémenter si on affiche dans l'autre sens).
Pas du tout d'affichage à faire, on économise donc toute la routine de masquage... C'est un gain énorme !!!

Pour un octet de masque=255 c'est la même chose. L'octet est plein. Le fond sera donc entièrement écrasé.
Dans ce cas, il faudra donc afficher un octet de gfx et incrémenter l'adresse. Rien de plus, pas besoin de masquer.

Le seul cas ou il faudra faire un vrai masquage sera donc quand l'octet de masque et différent de 0 et 255 puisque dans ce cas des octets de fond sont conservés et mixé au gfx du sprite.

Le code généré sera donc:

                            LD            A,(HL)                    ;Hl contiendra l'adresse écran
                            AND           octet_masque              ;on donne directement l'octet de masque
                            OR            octet_gfx                 ;on donne directement l'octet de gfx
                            LD            (HL),A                    ;on envoie à l'écran

Sur le principe c'est assez clair, mais il faut générer ce code. Hors de question de le taper à la main.

La encore c'est assez simple. Nous allons pour cela utiliser ce qu'on appelle les opcodes.
Un opcode c'est simplement la valeur de l'octet de l'instruction.

Voici une liste rapide des opcodes dont nous aurons besoin:

INSTRUCTIONOPCODE
LD A,(HL)#7E
AND data#E6,data
OR data#F6,data
LD (HL),A#77
INC L#2C
LD (HL),data#36,data

Pour notre exemple nous prendrons le cas d'un écran de 64 octets de large
Nous pourrons donc faire des INC L pour passer à l'octet suivant.

Le code généré sera initialisé avec HL pointant sur la destination à l'écran

                            LD            HL,sprite                 ;on pointe sur le sprite qui contient entrelacé le masque puis le gfx
                            LD            DE,adr_destination        ;adresse destination du code généré
                ;on passe aux tests            
                            LD            A,(HL)                    ;on lit l'octet de masque
                            OR            A                         ;est-ce égal à 0 ?
                            JP            NZ,masquage               ;si non alors on continue
                                          ;********SI MASQUE NUL*****************************
                                          LD        A,#2C           ;INC L
                                          LD        (DE),A          ;on poke dans le code généré
                                          INC       DE              ;on incrémente l'adresse du code généré
                                          INC       HL:INC HL       ;on incrémente le pointeur de données pour passer au suivant
                                          JP        OCTET_SUIVANT   ;on passe à l'octet suivant
                                          ;**************************************************

                            CP            255                       ;le masque est-il égal à 255
                            JP            NZ,masquage               ;si non alors on continue
                                          ;********Masque plein******************************
                                          LD        A,#36           ;LD (HL),data
                                          LD        (DE),A          ;on poke l'instruction
                                          INC       DE              ;on incrémente le pointeur
                                          INC       HL              ;on va pointer sur l'octet de gfx
                                          LD        A,(HL)          ;on prend l'octet
                                          LD        (DE),A          ;on le poke pour obtenir le data de l'instruction LD (HL),data
                                          INC       DE              ;on incrémente le pointeur du code
                                          INC       HL              ;on incrémente le pointeur du sprite
                                          JP        OCTET_SUIVANT   ;on passe à l'octet suivant
                            ;si on arrive la alors il faut masquer l'octet
                masquage    LD            A,#7E                     ;LD A,(HL)
                            LD            (DE),A                    ;on poke l'instruction dans le code
                            INC           DE                        ;on incrémente le pointeur du code
                            LD            A,#E6                     ;instruction AND data
                            LD            (DE),A                    ;on poke l'instruction dans le code
                            INC           DE                        ;on incrémente le pointeur du code
                            LD            A,(HL)                    ;on relis le masque
                            LD            (DE),A                    ;on poke le data de masque pour l'instruction AND DATA
                            INC           DE                        ;on incrémente le pointeur du code
                            ...
                            ;il faudra ajouter ici la meme chose pour ajouter le OR data
                            ;et le ld (hl),a
                            ;puis le label OCTET_SUIVANT pour passer à la suite

Je ne vous mets pas la routine complète, normalement vous aurez compris le concepte.

Comme vous pouvez le voir, faire des sprites générés nécessite de faire une routine de traitement qui vous prendra un peu de temps.

En cumulant toutes les astuces données, vous pourrez obtenir des routines très rapides
Cependant, il est évident que tout cela prend beaucoup plus de RAM qu'un affichage classique.
C'est donc une technique beaucoup plus adaptée au machines ayant de la RAM en plus comme les 6128.
Mais c'est aussi une technique très pratique sur CPC+ et GX4000 si on utilise une cartouche de 512Ko.
En effet, les routines d'affichage de sprites générés peuvent simplement être appelées par un call...

Affichage à la pile

J'ai presque faillit oublier cette technique bien pratique et bien rapide.

L'affichage à la pile dans le principe c'est assez simple.
Au lieu d'envoyer 1 octet à la fois avec nos différentes méthodes, nous allons les envoyer par deux.

Bien évidement ca ne sera utile que pour les sprites de largeur paire...

Pourquoi envoyer deux octets d'un coup ?
Eh bien parce que pour envoyer nos octets nous allons utiliser l'instruction PUSH.
Et qu'un PUSH ça sauvegarde bien 2 octets d'un coup (HL, DE ou BC pour ce qui nous intéresse les registres indexés prenants 1 Nop de plus...).

Un PUSH c'est 4 Nops... Soit déjà 1 NOP de moins qu'un LDI !!!
Sauf qu'un LDI n'envoi qu'un seul octet alors que notre PUSH en envoi 2... Soit 2 NOPs par octet !!!
Mais c'est autant qu'un LD (HL),A par contre, donc il faut bien étudier les cas pour savoir ce qui sera le plus avantageux.

L’intérêt d'afficher à la pile c'est surtout utile pour des sprites "pleins" c'est à dire sans masquage.
En gros, on envoi des données le plus rapidement possible sans faire d'opérations dessus.

La technique:

Comme on l'a déjà vu (du moins il me semble en avoir déjà parlé), la pile pointe sur l'adresse contenue dans SP.

Quand on fait un PUSH r16 (r16 étant un registre 16 bits), la pile recule, sauvegarde la première valeur (le poids FORT: dans le cas de PUSH HL la première valeur sauvegardée sera donc H); recule et sauvegarde la deuxième valeur (donc le poids FAIBLE, soit L pour un PUSH HL).

L'idée est la suivante: si on veut afficher deux octets en #C000 et #C001, on placera la pile en #C002 avec un LD SP,#C002.
Puis on mettra la valeur de nos octets dans HL et on fera un PUSH.

Mais attention... Remplir nos registres ca prend du temps.
Un LD HL,val ca reste 3 nops. Si bien qu'au final, si on compte le temps de remplir un registre+le PUSH, 3Nops+4=7 Nops pour 2 octets, soit 3.5 Nops par octet...
Il faut donc comme déjà dit voir si c'est vraiment valable...

L’intérêt pourrait être pour des motifs répétés... C'est même surtout la l'intéret.
En effet si vous avez des octets à envoyer qui ne changent pas, alors là vous serez gagnant.
Vous n'aurez plus à recharger les registres et en alignant les PUSH vous enverrez les octets le plus rapidement possible.

Si on charge HL DE et BC dès le début et qu'on les envoies, ça nous fait quand même 6 octets d'envoyés en 12 NOPs.

Le cas très intéressant par exemple est d’effacer l'écran à la pile...
On se place en #0000, on charge un registre avec 00 (ou autre si vous voulez le remplir avec autre chose) et hop on Push, on push...

A noter:

Avant de donner une nouvelle valeur à SP, sauvegardez l'ancienne.
Pour cela utilisez par exemple l'instruction ADD HL,SP (avec un HL=0).
Ce qui vous permettra de récupérer l'adresse de la pile dans HL avant de la sauvegarder.