Silnikologia (przynajmniej ta warsztatowa) ma swoje dziwnostki. Za jedną z nich uważam przykładanie zbyt dużego znaczenia do tych podsystemów engine‘u gry, które są zdecydowanie mniej ważne niż silnik graficzny czy fizyczny. Podpadają pod to (między innymi): mechanizmy logowania, VFS-y (wirtualne systemy plików), kod odpowiedzialny za wczytywanie i zapisywanie opcji konfiguracyjnych, itp. Blisko szczytu tej listy znajduje się też podsystem, o którym chcę napisać dzisiaj kilka słów. Chodzi mianowicie o menedżer zasobów (resource manager).
Pod tą nazwą kryję się obiekt, którego zadaniem jest – jak nazwa zresztą wskazuje – zarządzanie różnego rodzaju zasobami gry, gdzie pod pojęciem ‘zasobu’ kryje się zwykle jakiś element jej contentu: model, tekstura, próbka dźwiękowa, czcionka, shader… Menedżer ma za zadanie udostępniać odwołania do tych zasobów elementom silnika/gry, które ich potrzebują. To jest jego główny, nadrzędny cel.
Tu jednak dochodzimy do małego paradoksu. Jeśli bowiem jest to jedyne przeznaczenie modułu od zarządzania zasobami, to tak naprawdę mogłoby go w ogóle nie być!… W praktyce jego istnienie usprawiedliwia przynajmniej jeden z poniższych powodów:
Tego rodzaju wymagania rzeczywiście można rozsądnie zrealizować dopiero wówczas, gdy w logiczny sposób wydzielimy z kodu część, która za kontrolę zasobów będzie odpowiadać. Jeśli jednak żadna z powyższych sytuacji nie aplikuje się do nas, nie ma żadnego powodu, by zabierać się za tworzenie modułu zarządzania zasobami tylko dlatego, że “przecież zasoby gdzieś muszą być”. Coś takiego jak ‘prosty menedżer zasobów’ to oksymoron: jeśli faktycznie może być prosty, to najpewniej znaczy, iż jest zbędny. No, chyba że pod tym pojęciem rozumiemy też coś, co w praktyce da się zredukować do:
W takim przypadku powinniśmy zadbać przede wszystkich o to, by nasz Menedżer Zasobów (caps intended) dobrze współpracował z Modułem Wyświetlania Życia Gracza, że o Silniku Od PauzyTM nie wspomnę ;P
Przeglądając dokumentację do DirectX (a przynajmniej do części graficznej) można natknąć się na wiele typów wyliczeniowych. Większość z nich (a może wszystkie?) na końcu swojej definicji ma stałą o nazwie kończącej się na _FORCE_DWORD
. Przykładem jest znany, lubiany i przez wszystkich używany D3DRENDERSTATETYPE
:
typedef enum D3DRENDERSTATETYPE
{
// (co najmniej 1<<8 różnych stałych)
D3DRS_FORCE_WORD = 0x7fffffff;
} D3DRENDERSTATETYPE, *LPD3DRENDERSTATETYPE;[/cpp]
Zjechanie na sam dół pomocy pouczy nas, że stała ta zasadniczo... nie jest używana. Jednocześnie jednak ma ona wymuszać kompilację typu wyliczeniowego jako 32-bitowego. O co tutaj właściwie chodzi?
Kompilatory C++ mogą mianowicie wybierać dla typów wyliczeniowych właściwie dowolne typy liczbowe - byle tylko wszystkie wartości stałych się zmieściły. To sprawia, że wielkość enum
a może się różnić nie tylko między kompilatorami, ale i między różnymi ustawieniami kompilacji. Nietrudno na przykład wyobrazić sobie, że przy optymalizacji szybkości ów enum
będzie miał rozmiar równy słowu maszynowemu, zaś przy optymalizacji zajętości pamięci będzie to rozmiar najmniejszy możliwy.
No i tu zaczynają się schody tudzież pagórki. Zmienność (a raczej niezdefiniowanie) wielkości typu wyliczeniowego jest może kłopotliwa w pewnych sytuacjach. Trudno byłoby na przykład przewidzieć to, w jaki sposób należy odczytać wartość zwróconą przez metodę GetRenderState
urządzenia, która jest zapisana w 32-bitowym DWORD
-zie, jeśli nie zajmowałaby ona w nim wszystkich 4 bajtów. Podejrzewam też, że na którymś etapie renderowania we wnętrzu DirectX określony rozmiar pewnych flag (np. typów prymitywów) jest po prostu wymuszany przez sterownik karty graficznej. Całkiem rozsądne jest więc zapewnienie go od samego początku – czyli już w kodzie pisanym przez programistę-użytkownika DirectX.
Czemu jednak potrzebny jest takich hack? Ano tutaj znowu wychodzi niedookreślenie pewnych rzeczy w standardzie C++, zapewne z powodu źle pojętej przenośności. Częściowo zostanie to naprawione w przyszłej wersji standardu, gdzie – podobnie jak np. w C# – możliwe będzie określenie typów liczbowych używanych wewnętrznie przez enum
y.
W robieniu zrzutów ekranowych (screenshotów) nie ma, wydawałoby się, nic nadzwyczajnego. Wciskamy po prostu klawisz Print Screen i obraz ekranu ląduje nam w systemowym Schowku, po czym możemy go użyć w dowolny sposób. Przynajmniej tego właśnie się spodziewamy zazwyczaj.
W rzeczywistości robienie screenów to dość śliski temat, jeśli mamy do czynienia z czymś więcej niż zwykłymi aplikacjami okienkowymi. Dotyczy to chociażby pełnoekranowych gier, korzystających z bibliotek graficznych typu DirectX i OpenGL. Wykorzystują one mechanizm znany jako hardware overlay, pozwalający – w skrócie – na ominięcie narzutu systemu operacyjnego związanego z zarządzeniem oknami wielu aplikacji na współdzielonym ekranie monitora. Skutkiem ubocznym jest między innymi to, że wbudowany w system mechanizm tworzenia zrzutów ekranu staje się w tej sytuacji bezużyteczny, jeśli chcemy wykonać screenshota tego rodzaju aplikacji.
Skąd w takim razie biorą się screeny, a nawet całe filmy ze współczesnych gier?… Cóż, istnieją oczywiście metody pozwalające na przechwytywanie obrazów generowanych przez aplikację – można bowiem skłonić ją do wywoływania naszej własnej wersji metody IDirect3DDeviceN::Present
zamiast tej wbudowanej :) Brzmi to pewnie tajemniczo i nieco hakersko, ale tak mniej więcej działają programy do przechwytywania wideo typu Fraps.
Do zwykłych screenów zazwyczaj jednak przewiduje się odpowiednie rozwiązania w samych grach. Jednym ze sposobów jest, w przypadku wykrycia wciśnięcia klawisza odpowiadającego za screenshoty (czyli zwykle Print Screen), wyrenderowanie sceny najpierw do tekstury, a ją potem na ekran. Rzeczoną teksturę można później zapisać do pliku.
Czy to dobre rozwiązanie? Otóż nie, i to aż z trzech powodów. Stosunkowo najmniej ważnym jest to, iż implementacja może być kłopotliwa, bo wymaga dobrania się do początku i do końca potoku renderingu w aplikacji z kolejnym render targetem. Bardziej istotny jest brak multisamplingu (a więc wygładzania krawędzi wielokątów znanego jako anti-aliasing) w powstałym obrazku. Nie będzie on więc wyglądać zbyt ładnie, już nie wspominając o tym, że nie będzie on odpowiadał temu, co widzimy na ekranie.
Jak więc należy robić screeny? Dokładnie tak, jak nam podpowiada intuicja: należy wziąć to, co widać na ekranie – czyli zawartość bufora przedniego (front buffer) i zapisać to do pliku. W DirectX mamy do tego metodę urządzenia o nazwie GetFrontBufferData
, która pobiera nam to jako powierzchnię:
Ważne jest, by pamiętać o właściwych rozmiarach powierzchni – takich, jakie ma bufor przedni. W trybie pełnoekranowym odpowiada to buforowi tylnemu, ale dla aplikacji okienkowej wcale nie musi, jeśli rozmiar jej okna możemy zmieniać.
W OpenGL podobną rolę spełnia do GetFrontBufferData
spełnia funkcja glReadPixels
. Z zapisaniem pobranego obrazu może być tylko “nieco” trudniej ;-)
Zasadniczą i najważniejszą częścią DirectX SDK są pliki nagłówkowe oraz biblioteki (statyczne i dynamiczne), które pozwalają na pisanie programów korzystających z tego API. Do tego mamy jeszcze niezbędną dokumentację oraz przykładowe aplikacje (samples), pokazujące wykorzystanie poszczególnych jego elementów lub prezentujących implementacje różnych efektów graficznych.
Ale to nie wszystko, co można znaleźć w tym kilkusetmegabajtowym (i ciągle rosnącym) pakiecie. Niemal równie ważne są narzędzia pomocnicze, które można tam znaleźć. Podczas tworzenia aplikacji wykorzystujących zwłaszcza Direct3D umiejętność korzystania z tych programów jest niekiedy prawie tak samo ważna, jak znajomość samego API czy zagadnień z dziedziny grafiki.
Dlatego też postanowiłem pokrótce opisać niektóre z nich, żeby co mniej zaawansowani programiści DirectX mogli przynajmniej dowiedzieć się, że takowe istnieją :) Oto więc rzeczone aplikacje:
GetDeviceCaps
urządzenia – w postaci przejrzystego interfejsu drzewiastego. Dobrze jest rzecz jasna wiedzieć, czego szukamy, ale w większości przypadków programistów grafiki 3D interesować będzie gałąź Direct3D9/10 Devices/<model karty graficznej>/D3D Device Types/HAL/Caps.HRESULT
) na odpowiadające im stałe i komunikaty. To pierwsze potrafi też częściowo zrobić debuger Visual Studio (czujką $err,hr
lub $eax,hr
), ale mimo to programik ten bywa niekiedy przydatny.Niektóre z tych narzędzi są na tyle użyteczne, że warto zrobić sobie do nich skróty w łatwo dostępnych miejscach (dotyczy to chociażby Control Panelu). Wszystkie zaś możemy znaleźć wśród linków tworzonych w menu Start przez instalator SDK, w podkatalogu DirectX Utilities.
Kiedy programiści się nudzą, to często spierają się o terminologię. Przedmiotem takich sporów są zwykle nieco mgliste określenia, które czasami – zwykle niesłusznie – używane są jako atrybuty oceniające. Przykład? Choćby “obiektowość” – w zależności od punktu widzenia stwierdzenie, że dany kod/biblioteka/moduł/itp. są bardziej lub mniej obiektowe może stanowić albo zaletę, albo wadę. Zależy to głównie od ‘poglądów’ danej osoby na temat użyteczności podejścia OO w programowaniu.
Co to jednak znaczy, że dany kod jest obiektowy?… Nie uważam wcale, że należy go w tym celu napisać go w języku obiektowym. Twierdzę na przykład, że Windows API jest całkiem dobrym przykładem biblioteki realizującej obiektowy paradygmat, mimo tego że została ona stworzona w jak najbardziej strukturalnym C. Praktyczna różnica między poniższymi wywołaniami:
jest bowiem właściwie żadna. Dodajmy do tego fakt, że z punktu widzenia programisty-użytkownika w WinAPI występuje zarówno dziedziczenie (rodzajów uchwytów), jak i polimorfizm (funkcje niezależne od typu uchwytów, np. CloseHandle
), a więc bardzo obiektowe feature‘y.
Jeśli komuś wydaje się to naciągane i twierdzi, że w ten sposób pod obiektowość można podciągnąć właściwie każdy kod, to już spieszę z przykładem ukazującym, że tak nie jest. Otóż większa część biblioteki OpenGL obiektowa na pewno nie jest, zaś w tych nielicznych miejscach gdzie OOP jest niemal konieczny (jak zarządzanie teksturami czy buforami wierzchołków) pojawia się nieco dziwna koncepcja ‘indeksów’ używanych do identyfikowania poszczególnych obiektów.
Dla niektórych (łącznie ze mną) taki interfejs nie jest szczytem marzeń, a preferowane jest wyraźnie obiektowe podejście DirectX. Absolutnie jednak nie zgadzam się z twierdzeniem, że DirectX jest lepszy, bo bardziej obiektowy – to trochę tak jak powiedzenie, że ten obrazek jest ładniejszy, bo bardziej zielony. W obu przypadkach jest to kwestia gustu i nie powinniśmy zakładać, że cechy pozytywne dla nas są tak samo dobrze odbierane przez innych.
A wyższość DirectX nad OpenGL da się przecież uargumentować na wiele innych, lepszych sposobów :)
Internet pełen jest opisów, tutoriali i przykładowych kodów pokazujących, jak implementować różne efekty graficzne. Zakodowanie ich pojedynczo zazwyczaj nie jest więc problemem, o ile mamy jako takie pojęcie o grafice czasu rzeczywistego, bibliotece DirectX/OpenGL i programowaniu w ogóle.
Znacznie większym problemem jest połączenie kilku(nastu/dziesięciu) efektów tak, by było one zaaplikowane w jednym momencie do tej samej sceny. Ze względu na to, że każdy pojedynczy efekt może wymagać kodu w bardzo różnych miejscach potoku graficznego (chociażby w samej aplikacji oraz w kodzie shaderów), zintegrowanie wszystkich tych fragmentów nie wydaje się sprawą prostą.
Ostatnio aczkolwiek zajmowałem się praktycznym rozwiązywaniem tych kwestii; było to łączenie różnych rodzajów oświetlenia z cieniami generowanymi techniką shadow depth mapping i efektami postprocessingu w rodzaju depth of field. Pozwolę więc sobie podzielić kilkoma uwagami na ten temat. To może jeszcze nie są rady, jak dobrze zaprojektować architekturę silnika 3D, ale mały framework pewnie można o nie oprzeć ;] A zatem:
DrawPrimitive
czy DrawSubset
są w tej samej funkcji co Begin/EndScene
? W rzeczywistym kodzie zapewne tak nie będzie, bo dana scena będzie na pewno renderowana wielokrotnie.WORLD
(lub MODELVIEW
w OpenGL), bo nasza scena będzie renderowana kilka razy w potencjalnie różnych widokach (kamery, światła, obiektu odbijającego otoczenie, itp.). Dodatkowo mogą być nam potrzebne punkty w różnych przestrzeniach, np. w układzie widoku obserwatora i widoku od konkretnego światła naraz. Wreszcie, nie należy zapominać o prawidłowym przekształcaniu wektorów normalnych. W sumie więc sekcja deklaracji pliku z shaderami może wyglądać np. tak:
Są tutaj jeszcze dwie sprawy warte zaznaczania. Po pierwsze, obiekty rysujące się na scenie muszą wiedzieć, gdzie ustawiać swoją macierz lokalnego przekształcenia. We wszystkich używanych shaderach nazwa odpowiedniej stałej (tutaj ObjectTransform
) musi być taka sama; najlepiej też żeby mapowała się na te same rejestry stałych cn
. Naturalnie kod renderujący obiekty musi też “wiedzieć”, żeby korzystać właśnie z niej zamiast z macierzy przekształceń z fixed pipeline – czyli np. wywoływać effect->SetMatrix("ObjectTransform", &mat);
zamiast device->SetTransform (D3DTS_WORLD, &(currWorld * mat));
w przypadku DirectX).
Po drugie, nie trzeba “dla efektywności” przekazywać do shadera iloczynów macierzy, jeśli używamy także ich poszczególnych czynników. Można bowiem zupełnie bezkarnie mnożyć je na początku kodu shadera:
Kompilator wydzieli ten kod w postaci tzw. preshadera i zapewni, że będzie on wykonywany tylko raz (a nie dla każdego wierzchołka/piksela).
"ShadowMap"
, "DepthMap
, "Scene"
itp.Ogólnie trzeba przyznać, że implementowanie wielu efektów działających naraz w tej samej scenie to zagadnienie złożone i dość trudne. Chociaż więc starałem się podać kilka porad na ten temat, to w rzeczywistości niezbędne jest tutaj spore doświadczenie z różnymi rodzajami efektów, zarówno w teorii jak i praktyce.
Płynne przejście od programowania grafiki 3D przy pomocy fixed pipeline do wykorzystania shaderów nie jest z początku takie proste. Jest oczywiście w sieci mnóstwo tutoriali, które to ułatwiają. Zauważyłem jednak, że mają one tendencję do przekazywania naciąganych – mówiąc delikatnie – faktów na temat tego, w jaki sposób shadery oraz pliki efektów .fx (często omawiane łącznie) muszą być wykorzystywane w aplikacjach DirectX.
Dlatego pomyślałem sobie, że dobrze byłoby sprostować kilka mitów, jakie się tutaj pojawiają i wprowadzają zamieszanie do i tak niełatwej dziedziny programowania. Warto bowiem wiedzieć, iż to nieprawda, że:
SetVertex/PixelShader
urządzenia DirectX. Plik efektów nie jest wtedy do niczego potrzebny.D3DFVF
tak samo dobrze opisują format danych dla vertex shaderów, jak deklaracje wierzchołków (IDirect3DVertexDeclarationX
) i DirectX nie ma problemu z łączeniem jednego z drugim. Analogia działa zresztą też w drugą stroną: użycie fixed pipeline nie wymusza korzystania z FVF.m4x4 oPos, v0, c4
) sam fakt korzystania z programowalnego potoku grafiki nic nie wymusza w zakresie formatu danych wierzchołków.