SDL3 został zaprojektowany w taki sposób, aby z jednej strony mieć proste API do renderowania dwuwymiarowych pierdół po stronie GPU (które było też dostępne w SDL2), natomiast dla bardziej wymagających zostało przygotowane osobne API, zwane SDL GPU, które nie tylko daje znacznie większe możliwości, ale też zapewnia wsparcie shaderów. Tak więc można wybrać z którym API będzie się pracowało, w zależności od wymagań projektowych.
Problem jednak jest w tym, że jeśli wystarczy nam API renderera 2D, to nie mamy dostępu do shaderów — z jednej strony mamy banalne w obsłudze API, ale np. filtrów już nie nałożymy (np. filtr CRT, bloom itd.). Z drugiej strony, jeśli chcemy korzystać z shaderów, to musimy skorzystać z SDL GPU, a to ma znacznie wyższy próg wejścia, dlatego że wymaga szerszej wiedzy na temat tego jak montuje się cały pipeline renderowania. Cóż — albo rybki, albo akwarium.
A tu znienacka wesoła nowina — API renderera 2D dostaje wsparcie shaderów!
Oznacza to, że będziemy mogli pozostać przy prostym zestawie funkcji do renderowania tekstur 2D i tego typu geometrii, natomiast dodatkowo będziemy mieli możliwość użycia shadera w trakcie renderowania danej tekstury na innej, używając tej samej funkcji co zwykle, czyli SDL_RenderTexture (prawdopodobnie również innych, o podobnym przeznaczeniu). W ten sposób będziemy mogli łatwo przeprowadzić postprocessing, w jednym lub wielu krokach, w zależności od potrzeb.
Dla zainteresowanych, slouken przygotował demówkę ilustrującą to jak korzystać z shaderów w połączeniu z funkcjami z API renderera 2D. Cały testowy program znajduje w repo, tutaj bezpośredni link do pliku — testgpurender_effects.c.
Dziś mijają równo trzy lata od rozpoczęcia prac nad moją grą.
1 marca 2022 roku to dzień, w którym utworzyłem katalog oraz projekt gry w Lazarusie. Kupa kodu napisana, efekty bardzo zadowalające, zapał taki sam (jeśli nie większy niż) na początku. Tak właściwie to prace trwają krócej, bo łącznie przez kilka miesięcy nie było czasu na dłubanie, a potem wyszedł SDL3 i portowanie kodu zajęło ze dwa miesiące (przy okazji zrobiłem spory refactoring i co nieco zoptymalizowałem, głównie alokacje). Finalnie, mniej więcej dwa i pół roku klepania kodu za mną.
Początkowo planowałem, że pracy będzie na ~pięć lat, więc jestem na półmetku. Choć bardziej skłaniam się ku sześciu latom, bo nie chcę poświęcać jakości kodu i gry kosztem wcześniejszego release'u. No nic, wracam do klepania kodu — nie ma czasu na świętowanie.
Czy ty nie miałeś wcześniej nicku furious programmer? Co się stało, że nick zmieniłeś? :(
@Escanor16: tak, ~dobrze pamiętasz. Zmieniłem, bo potrzebowałem zmiany — nowy etap, nowy nick, stary ja. A poprzedni już dawno temu przestał być aktualny, więc czas na zmianę i tak nadszedł.
Całkiem niedawno pisałem o tym, że dodałem do kernela UI wsparcie kontrolek do wpisywania tekstu i z tej okazji również przygotowałem demówkę, w której mogłem się bawić testowymi kontrolkami oraz śledzić komunikację kernela UI z kontrolkami, czyli wymianę zdarzeń i ich kolejkowanie. Demówka ta przy okazji służyła mi do testowania kursorów własnego pomysłu, które nie tylko obsługują animowane kształty (tak jak typowe kursory systemu Windows), ale też zmiana kształtu może być wykonywana w sposób animowany.
Zauważyłem, że znimowana zmiana kształtu kursora (w uproszczeniu fade-in/fade-out) wygląda super, ale nie zawsze. Niektóre kształty, np. łapka z wyprostowanym palcem (symbolizująca element do kliknięcia) oraz łapka bez tego palca (symbolizująca wciśnięty przycisk myszy lub przeciąganie elementu), niewiele się różnią, przez co animacja zmiany wygląda nie najlepiej. Postanowiłem więc dodać do formatu swoich kursorów informacje o tym, które pary kształtów mają być zamieniane w sposób animowany, a które nie, tak aby wszystko wyglądało ładnie.
Każdy kursor może wspierać od jednego do piętnastu kształtów. Kształt to nic innego jak po prostu typ wskaźnika, tak jak w przypadku kursorów znanych z systemu Windows — strzałka, łapka, klepsydra itp. Aby poinstruować silnik na temat tego, czy dana para kształtów ma być zmieniana w sposób animowany czy nie, dla każdej takiej pary wystarczy flaga logiczna.
Kształtów jest 15
, więc możliwych kombinacji par jest 15 * 15
, czyli 225
. Zmiana kształtu ze samym sobą nie ma sensu i nie jest używana w silniku, więc flagi po przekątnej tablicy 15×15
nie są potrzebne — odpada 15
flag, zostaje 210
. Idąc dalej, trzeba wziąć pod uwagę to, że dla danej pary kształtów nie trzeba dwóch flag, a tylko jedna — odpada połowa flag, zostaje 105
.
Jak te flagi są przechowywane w pamięci? Ot w formie dwuwymiarowej tablicy, w rozmiarze 15×15
, gdzie indeks dla każdego wymiaru to ID kształtu (dwa wymiary, ID dwóch kształtów). Flagi po przekątnej są zgaszone (bo nie są używane), natomiast po obu stronach przekątnej są dwa podzbiory flag, wartości tych podzbiorów są lustrzane, względem tej przekątnej. Lustrzany podzbiór flag przydaje się do tego, aby mając ID dwóch kształtów, móc pobrać flagę nie zważając na to które ID ma określać który wymiar.
Tablica zadeklarowana jest jako spakowana bitowo — jeden bit na jedną flagę, łącznie 30
bajtów.
Aby móc wygodnie i szybko edytować zawartość tablicy flag, przyda się nowe okienko dialogowe. Każda flaga logiczna to dwa możliwe stany, flag trzeba 105
, więc wystarczy 105
checkboxów, ułożonych w formie trójkąta (bez flag dla przekątnej i tych lustrzanych). Postawiłem na formularzu 105
checkboxów. Krew mnie zalała…
Designer w Lazarusie przy tak gigantycznej liczbie kontrolek jak 105
(to żart) ssie niemiłosiernie… Kiedy zaznaczyłem wszystkie te checkboxy, to przesunięcie takiej grupy kontrolek trwa ze dwie sekundy. Przy czym jeśli ciągnie się myszą taką grupę, to co kilka pikseli designer aktualizuje pozycję ich wszystkich, każdorazowo gotując CPU przez 2 sekundy. Normalnie pokaz slajdów… W runtime lepiej nie jest, bo pokazanie formularza z setką checkboxów też ssie — do tego stopnia, że okno pojawia się niemal w sposób animowany. Nie chcę nawet wiedzieć ile by to trwało, gdybym te kontrolki pospinał ze sobą w edytorze kotwic, tak aby uniezależnić układ kontrolek od DPI — wszechświat by pewnie szybciej powstał niż takie okno…
Trochę to śmieszne, że CPU jest w stanie wykonać setki milionów instrukcji w każdej sekundzie, ale to za mało, aby przesunąć w oknie setkę prostokątów. Cóż, tak w akcji wyglądają kilotony abstrakcji obiektowych, masa bezsensownych zabezpieczeń i słabe algorytmy (tragiczna złożoność obliczeniowa). Kurtyna.
Olałem setkę checkboxów i taką macierz zaimplementowałem ręcznie, renderując ją w kontrolce typu TPaintBox
:
Aby dynamicznie renderowane w tej kontrolce checkboxy wyglądały jak te systemowe, skorzystałem z ThemeServices
, czyli poprosiłem system operacyjny o to, aby mi namalował na pomocniczych bitmapach checkboxy dla różnych stanów, które następnie renderuję w TPaintBox
, w wyznaczonych miejscach. Ostatecznie wybrałem, że jeśli kursor znajduje się nad którąś komórką, to renderowany jest checkbox hot, a także wszystkie dla danego wiersza i kolumny. Natomiast wszystkie pozostałe, nie hoverowane, renderowane są jako zablokowane (zafajkowane lub nie), tak aby jak najmocniej odróżniały się od tych hoverowanych).
Żeby było wiadomo których kształtów dotyczy hoverowana flaga, podświetla się nazwa kursora dla aktywnego wiersza i kolumny, a także — w dwóch TPaintBox
po prawej stronie, renderowane są główne stemple tych kształtów. Do ich renderowania skorzystałem z już istniejącego kodu, którego edytor używa do renderowania komórek kształtów w głównym oknie edytora. Dzięki temu w tych dwóch okienkach kształt renderowany jest razem z tłem (w aktualnie ustawionym stylu/kolorze), a także belka tytułowa z nazwą kształtu.
Fajnie to wygląda i wygodnie się z tego narzędzia korzysta. Żeby w przyszłości nie musieć wracać do kodu kodu edytora, od razu zaimplementowałem możliwość zmiany zaznaczenia również dla całego rzędu i kolumny, co realizuje się poprzez trzymanie odpowiednio klawisza Shift i/lub Alt w trakcie klikania LPM.
Efekt końcowy:
Format swoich kursorów zaprojektowałem w taki sposób, że dla każdego kształtu z osobna przewidziana była osobna tekstura atlasu. Jeśli kształt był statyczny, to w atlasie posiadał tylko jeden stempel, a jeśli animowany, to wszystkie stemple jako klatki animacji. Kursory są tak maleńkie (raptem kilkanaście na kilkanaście pikseli), że używanie 15
mikroskopijnych tekstur nie ma za bardzo sensu, skoro może to być jedna tekstura i zawierać wszystkie klatki wszystkich obsługiwanych przez kursor kształtów.
Przerobiłem więc kod silnika oraz edytora, tak aby kursor posiadał tylko jedną teksturę. Niewiele pracy to kosztowało, a przynajmniej ten temat nie będzie mi wiercił dziury w głowie, że robiłem 15
alokacji w GPU zamiast jednej.
Tekstura atlasu dla testowego kursora (tego co widać na powyższym gifie) wygląda tak:
To była ostatnia zmiana, jaką chciałem zrobić przed kolejnym krokiem dewelopmentu. Silnik jest już gotowy, teraz zabieram się za projektowanie podsystemu obsługi map świata oraz edytora map, obiektów, questów itd., a także renderera takich map. Czeka mnie niezła przeprawa — pochłonie łącznie z 50kLoC.
Tablica zadeklarowana jest jako spakowana bitowo
Free Pascal ma coś takiego, że nie musisz ręcznie robić tego pakowania działaniami bitowymi?
@Manna5: można bez problemu pakować bitowo, jest do tego modyfikator bitpacked.
U mnie deklaracja wygląda tak:
Animations: bitpacked array [WNC_CURSOR_SHAPE, WNC_CURSOR_SHAPE] of TWnc_Bool8 readonly;
WNC_CURSOR_SHAPE
to makro z zakresem dostępnych ID kursorów, natomiast readonly
to puste makro, którym dodatkowo oznaczam wszelkie pola struktur, których nie powinienem modyfikować poza modułem deklaracji struktur. W edytorze mam prostą tablicę jako pole klasy z triggerami animacji:
FBuffer: bitpacked array [0 .. EDITOR_CURSOR_SHAPE_NUM - 1, 0 .. EDITOR_CURSOR_SHAPE_NUM - 1] of Boolean;
We Free Pascalu można bit-pakować macierze i struktury, a także — tak samo jak w C — deklarować pola bitowe w strukturach. Choć akurat składnia do określania rozmiarów pól bitowych jest nieco upierdliwa, bo zamiast rozmiaru pola w bitach, podać trzeba zakres wartości. Z jednej strony trochę to wkurza, ale z drugiej strony daje więcej możliwości niż to samo w C.
TL;DR: demówka do testowania okna i dekoracji — WtNC — decoration.zip
Na końcu wpisu jest krótki opis działania oraz mała instrukcja obsługi.
Ponieważ gry wideo to forma sztuki, deweloperzy gier od zarania dziejów konkurowali ze sobą i prześcigali się w implementacji ficzerów, mających na celu przykuć wzrok i wprawić w zachwyt graczy. Interfejsy w grach to nierzadko arcydzieła, pokaz skilla programistów i artystów. A co z dekoracją okna? No właśnie — systemowa ramka okna z tytułem i przyciskami nijak nie pasuje stylem do interfejsu gry, więc pasuje coś z tym faktem zrobić.
Długo na to czekałem, głównie ze względu na trwające prace nad wydaniem stabilnej biblioteki SDL3. W ostatnich dwóch tygodniach ostro pracowałem nad implementacją własnej dekoracji okna, prace dobiegły końca i z tej okazji nowy wpis, w gigantycznym skrócie opisujący jak to wygląda z perspektywy silnika gry.
Najpierw należy usunąć systemową ramkę, czyli stworzyć okno z flagą SDL_WINDOW_BORDERLESS
. Ważne jest tutaj to, że jeśli nasza dekoracja ma umożliwiać rozciąganie okna za pomocą myszy oraz maksymalizację, trzeba też użyć flagi SDL_WINDOW_RESIZABLE
. W ten sposób informuje się system, że ma nie dodawać standardowej dekoracji, czyli po prostu ma wyświetlać na ekranie sam obszar klienta okna.
Jak dodać własną dekorację do okna? Odpowiedź jest prosta — nijak, dekoracji się nie dodaje, a po prostu tworzy się wirtualną i renderuje ją w obszarze klienta, zamiast wokół niego. A biorąc pod uwagę to, że klient okna (czyli obszar, nad którym mamy pełną kontrolę) zawierać będzie również elementy dekoracji, możemy robić co nam się żywnie podoba, stworzyć dekorację z dowolną liczbą i pozycją elementów, renderować je z dowolnymi efektami wizualnymi itd. Róbta co chceta.
Żeby nie obliczać wszystkiego w każdej klatce, obiekt okna gry posiada pola z obszarami wirtualnych elementów dekoracji. Te obszary używane są zarówno w trakcie przetwarzania zdarzeń inputu, jak i w trakcie renderowania zawartości okna.
Obiekt okna przechowuje aktualny obszar całego okna (rozmiar na ekranie), obszar dla stylu okienkowego (do przywracania z trybów pełnoekranowych oraz zapamiętywania pomiędzy sesjami), oraz obszary wszystkich elementów dekoracji: paska tytułowego, wszystkich systemowych przycisków znajdujących się na nim, obszaru klienta okna, obszaru przeznaczonego do renderowania klatki w zadanych proporcjach, obszaru renderowania wirtualnego kursora oraz obszaru paska stanu.
Sporo tego, a to i tak nie wszystko, bo do tego jest jeszcze kilka flag oraz pól przechowujących kluczowe dane layoutu okna, które wpływają na rozmiar okna w stylu okienkowym. Głównie mowa o proporcjach pikseli (zakres od kwadratowych po prostokątne, 8:7
) oraz widoczność paska stanu.
Żeby informować system o tym, gdzie w obszarze okna znajdują się elementy dekoracji, rejestruje się callback, za pomocą funkcji SDL_SetWindowHitTest. Callback ten wywoływany jest przez SDL za każdym razem, gdy system potrzebuje informacji o tym co znajduje się w danym pikselu w oknie (z reguły na podstawie współrzędnych kursora w oknie). Callback otrzymuje współrzędne punktu, sprawdza czy punkt ten leży na krawędzi okna, czy na belce, czy w obszarze wirtualnego klienta i zwraca odpowiednią wartość (SDL_HitTestResult).
System na podstawie zwróconej wartości ustawia odpowiedni kształt kursora i pozwala przeciągać lub rozciągać okno za pomocą myszy. Są też dostępne dodatkowe ficzery, znane ze standardowej dekoracji — np. podwójne kliknięcie LPM w obszarze belki okna wyzwala maksymalizację lub przywrócenie okna.
Rejestruje się go funkcją SDL_SetWindowsMessageHook. Jest on wywoływany gdy pętla komunikatów okna (która obsługiwana jest w całości przez SDL) otrzyma od systemu jakikolwiek komunikat. SDL przekazuje go do callbacku, tak aby aplikacja mogła na niego zareagować — zmodyfikować go lub zjeść. Jeśli callback nie zjadł komunikatu, SDL przepakowuje go do formy zdarzenia i umieszcza w swojej kolejce zdarzeń, która potem przetwarzana jest przez aplikację (np. za pomocą SDL_PollEvent).
Po co mi hook na systemowe komunikaty, skoro silnik aktualizuje swój stan na podstawie zdarzeń? A no jest jeden powód, bardzo ważny — wsparcie rozciągania okna myszą, z zachowaniem proporcji klatki gry. Aby to wykonać, należy wyłapać w tym hooku komunikat WM_SIZING i jeśli użytkownik trzyma Ctrl, zmodyfikować rozmiar nowego obszaru okna w strukturze komunikatu.
System użyje zmodyfikowanego obszaru do aktualizacji rozmiaru okna, wyśle aplikacji komunikat WM_SIZE, ten zostanie wyłapany przez SDL, przepakowany do formy zdarzenia SDL_WINDOWEVENT_RESIZED
i go zakolejkuje. I to wszystko — to takie proste.
Aby dekoracja zawsze była obsługiwana i renderowana prawidłowo, obszary muszą być przeliczane na nowo za każdym razem, gdy fizyczny rozmiar okna ulega zmianie. W takim przypadku SDL wysyła zdarzenie SDL_WINDOWEVENT_RESIZED
, w którym obszary są rekalkulowane. Dodatkowo, zawsze w takim przypadku aktualizowane są również minimalne rozmiary okna, dlatego że zmiana rozmiaru okna mogła zostać spowodowana zmianą proporcji pikseli lub widoczności paska stanu (o tym w dalszej części).
System w kilku przypadkach nie wykonuje kodu wątku aplikacji, a zamiast tego wykonuje tzw. modalną pętlę komunikatów, bez wiedzy aplikacji. Robi to w trakcie przeciągania okna lub jego rozciągania za pomocą myszy, a także gdy pokazywane jest systemowe menu (np. skrótem Alt+Space). W tym czasie pętla gry wisi, a więc nie tylko logika gry nie jest aktualizowana, ale i okno nie jest przemalowywane.
Przemalowywanie okna w trakcie rozciągania myszą można zaimplementować na wiele sposobów, ale że renderowanie może być wykonywane tylko i wyłącznie w głównym wątku aplikacji, paleta rozwiązań kurczy się do jednego — hook na zdarzenia SDL-a. Wystarczy zarejestrować tzw. event watcher za pomocą funkcji SDL_AddEventWatch i od tej pory SDL będzie wywoływał ten callback zawsze po przepakowaniu komunikatu do formy zdarzenia.
Nam zależy na tym, aby wyłapać moment zakolejkowania zdarzenia zmiany rozmiaru okna, więc w callbacku watchera wyłapuje się zdarzenie SDL_WINDOWEVENT_RESIZED
i przemalowuje okno. Jest to bezpieczne, bo callback działa w ramach głównego wątku aplikacji, a więc przemalowywanie okna nie musi być w żaden sposób synchronizowane.
Jedna ważna rzecz — renderowanie klatki gry to najbardziej czasochłonna czynność, więc odmalowywanie okna maksymalnie zopymalizowałem. Logika gry wisi w trakcie pętli modalnej, więc nie ma sensu renderować całej klatki od podstaw. Biorę po prostu bieżącą klatkę (jest wyrenderowana na teksturze tylnego bufora) i jedyne co robię to renderuję dekorację oraz tę bieżącą klatkę w oknie. W przyszłości na tej gotowej klatce będzie też wykonywany post-processing (shadery), ale na razie nie mam ich zaimplementowanych. Dodatkowo, aby przyspieszyć przemalowywanie okna, na tę chwilę dezaktywuję VSync. I to tyle — rozciąganie okna jest płynne, nawet setki zmian na sekundę.
Tylko niektóre są wykonywane automatycznie przez system — przeciąganie i rozciąganie okna, zmiana kształtu systemowego kursora oraz dwuklik na belkę do maksymalizacji/przywrócenia okna. Wszystko inne, czyli obsługa imitacji systemowych przycisków, a także systemowe menu (prawoklik na belce lub Alt+Space) muszą być obsłużone w kodzie aplikacji (silnika).
Wiemy gdzie znajdują się wszystkie systemowe przyciski, bo przechowujemy ich obszary w obiekcie okna. Tak więc aby móc podświetlać te przyciski i wykonywać ich akcje po kliknięciu, wystarczy bazować na zdarzeniach SDL dotyczących ruchu kursora w obrębie okna oraz wykrywać klikanie LPM i PPM. Na podstawie ruchu kursora aktywuję i dezaktywuję obiekt animacji przycisków, natomiast wciskanie przycisków zapamiętuję i po ich puszczeniu wykrywam pełne kliknięcie. Jeśli kliknięto w przycisk systemowy, to wykonuję jego akcję — nic nadzwyczajnego.
Kliknięcie PPM rejestrowane jest tak samo jak w przypadku systemowej dekoracji, czyli w dowolnym miejscu belki okna (również w obszarze ikonki aplikacji oraz w obszarach przycisków na belce). Po jego wykryciu pokazuję systemowe menu za pomocą funkcji SDL_ShowWindowSystemMenu, w miejscu kursora. Wychwytuję też zdarzenia wciśnięcia klawiszy i jeśli wykryję skrót Alt+Space, tą samą funkcją wyświetlam to menu w lewym górnym rogu okna. Kliknięcie LPM w ikonkę aplikacji na pasku tytułowym też pokazuje systemowe menu, ale tym razem wyrównane do ikonki (czyli pod nią).
Oczywiście obsługiwanych zdarzeń oraz wykonywanych czynności jest o wiele więcej, bo trzeba też resetować różne flagi czy obiekty animacji, gdy okno traci fokus lub input myszy, ale to pomijam, bo tego jest za dużo na jeden wpis blogowy.
Silnik zaprogramowałem tak, aby gdy okno jest wyświetlone jako mniejsze niż ekran i z dekoracją (tryb okienkowy), pokazanie lub ukrycie paska stanu zwiększa lub zmniejsza wysokość okna. Dzięki temu obszar klatki gry na ekranie ani drgnie — będzie to przydatne, w razie gdyby ktoś chciał nagrywać okno albo coś.
Podobnie dzieje się w przypadku zmiany proporcji pikseli — okno jest rozszerzane lub zwężane. Jednak w tym przypadku silnik najpierw sprawdza, czy okno posiadało rozmiar dopasowany do proporcji klatki gry i jeśli nie, to nie zmienia rozmiaru okna. Skoro użytkownik ustawił swój rozmiar okna, to lepiej się mu nie wtrącać. Okno można w dowolnym momencie zmusić do dopasowania, łapiąc za krawędź i trzymając Ctrl.
W przypadku gdy okno jest pełnoekranowe (zmaksymalizowane lub w fullscreenie), rozmiar okna nie może być zmieniany, więc jedynie rekalkuluje się obszary elementów okna i zapamiętuje żądanie zmiany widoczności paska stanu lub proporcji pikseli. Po co? A po to, że po wyłączeniu fullscreenu/maksymalizacji, rozmiar okna zostanie wtedy zaktualizowany, zgodnie z tym to zostało zmienione gdy okno było pełnoekranowe. Ot zmiana rozmiaru okna jest odraczana, gdy dotyczy okna pełnoekranowego. Trochę kodu trzeba naklepać, ale uzyskałem taki efekt, na jakim mi zależało.
W stylach pełnoekranowych, na ekranie powinna być widoczna wyłącznie klatka gry, tak więc dekoracja powinna być obsługiwana i renderowana tylko w trybie okienkowym oraz zmaksymalizowanym. Mało tego — w trybie zmaksymalizowanym, callback do hit-testowania nie może umożliwiać przesuwania i rozciągania okna. Tak więc w trakcie aktywacji fullscreenu, callback ten powinien być wyrejestrowany.
Tutaj, jak pisałem na początku, mamy pełną swobodę. Renderowanie zawartości okna realizowane jest obecnie w formie łańcuszka (rendering chain), wykonując wiele kroków, na wielu teksturach tylnych buforów:
336×240
pikseli, za pomocą software'owego ray-tracingu (jeszcze nie zaimplementowane),A następnie renderowanie na płótnie okna:
Renderowania zawartości paska stanu nie implementowałem, bo nie mam jeszcze importów funkcji SDL-a do formatowania stringów, a poza tym nie wiem jaka będzie ostateczna forma tych danych, więc nie poświęcałem na to czasu, bo i tak bym ten kod usunął. Zresztą po zbudowaniu demówki, cały kod renderowania wywaliłem i mam puste, czarne okno.
Wiele gier renderuje obraz klatki z zachowaniem konkretnych proporcji klatki, szczególnie gry w stylu retro. Ponieważ rozdzielczości monitorów są różne, po bokach lub u góry i dołu ekranu zostaje nieużywana przestrzeń (z reguły cienkie pasy). Zwykle wypełnia się je na czarno, ale niektóre gry pozwalają wypełnić tę przestrzeń w formie np. kafli z powtarzającym się wzorkiem lub logo.
Mój silnik robi dokładnie to samo — może renderować ”coś” w tych pustych paskach. W demówce użyłem bieżącej klatki, rozpikselowanej i przyciemnionej, aby aktualny obszar klatki był wyróżniony. Docelowo najpewniej skorzystam z tego efektu, ale możliwe, że zamiast pikselozy, użyję rozmycia. Sprawdzałem to, użyłem bieżącej klatki, ale ustawiłem skalowanie liniowe zamiast nearest
— efekt podobny do szklanej belki, znanej z motywu Aero systemu Windows Vista/7, całkiem fajny.
Różnica pomiędzy moim silnikiem a ww. grami (pozwalającymi coś renderować w pustej przestrzeni klienta) jest taka, że gdy gra jest w małym okienku lub jest zmaksymalizowana, za tę pustą przestrzeń uznaje się też obszar belki okna i paska stanu, dzięki czemu wyrenderowane wypełnienie dodatkowo staje się tłem elementów dekoracji. Natomiast w trybie pełnoekranowym, dekoracja nie jest używana, więc wypełnienie dotyczy tylko nieużywanych obszarów okna, wynikających z różnych proporcji okna i klatki gry.
Tutaj porównanie dekoracji własnej (z własną liczbą kontrolek i własnym paskiem stanu) oraz tej systemowej, której belka tytułowa oraz pasek stanu renderowane są przez system, zgodnie z aktualnie ustawionym motywem:
No nie trzeba być geniuszem, aby wywnioskować, że własna dekoracja to strzał w dziesiątkę.
Tak jak poprzednie demówki, tak i ta nie zapisuje niczego na dysku. Po uruchomieniu, pokaże się konsola z logami oraz okno gry. Klatka gry jest animowana (origin przesuwa się zgodnie z różą polarną), aby było widać, że silnik pracuje (i kiedy nie, czyli w trakcie modalnych pętli komunikatów). Można sobie poklikać, porozciągać itd. — to tylko showcase.
Klawiszologia:
Jeśli chodzi o systemowe przyciski, te na belce okna, ten pierwszy z lewej służy do aktywacji renderowania tła okna w formie ciemniejszej i rozpikselowanej, natomiast ten drugi służy do pokazania/ukrycia paska stanu. Przeznaczenie pozostałych trzech z prawej raczej powinno być wam znane.
W razie czego, w katalogu demówki znajduje się plik background.png
i jeśli ktoś chce się pobawić, to możecie do niego wkleić własny obrazek (reboot demówki wymagany). Trzymajcie się oryginalnego rozmiaru tej grafiki.
Zostało mi jeszcze kilka pierdółek do zrobienia, aby lepiej sobie źródła przygotować do dalszego rzeźbienia. Jedną z nich jest zmiana stylu prefiksów dla identyfikatorów API silnika — zamieniam prefiksy z Game_
na Wnc_
(API silnika), Game_SDL_
na SDL_
(API SDL-a) oraz Game_Win_
na Win_
(dla Win32 API). Zajmie mi to pewnie 15 minut, bo Lazarus ma różne fajne narzędzia do edycji tekstu.
Czas w końcu ruszyć dupę i zacząć klepać kernel obsługujący mapy — do końca tego roku musi być gotowy.
No i poszło na proda! SDL3 oficjalnie wydany!
Ale to nie wszystko — wydano też tzw. SDL2-compat, czyli bibliotekę z API kompatybilnym z tym dla SDL2, ale wewnętrznie używająca SDL3. Można taką DLL-kę podmienić w jakiejś starej grze (a obok wkleić SDL3.dll
) i od tego momentu gra będzie bezwiednie używać najnowszego SDL3. Ten projekt po prostu miażdży.
Ok, spóźniony, ale jest nowy wpis — Własna dekoracja okna bezramkowego (borderless). Miłego czytanka.
Kod silnika mam już przystosowany do SDL3 i się kompiluje, ale czekam jeszcze na wydanie SDL_image, aby móc ostatecznie potwierdzić, że aktualizacja kodu silnika przebiegła pomyślnie. Z tej okazji podłubałem w nim trochę i usprawniłem/dodałem pewne rzeczy, skoro i tak muszę czekać.
Pierwsze co zrobiłem to lekko zmodyfikowałem mapper, tak aby wykorzystać usprawnioną w SDL3 obsługę urządzeń wejścia, czyli operować wyłącznie na identyfikatorach urządzeń. Dodałem też do mappera logikę odpowiedzialną za wykrywanie konfliktów mapowania oraz braku mapowania kluczowych akcji, a także dorzuciłem kilka funkcji, które w przyszłości przydadzą się do obsługi UI menu z ustawieniami sterowania. Od teraz możliwe jest wygodne sprawdzanie czy coś jest nie tak z mapowaniem i jeśli tak, pobrać szczegółowe informacje na temat tego która akcja powoduje problem. Ogólnie mapper dostarcza tyle danych, że można dynamicznie budować UI z pozycjami dla poszczególnych akcji oraz dowolnie wizualizować wszelkie problemy z mapowaniem.
Dopisałem też w końcu funkcje pozwalające zapisać ustawienia mapowania do pliku lub strumienia, a także funkcje ładujące i aplikujące mapping. Proces ładowania również sprawdza potencjalne problemy (konflikty i braki) i po ich wykryciu, nie aplikuje wczytywanych danych. Ogólnie proces remapowania (docelowo za pomocą dedykowanego w grze menu ustawień sterowania) oraz ładowania mappingu jest zrobiony w taki sposób, że zabezpiecza przed zaaplikowaniem błędnego mappingu, a także — w razie błędów lub chęci anulowania zmian — pozwala odrzucić nowe dane. Po prostu mapper korzysta z obiektów pomocniczych, z których dane kopiowane są do właściwych obiektów tylko jeśli są one prawidłowe i/lub gracz wyraził chęć ich zaaplikowania.
Drugą rzeczą, za którą się zabrałem, to ograniczenie alokacji pamięci dla wszystkich aktualnie używanych obiektów. Do tej pory, jeśli potrzebowałem kolekcji obiektów tego samego typu, alokowałem każdy z nich oddzielnie i pakowałem do kontenera. To takie typowe rozwiązanie, jakie znamy z OOP, czyli każda instancja klasy alokowana jest na stercie (w lokalizacji określonej przez menedżer pamięci — ot tam gdzie jest wolne miejsce).
U mnie jednak nie ma klas, a wszelkie obiekty są prostymi strukturami danych, które mogą być alokowane zarówno na stosie, jak i na stercie. To nie tylko zmniejsza zasobożerność, ale też pozwala alokować obiekty w wybranym przeze mnie miejscu (a nie przez menedżer pamięci). Dzięki temu mogę mieć wiele obiektów umieszczonych obok siebie, w formie ciągłego bloku pamięci, co można wykorzystać np. do deklarowania tablic obiektów, co jest niemożliwe w przypadku klas.
Zrobiłem więc tak, że jeśli liczba obiektów danego typu jest znana w trakcie kompilacji, ich kolekcja jest zadeklarowana jako tablica a stałym rozmiarze. Przykładem jest lista obiektów stanu dla wszystkich klawiszy i przycisków myszy. Natomiast jeśli liczba ta nie jest znana w czasie kompilacji (np. liczba przycisków joysticka), to korzystam z listy, której bufor jest jednym, ciągłym blokiem i przechowuje w sobie struktury obiektów.
Cóż, proste struktury danych są na tyle proste, że można z nimi robić co tylko się chce. Zdecydowanie łatwiej jest dzięki ich użyciu dbać o niskie zapotrzebowanie na pamięć, układać je w ciągłe bufory i oszczędzić czasu CPU, dzięki brakowi VMT.
I nareszcie, trzecią sprawę, którą ogarnąłem, było solidne przetestowanie wsparcia trybu pełnoekranowego, dla dowolnych ekranów i rozdzielczości. W SDL3, wsparcie wielu ekranów i trybów wyświetlania zostało napisane od podstaw, tak aby mieć pewność, że jest zaimplementowane prawidłowo. Jakiś czas temu obiecałem deweloperom SDL-a, że sprawdzę czy nadal istnieją problemy takie jak w SDL2, więc pasowało dotrzymać słowa.
Napisałem więc prosty tester, który pozwala sprawdzić czy to co mam obecnie w silniku, będzie działało lepiej w SDL3 niż w SDL2 i czy będę mógł uniknąć dziwnych hacków, aby wszystko działało jak należy. Wygląda na to, że nareszcie wszystko działa jak należy, jeśli napiszę kod dokładnie w taki sposób, jak to opisuje dokumentacja SDL-a. Co prawda są jeszcze małe zgrzyty (zgłosiłem w repo), ale nie dotyczą one najpopularniejszych driverów. OpenGL, Direct3D 11 i 12 nie wykazują żadnych problemów, a mój silnik używa OpenGL jako renderera, więc raczej nie ma się co przejmować zgrzytami.
To co zakładałem, czyli wsparcie dla ekskluzywnego trybu pełnoekranowego, będzie przez mój silnik zapewnione. Gra będzie mogła być odpalona na dowolnym ekranie, w dowolnej wspieranej przez niego rozdzielczości i częstotliwości odświeżania, z akceleracją sprzętową i VSyncem. Nie ma lipy.
Przy okazji testów fullscreenu, wpadłem na jeszcze jeden pomysł. Czasem odpalam pobieranie jakiegoś dużego pliku i czekając aż się pobierze, np. pykam w Fairtrisa. Przeglądarka wyświetla postęp pobierania na swoim przycisku na pasku zadań, ale że gra jest odpalona na pełen ekran, to paska zadań nie widzę.
Dodałem więc do swojego silnika wsparcie trybu pełnoekranowego, ale bez przykrywania paska zadań. W tym trybie okno nie ma obramowania, a jego klient rozciągnięty jest na całą powierzchnię roboczą ekranu. W ten sposób ma się cały ekran przykryty oknem gry, ale jednocześnie pasek zadań jest cały czas widoczny. SDL posiada funkcję do pobrania obszaru roboczego ekranu, więc implementacja tego trybu wymagała ~dziesięciu linijek kodu.
Koniec końców, mój silnik wspiera pięć trybów wyświetlania okna:
Każdy z tych pięciu trybów można uruchomić w dowolnym momencie. Ich uruchamianie będzie docelowo na stałe przypisane do konkretnych klawiszy, ale dorzucę też wsparcie skrótów dodatkowych, np. Alt+Enter do odpalenia ekskluzywnego trybu pełnoekranowego.
Silnik ma też zaimplementowane pamiętanie trybu oraz pozycji i rozmiaru okna, kiedy okno nie jest wyświeltone w ekskluzywnym fullscreenie. Wszystko dlatego, że SDL nie zapamiętuje jak okno wyglądało przed odpaleniem ekskluzywnego fullscreena, więc po wyjściu z niego, okno otrzymuje inne wymiary i pozycję niż miało. Jeśli np. okno miałem zmaksymalizowane i włączyłem ekskluzywny fullscreen, to po jego wyłączeniu, okno nie jest przywracane do formy zmaksymalizowanej. Tak więc silnik sam to pilnuje i po wyłączeniu fullscreenu, zawsze przywraca poprzedni styl okna.
Słyszałeś może o Castle Engine? https://castle-engine.io/
Programuje się tam w Pascalu.
Może warto spróbować?
Oprócz PC, obsługuje Androida, iOS i Nintendo Switch.
@Azarien: SDL3 został napisany zupełnie od podstaw i nie jest kompatybilny z SDL2, stąd nowy główny numer biblioteki (czyli 3).
SDL2 nadal jest rozwijany (głównie naprawianie błędów), więc jeśli ktoś nie chce migrować na nowe API, to nie musi. Nadal nie zmienia to jednak faktu, że można nie ruszać źródeł projektu używającego SDL2, ale i tak skorzystać z SDL3 — do tego służy wersja sdl2-compat. Wystarczy
SDL2.dll
zamienić naSDL2.dll
compat, obok wkleićSDL3.dll
i to tyle — nie trzeba rekompilować aplikacji. To samo jest od początku dostępne dla SDL 1.2, czyli sdl12-compat, po cichu używający SDL2.Mimo wszystko, pozostawanie przy SDL2 nie ma większego sensu, jeśli mamy dostęp do źródeł aplikacji i możemy ją przekompilować. Migracja na nowe API nie jest zbyt trudna.