Sztuczki w "Action!" by TDC 2009-05-06 01:35:56

Tomasz "TDC" Cieślewicz:

Po ostatnim artykule Yosha pod tytułem "Gra akcji w "Action!", zdecydowałem się skomentować kilka tematów poruszonych przez niego odnośnie planowanej gry Robbo Dash. Może moje uwagi przydadzą się też osobom mającym zamiar rozpocząć przygodę z językiem "Action!".



1. Deklaracja zmiennych

W "Action!" mamy dość szczególne podejście, w którym trzeba odróżnić deklarację zmiennej od jej wykorzystania. W deklaracji, na przykład CARD SCORE, możemy dokonać przypisania konkretnego adresu, w którym zmienna będzie przechowywała wartość. Natomiast w użyciu zmiennej, na przykład:

PLOT(SCORE,50)
SCORE=0


już nie operujemy na adresach, lecz na wartości przechowywanej albo w konkretnym adresie, albo w kolejnym wolnym jaki przydzieli kompilator. Jest to o tyle istotne, że w innych językach programowania jesteśmy przyzwyczajeni do tego, aby przy deklaracji zmiennej już nadawać jej wartość początkową. Dlatego trzeba uważać, bo w "Action!" w ten sposób się określa adres (można tym błędem nawet zawiesić Atari). W "Action!" też jest to możliwe, na przykład:

CARD SCORE=[0]

czyli jak widać robi się to dość specyficznie, szczególnie na tle języków dziś popularnych jak C/C++, Java i tym podobnych.

2. BYTE RND = $D20A

Ten niezwykle wydajny trick jest bardzo ważny dla programistów gier. Dobrze jest, jeśli w grze nie ma ani jednego wykonania bibliotecznej funkcji losowej, jest ona dość powolna, co w grach jest niedopuszczalne. Proporcje prędkości są w przybliżeniu takie, jak pomiędzy POKE w "Action!" i POKE w "Atari Basicu". Dlatego wszystkim polecam RND. Pytanie - jak to zrobić, skoro RAND() jest bardzo wygodne w użyciu, a w grach istotne jest losowanie z różnych zakresów oraz nawet z różnym rozkładem? Pierwszym problemem jest zagadnienie parametryzowania zakresu otrzymywanych wartości losowych, gdyż wspomniany RND zwraca wartości tylko z zakresu od 0 do 255. Jednak trzeba sobie z tym poradzić i oczywiście przy tym należy pamiętać co jest szybkie, a co nie. Najprostszym rozwiązaniem są proste obliczenia, przykładowo jeśli chcemy osiągnąć wartości z zakresu od 0 do 40 piszemy:

RND/6,3

Oczywisty problem jest taki, że po pierwsze nie jest zbyt łatwo wyznaczyć zakres - nie jest to tak intuicyjne, jak w przypadku klasycznych funkcji losowych, które zwracają wartości ułamkowe z zakresu od 0 do 1 (czyli wystarczy jedynie pomnożyć przez wybrany zakres, na przykład 40). Tu zakres został wyznaczony następująco: RND / (255/40).

Następnym problemem jest to, że w "Action!" nie ma wartości zmiennopozycyjnych, a to może czasami utrudnić sprawę lub spowodować jakieś trudne do wykrycia błędy. W tym wypadku zapis 255/40 po przeliczeniu dla wartości stałopozycyjnych faktycznie spowoduje ustalenie zakresu do 42 - czyli dość niedokładnie, choć na przykład w grze skok o kilka jednostek nie będzie miał większego znaczenia. Mimo wszystko tak do tematu podchodzić nie można z powodów związanych z wydajnością. Mamy tu albo dwa albo jedno dzielenie, które nie powinno się tu znaleźć. W przypadku pisania gier czy nawet dem zdecydowanie odradzam mnożenia i dzielenia, gdyż w "Action!" to nie będzie szybkie, a szkoda, aby tracić sporo cennego czasu CPU na tak nieistotne elementy jak otrzymanie wartości losowej.



Rozwiązań problemu może być wiele, w szczególności są to rozwiązania takie, które cały program tak zmieniają koncepcyjnie, że ten nie wymaga wartości innych niż te, które otrzyma. Można to zrealizować na wiele sposobów, ja zaproponuję najprostszy: załóżmy, że chcemy aby przeciwnik strzelał z prawdopodobieństwem wynoszącym 0,25 (czyli 25%). Oto nieco zoptymalizowany przykład w pseudokodzie C/C++:

a=rand()%4;
if (b==0)
{
........... // tu odbywa się zainicjowanie nowego
wystrzału przeciwnika
}


Gdy jednak chcemy to prawdopodobieństwo nieco zaawansować to trzeba na przykład używać mnożeń do skalowania i innych przekształceń. Powyższy kod w "Action!" wygląda następująco:

A=RAND(4)
IF A=0 THEN
.....
FI


A teraz czas na nowe podejście, które rezygnuje z bibliotecznej procedury RAND() i opiera swe działanie jedynie na zmiennej RND:

IF RND<64 THEN
...
FI


Wartość 64 to 25% z maksymalnej wartości jaką przyjmuje zmienna RND czyli 255. Przykładowo 50% to będzie 128. Wyznaczanie bardziej skomplikowanych wartości wymaga prostych obliczeń związanych ze wzorem, o którym wcześniej pisałem - jednak dobrze jest nie prowadzić żadnych obliczeń w programie (czyli wyznaczania procentów i tym podobnych).

Powszechnie wiadomo, że jeśli coś jest niezwykle skomplikowane to autorzy demek sobie w tablicach przechowują najistotniejsze dane, w szczególności są to bardzo duże tablice, na przykład do jakiś dłuższych i fajnych animacji. Jednak duże tablice w przypadku komputerów ośmiobitowych to spory problem... Niejednokrotnie robiłem tak, że gdy chciałem generować dużo wartości losowych tak aby całość kodu działała w czasie rzeczywistym, robiłem dużą tablicę ze specjalnie wygenerowanymi wartościami losowymi. Często trzeba było tę tablicę zwiększać, bo na przykład w generowanych efektach było widać jakieś "podejrzane" cykle i tym podobne. Jednak zwiększanie tablicy zwykle nie jest możliwe, gdy mamy do dyspozycji małą pamięć komputera ośmiobitowego.

Zaprezentowane tu rozwiązanie z "IF" jest niemal tak szybkie jak odwołanie do danych z tablicy (szybkość tu zależy od kompilatora), lecz nie zajmuje ani trochę miejsca w pamięci. POKEY generuje nam dane, a te są właśnie takie jakich potrzebujemy... To jest niezwykła cecha komputerów Atari. Inne komputery, które nie mają sprzętowego generatora liczb pseudolosowych lub mają to rozwiązane bardzo słabo, często muszą stosować półśrodki (np. tablice), natomiast rozwiązanie na małym Atari jest najwydajniejsze, a przy okazji podawane przez POKEY-a wartości mają bardzo dobre cechy (losowości), w przeciwieństwie do np. liczb zapisanych w tablicy.

3. Test szybkości

Poniżej znajduje się przykład, który przedstawia wydajność kolejnych rozwiązań w "Action!". Wydajność jest mierzona w podobny sposób jak w ostatnim moim tekście, czyli ilość kolejnych kolorowych linii rastra ukazuje nam wydajność danej metody. Przeprowadzane testy są ilustrowane kolorami:

MODULE

BYTE A

BYTE C=53274
BYTE K=764
BYTE RND=$D20A

PROC AS()

FOR A=0 TO 20
DO
PRINTBE(A)
OD

WHILE K<>28 DO

; SZARY:
; RAND() Z BIBLIOTEKI ACTION
; IF Z 25%
[173 $D40B 201 20 208 249]
C=$9
A=RAND(4)
IF A=0 THEN
;....
FI
C=0

; BRAZ:
; BEZ BIBLIOTKI LICZ. ZAKR. 0-40
[173 $D40B 201 30 208 249]
C=$19
A=RND/(255/40)
C=0

; ROZ:
; BEZ BIBLIOTEKI - IF Z 25%
[173 $D40B 201 40 208 249]
C=$39
IF RND<255/4 THEN
;....
FI
C=0

; ZIELONY:
; BEZ BIBLIOTEKI I BEZ OBLICZEN
; METODA YOSHA Z ROBBO DASH
; IF Z 25%
[173 $D40B 201 50 208 249]
C=$BB
IF (RND AND 31)<5 THEN
;....
FI
C=0

; NIEBIESKI:
; BEZ BIBLIOTEKI I BEZ OBLICZEN
; IF Z 25%
[173 $D40B 201 60 208 249]
C=$9B
IF RND<64 THEN
;....
FI
C=0

; ZIELONY DRUGI: CYKLE CPU:
[173 $D40B 201 115 208 249]
POKE($D40A,1)

; PRZESUNIECIE NA SRODEK EKRANU:
FOR A=0 TO 5 DO [234] OD
[234] [234] [234] [234] [234]
C=$BB
IF (RND AND 31)<5 THEN
;....
FI
C=0

; NIEBIESKI DRUGI: CYKLE CPU:
[173 $D40B 201 120 208 249]
POKE($D40A,1)

; PRZESUNIECIE NA SRODEK EKRANU:
FOR A=0 TO 5 DO [234] OD
[234] [234] [234] [234] [234]

C=$9B
IF RND<64 THEN
;....
FI
C=0

OD
K=42
[96]


Ostatnie dwa testy zostały powielone w linii 115 i 120, gdyż wcześniej nie było dokładnie widać ile faktycznie zajmują czasu te IF-y. Dzięki temu powieleniu zawartość 0 trybu tekstowego nie zasłania tego czego nie widać, czyli tego, że oba paski są bardzo krótkie (zajmują po kilka cykli CPU). Jak widać wydajność zaproponowanej metody z samym "IF" jest kilkudziesięciokrotnie szybsza od bibliotecznej procedury RAND(). Czyli nawet taki wolny komputer jak Atari może sobie dowolnie szaleć z wartościami losowymi i odpowiednimi reakcjami na nie.



Zarówno metoda IF oraz Yosha z AND mają podobną wydajność (zielona jest wolniejsza o około 1 cykl CPU), jednak każda ma swoje zastosowanie. Wersja z AND może być przykładowo nieco mniej czytelna dla początkujących programistów. Ciekawostką, która pewnie ucieszy programistów w Action! jest przedostatnia linijka "k=42" ;)

Warto zaznaczyć, że testy szybkości zaprezentowane w liniach 115 i 120 przenoszą nas w nową strefę wydajności, gdyż wydajność nie jest już liczona w linijkach rastra lecz w pojedynczych cyklach CPU. Przeprowadzane testy w latach 90-tych na pecetach (w zmienianiu kolorów linijek ekranu - co jest dla peceta dużym wyzwaniem) ujawniły, że 486 SX 50 MHz może najwyżej zmienić kolor 1 linijki czyli mierzenie wszelkiej wydajności nigdy nie spadnie poniżej tej wielkości... Dziś gdy są pisane gry na peceta, czy konsole to raczej nie dba się o takie rzeczy i wszystkie wartości można najczęściej osiągnąć poprzez proste przekształcenia matematyczne, jednak zaproponowane tu techniki okiełznania zagadnienia oraz optymalizacji wydajności będą się sprawdzały również na innych platformach sprzętowych (choć będą problemy z brakiem POKEYa :D). Tyle, że poza silnikami graficznymi to się z reguły nikt w takie rzeczy nie bawi. Jednak na Atari szybkość jest na tyle duża, że warto wykorzystywać to co w Action! jest najszybsze.

4. SCREEN(4+40*10) = 128

Odwołania do pamięci zaprezentowane przez Yosha za pomocą tablicy znajdującej się w konkretnym adresie są znacznie szybsze od polecenia na przykład POKE(ADR+4+40*10,128), gdyż POKE to procedura biblioteczna, czyli procesor wykonuje: skok do procedury bibliotecznej, przekazuje parametry, wstawia żądaną wartość we wskazane miejsce i wykonuje powrót. Natomiast w operacjach na tablicy nie jest wykonywany żaden skok. Przykład testu wydajności obu rozwiązań (problem mnożenia został umyślnie pominięty):

MODULE

BYTE C=53274, K=764
BYTE X,Y ; ATRAPY ;)
BYTE ARRAY SCREEN=40000

PROC AS()
POKE(559,0)

X=5 Y=0

WHILE K<>28 DO
[173 $D40B 201 50 208 249]
POKE($D40A,1)
C=$BB
POKE(40000+X+Y,1)
C=0

[173 $D40B 201 60 208 249]
POKE($D40A,1)
C=$9B
SCREEN(X+Y)=1
C=0

[173 $D40B 201 80 208 249]
POKE($D40A,1)
[234][234][234][234][234][234][234][234][234][234][234]
C=$BB
POKE(40000+X+Y,1)
C=0

[173 $D40B 201 90 208 249]
POKE($D40A,1)
[234][234][234][234][234][234][234][234][234][234][234]
C=$9B
SCREEN(X+Y)=1
C=0

OD
POKE(559,34)
K=42
[96]


Pierwszy test w kolorze zielonym przedstawia szybkość wykonania procedury bibliotecznej POKE() wraz z najczęściej w praktyce spotykanym zestawieniem parametrów czyli X+Y. Następny niebieski test ukazuje tą samą operację, ale wykonaną za pomocą tablicy SCREEN. Oba testy zostały powielone (i przesunięte) ze względu na fakt, że część z wykresów znajdowała się poza widoczną częścią ekranu (szczególnie w emulatorze). Jednak w tym wypadku nie mieści się na ekranie test zielony - mimo wszystko teraz widać rząd wielkości jaki różni oba rozwiązania.

Należy oczywiście pamiętać, że faktyczna wydajność tego kodu będzie o ~30% mniejsza, o ile zostanie on wykonany podczas rysowania ekranu przez procesor Antic (czyli tak jak zostało to ukazane w poprzednim przykładzie). W drugim programie procesor graficzny został wyłączony, aby precyzyjnie na „wykresie” zaprezentować ilość cykli procesora.



W tym przykładowym programie testującym szybkość procedury bibliotecznej i odwołania poprzez tablicę - widoczna jest różnica, która nie jest tak druzgocząca (wynosi około 30%), jednak to test pojedynczej operacji wyświetlenia znaku. W skali programu zawierającego na przykład sto takich operacji - zysk wydajności będzie dość znaczący.

Narzut wykonania procedury bibliotecznej jest porównywalny z całkowitym czasem wykonania IF z AND z poprzedniego programu testującego (kolor zielony). Ta różnica to właśnie czas jaki CPU potrzebuje na wykonanie skoku, przekazanie parametrów oraz powrót. I tak dobrze, że w Atari jest procesor 6502, bo jeśli mielibyśmy w nim Intela 8088 to ten operację skoku wykonywał nawet w dobrze ponad 150 cykli! (czyli porównywalny czas jaki wymagało dzielenie z poprzedniego przykładu w kolorze różowym).

Jednak przykład zaprezentowany w poprzednim artykule nie jest wystarczająco wydajny, gdyż zawiera mnożenie oraz dodawanie (mniej istotne). Jak wspominałem mnożeń w "Action!" należy unikać, gdyż są zbyt powolne, gdy się z nich zrezygnuje to przyspieszenie całego kodu może być nawet kilkunastokrotne!

Jednak rezygnacja ta nie może polegać na triku z makrem:

DEFINE XY = "+40*"

Gdyż taki zabieg nadal spowoduje wykonywanie mnożenia *40 po skompilowaniu, np.

SCREEN(4 XY 10) = WALL

Podobnie:

PLAYERPOS = 3 XY 1

Unikanie mnożenia powinno polegać na tym, aby szybko wyznaczać sobie parametr, który jest indeksem w tej szczególnej tablicy (inna sprawa, że chyba w mało którym języku na dowolnej platformie używa się tablic w taki wyrafinowany sposób, który gwarantuje taką wydajność!).
Jak to zrobić ? To zależy co się chce zrobić, reguła jest prosta: każda realizacja powinna być dostosowana do wymagań, zwykle najgorsze są procedury, które mają służyć wszystkim i do wszystkiego (czyli projektowanie swoistej procedury bibliotecznej). Najlepiej jest zaprojektować kilka oddzielnych rozwiązań dla najistotniejszych przypadków. Zapewne bardziej szczegółowe i interesujące przykłady - wraz z Yoshem zaprezentujemy już niebawem.

5. Podsumowanie

Zaprezentowane tu techniki może wydadzą się niektórym niewygodne, jednak stosując się do nich można się zbliżyć wydajnością wykonywania kodu do asemblera - co chyba dla wszystkich jest celem. Przykładowo osiąganie szybkich wyników z dzielenia i mnożenia (8 i 16 bitowego) jest bardzo ważnym zagadnieniem. Jak widać w zaprezentowanym przykładzie po takiej tak zwanej refaktoryzacji kodu da się zaoszczędzić wiele czasu CPU, a najprostsze wyeliminowanie tych operacji jest często wykonalne nawet w ponad 90% kodu źródłowego, dodatkowo wcale nie musi być takie trudne.

Warto zauważyć, że problem wydajności mnożenia i dzielenia w równym stopniu dotyczy również programowania w asemblerze, co prawda w tym przypadku jest więcej możliwości, ale efekty pod względem wydajności podobne, a na pewno dla wielu (szczególnie tych początkujących) są łatwiejsze do osiągnięcia właśnie w "Action!".

Uwaga! Oba powyższe programy testowe są dostępne także w postaci plików ACT na serwerze.
mono 2009-05-06 13:34:50

O! Bardzo fajny atrykuł. Ja bym prosił o więcej, bo może i do niektórych zadań przesiadłbym się na ACTION!...

Kaz 2009-05-06 14:08:25

Asemblerowiec chce sie przesiadac na "Action!"? :) Kurtuazja?

tdc 2009-05-06 14:20:50

Witam Mono !:) Staramy się wspólnymi siłami stworzyć ciekawy cykl artykułów, niebawem Yosh przygotuje następny odcinek ukazujący ciekawe cechy Action! natomiast ja potem zamieszczę jakieś źródła gry (jak wcześniej obiecałem).

ps. mam w zakładce Twój ostatni e-mail, cały czas o nim pamiętam, mam nadzieję że niebawem na niego (w końcu !) odpowiem.

mono 2009-05-06 17:01:34

E tam kurtuazja. Względy praktyczne. Prototypy szybciej da się pisać w języku wyższego poziomu. A może i niektóre narzędzia.

Yosh 2009-05-07 21:06:23

Dokładnie,

DEFINE XY = "+40*"

ze względów praktycznych wykorzystuje w końcowym przykładzie w miejscach gdzie wydajność nie ma znaczenia (rysowanie planszy na starcie)

Dla każdego coś miłego - a może dla użytkowników cc65 też.... ups..

Tdc 2009-05-08 01:53:05

Mono: tak zdecydowanie polecam Action! do czasu gdy nie zajmie się całej pamięci RAM (czyli właśnie jakieś narzędzia itp.) to programowanie w tym języku jest niezwykle wygodne. W Mirage robiliśmy wiele gier poglądowych, które miały zaprezentować jakiś pomysł itp. Nie byłoby to możliwe w asm, natomiast w Action! po kilku dniach można było już w coś zagrać i podjąć jakieś decyzje (nie będę wspominał, które wydane programy powstały w kilka dni :D :D).

Yosh: Nikt nie ma wątpliwości, że doskonale wiesz o co chodzi, jednak jeśli czytelnicy mają skorzystać z lektury to dobrze aby mieli to dokładnie uwypuklone. Dlatego cały czas wspólnie będziemy o to dbać.

Zastanawiam się jeszcze czy nie zrobić podobnych testów wydajności dla mnożenia i dzielenia (8 i 16 bitowego) bo wspominałem o tym, ale nie zaprezentowałem przykładów. Pytanie czy to kogoś interesuje ?

WujekDobraRada 2009-06-30 21:24:55

Panowie, kilka pytan poczatkujacego:

1) gdzie znajde manual do Action?
2) jak ustawić emulator i co zrobic w samym Action! /krok po kroku/ aby program zapisany w pliku "c:helloworld.act" został wczytany

Na mojej niemieckiej klawiaturze nie idzie wyznac klawiszy atari wiec chce pisac w notepad i potem wczytywac program pod emulatorem.

WujekDobraRada 2009-06-30 21:35:15

ad. 2) plik oczywiscie znajduje sie na dysku PCta pod ktorym dziala emulator. Emulator dziala pod windows (dla info, bo widze ze wiekszosc pod linuksem programuje)

Kaz 2009-07-01 02:02:11

Wujek - piszac forum, mialem na mysli forum, a nie komentarze pod artykulami :). Tutaj trudno zauwazyc nowe wpisy, chyba, ze ktos przeglada nowosci przez RSS...