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:
- szary: 25% przy wykorzystaniu bibliotecznej procedury
RAND()
- brąz: bez biblioteki z obliczeniami: zakres 0-40 (a konkretnie
do 42)
- róż: bez biblioteki dzielenie wyniku, aby osiągnąć 25%
- zielony: metoda wyznaczania zamierzonego zakresu zaproponowana
przez Yosha w grze "Robbo Dash" - (RND AND 31)
- niebieski: 25% z samym "IF" bez obliczeń
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.