Nie tak dawno temu w notce Trzy rozwiązania dla relacji zawierania zastanawiałem się nad tym, jak elegancko i odpornie (na błędy) zrealizować interfejs klasy-kontenera zawierającego różne elementy. Po jakimś czasie jednak zapomniałem o całej sprawie, gdyż zająłem się menedżerem fontów, gdzie kwestia ta nie była mi potrzebna.
W końcu przyszła pora na zastanowienie się nad systemem GUI i wtedy przypomniałem sobie o tym problemie. Rezultatem tego jest wątek na forum Warsztatu.
Wywiązała się w nim ciekawa dyskusja, ale ostatecznie tylko odrobinę przybliżyła mnie do dokonania jakichś decyzji. Bardziej interesujące są może nieco inne wnioski, które przy tej okazji wyciągnąłem… Tak, chciałbym sobie teraz trochę pofilozofować :)
Zaskoczyło mnie trochę to, że na proste i jednoznaczne pytanie (“Które rozwiązanie jest lepsze?”) w dziedzinie tak zdawałoby się ścisłej jak programowanie nie ma dobrze uzasadnionej i obiektywnej odpowiedzi. Właściwie wszystkie argumenty, jakie tam padały były wielce subiektywne, na zasadzie: “wydaje mi się”, “bardziej naturalne będzie”, “najbardziej logiczne jest”, itd.
Okazało się, że programowanie – a zwłaszcza faza projektowania – wcale nie musi być taką ścisłą dziedziną, a każdy jego wytwór może mieć indywidualny charakter. Oczywiście, spora część podejmowanych przy okazji decyzji ma inny charakter niż dylemat, czy ładniejszy jest kolor zielony czy czerwony. Zdarzają się jednak i takie, gdzie kryteria są niejasne, nieostre i subiektywne.
Prostota, przejrzystość, elegancja… estetyka… piękno? Czy to dowód, że programowanie można w pewnym stopniu uważać za dziedzinę sztuki?
Skoro posiadam już w miarę sprawnie działający moduł odpowiedzialny za grafikę 2D, pora zająć się jednym z jego najważniejszych zastosowań. Bo oprócz menu czy ekranu powitalnego, najważniejszymi dwuwymiarowymi elementami w każdej grze są składowe jej interfejsu użytkownika.
Po co jednak pisać własny system GUI?… Cóż, oprócz uniwersalnej odpowiedzi (“bo tak”) można odrzec, że to po prostu fajne :) W bardziej rozwiniętej wersji uzasadnienie brzmi tak, iż struktura wewnętrzna dobrze napisanego systemu UI to jeden z najelegantszych przykładów zastosowania programowania obiektowego w praktyce. Jest w niej aż gęsto od przeróżnych wzorców projektowych i całość aż kipi od kipi od tego, co nazywam estetyką projektowania.
Tej estetyki niestety będzie mi prawdopodobnie brakować, kiedy później zajmę się już całkiem “trójwymiarowymi” aspektami silnika, jak modelami, organizacją sceny, materiałami, oświetleniem, itd. Zbalansowanie wydajności z przejrzystością interfejsu (i kodu) na pewno będzie wymagało wielu nieprzyjemnych wysiłków…
A zatem z pewną przesadą można powiedzieć, że system GUI będę pisał dla… relaksu :) Wobec takiego postawienia sprawy nie ma rzecz jasna potrzeby odpierania argumentów w stylu: “Przecież to nie jest potrzebne!”, “W D3DX jest już system GUI (screen powyżej)”, “Dublujesz kontrolki systemowe”, itd. Dla porządku dam aczkolwiek jedno logiczne uzasadnienie: te gatunki gier, które najbardziej lubię – a więc RPGi, strategie czy gry ekonomiczne – wykorzystują UI w dużych i różnorodnych ilościach. Więc jeśli kiedyś… no ale do tego chyba przejdziemy znacznie później :]
GUI jest jedną z tych rzeczy, które można rozbudowywać właściwie w nieskończoność. Dlatego przydałoby się od razu ustalić założenia, jakie chciałbym spełnić. Przede wszystkim nie chcę mnożyć kontrolek ponad potrzebę, gdyż sądzę, że w dużej części przypadków do szczęścia wystarczą poniższe cztery:
Faktycznie przydałaby się jeszcze jakaś kontrolka listowa, ale na razie dla prostoty chcę ją pominąć.
Oprócz samych kontrolek ważne jest też odpowiednie ich zachowanie się. Przede wszystkim chodzi tu o kwestię fokusu klawiatury czy capture myszki, a także funkcjonalności klawisza Tab. Krótko mówiąc, chciałbym żeby mój system przypominał UI, które na co dzień można spotkać w Windows.
Tyle że moje UI nie musi być wcale takie “piękne” jak Aero z Windows Vista ;P
Pod koniec notki na temat czcionek i wyświetlania tekstu stwierdziłem, że podstawowym zastosowaniem tej części biblioteki 2D jest wyświetlanie licznika FPS :) No cóż, pewnie wcale nie byłem daleko od prawdy, bo – jak wiadomo – pomiar ilości generowanych na sekundę klatek jest bardzo ważny. Można to jednak robić na kilka sposobów.
Najprościej jest chyba policzyć czas generowania ostatniej klatki w sekundach – i tak jest to potrzebne do wyliczenia nowych pozycji obiektów i uaktualnienia całej reszty symulacji. Odwrotność tego czasu będzie chwilową wartością FPS.
Można też policzyć ilość klatek narysowanych w jakimś krótkim odstępie czasu poprzedzającym chwilę obecną – zwykle w ciągu jednej sekundy. Wtedy podzielenia owego czasu przez zliczoną ilość ramek da nam średnie FPS.
Wreszcie można te obliczenia prowadzić od momentu uruchomienia gry, śledząc liczbę klatek i całkowity czas jej działania. Wówczas iloraz tych wartości da nam całkowite FPS.
Która wartość jest najlepsza do pokazania na ekranie?… Wartość chwilową najłatwiej wyliczyć, ale zmienia się ona co klatkę, więc podlega przypadkowym wahaniom, niemającym związku z kodem samej gry. Poza tym tak częsta aktualizacja sprawiałaby, że nie dałoby się tej wartości odczytać. W praktyce trzeba by więc wypisywać ją rzadziej, np. co sekundę, co komplikuje sprawę.
Z kolei całkowite FPS ze względu na swój globalny charakter “spłaszcza” wszelkie wahania. Im dłużej działa gra, tym mniejszy jest związek między jego wartością, a tym co widać na ekranie. Może się więc zdarzyć, że gra będzie strasznie przycinała, podczas gdy całkowite FPS będzie stało na wartościach 60-70 z lekką tylko tendencją spadkową.
Najlepiej jest więc pokazywać średnie FPS wyliczone dla jakiegoś krótkiego odcinka czasu. Ponieważ jego wartość zmienia się często (zazwyczaj co sekundę), dobrze odzwierciedla aktualną szybkość generowania klatek. Zmiana parametrów sceny (ilość wielokątów, światła, cienie, odbicia, cząsteczki, pozycja kamery, itd.) może więc prawie natychmiast przełożyć się na wartość FPS. Negatywnie, rzecz jasna :]
Jednocześnie średnie FPS nie podlega tak bardzo chwilowym i losowym zatorom na linii procesor-RAM-karta graficzna, gdyż jest liczone na podstawie przynajmniej kilkudziesięciu próbek.
I chociaż średnie FPS liczy się w nieco bardziej skomplikowany sposób niż chwilowe, jego wyznaczenie nie jest niczym trudnym ani kosztownym:
Jedno jest pewne: wartość FPS nie powinna spadać od samego jej liczenia ;)
Kolejnymi kawałkami kodu z poprzedniej wersji mojej biblioteki uniwersalnej, którym postanowiłem się przyjrzeć, były różnego rodzaju funkcje “systemowe”. Nazwa jest może trochę na wyrost, jako że dotyczyły one głównie pewnych operacji na plikach i ich nazwach. Wśród z nich znalazły więc procedury do pobierania nazw plików, ich rozszerzeń, sprawdzania istnienia plików, tworzenia całych ścieżek katalogów i tym podobne.
Ostały się też dwie najciekawsze funkcje o bardzo podobnych do siebie sygnaturach:
Wykonują one interesującą pracę: konwertują ścieżki względne na bezwzględne i odwrotnie. Jak wiadomo, ścieżka bezwzględna określa położenie w systemie plików w sposób jednoznaczny, np.:
oznacza podkatalog Taphoo w katalogu Program Files na dysku C – niezależnie od tego, gdzie się teraz znajdujemy. Natomiast ścieżka względna – taka jak ta:
mówi jedynie, że w celu dostania się do pliku logo.gif należy wyjść do nadrzędnego katalogu (..), a stamtąd przejść do katalogu images.
Do czego może przydać się konwersja między oboma typami ścieżek? Dobrym przykładem jest dyrektywa #include
w C(++), która akceptuje ścieżki względne:
Preprocesor musi jest rozwinąć, aby móc wstawić zawartość dołączanego pliku. Z użyciem wymienionej wyżej funkcji RelativeToAbsolutePath
można łatwo dodać podobną dyrektywę do języka opisu lub skryptowego.
Żadna porządna biblioteka do grafiki 2D nie może obyć się bez narzędzi służących do wypisywania tekstu. Kiedy jednak mamy na uwadze głównie programowanie gier (lub pokrewnych aplikacji), sprawa wygląda nieco inaczej niż w bardziej “ogólnych” zastosowaniach. Nie trzeba na przykład rozkładać tekstu na czynniki pierwsze:
Pozwalałoby to oczywiście w razie potrzeby dodać kursywę, pod-, nad- i przekreślenie. Zazwyczaj jednak nie jest to potrzebne.
Tak więc chociaż wygląda to na krok wstecz, stosuje się najczęściej czcionki bitmapowe. Pomysł polega na tym, że cały używany zestaw znaków danego kroju i danej wielkości jest rysowany na jednej teksturze w taki sposób, by łatwo można było obliczyć pozycję każdego znaku:
Wyświetlanie napisu polega wtedy na renderowaniu oteksturowanych prostokątów – po jednym dla każdego znaku. Jest to bardzo wydajne, bo chociaż trójkątów może być bardzo dużo, to ich tekstura jest zawsze taka sama. Można zatem wyrzucić całe połacie tekstu na ekran tylko jednym wywołaniem Draw[Indexed]Primitive.
Tylko skąd wziąć taką sprytną teksturę? Można rzecz jasna generować ją samemu przy pomocy funkcji GDI; choć jest to wolne, byłoby wykonywane tylko raz, więc nie stanowiłoby problemu. Lepszym rozwiązaniem jest użycie odpowiednich programów, z których najlepszym jest chyba Bitmap Font Generator. Potrafi on w sprytny sposób upakować w jednym obrazku sporą ilość znaków, zaś ich parametry opisuje w łatwym do odczytania formacie tekstowym przypominającym szczątkowy INI.
Obecnie używam więc właśnie jego i dzięki temu mogłem w końcu dodać do swojego silnika podstawowy i zupełnie niezbędny element: licznik FPSów :)
Chcę dzisiaj zająć się pewną sprawą natury projektowej. Mam tu mianowicie na myśli sytuację, gdy mamy do czynienia ze zbiorem różnych elementów, wywodzących się z tej samej klasy bazowej. Najlepiej widać to będzie na odpowiednim diagramie:
Jest tu klasa kontenera (Container), która posiada m.in. zestaw różnych Elementów. Ich dokładne typy nie są i nie muszą być znane – ważne, że wywodzą się od klasy bazowej Element. W szczególności klasy Container i Element mogą być jednym i tym samym – wtedy będziemy mieli do czynienia ze strukturą hierarchiczną, reprezentującą np. drzewo węzłów dokumentu XML czy kontrolki systemu GUI. Zauważmy jeszcze, że każdy element wie o swoim właścicielu (pole Owner), czyli o pojemniku, który go zawiera (albo o elemencie nadrzędnym w przypadku drzewa).
Z obsługą już dodanych elementów nie ma zbytniego problemu, jako że wyjątkowo naturalne jest tu wykorzystanie metod wirtualnych i polimorfizmu. Kłopot polega właśnie na tworzeniu i dodawaniu nowych elementów – a ściślej na interfejsie (funkcjach i parametrach), które mają do tego służyć. I tutaj właśnie pojawiają się te trzy rozwiązania.
Rozwiązanie pierwsze polega na wyposażeniu klasy Container w garnitur metod zajmujących się tworzeniem każdego typu elementów i dodawaniem ich do zbioru. W naszym przypadku byłyby to więc funkcje AddElementA, AddElementB i AddElementC:
Jak widać w tym przypadku dobrze jest wyposażyć konstruktory klasy Element i pochodnych w parametr, przez który będziemy podawali wskaźnik do nadrzędnego kontenera lub elementu.
Drugie rozwiązanie zakłada, że pojemnik dysponuje tylko jedną ogólną metodą AddElement. Przekazujemy jej już utworzone obiekty, a jej zadaniem jest tylko dodać je do już posiadanych:
Zauważmy, że tutaj konstruktory elementów już nie przyjmują wskaźników na swoich właścicieli. Dzięki temu możemy uchronić się przed omyłkowym dodaniem elementu do innego pojemnika niż ten, który podaliśmy podczas jego tworzenia.
Skąd jednak element wie, do kogo należy?… Za to odpowiada już metoda dodająca, która modyfikuje pole określające właściciela:
Oczywiście w tym celu klasa Container musi być zaprzyjaźniona z klasą bazową Element, co na szczęście w C++ nie stanowi problemu :) (W innych językach, jak np. C# czy Java, można osiągnąć bardzo podobny efekt przy pomocy pakietów.)
W końcu trzecie wyjście przenosi cały ciężar pracy na same elementy. W tej koncepcji to elementy same się dodają do podanego pojemnika:
Żeby miało to sens, muszą być one aczkolwiek tworzone dynamicznie poprzez operator new.
Czas na nieodparcie nasuwające się pytanie, czyli… które rozwiązanie jest najlepsze? No cóż, sprawa nie jest taka prosta (inaczej bym o niej nie pisał ;D), więc może przyjrzyjmy się kilku argumentom:
Jest więc new, którego rezultat ignorujemy i tylko po dłuższym przyjrzeniu się można zauważyć, że to jednak nie jest wyciek pamięci.
Cóż więc wybrać? Ostatnio skłaniam się ku drugiemu rozwiązaniu. Według mnie jest ono z tych trzech najbardziej przejrzyste – tworzenie obiektu to jedno (operator new), a jego dodawanie drugie (metoda Add). Ponadto dzieli ono pracę w miarę równo między element, jak i pojemnik – w przeciwieństwie do pozostałych modeli. I w końcu, nic w tym rozwiązaniu nie jest robione za plecami programisty, co oczywiście może być zaletą, ale i wadą oznaczającą, że przecież wszystko musimy zrobić samemu.
A zatem… kwadratura koła? W takich sytuacjach warto chyba iść na kompromis, czyli poprzeć prostokąt z zaokrąglonymi rogami ;)
Czasami mylne wyobrażenie o pewnych elementach języka programowania potrafi ściągnąć na nas nie lada kłopoty. Właśnie ostatnio mogłem o się o tym przekonać, a sprawa dotyczy niezbyt często wykorzystywanego elementu C++, a mianowicie placement new.
Cóż to takiego? Jest to sposób na podanie operatorowi new adresu miejsca w pamięci, który ma wykorzystać. Może to brzmieć niedorzecznie, bo przecież new ma za zadanie właśnie alokację pamięci. To jednak tylko część prawdy, bowiem ten operator wywołuje też konstruktor tworzonego obiektu. W przypadku wersji placement będzie to więc jedyna czynność, jaką wykona.
Składnia placement new wygląda mniej więcej tak:
Na czym jednak polega problem? Otóż tego specjalnego wariantu operatora new
nie można wywoływać bezkarnie. W szczególności nie jest to sposób na “przezroczyste” wywołanie konstruktora dla jakiegoś obiektu.
Pytanie oczywiście brzmi: do czego było mi potrzebne użycie tego rzadkiego mechanizmu języka? No cóż, teraz już wiem, że do niczego, jednak wcześniej myślałem, że będzie to niezły sposób na wczytywanie zasobów.
W skrócie: w mojej bibliotece zasoby takie jak np. tekstury czy bufory tworzą dość prostą hierarchię dziedziczenia. Parametry niektórych z nich – jak np. tekstur – można wczytywać z pliku tekstowego – w tym przypadku chodzi chociażby o nazwę pliku graficznego z obrazkiem tekstury.
Sęk w tym, że zasób tekstury (reprezentowany przez klasę CTexture) jest też zasobem DirectX (dziedziczy po IDxResource). A z tym związane są kolejne parametry, jak np. pula pamięci czy sposób użycia zasobu (to akurat nie jest mój wymysł, tylko DirectXa ;P). One również mogą być zapisane w pliku i trzeba je uwzględnić przy wczytywaniu tekstury.
W moim “genialnym” rozwiązaniu wymyśliłem więc, że podczas tworzenia obiektu CTexture zostanie najpierw stworzony obiekt IDxResource (dzięki czemu zostanie załatwiona kwestia “DX-owych” parametrów). Następnie w tym samym miejscu pamięci – placement new! -skonstruujemy obiekt CTexture, który zajmie się załadowaniem pozostałych danych.
I to rzeczywiście działało, dopóki się nie zorientowałem, że zapomniałem dopisać do menedżera zasobów kodu, który zwalniałby wszystko przy kończeniu programu. Wtedy to w ruch poszły destruktory, a wtedy pojawiły się… przerwania systemowe informujące o uszkodzeniu sterty.
Powodem jest fakt, że obiekty (a ściślej mechanizm ich alokacji) pamiętają sposób, w jaki zostały stworzone. Jeżeli było to zwykłe new, to zniszczenie obiektu pociąga za sobą zwolnienie pamięci. Lecz jeżeli było to placement new, to operator delete zakłada, że nic nie wie o obszarze pamięci, w którym obiekt rezyduje – więc go nie zwalnia. W istocie FAQ C++ wyraźnie pisze, by przy używaniu placement new samemu wywoływać destruktor, a potem samodzielnie zwalniać pamięć.
placement new nie jest po prostu sposobem na wywołanie konstruktora – z jego użyciem wiążą się określone konsekwencje. Jak widać czasami można przekonać się o nich w mało przyjemny sposób :)