- Drukuj
- 26 Jan 2023
- Programowanie
- 3118 czytań
- 0 komentarzy
Tekst został opublikowany w Zin80 #4
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.
Zaloguj się , żeby móc zagłosować.