speccy.pl
Facebook Like


SPECCY.PL

[SPECCY.PL PARTY 2023.1]

[WIKI SPECCY.PL]
Polecamy

KOMITET SPOŁECZNY KRONIKA POLSKIEJ DEMOSCENY
PIXEL HEAVEN 2023
AYGOR
Forum ZX Spectrum
Zawartość panelu chwilowo niedostępna
Archiwum plików ZX Spectrum
Nawigacja
[Zin80#4] Oldshool demomaking, część 4

Tekst został opublikowany w Zin80 #4

OLDSCHOOL DEMOMAKING
Część 4
by mat/ESI

W poprzedniej części cyklu (patrz Zin80 nr 2) stworzyliśmy scroller poruszający się w górę i w dół po pustym ekranie. Teraz spróbujemy wprowadzić do niego trochę zmian, dodając mu „smaku” a potem postaramy się całość zoptymalizować. Podstawową zmianą będzie wyświetlanie zamiast pustego ekranu, obrazka, na którym będzie się poruszał nasz scroller. Zaczynamy od dodania przed główną pętlą wypełnienia ekranu naszym obrazkiem. W przykładzie, zamiast obrazka, użyję zawartości ROMu – da to efekt „śmieci” w tle.

Wypełnianie obrazu (tylko bitmapa bez atrybutów) zakłada, że dane obrazka w pamięci nie są zgodne ze standardową organizacją ekranu ZX Spectrum tylko są liniowym zrzutem danych. To powoduje co prawda, że ewentualny obrazek trzeba wcześniej przygotować, i to, że samo kopiowanie na ekran jest bardziej skomplikowane niż proste LDIR, ale za to przy kasowaniu scrollera nie musimy dwa razy przeliczać pozycji wg organizacji ekranu i wystarczą prostsze obliczenia.

Zaczynamy od załadowania do HL adresu źródłowego dla danych obrazka (etykieta image w naszym wypadku ustawiona na $0000) i adresu początku pamięci ekranu do DE.

image: equ $0000
    ld hl,image
    ld de,$4000

Pierwsza pętla ma 3 przebiegi – to tercje ekranu. Przed wejściem do drugiej pętli zapamiętujemy BC i DE na stosie i ładujemy do B licznik drugiej pętli – 8 to liczba linii znakowych w każdej tercji. Licznik trzeciej pętli w C to 8 linii w każdej linii znakowej. Przed samym kopiowaniem zrzucamy na stos DE i BC, ponieważ kopiowanie przy użyciu LDI modyfikuje obie te pary rejestrów. Kopiujemy 32 bajty czyli jedną linię bitmapową, a następnie podnosimy BC i DE, modyfikujemy DE – zwiększenie D powiększa DE o 256 czyli odległość do kolejnej linii w ramach tego samego znaku. Na koniec domykamy pętlę wewnętrzną z licznikiem w C. Drugą pętlę kończymy podobnie – podnosimy DE wskazujące na początek linii znakowej, którą właśnie skopiowaliśmy i przy pomocy HL (i stosu do tymczasowego przechowania wartości) powiększamy DE o 32, czyli odległość między kolejnymi liniami znakowymi w jednej tercji ekranu. Pętlę domyka DJNZ. W zewnętrznej pętli podnosimy najpierw DE (wskazujące na początek tercji, którą właśnie skończyliśmy wypełniać), dodajemy do D $08 za pomocą A zwiększamy tym samym DE o $0800 (dziesiętne 2048) czyli rozmiar tercji ekranu. Na koniec podnosimy BC i domykamy pętlę przez DJNZ.

Kod nie jest ani prosty ani optymalny, ale pozwala oszczędzić trochę obliczeń tam gdzie jest to istotne.

    ld b,3
fill_screen0:
    push bc
    push de
    ld b,8
fill_screen1:
    ld c,8
    push de
fill_screen2:
    push de
    push bc
    rept 32
      ldi
    endm
    pop bc
    pop de
    inc d
    dec c
    jr nz,fill_screen2
    pop de
    push hl
    ld hl,32
    add hl,de
    pop de
    ex de,hl
    djnz fill_screen1
    pop de
    ld a,$08
    add a,d
    ld d,a
    pop bc
    djnz fill_screen0

Dalszy ciąg kodu jest identyczny aż do punktu w procedurze copy_buffer, w którym zaczynamy samo kopiowanie danych. Zamiast dotychczasowego kodu mamy taki:

    ld hl,scroll_buf-32
    ld b,10

Zmieniliśmy adres początku kopiowanych danych o 32 oraz licznik linii do 10. Zmiany te dodadzą do scrollera jednopikselowe linie na całą szerokość ekranu na samej górze i na samym dole scrollera, dzięki czemu będzie się on lepiej odcinał na tła. Zmiany te wymagają też modyfikacji definicji buforów na końcu programu. Dyrektywa kompilatora ds rezerwuje miejsce w pamięci o podanej liczbie bajtów. Jeśli ma ona drugi parametr, to ta właśnie wartość jest wykorzystywana do wypełnienia bloku pamięci. Dzięki dodaniu dwóch ds dostajemy przed i za buforem jednopikselowe linie.

            ds 32,$ff
scroll_buf: ds 256
            ds 32,$ff

Cała reszta procedury kopiowania jest identyczna jak dotychczas. Następne zmiany pojawiają się w procedurze kasowania starej zawartości clear_scroll. Pobieranie pozycji scrollera jest takie samo jak dotychczas, zmiana pojawia się po pobraniu pozycji – dodajemy jak dotychczas 50 do numeru linii a potem tę wartość odkładamy na stos – będzie nam potrzebna później do wyliczenia pozycji w obrazku tła.

    ld a,(hl)
    add a,50
    push af

Wyliczenie adresu na ekranie jest takie jak dotychczas i na koniec adres na ekranie, pod którym musimy „skasować” poprzednią wersję scrollera, mamy w HL. Potrzebny jest nam jeszcze adres w pamięci, pod którym znajduje się nasz obrazek. Podnosimy ze stosu zapamiętany wcześniej numer linii, ładujemy go do DE a następnie zamieniamy z HL (w którym mamy adres ekranu – będzie nam potrzebny) i numer linii mnożymy przez 32 po czym dodajemy adres początku obrazka, używając BC (w tym wypadku $0000). Po zakończeniu mamy w DE adres na ekranie, do którego mamy przekopiować 10 linii z obrazka, a w HL adres w obrazku skąd mamy pobierać dane.

    pop af
    ld d,0
    ld e,a
    ex de,hl
    add hl,hl
    add hl,hl
    add hl,hl
    add hl,hl
    add hl,hl
    ld bc,image
    add hl,bc

Pętla „kasująca” wygląda podobnie jak kopiowanie danych z bufora. Najpierw do B ładujemy licznik linii – 10 czyli wysokość scrollera i dwie linie ograniczające. Potem w pętli odkładamy na stos BC i DE i kopiujemy 32 bajty używając ciągu LDI.

    ld b,10
clear_loop1:
    push bc
    push de
    rept 32
      ldi
    endm

Po skopiowaniu danych musimy wyliczyć adres następnej linii ekranu – podnosimy DE i tak jak przy kopiowaniu scrollera przeliczamy adres a na koniec podnosimy BC i zamykamy pętlę kopiującą kolejne linie.

    pop de
    inc d
    ld a,d
    and 7
    jp nz,clear_skip
    ld a,e
    add a,32
    ld e,a
    jp c,clear_skip
    ld a,d
    sub 8
    ld d,a
clear_skip:
    pop bc
    djnz clear_loop1
    ret

Po skompilowaniu i uruchomieniu na ekranie pojawi się nasz obrazek, nad którym będzie się poruszał scroller. W praktycznym zastosowaniu obrazek byłby raczej przygotowany wcześniej i np. rozpakowany do pamięci, żeby ograniczyć rozmiar pliku. Wiedząc dokładnie gdzie i jaki duży jest zakres ruchu naszego scrollera, nie musimy również przechowywać w pamięci całego obrazka a tylko ten jego fragment, który faktycznie jest potrzebny jako tło. Oczywiście wszystko to zacznie mieć znaczenie praktyczne dopiero wtedy, kiedy zaczniemy pisać demo wykorzystujące pokazane tutaj algorytmy.

Kolejnym krokiem będzie pewna optymalizacja całego kodu. Zanim jednak do tego dojdziemy dobrze byłoby przynajmniej z grubsza orientować się, ile czasu procesora w odniesieniu do czasu dostępnego w jednej ramce zajmuje nasz kod. Najprostszą metodą nie wymagającą dodatkowych narzędzi jest użycie ramki ekranu jako wskaźnika. W tym celu modyfikujemy trochę główną pętlę programu.

main_loop:
    halt
    ld a,1
    out ($fe),a
    call clear_scroll
    ld a,2
    out ($fe),a
    call one_scroll_buffer
    ld a,3
    out ($fe),a
    call copy_buffer
    ld a,7
    out ($fe),a

Dodane instrukcje OUT z kolejnymi wartościami w A zmieniają kolor ramki – na początku po synchronizacji na niebieski, potem po kasowaniu poprzedniej wersji scrollera na czerwony, po przygotowaniu następnej wersji na fioletowy i na koniec po skopiowaniu bufora na biały. Po skompilowaniu i uruchomieniu programu zobaczymy po pierwsze, że kasowanie poprzedniej wersji mieści się całkowicie w okresie powrotu pionowego (jeśli nasz emulator ma możliwość wyświetlania pełnego BORDERa, nawet tych jego fragmentów, które normalnie są niewidoczne, będzie widać, że niebieski kawałek to mniej więcej 1/4 całego czasu wykonania naszego kodu). Czerwona część to samo przesuwanie scrollera – widać, że zajmuje prawie dwa razy więcej czasu. Ostatni – fioletowy – segment to kopiowanie nowego scrollera na ekran – zajmuje mniej więcej tyle samo czasu co kasowanie. Tutaj też widać skąd się wzięło 50 linii przesunięcia – jeśli zmienimy występującą w programie wartość 50 na mniejszą, to po uruchomieniu programu będzie widać wyraźnie w którym miejscu mijamy się z rastrem – część scrollera z góry albo z dołu nie będzie widoczna. Zależnie od przesunięcia będzie to albo fragment górny, bo poprzedni już został skasowany a nowy jeszcze nie został narysowany, albo dolny, kiedy przy przejściu rastra zdążyliśmy już narysować kawałek scrollera, ale reszta jeszcze nie pojawiła się na ekranie. 50 linii to w wypadku naszego scrollera bezpieczny punkt, w którym raster nigdy nie „dogoni” kodu i zawsze wyświetlany będzie cały efekt.

Teraz kiedy już widać ile czasu zajmuje nasz kod, możemy spróbować go przyspieszyć. Nie będziemy się zajmować liczeniem pojedynczych taktów procesora i wyciskaniem z niego ile się da – takie zajęcie wykracza poza ramy tego tekstu. Spróbujemy za to wyeliminować przeliczanie adresów w pamięci ekranu, oraz zrezygnować z pętli tam, gdzie jest to możliwe. Efekt nie będzie prawdopodobnie bardzo spektakularny, ale powinniśmy zaoszczędzić trochę czasu. Niestety trzeba się liczyć z tym, że zwykle taki proces przyspiesza wykonanie, ale często powoduje powiększenie kodu programu.

Pierwsza rzecz, którą dodamy do kodu to makro, które będzie nam przydatne później.

pad macro size
    if (size > 1 ) and (($ mod size) != 0)
        ds size-($ mod size),0
    endif
endm

Kod dodajemy bezpośrednio za linią ORG. Makro w Pasmo (jak i innych kompilatorach) pozwala na użycie w kodzie źródłowym krótkiej formy, która przy kompilacji zostanie zamieniona na odpowiedni – zwykle dłuższy – kod wynikowy. W tym wypadku makro pad służy do wstawienia do kodu wynikowego pewnej liczby bajtów zerowych tak, aby następna generowana linia zaczynała się od adresu podzielnego przez podany parametr. Do czego będzie potrzebne, przekonamy się za chwilę.

Pierwszy kawałek kodu, który zmienimy to procedura przesuwania bufora – dotychczas używaliśmy w niej dwóch pętli, za każdym razem pobierając bajt z pamięci, przesuwając go i zapisując z powrotem w to samo miejsce. Pozbędziemy się pętli i zamienimy przesuwanie na operację bezpośrednio w pamięci. Zmiany zaczynają się od do_scroll. Tak jak wcześniej do HL ładujemy adres końca pierwszej linii bufora, a do DE adres bufora znaku, potem jednak ładujemy do BC wartość 63, która będzie nam potrzebna za moment.

do_scroll:
    ld hl,scroll_buf+31
    ld de,buf
    ld bc,63

Dalej otwieramy dyrektywę rept, która podaną jako parametr liczbę razy powtórzy kompilację całego kodu, aż do odpowiedniej dyrektywy endm. Konstrukcję tę można zagnieżdżać, dzięki czemu zamiast tracić czas na obsługę pętli, zużywamy więcej miejsca w pamięci na szybszy kod. Wewnątrz naszego pierwszego bloku zaczynamy tak jak dotychczas, w pętli zewnętrznej – przesuwamy bajt z bufora znaku w lewo z wysunięciem najstarszego bitu do Cy. Zwiększamy też adres w DE – przesuniemy lokalizację bufora w pamięci tak, żeby na pewno wystarczyła operacja tylko na młodszym bajcie.

    rept 8
      ld a,(de)
      rla
      ld (de),a
      inc e

Kolejne rept to odpowiednik wewnętrznej pętli – wykonujemy 31 razy przesunięcie zawartości komórki adresowanej przez HL i zmniejszenie L. Przesunięcie bezpośrednio na pamięci to 15 taktów procesora, dotychczasowy blok ld a,(hl)/rla/ld (hl),a to odpowiednio 7+4+7=18 taktów. Niby niewiele, ale jeśli powtórzymy to 256 razy zyskujemy ponad trzy linie rastra.

Wyjaśnienia wymaga dlaczego rept powtarza tylko 31 razy – chodzi o to, że główny bufor wyrównamy do granicy $100, dzięki czemu mamy pewność, że cały bufor można indeksować tylko młodszym bajtem adresu. Wykonanie dec l po raz trzydziesty drugi spowodowałoby jednak w pierwszej linii niepoprawny przeskok stąd pomijamy ostatnie zmniejszenie L, wykonujemy przesunięcie pamięci i… dodajemy do HL BC, w którym znajduje się wartość 63. Dzięki temu HL wskazuje teraz na koniec drugiej linii bufora (kompensujemy 31 powtórzeń dec l, które zrobiliśmy plus 32 bajty następnej linii). Kończymy powtarzany blok i całą procedurę.

      rept 31
        rl (hl)
        dec l
      endm
      rl (hl)
      add hl,bc
    endm
    ret

Przeskakując na koniec kodu wyjaśnijmy w skrócie zmiany, które tam robimy. Polegają one głównie na zmianie kolejności i dodaniu „wyrównania” makrem pad. Dane, które nie wymagają wyrównania przenosimy na początek tego fragmentu. Główny bufor wyrównujemy do granicy $100 upewniając się, że będzie go można adresować używając tylko młodszego bajtu adresu.

Następnie dodajemy tablicę adresów poszczególnych linii ekranu, której będziemy używać zamiast przeliczania numeru linii na adresy. Tablica scr_tab zostanie umieszczona zaraz za głównym buforem, czyli również będzie się zaczynała od granicy $100. Adresy w niej zawarte wyliczamy używając znanej już dyrektywy rept z dodatkowym parametrem – nazwą zmiennej lokalnej, pod którą przypisywane są kolejne wartości powtórzeń. Kolejne powtórzenia to oczywiście 3 tercje, 8 linii znakowych i 8 linii na znak, a wyrażenie podane jako parametr dla dw wyliczy adres, mnożąc poszczególne liczniki przez odpowiednie wartości (odpowiednio 2048, 32 i 256). Tablica ma długość 384 bajtów i umieszczenie zaraz za nią bufora znaku daje pewność, że nie trafi on pomiędzy bloki adresowalne tylko młodszym bajtem. Na koniec pozostaje jeszcze tylko tablica sin_tab, którą również wyrównujemy do $100.

bpos:       db 0
text:       db "oldschool demomaking, czesc4 (c) 2020 mat " 
            db "dla Zin80...", 13

            pad $100
scroll_buf: ds 256

scr_tab:		
    rept 3,tc
      rept 8,cc
        rept 8,lc
          dw $4000+tc*$0800+cc*$20+lc*$100
        endm
      endm
    endm

buf:        ds 8

            pad $100
sin_tab:    incbin sin.bin

Kolejna optymalizowana procedura to copy_buffer. Tutaj zmiany wprowadzamy już od samego początku. Pozycję w „tablicy sinusów” zapisujemy do L i od razu zwiększamy i przycinamy do długości tablicy. Do H ładujemy, używając operatora high starszy bajt adresu „tablicy sinusów”, w efekcie przesunęliśmy wskaźnik, i pozycję w tablicy mamy w tym momencie w HL.

copy_buffer:
sin_pos: equ $+1
    ld a,0
    ld l,a
    inc a
    and $7f
    ld (sin_pos),a
    ld h,high sin_tab

Następny fragment kodu zastępuje wyliczanie adresu na ekranie na podstawie numeru linii. Zaczynamy od pobrania wartości z tablicy, dodajemy do niej 50, czyli naszą początkową pozycję, i mnożymy wartość przez 2. Tablica sinusów ma amplitudę 64, dodając 50 nie przekraczamy wartości 128, czyli po przemnożeniu przez 2 nadal mieścimy się w jednym bajcie bez przeniesienia. Tak przeliczoną wartość zapisujemy do zmiennej restore_pos, której będziemy używać w procedurze kasowania starej wersji scrollera. Następnie wartość tę zapisujemy do L a do H ładujemy starszy bajt adresu tablicy scr_tab, poprzez stos przepisujemy ten adres do IX i zapisujemy do zmiennej restore_addr w procedurze kasowania.

    ld a,(hl)
    add a,50
    add a,a
    ld (restore_pos),a
    ld l,a
    ld h,high scr_tab
    push hl
    pop ix
    ld (restore_addr),hl

Adres, który wyliczyliśmy, wskazuje na adres linii ekranu, w której mamy wyświetlić zawartość bufora. Odczytujemy ten adres do HL zwiększając za każdym razem IX (skrót (ix) oznacza (ix+0), ale wszystkie kompilatory pozwalają pomijać zerowe przesunięcie). Następnie, zamiast kopiować górną (i później dolną) linię z pamięci, ładujemy do A wartość $ff (czyli ustawione wszystkie piksele w bajcie) i 32 razy powtarzamy ładowanie do pamięci i zwiększanie L (a co za tym idzie HL, która nie może przekroczyć granicy jednego bajtu).

    ld l,(ix)
    inc ix
    ld h,(ix)
    inc ix
    ld a,$ff
    rept 32
      ld (hl),a
      inc l
    endm

Zamiast pętli oczywiście ponownie użyjemy rept – ładujemy do HL adres bufora, 8 razy pobieramy do DE kolejny adres z tablicy adresowanej przez IX i kopiujemy 32 bajty. Dzięki pominięciu pętli nie musimy zapamiętywać BC na stosie.

    ld hl,scroll_buf
    rept 8
      ld e,(ix)
      inc ix
      ld d,(ix)
      inc ix
      rept 32
        ldi
      endm
    endm

Na koniec pozostaje jeszcze tylko narysować dolną kreskę – pobieramy adres kolejnej linii do HL i 32 razy ładujemy A, które nadal zawiera $ff pod HL, za każdym razem zwiększając L.

    ld l,(ix)
    inc ix
    ld h,(ix)
    rept 32
      ld (hl),a
      inc l
    endm
    ret

Ostatnia procedura do optymalizacji to oczywiście kasowanie poprzedniej wersji scrollera. Najpierw do IX ładujemy adres tablicy ekranu – w tym miejscu jest to robione wyłącznie dla bezpieczeństwa pierwszego wykonania, każde kolejne wykonanie będzie miało w tym miejscu wyliczony w procedurze kopiowania odpowiedni adres do tablicy. Następnie do HL ładujemy 0 – również dla bezpieczeństwa – docelowo będzie tam wyliczona wcześniej pozycja scrollera do skasowania, przesunięta o 50 i pomnożona przez 2. Mnożymy tę pozycję dodatkowo przez 16 i dodajemy adres początku naszego obrazka w pamięci.

clear_scroll:
restore_addr: equ $+2
    ld ix,scr_tab
    ld h,0
restore_pos: equ $+1
    ld l,0
    add hl,hl
    add hl,hl
    add hl,hl
    add hl,hl
    ld bc,image
    add hl,bc

Następnie 10 razy wykonujemy operację odczytu adresu z tablicy adresów ekranu (poprzez IX) i kopiowanie 32 bajtów danych – z obrazka do ekranu. I to cała procedura – zniknęło, tak jak wcześniej, wyliczanie pierwszego adresu oraz przeliczanie adresów kolejnych linii.

    rept 10
      ld e,(ix)
      inc ix
      ld d,(ix)
      inc ix
      rept 32
        ldi
      endm
    endm
    ret

Po skompilowaniu programu widać wyraźnie na ramce, że zaoszczędziliśmy ponad 30 linii rastra kosztem tego, że wynikowy TAP powiększył się ponad trzykrotnie (z ~1100 do ~3800 bajtów). Taki zysk może się wydawać niewielki, jednak w praktyce może to oznaczać, że w nasze demo zmieścimy jeszcze jeden efekt graficzny, dla którego wcześniej nie mieliśmy czasu.

Brak komentarzy. Może czas dodać swój?
Dodaj komentarz
Zaloguj się, aby móc dodać komentarz.
Oceny
Tylko zarejestrowani użytkownicy mogą oceniać zawartość strony
Zaloguj się , żeby móc zagłosować.

Brak ocen. Może czas dodać swoją?