Format SVG, czyli Scalable Vector Graphics, jest podstawą do tworzenia skalowalnych obrazów wektorowych, które zachowują jakość niezależnie od rozmiaru. To format oparty na XML, szeroko wspierany przez przeglądarki internetowe oraz programy do grafiki wektorowej. W implementacji omawianej aplikacji do generowania artystycznych impresji zdjęć, zamiast korzystać z gotowej biblioteki, zdecydowano się na stworzenie własnej, minimalistycznej klasy do generowania plików SVG. Choć specyfikacja SVG jest rozbudowana, do naszych potrzeb wystarczy niewielki wycinek tej specyfikacji, co pozwala na prostą i przejrzystą implementację.
Plik SVG rozpoczyna się od deklaracji XML, po której następuje element <svg>, określający wersję SVG oraz rozmiary obrazu. Ważnym elementem jest tło, reprezentowane jako prostokąt (<rect>) wypełniony średnim kolorem oryginalnego zdjęcia, co wspiera algorytm w lepszym dopasowaniu kolejnych kształtów do obrazu.
Kolory w tej implementacji reprezentowane są jako krotki trzech liczb całkowitych w zakresie od 0 do 255, odpowiadające intensywności składowych RGB (czerwony, zielony, niebieski). Przykładowo, czysta czerwień to (255, 0, 0), a purpura to (128, 0, 128), będąca mieszanką czerwieni i niebieskiego.
Rysowanie trzech rodzajów kształtów – elips, linii oraz wielokątów – odbywa się poprzez odpowiednie generowanie elementów SVG: <ellipse>, <line> i <polygon>. Końcowym etapem jest zamknięcie elementu <svg> i zapis wygenerowanego tekstu do pliku, który może zostać otwarty w dowolnej przeglądarce lub programie graficznym obsługującym SVG.
Sam algorytm tworzenia obrazu impresjonistycznego jest prosty, choć skuteczny. Działa na zasadzie iteracyjnego próbowania losowo rozmieszczonych i rozmiarowanych kształtów. Każdy nowy kształt jest rysowany na płótnie i oceniany pod kątem zbliżenia finalnego obrazu do oryginalnego zdjęcia. Jeśli dodanie kształtu poprawia podobieństwo, kształt zostaje zachowany; jeśli nie – jest odrzucany, a próbuje się innego. Po dodaniu kształtu następuje etap jego optymalizacji – punkty kształtu są przesuwane, by jeszcze bardziej zmniejszyć różnicę pomiędzy generowanym obrazem a oryginałem. Proces ten jest powtarzany wielokrotnie, co pozwala uzyskać efekt finalnego obrazu złożonego z setek, a czasem tysięcy, kształtów.
Przed rozpoczęciem iteracji tworzone jest puste płótno o rozmiarach oryginalnego zdjęcia, wypełnione średnim kolorem zdjęcia, co stanowi tło dla kolejnych kształtów. Kolor każdego kształtu może być wybrany na kilka sposobów – jako średni kolor obszaru oryginału odpowiadający lokalizacji kształtu, najczęściej występujący kolor tego obszaru, albo zupełnie losowo.
Ważnym aspektem jest sposób mierzenia różnicy pomiędzy generowanym obrazem a oryginałem. W praktyce porównywane są wartości RGB poszczególnych pikseli, co wymaga efektywnego wyliczania różnic kolorystycznych oraz znajdowania pikseli pokrywających dany kształt.
Podstawą efektywności i przejrzystości implementacji jest użycie biblioteki Pillow – potężnego narzędzia do przetwarzania obrazów w Pythonie. Pillow upraszcza odczyt i zapis różnych formatów bitmapowych, rysowanie prostych figur oraz porównywanie obrazów. To pozwala skupić się na kluczowym algorytmie, pozostawiając typowe zadania graficzne do realizacji przez zewnętrzne narzędzie.
Mimo że nasza własna implementacja klasy SVG jest ograniczona do podstawowych funkcji, to wystarczy, aby uzyskać pełnowartościowy i kompatybilny z powszechnymi programami wektorowy obraz. Pozwala to także na łatwe dalsze rozbudowywanie i eksperymentowanie z różnymi kształtami i kolorami, dzięki czemu możliwe jest tworzenie złożonych i estetycznie ciekawych impresji fotograficznych.
Ważne jest, aby czytelnik zrozumiał, że choć SVG to rozbudowany standard, to w praktyce do tworzenia konkretnych aplikacji często wystarczy jego niewielka część. Istotą algorytmu jest optymalizacja wizualnego podobieństwa obrazu wynikowego do oryginalnego, co w połączeniu z prostą, iteracyjną metodą budowania obrazu daje efekt artystyczny, łączący losowość z kontrolą. Ponadto, wybór koloru i kształtów oraz sposób mierzenia różnicy obrazów stanowią fundamentalne parametry decydujące o jakości i charakterze powstałego dzieła.
Jak działa pętla główna w maszynie wirtualnej CHIP-8?
W przypadku wirtualnej maszyny CHIP-8, która była używana na komputerach z lat 70-tych XX wieku, podstawowe operacje obejmują cykliczne uruchamianie pętli głównej, która zarządza czasem, wyświetlaniem grafiki, obsługą klawiatury oraz dźwiękiem. Kluczową cechą tej pętli jest synchronizacja z zegarem, który zmienia się w cyklu 60 Hz, oraz zarządzanie czasem, aby zachować kompatybilność z oryginalnymi grami zaprojektowanymi dla komputerów o znacznie niższej mocy obliczeniowej.
Na początku każdej iteracji pętli głównej rejestrowany jest czas (przy pomocy zmiennej frame_start), co pozwala na dokładne mierzenie czasu trwania danej iteracji. Jest to istotne, ponieważ timery CHIP-8 muszą być dekrementowane dokładnie 60 razy na sekundę, o ile ich wartość nie wynosi zero. Następnie wirtualna maszyna wykonuje instrukcję za pomocą metody vm.step(), co przesuwa ją do kolejnej instrukcji w programie. Jeśli w trakcie tego procesu zmienna vm.needs_redraw jest ustawiona na True, następuje odświeżenie wyświetlacza. Pygame umożliwia to za pomocą dwóch prostych wywołań: pierwsze kopiuje bufor wyświetlania maszyny wirtualnej do ekranu, a drugie rysuje ekran na nowo.
Warto zauważyć, że termin „klatka” w tym kontekście jest używany w nieco inny sposób, niż można by się spodziewać. W większości programów klatka oznacza pełne odświeżenie obrazu na ekranie, ale w tej pętli głównej odświeżenie nie musi mieć miejsca w każdej iteracji, ponieważ zmienna vm.needs_redraw nie zawsze będzie ustawiona na True. Natomiast w każdej iteracji z pewnością zostanie wykonana jedna instrukcja w wyniku wywołania metody vm.step(). Choć można by użyć terminu „instrukcja” zamiast „klatka”, jest to pojęcie zbyt ograniczone, biorąc pod uwagę, że w pętli głównej odbywa się również obsługa grafiki, klawiatury oraz dźwięku.
Po wykonaniu tych operacji pętla główna przechodzi do obsługi zdarzeń klawiatury. W tym celu sprawdzane są wszystkie zdarzenia z Pygame. Jeśli wykryte zostanie naciśnięcie klawisza, sprawdzane jest, czy dany klawisz należy do zbioru dozwolonych klawiszy (ALLOWED_KEYS), a jeśli tak, odpowiedni wpis w tablicy vm.keys zostaje ustawiony na True. Z kolei po zwolnieniu klawisza wartość zostaje zmieniona na False. Pętla również sprawdza, czy użytkownik chce zamknąć program, obsługując zdarzenie pygame.QUIT.
W sekcji dźwiękowej, jeśli zmienna vm.play_sound jest ustawiona na True, odtwarzany jest dźwięk, który będzie powtarzany, dopóki ten stan nie ulegnie zmianie. Dźwięk jest zatrzymywany, gdy wartość zmiennej vm.play_sound stanie się False.
Na końcu pętli głównej następuje kontrolowanie czasu wykonania iteracji. Zmienna frame_end rejestruje czas zakończenia, a różnica między frame_end i frame_start daje czas wykonania iteracji. Ten czas jest dodawany do akumulatora timer_accumulator, który zlicza upływający czas, aby co 1/60 sekundy dekrementować timery maszyny wirtualnej (jeśli te są ustawione na wartość większą niż zero). Jeśli różnica między czasem wykonania iteracji a oczekiwanym czasem jednej klatki jest zbyt duża, pętla wykonuje opóźnienie, aby zapewnić prawidłową synchronizację z zegarem maszyny wirtualnej.
Równocześnie, pętla główna dba o to, by cały system nie przekroczył prędkości 500 „klatek” na sekundę, co jest odpowiednią wartością dla większości gier. Prędkość maszyny wirtualnej można regulować, zmieniając stałą FRAME_TIME_EXPECTED. Zmniejszając ją, można spowolnić działanie maszyny, co jest istotne dla uzyskania odpowiedniej rozgrywki w starszych grach zaprojektowanych z myślą o wolniejszych komputerach.
Zastosowanie odpowiedniego czasu opóźnienia oraz akumulacji czasu ma kluczowe znaczenie dla zapewnienia poprawnego działania maszyny wirtualnej. Jeśli maszyna wirtualna działa zbyt szybko, gry mogą stać się niegrywalne, ponieważ były one projektowane z myślą o starszych komputerach o ograniczonej mocy obliczeniowej.
Kiedy wirtualna maszyna zostanie uruchomiona, program przyjmuje argument wiersza poleceń, który wskazuje na plik zawierający grę CHIP-8. Zawartość tego pliku jest odczytywana w postaci surowych bajtów i przekazywana do funkcji run(), która z kolei inicjalizuje maszynę wirtualną. Dzięki temu możliwe jest uruchomienie dowolnej gry dla CHIP-8 na naszej maszynie wirtualnej.
Kiedy przychodzi do samej implementacji maszyny wirtualnej, korzystamy z kilku stałych, które definiują rozmiar pamięci (4KB), rozdzielczość ekranu (64x32 piksele), częstotliwość odświeżania timerów (60 razy na sekundę), a także zestaw dozwolonych klawiszy oraz czcionkę (FONT_SET) do wyświetlania znaków 0-9 i A-F. Czcionka ta jest wykorzystywana przez gry, które wyświetlają tekst na ekranie. Jest to prymitywna czcionka, która ma jedynie 16 znaków, ale jest niezbędna dla wielu gier, które były napisane w początkowych latach rozwoju komputerów.
Warto również zwrócić uwagę na pomocniczą funkcję concat_nibbles(), która umożliwia łączenie liczb 4-bitowych w jedną wartość. Dzięki tej funkcji możemy łatwo zrealizować operacje bitowe potrzebne do prawidłowego przetwarzania instrukcji.
Cały proces wymaga precyzyjnej synchronizacji z zegarem oraz dokładnej obsługi timerów, co sprawia, że zrozumienie, jak działa pętla główna w wirtualnej maszynie, jest kluczowe dla każdego, kto chce zgłębić działanie maszyn wirtualnych oraz stworzyć gry na taką platformę.
Jak zrealizować wykonanie instrukcji w maszynie wirtualnej?
W przypadku każdej maszyny wirtualnej (VM) kluczowym elementem jest sposób, w jaki wykonuje ona instrukcje. Tak, jak w interpreterach z poprzednich rozdziałów, również w maszynach wirtualnych czy emulatorach musimy dostrzegać odpowiednią instrukcję i wykonać zestaw operacji, które wpłyną na stan maszyny. W kontekście CHIP-8, instrukcje, które będziemy omawiać, mogą wydawać się banalne, ale każde zlecenie, od dodawania liczb po rysowanie sprite'ów, ma swoje specyficzne zadania.
Załóżmy, że maszyna wirtualna jest odpowiedzialna za emulację urządzenia, które przetwarza instrukcje takie jak: dodawanie liczb, przeskakiwanie do innych lokalizacji w pamięci czy rysowanie sprite'ów na ekranie. W najprostszej postaci wystarczy rozpoznać, jaka to instrukcja i wykonać odpowiednie działania. Na przykład, jeśli napotkamy instrukcję dodawania, musimy dodać dwie liczby i zapisać wynik w odpowiednim miejscu. Jeśli napotkamy instrukcję skoku, zmieniamy wskaźnik programu (PC) na inną lokalizację pamięci, a przy instrukcji rysowania sprite'a zmieniamy zawartość bufora ekranu. Taki proces wymaga prostego mechanizmu identyfikacji i odpowiednich zmian w rejestrach oraz pamięci.
Aby osiągnąć taki cel, najczęściej posługujemy się instrukcjami warunkowymi, które w pseudo-kodzie mogą wyglądać mniej więcej tak:
Z tego rodzaju prostym podejściem poradzi sobie każda maszyna wirtualna. Jednakże, w przypadku większych zbiorów instrukcji, ten sposób może okazać się nieefektywny. Istnieje kilka wzorców, które pomagają w bardziej efektywnym zarządzaniu instrukcjami.
Pierwszym z nich jest użycie tzw. "switch statement", znanego w wielu językach programowania. Chociaż w Pythonie nie występuje dokładnie ta konstrukcja, można zrealizować coś podobnego przy użyciu konstrukcji match, która pojawiła się w wersji 3.10 Pythona. Taki sposób jest stosunkowo prosty i wydajny, chociaż może być nieczytelny w przypadku długich instrukcji.
Drugim wzorcem jest tzw. "jump table", czyli tablica skoków. Zamiast sprawdzać instrukcję przy pomocy wielu if-ów, możemy stworzyć tablicę wskaźników do funkcji. Każda instrukcja jest liczbą, co pozwala użyć jej jako indeksu w tablicy, a następnie wywołać odpowiednią funkcję. Takie podejście jest bardziej elastyczne i czyste niż tradycyjny "switch".
Trzeci wzorzec to dynamiczna rekompilacja, która jest najtrudniejsza do implementacji. W tym przypadku instrukcje maszyny wirtualnej tłumaczymy na kod maszynowy zrozumiały dla rzeczywistego procesora, np. procesora x86. Taki sposób realizacji jest najbardziej wydajny, ale wymaga szczegółowej wiedzy na temat zarówno oryginalnego zestawu instrukcji, jak i instrukcji docelowego procesora. Jest to najczęściej stosowane w przypadku emulatorów, które muszą osiągnąć maksymalną wydajność.
W naszej implementacji będziemy korzystać z prostej konstrukcji match, ponieważ zestaw instrukcji CHIP-8 jest stosunkowo mały. W przyszłości, przy emulacji bardziej skomplikowanych systemów, np. NES, lepiej sprawdzi się tablica skoków, ponieważ procesor 6502 ma instrukcje o znacznie większej liczbie.
Ważnym elementem jest także sposób pobierania instrukcji. Instrukcje są 16-bitowe i składają się z dwóch bajtów, z których każdy dzielimy na cztery "nibbles" (4-bitowe fragmenty). W kodzie wykorzystujemy odpowiednią operację bitową do ich ekstrakcji i przetwarzania. W przypadku CHIP-8, poszczególne fragmenty mogą odpowiadać za różne części instrukcji, takie jak identyfikator instrukcji, adresy czy operacje. Każda instrukcja jest sprawdzana przez nasz mechanizm dopasowania i po jej zidentyfikowaniu wykonujemy odpowiednią operację. Przykładowo, jeżeli instrukcja jest rysowaniem sprite'a, porównujemy każdy bit nowego rysowanego pikselu z istniejącym stanem ekranu, aby określić, czy nie zachodzi kolizja. Jeżeli sprit dotyka już zapalonego piksela, ustawiamy odpowiedni wskaźnik, aby śledzić, kiedy piksel na ekranie zostanie zgaszony.
Warto zauważyć, że w tej realizacji mamy także mechanizm śledzenia potrzeby przerysowania ekranu (np. przez ustawienie flagi needs_redraw). Oznacza to, że jeżeli żadna zmiana na ekranie się nie zdarzyła, nie ma potrzeby przerysowywać zawartości bufora ekranu, co może poprawić wydajność.
W kontekście realizacji instrukcji w maszynach wirtualnych ważne jest, aby nie koncentrować się na detalach implementacji, ale na samym procesie i logice, jak poszczególne instrukcje wpływają na stan maszyny wirtualnej. Chociaż opisy instrukcji mogą być krótkie, pełne szczegóły dotyczące ich działania znajdziesz w dokumentacji CHIP-8 lub innych materiałach odniesienia. Kluczowe jest, aby dobrze rozumieć, co każda instrukcja powinna robić i jak wpłynie na rejestry oraz pamięć maszyny.

Deutsch
Francais
Nederlands
Svenska
Norsk
Dansk
Suomi
Espanol
Italiano
Portugues
Magyar
Polski
Cestina
Русский