Blog

Dots tunnel : deux écrans, et n'effacer que les points

29/10/2026effets de démodots tunnelCPCZ80

Le deuxième article a laissé le tunnel sur le CPC dans une première version lente, et qui clignote. Sa mesure accusait l’effacement des 16 Ko de l’écran, alors qu’une image n’allume que 256 octets au plus.

Les deux défauts tombent ici l’un après l’autre. La v2 dessine dans un second écran, caché, et ne clignote plus. La v3 n’efface plus que les octets que le tunnel a allumés. Il restera à mesurer ce que rapporte chaque version, et ce que coûte encore une image quand l’effacement ne pèse presque plus rien.

Pour rappel, une trame du CPC dure 19 968 NOPs, et la v1 en prend 7 par image.

Pourquoi l’image clignote

La v1 dessine dans l’écran affiché, à #C000. Le faisceau balaie donc cet écran pendant que clear16k le vide, puis pendant que les points reviennent un par un. Il montre ainsi près de 5 trames d’un écran noir ou à moitié vide, puis 1 ou 2 trames d’un tunnel qui se reconstruit. L’œil voit les deux, et l’image clignote.

Le remède est connu de toutes les démos : dessiner dans un écran que personne ne regarde, et ne le montrer qu’une fois fini.

Deux écrans

Le CPC n’a pas d’écran fixe. Le CRTC, le circuit qui fabrique les adresses de la mémoire vidéo, lit l’adresse de départ de l’image dans ses registres 12 et 13. Les bits 5 et 4 de R12 choisissent le bloc de 16 Ko : 11 pour #C000, 10 pour #8000. Les bits 3 et 2 fixent la taille de l’écran, 16 Ko quand ils valent 00. Les bits 1 et 0 de R12, avec R13, donnent un décalage dans ce bloc, que le tunnel laisse à 0.

La mémoire a la place d’un second écran. Les tables vont de #4000 à #69FF, le code part de #1000, et le bloc #8000 à #BFFF est libre. Le snapshot de RASM le livre rempli de zéros, donc noir.

Choisir l’écran affiché

show_screen reçoit l’octet haut de l’écran, #C0 ou #80, et l’écrit dans R12. Le CRTC a deux ports : #BCxx choisit le registre, #BDxx y écrit :

; show_screen : le CRTC affichera l'écran de poids fort A (#C0 ou #80)
; à partir de la prochaine image.
show_screen:
        ld bc,#BC0C             ; CRTC : choisit le registre 12
        out (c),c
        rrca
        rrca
        and #30                 ; #C0 -> #30, #80 -> #20
        inc b                   ; #BD : écrit dans le registre
        out (c),a
        ret

Deux rrca descendent les bits 7 et 6 de l’octet haut aux places 5 et 4, et and #30 efface le reste. inc b passe de #BC à #BD sans toucher à C.

Le CRTC ne prend la nouvelle adresse qu’au début de l’image suivante, quand il recommence en haut de l’écran. Écrire R12 juste après le début de la VBL laisse donc l’image en cours se finir sur l’ancien écran. La suivante part du nouveau, en entier.

La boucle

back garde l’octet haut de l’écran de travail, celui où l’on dessine :

back:   db #C0                  ; poids fort de l'écran de travail

Chaque image commence par montrer l’écran que la boucle vient de finir, puis passe à l’autre. xor #40 fait l’échange, car #C0 et #80 ne diffèrent que par le bit 6 :

main:   call wait_vbl
        ld a,(back)
        call show_screen        ; affiche l'écran qu'on vient de dessiner
        ld a,(back)
        xor #40                 ; #C0 <-> #80
        ld (back),a             ; et dessine dans l'autre

        border RED              ; début du travail

        ld a,(back)
        ld h,a                  ; l'écran de travail, caché
        ld l,0
        call clear16k

Au premier tour, back vaut #C0 : la boucle montre l’écran #C000, encore noir, et dessine dans #8000. Au tour suivant, elle montre #8000 et dessine dans #C000, et ainsi de suite.

Il faut relire back après border RED, car la macro border charge sa couleur dans A. Sans cette relecture, clear16k effacerait 16 Ko à partir de #4C00, au beau milieu des tables. Le tunnel se déferait dès la première image. Les anneaux proches laisseraient des cercles pleins, car l’écran de travail ne serait plus jamais effacé, et les autres, dont la forme serait remise à zéro, se réduiraient à leur centre.

Un point

plot ajoutait SCREEN>>8, une constante, à l’octet haut de l’adresse. Il ajoute maintenant back, qui change à chaque image :

        inc h                   ; YHI = YLO + 256
        ld a,(back)
        adc a,(hl)              ; + poids fort, retenue et début de l'écran

ld a,(back) coûte 4 NOPs au lieu de 2 pour ld a,#C0. Sur 256 points, cela fait 512 NOPs par image, une broutille à côté de l’effacement.

Le résultat

Assemblé, tunnel-v2-fr.asm donne tunnel-v2.sna. Lancé dans un émulateur, ce snapshot montre un tunnel qui ne clignote plus. Chaque image affichée est complète, et l’écran dessiné est identique, octet pour octet, à celui de la v1 pour la même valeur de t. On l’a vérifié dans un émulateur Z80, en comparant les 16 Ko de l’écran de travail des deux versions pour les 256 valeurs de t.

La vitesse n’a pas bougé, avec 7 trames par image, car la boucle efface toujours 16 Ko, dans un écran que personne ne voit.

N’effacer que ce qu’on a dessiné

Le tunnel allume 256 points par image, donc 256 octets au plus. Deux points voisins peuvent tomber dans le même octet, et une image en allume donc souvent moins. Pour effacer une image, il suffit de remettre ces octets à 0, à condition de savoir où ils sont.

plot connaît chaque adresse au moment où il l’écrit. Il peut donc la noter dans une liste, et la boucle de l’image suivante relira cette liste pour effacer.

Une liste par écran

Avec deux écrans, l’image qu’on efface n’est pas la précédente. L’écran de travail contient l’image dessinée deux tours plus tôt, puisque entre-temps la boucle a dessiné dans l’autre. Il faut donc une liste par écran, et la liste change en même temps que l’écran.

Chaque liste tient 256 adresses de 2 octets, soit 512 octets. Le bloc #6A00 à #7FFF, entre les tables et le second écran, est libre, et les deux listes s’y alignent sur 256 :

LIST_C0 = #7C00                 ; les octets allumés dans l'écran #C000
LIST_80 = #7E00                 ; et dans l'écran #8000

erase_list garde l’octet haut de la liste de l’écran de travail :

erase_list: db LIST_C0>>8       ; poids fort de sa liste

Des listes prêtes dès la première image

Au premier passage sur un écran, sa liste n’a encore jamais été écrite. Elle ne contient que les zéros du snapshot, et l’effacement écrirait 256 fois à l’adresse #0000, hors de l’écran. fill_list remplit donc chaque liste avec l’adresse du premier octet de son écran :

; fill_list : remplit la liste HL de 256 fois l'adresse A * 256, le premier
; octet d'un écran encore noir : le premier effacement n'y change rien.
fill_list:
        ld b,0
next_entry:
        ld (hl),0
        inc hl
        ld (hl),a
        inc hl
        djnz next_entry
        ret

ld b,0 fait faire 256 tours à djnz, car le premier djnz fait passer B de 0 à 255. Le premier effacement écrit alors 256 fois 0 dans un octet qui vaut déjà 0.

Le programme appelle fill_list une fois par liste, juste après init :

        call init
        ld hl,LIST_C0
        ld a,#C0
        call fill_list
        ld hl,LIST_80
        ld a,#80
        call fill_list

Effacer en suivant la liste

La boucle bascule la liste avec l’écran. #7C et #7E ne diffèrent que par le bit 1, et xor #02 fait l’échange :

        ld a,(erase_list)
        xor #02                 ; #7C <-> #7E : la liste suit l'écran
        ld (erase_list),a

L’effacement remplace ensuite clear16k. IY parcourt la liste, deux octets par adresse, et chaque adresse reçoit un 0 :

        ld a,(erase_list)       ; efface les octets allumés deux images plus tôt
        ld h,a
        ld l,0
        push hl
        push hl
        pop iy                  ; IY parcourt la liste
        ld b,0                  ; 256 adresses
erase:  ld l,(iy+0)
        ld h,(iy+1)
        ld (hl),0
        inc iy
        inc iy
        djnz erase
        pop iy                  ; plot réécrit la liste depuis le début

Le double push hl garde deux copies du début de la liste sur la pile. La première passe dans IY pour l’effacement. La seconde remet IY au début une fois l’effacement fini, pour que plot réécrive la liste par-dessus l’ancienne.

Un octet qui porte deux points figure deux fois dans la liste, et reçoit deux fois 0. Le second effacement ne coûte que du temps, et ne détruit rien : à ce moment, aucun point de la nouvelle image n’est encore dessiné.

Noter chaque adresse

plot note l’adresse qu’il vient d’écrire, juste avant son ret :

        or (hl)                 ; garde les autres pixels de l'octet
        ld (hl),a
        ld (iy+0),l             ; note l'adresse pour l'effacement
        ld (iy+1),h
        inc iy
        inc iy
        ret

Rien d’autre dans le programme ne touche à IY, qui peut rester réservé à la liste pendant toute l’image.

Le tunnel sur le CPC

L’émulateur CPCEC ci-dessous lance tunnel-v3.sna, assemblé à partir de tunnel-v3-fr.asm :

L’image ne clignote pas, et le tunnel va nettement plus vite. La bordure rouge déborde encore sur une deuxième trame. Là aussi, l’écran dessiné est identique, octet pour octet, à celui de la v1 pour la même valeur de t.

Ce que rapporte chaque version

On a mesuré les trois versions comme la v1 dans le deuxième article, en trames par image dans amspirit-lite (6128), sur 1 000 trames. La mesure passe cette fois par sa version sans fenêtre, amspirit-lite-headless :

Version Trames par image Images par seconde
v1 : un écran, clear16k 7 7
v2 : deux écrans, clear16k 7 7
v3 : deux écrans, liste d’effacement 2 25

Le compte à la main explique l’écart. ldir efface 16 Ko en 98 297 NOPs. Un tour de la boucle erase coûte 23 NOPs :

Instruction NOPs
ld l,(iy+0) 5
ld h,(iy+1) 5
ld (hl),0 3
inc iy 3
inc iy 3
djnz erase 4 (3 au dernier tour)

Les 256 tours font 255 × 23 + 22 = 5 887 NOPs, soit 17 fois moins que clear16k. En échange, plot paie 16 NOPs de plus par point pour noter l’adresse (5 + 5 + 3 + 3), soit 4 096 NOPs par image.

Ces 16 NOPs expliquent aussi pourquoi la v3 ne tient pas dans une trame. Le deuxième article comptait déjà plus de 78 NOPs par point. Avec les 2 NOPs de ld a,(back) et les 16 de la liste, il en faut maintenant plus de 96. Les points seuls coûtent donc plus de 256 × 96 = 24 576 NOPs, au-delà des 19 968 NOPs d’une trame, avant même l’effacement. Effacer moins ne servira plus à rien.

Conclusion

La v3 ne clignote plus et tourne à 25 images par seconde, contre 7 pour la v1, avec les mêmes images, octet pour octet. Elle garde pourtant les défauts qui suivent :

Le prochain article s’attaquera au coût de chaque point.

Les fichiers de l’article :

Les deux versions s’assemblent avec RASM 3.0.9.