W poprzedniej części artykułu pokazałem, od czego zacząć produkcję kodu cross-platformowego dla natywnych aplikacji. Zatrzymałem się na przedstawieniu problemów związanych z rozwiązywaniem implementacji specyficznych dla danej platformy oraz na pokazaniu jak niektóre platformy przechowują dane aplikacji. Z drugiej części dowiecie się m.in. jak powstawał przenośny interfejs użytkownika mojej gry.


Marcin Małysz. Na co dzień pracuje jako architekt oprogramowania w firmie Explain Everything budując i rozwijając silnik animacyjny zasilający nasz produkt. W wolnych chwilach tworzy gry i jest zapalonym graczem Counter Strike. Niektóre z jego prac można znaleźć na https://warsztat.gd/user/noxy/projects. Jako człowiek wychowany na 4chanie jedyna formę komunikacji jaką uznaje za słuszną to memy i gify.


Poruszymy teraz temat narzędzi, które przydają się na co dzień przy budowaniu naszego kodu, zwłaszcza gdy chcemy, aby został dołączony jako zewnętrzna gotowa biblioteka statyczna. Z pomocą przyjdzie nam pakiet narzędzi popularnie zwany Cmake — w skrócie pozwala on na stworzenie “skryptu”, który przy użyciu narzędzia make wygeneruje plik potrzebny do kompilacji kodu i jego ewentualnej instalacji. Jest to bardzo wygodne rozwiązanie ponieważ cmake automatycznie wygeneruje odpowiednie pliki potrzebne do kompilacji w zależności od platformy, na której zostanie uruchomiony.

Poruszam temat Cmake ponieważ jest on używany przy integracji naszego kodu C++ z Androidem. Android używa NDK zestawu narzędzi, które pozwalają skompilować nasz kod dla procesora ARM i wykonać ten kod w kodzie Java. Najwygodniejsza formą integracji naszego kodu z NDK jest właśnie wykorzystanie Cmake do wygenerowania biblioteki, która zostanie załadowana do docelowej aplikacji na platformie Android. Przykład skryptu CMakeList.txt użytego do wygenerowania biblioteki statycznej zamieszczam poniżej:

Dzięki użyciu Cmake nie musimy ograniczyć się tutaj tylko do platformy Android, modyfikując go odrobinę możemy w łatwy sposób dodać wsparcie dla projektu stworzonego pod Windows w Visual Studio czy wygenerować bibliotekę dla platformy Linux.

Ostatnim tematem jaki poruszę w tym rozdziale jest tworzenie przenośnego interfejsu użytkownika. Jeżeli interfejs naszej aplikacji będzie lub jest projektowany w sposób, który zakłada identyczną logikę i wygląd, bez względu na użytą platformę, warto zastanowić się czy nie napisać, bądź przepisać naszego interfejsu na taki, który będzie wspierał przenośność. Idealnym przykładem może być użyta przeze mnie biblioteka nuclear, która pozwoliła na zbudowanie interfejsu dla Platformy Windows otwierając drogę do odpalenia jej na platformie Linux bez konieczności zmiany nawet jednej linijki kodu (mowa tu oczywiście o samym interfejsie).

Dzięki takiemu rozwiązaniu udało się nawet odpalić całość w przeglądarce przy pomocy emscripten, który pozwala na kompilacje bytecodu LLVM do javascriptu. Było to o tyle łatwe, że zajęło mi dosłownie jeden wieczór!

Problemy:

Pomimo iż kod C++ jest łatwy do przenoszenia, nie zawsze można powiedzieć, że uda się to zrobić bez napotkania problemów, które mogą wstrzymać development, a nawet spowodować, że będzie trzeba nieco przeprojektować nasz kod. Dlatego warto omówić projektowanie w większym gronie, zwłaszcza gdy nie jesteśmy pewni wszystkich kruczków, które może zaserwować nam jedna z platform, na którą przygotowujemy nasz kod. W tej części artykułu postaram się omówić niektóre problemy jakie napotkałem podczas tworzenia gry.

Jeżeli piszemy kod wielowątkowy na pewno musimy zwrócić uwagę na to, na jakich wątkach dana platforma wykonuje nasz kod i czy na pewno jest to właściwy wątek jaki zakładamy. Dobrym przykładem będzie platforma Apple, gdzie wątek UI, który służy nam do odrysowywania elementów naszej aplikacji, nie zawsze musi iść w parze z wątkiem głównym. Jako że do rysowania contentu gry wykorzystuję OpenGL muszę rysować elementy na wątku, na którym ten kontekst został utworzony. I tak dla iOS często będzie to właśnie wątek główny aplikacji, gdzie np. dla macOS wątek renderujący często tworzy się poprzez tak zwany displaylink, który synchronizuje za nas wywołanie rysowania z odświeżaniem ekranu dla danego urządzenia. Może okazać się to nie lada wyzwaniem gdy dodatkowo nasz kod musi asynchronicznie komunikować się z natywnym kodem aplikacji. Na szczęście większość środowisk dostarcza dedykowane API pozwalające na synchronizację kodu takich jak Grand Central Dispatch czy choćby runOnUiThread() dostępnej w API Androida. Kontynuując temat wątków, ciekawym przykładem dobrze zaprojektowanej komunikacji jest odpowiednie przygotowanie kodu NDK na platformie Android. NDK pozwala nam na wywoływanie naszego natywnego kodu C++ w aplikacji Android oraz na wywoływanie natywnego kodu Java z kodu C++ (czyli pełna dwustronna komunikacja).

Jest jednak pewien haczyk -+ kod maszyny wirtualnej Java może być wywołany na innym wątku musimy jednak pobrać wszystkie potrzebne wskaźniki na funkcje i klasy. Dodatkowo, wszystkie metody muszą być statyczne i spełniać warunki określone przez NDK, przykładowo specjalne nazewnictwo metod:

Deklaracja, która łączy kod z Java z C++ będzie więc wyglądać następująco:

Warto zauważyć dość dziwaczne nazewnictwo. Jest to prawdopodobnie jeden z największych minusów tworzenia kodu w NDK Androida… Wracając jednak do problemu wielowątkowości NDK, najprostszym sposobem na przygotowanie naszego kodu na obsługę wywołania z innego wątku, co może się wydarzyć gdyż np. pewne obliczenia naszego silnika zostaną wykonane asynchronicznie i przekazane do kodu Java, aby oddelegować je na główny wątek aplikacji. Aby je przygotować użyjemy metody dostarczonej przez NDK jint JNI_OnLoad(JavaVM* aVm, void* aReserved).

Pełny kod może wyglądać następująco:

Jako ciekawostkę podam, że cała maszyna wirtualna javy jest tutaj zaimplementowana w C++, co ułatwia jej użycie z natywnym kodem i łączy zarządzanie pamięcią pomiędzy kodem C++ i Java poprzez zliczanie referencji.

Pomówmy teraz nieco o zewnętrznych bibliotekach. Jak wspomniałem na początku artykułu, do obsługi audio w grze użyłem cross-platformowej biblioteki FMOD. Mogłoby się wydawać, że samo poprawne zaimplementowanie obsługi tworzenia urządzenia audio pozwoli na poprawne odtwarzanie dźwięku. Jakież było moje zdziwienie, gdy podczas przystosowywania kodu pod platformę Raspberry-PI (która de facto jest wersją Linuxa skompilowaną pod procesory z rodziny ARM) nie pojawił się żaden dźwięk. Na początku sądziłem, że problem tkwi w ustawieniach bufora audio, który na Linuxach z racji używania ALSA często nie jest do końca sprzętowy i wymaga większego ring bufora do przetworzenia danych audio.

Okazało się, iż problem tkwi tak naprawdę w konfiguracji samego ALSA między platformami. Na systemach Linux z rodziny Ubuntu 16.XX sterownik audio znajduje się pod pozycją 0, co wydaje się być naturalne w Raspberry-PI jednak jest to pozycja 1. Wynika to z faktu wymuszania przez Raspberry-PI przesyłu dźwięku poprzez kabel HDMI. Nie jest to błąd, ale pokazuje, że nie można odgórnie zakładać, że pewnie ustawienia będą zawsze takie same i odpowiednio przygotować ładowanie ustawień naszego kodu tak, aby dobrać ustawienia najbardziej optymalne, a najlepiej pozwolić użytkownikowi wybrać samemu.

Kolejną z zewnętrznych bibliotek jaką użyłem do stworzenia tego projektu jest OpenGL — jedna z najpopularniejszych bibliotek graficznych, głównie dzięki jej wsparciu przez wszystkie wymienione platformy. API OpenGL jest opisane przez standard stworzony przez grupę Kronos. Standard pozwala jednak na użycie ew. rozszerzeń, jeżeli karta graficzna wspiera dodatkową funkcjonalność zaimplementowaną w sterowniku karty. Jest to pierwsza z rzeczy, na jakie trzeba zwrócić uwagę podczas projektowania naszego kodu. Mówię o tym ponieważ wybierając wersję API OpenGL postanowiłem użyć API 3.2 Core dla dedykowanych kart graficznych oraz ES2.0 stworzonym dla systemów przenośnych. Większość podstawowego API pokrywa się, trzeba jednak przygotować odpowiednie wersje programów karty graficznej nazywanych Shaderami. Pozwalają one na wykonanie obliczeń bezpośrednio na karcie graficznej. Przykład dwóch takich samych programów przygotowanych dla wersji przenośnej oraz stacjonarnej:

ES2.0:

3.2 Core:

W dzisiejszych czasach nie jest to jednak wymóg, ponieważ większość nowych kart graficznych pozwala na uruchomienie kontekstu Embedded System 2.0, ułatwiając jeszcze bardziej integrację API graficznego. Ostatnią z rzeczy, o której warto wspomnieć jest wykorzystanie rozszerzeń, o których mówiłem wcześniej. Bardzo dobrym przykładem będzie funkcja glMapBuffer, która pozwala dostać się do pamięci karty graficznej i zmodyfikować ją bezpośrednio z naszego kodu, tworząc nasz kod znacznie wydajniejszym. Poniżej przykład wykorzystania rozszerzenia OpenGL w porównaniu z typowym alokowaniem pamięci i skopiowaniem jej z pamięci ram do pamięci GPU.

Kod w przeglądarce

Omówmy teraz możliwość uruchomienia naszego kodu w przeglądarce. Jest to o tyle ekscytujące, gdyż jesteśmy w stanie skompilować do jasm niemal 100% naszego kodu. Dzięki temu możemy odpalić naszą aplikację bez konieczności dopisywania jakiegokolwiek kodu HTML czy javascript. Przykładem niech będzie wersja przeglądarkowa prezentowanej gry noxytrux.github.io. Nie jest to jednak idealne rozwiązanie. Emscripten jako technologia wciąż jest rozwijana i zawiera masę błędów. Dodatkowo ew. zewnętrzne biblioteki, które chcemy użyć też muszą być skompilowane przy użyciu emscripten, bądź zawierać się w przygotowanym przez twórców zestawie bibliotek standardowych.

Co gorsza z powodu luki bezpieczeństwa meltdown — spectre wiele przeglądarek zablokowała możliwość wykonywania kodu wielowątkowego, co wymusza na nas ew. obejścia problemu poprzez wymuszanie jednowątkowej pracy. Jednym z rozwiązań problemu może być uruchomienie flag przeglądarek pozwalających na użycie niektórych rozszerzeń, które są w fazie testów. Dla przykładu uruchomienie flagi SAB (Shared Array Buffer) pozwoli nam wykorzystać wielowątkowość. Niestety nie nadaje się ona do produkcyjnego oprogramowania, a sama kompatybilność wsteczna emscripten dla wsparcia wielowątkowości jest bardzo zawodna. Podsumowując, o ile sama kompilacja kodu C++ do jasm jest procesem dość łatwym i szybkim tak jego działanie może nie być do końca stabilne.

Ostatnim wartym poruszenia tematem jest projektowanie kodu tak, aby był gotowy do pracy z różnymi architekturami procesorów. Mam tutaj na myśli wersje 32 i 64 bitowe. O ile większość dostępnych dziś procesorów pracuje na 64 bitach, tak wciąż zdarzają się wersje 32 bitowe zwłaszcza w tanich smartfonach. Kompilacje do 32 bitowej architektury może wymuszać na nas brak odpowiednich bibliotek 64 bitowych na niektórych platformach. Dlatego też tworząc nasz kod powinniśmy skupić się na odpowiednich flagach kompilatora oraz jego makrodefinicjach, służących do ustawiania odpowiednich wyrównań struktur danych oraz odpowiedniego dobierania zmiennych, by zapewnić ich stały rozmiar bez względu na wybraną platformę. Przykładem może być zmienna uint32_t, która zapewnia rozmiar stałych 4 bajtów bez względu na platformę. Są to rzeczy bardzo istotne zwłaszcza, gdy nasz kod komunikuje się sieciowo między dwoma tymi samymi aplikacjami uruchomionymi na różnych procesorach.

Tym oto sposobem dotarliśmy do końca artykułu. Mam nadzieję, że rozjaśniłem nieco jak wygląda przygotowanie i rozwiązywanie wielu z problemów, które możemy napotkać podczas projektowania i pisania kodu w języku C++ przeznaczonego dla wielu platform.

Plusy i minusy takiego rozwiązania

Największym plusem takiego rozwiązania jest z pewnością koszt wytworzenia i utrzymania takiego kodu. Dodatkowo, mamy pełną kontrolę nad kodem i nie jesteśmy uzależnieni od zewnętrznych firm dostarczających rozwiązania, które mogą posłużyć jako baza logiki biznesowej jak np. Xamarin. Lepiej rozumiemy co się dzieje “pod spodem” naszego kodu. Każda poprawka wymaga jedynie rekompilacji poszczególnych platform.

Minusem jest fakt, że wciąż uzależniamy się od implementacji szczególnych dla danej platformy, co wciąż niesie za sobą ryzyko błędów, gdyż nie zawsze ograniczamy się tylko do napisania warstwy interfejsu dla naszego kodu. Przykładem może być NDK, które kładzie dodatkowy narzut implementacji. Często też w przypadku wystąpienia undefined behaviour różna implementacja warstw może powodować mylne przekonanie, że to inne platformy mają coś źle napisane, gdy tymczasem błąd leży znacznie głębiej.

Podsumowanie

Projekt ten pomógł mi zrozumieć w dużej mierze, jakie zagrożenia i problemy może napotkać zespół, który będzie musiał integrować kod C++ w swoich aplikacjach oraz jak należy taki kod pisać, aby jego integracja przebiegała poprawnie bez konieczności dużych zmian w architekturze samego kodu. Dodatkowo była to bardzo dobra zabawa i możliwość poznania nowych języków i narzędzi, które pomagają w codziennej pracy programisty. Niestety nie udało mi się zawrzeć wszystkich szczegółów, jest to jednak temat tak obszerny i złożony, że spokojnie można by było o nim napisać książkę. Jeszcze raz gorąco zachęcam do zabawy repozytorium, które zamieściłem w artykule. Jest tam zawarte wiele dobrych i złych rozwiązań problemów, więc będzie to bardzo przydatna wiedza.


Zachęcamy do przeczytania pierwszej części tego artykułu. Znajdziesz ją pod tym adresem.

 

Zapraszamy do dyskusji
Nie ma więcej wpisów

Send this to a friend