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.Wiele programów z sieci wciąż jeszcze ściąga się w postaci archiwów do samodzielnego wypakowania, jak choćby w formacie .zip. Ma to swoje zalety i wady – do tych drugich należy fakt, że nie bardzo wiadomo, jak wygląda wewnętrzna struktura katalogów takiej paczki. Używając opcji typu Wypakuj tutaj ryzykujemy zaśmiecenie folderu Downloads plikami programu. Dlatego osobiście zawsze stosuję polecenie Wypakuj do nowego katalogu.
I tu czasem jest mały zonk, gdy twórca archiwum zdecydował się na spakowanie całego folderu, a nie tylko zawartych w nim plików. Powstają wtedy nadmiarowe katalogi, wydłużające ścieżki do plików (co widać obok – w wersji trochę przesadzonej ;)).
Ponieważ podobne sytuacje zdarzają mi się dość często, postanowiłem im zaradzić przy pomocy najlepszego narzędzia na takie okazje, czyli PowerShella rzecz jasna :) Wynikiem jest poniższy skrypt do sprytniejszego rozpakowywania archiwów:
Jego działanie polega wpierw na zwykłej dekompresji archiwum. Jak można zauważyć, używa do tego obiektu COM-owskiego Shell.Application
. To sprawia, że skrypt ma pod tym względem te same możliwości co zwykły windowsowy Eksplorator (dla większych plików pokaże nawet pasek postępu ;]).
Później wypakowana zawartość jest poddawana operacji, którą nazywam tutaj ‘skróceniem ścieżki’. Polega ona wyrzuceniu jednego poziomu drzewa folderów, o ile tylko pewien katalog jest jedynym elementem swojego katalogu nadrzędnego. Takie właśnie sytuacje powstają przy dekompresji do nowego folderu archiwów źle zapakowanych (przynajmniej z mojego punktu widzenia ;P). Wynikiem działania skryptu będzie więc w sumie jeden nowy podkatalog zawierający bezpośrednio całą interesującą zawartość archiwum.
Oczywiście używanie powyższego skryptu tylko z poziomu linii komend PowerShella nie jest specjalnie wygodne; lepiej jest dodać go do menu kontekstowego archiwów, czyli np. plików .
Przeglądając znalezione niedawno czasopisma o grach sprzed kilku lat, zauważyłem ciekawy schemat pojawiający się w ówczesnych recenzjach. Wiele z nich wskazywało mianowicie na liniowość jako cechę bardzo niepożądaną w każdej właściwie grze, niekoniecznie przygodówce czy RPG-u (gdzie istotnie byłaby ona zbrodnią). Gracz miał bowiem mieć jak najwięcej swobody i jak najwięcej możliwości dokonywania znaczących wyborów – wtedy rozgrywka uznawana była za interesującą.
Gdy przyjrzymy się teraz tytułom wychodzącym obecnie, to da się zauważyć, że lata forsowania tej idei zrobiły swoje. Już niewiele jest jakich gier, w których trzeba przechodzić kolejne etapy/misje/poziomy/itp. w dość ściśle określonej kolejności. Zamiast tego mamy coraz więcej produkcji, gdzie właściwie cały rozwój rozgrywki znajduje się w rękach gracza.
Przykłady? Chociażby cały podgatunek MMORPG: zwykle poza jedną linią głównych questów (zadań) wszystkie pozostałe są poboczne, a gra polega generalnie na współpracy z innymi graczami, by osiągnąć wyznaczone grupowo cele. Podobny schemat występuje też niekiedy w rozgrywce single player (np. cała seria Grand Theft Auto). W końcu mamy całą paletę gier symulacyjnych, z ikonicznymi “simsami” na czele.
Można więc przypuszczać, że liczba gier z dużym stopniem swobody dla gracza będzie się zwiększać. Po części jest tak może dlatego, że ciężko usłyszeć opinie, aby ten kierunek rozwoju był złym. Kto wie, może faktycznie tak nie jest? :)
Jednak mam pewne wątpliwości. Wysoka nieliniowość gry może bowiem oznaczać tyle, że jej twórcy poszli na łatwiznę i tak naprawdę nie zainteresowali się tym, jak będzie ostatecznie przebiegać rozgrywka. Postawienie na samą mechanikę i uczynienie jej maksymalnie elastyczną może z początku wyglądać na działanie wielce innowacyjne, ale po bliższym przyjrzeniu się na wierzch mogą wyjść duże zaniedbania od strony fabularnej. (Wydaje mi się na przykład, że to właśnie była przyczyna chłodnego przyjęcia długo oczekiwanej gry Spore).
Jest jeszcze jeden aspekt tej sprawy: czy wysoce nieliniowe gry są tym, czego gracze faktycznie oczekują? Nie byłbym tego taki pewien, a poważnym argumentem, który za tym przemawia, jest “ostatni krzyk mody” w grach wszelakich, czyli tzw. osiągnięcia (achievements). Twór ten przyszedł z produkcji konsolowych, a jego oczywistą funkcją jest przedłużanie czasu życia gry poprzez wskazywanie graczowi, co jeszcze mógłby w niej zrobić (i jak mógłby być lepszym od innych).
Popularność tego mechanizmu dowodzi, że jego wprowadzanie jest zazwyczaj dobrym krokiem. Dlaczego? Poza widocznym wyraźnie czynnikiem rywalizacji z innymi (zawsze pożądanym), osiągnięcia mogą organizować przebieg rozgrywki i subtelnie wskazywać jej właściwy kierunek.
Inaczej mówiąc, jest to sposób na ponowne wprowadzenie do gier liniowości – tym razem w wersji light, jako opcji. Czy oznacza to więc cofanie się w rozwoju? Otóż nie wydaje mi się; to raczej odpowiedź na zapotrzebowanie. Bo po co nam gry, w których można robić wszystko, skoro w rzeczywistości oznacza to, że niczego robić nie warto?…
Jednym z bardziej zauważalnych składników Windows 7, które odróżniają ten system od Visty, jest nowy wygląd paska zadań. Jest szerszy, wyświetla duże ikony i przesunięcie go z dołu na bok ekranu w końcu ma sens (hurra dla monitorów wide-screen). Ale nowy wygląd to w tym przypadku nie wszystko, bo pasek ten zyskał też trochę na funkcjonalnościach.
Wśród nich mamy tzw. listy skoków (Jump Lists), zastępujące tradycyjne menu sterowania. Listy te pojawiają się po kliknięciu prawym przyciskiem na ikonkę na pasku zadań.
Co zawierają takie listy? Jak widać z boku, ich elementami mogą być skróty do ostatnio otwartych w programie dokumentów. Żeby było zabawniej, będzie ona wygenerowana automatycznie nawet dla tych aplikacji, których twórcy w momencie ich pisania nie mieli bladego pojęcia o tym, że kiedyś będziemy mieli taki system jak Windows 7 :) W tej wersji bowiem sam Windows zarządza listami MRU (Most Recenty Used) dla poszczególnych programów, o ile tylko wywołują one funkcję ShAddToRecentDocs
przy otwieraniu poszczególnych plików.
A to wbrew pozorom nie jest takie duże wymaganie, gdyż jest automatycznie ono spełnione, jeśli zachodzi jedna z poniższych sytuacji:
GetOpenFileName
z WinAPI albo OpenFileDialog
z .NET)Na listę skoków możemy też dodawać własne pozycje w postaci tak zwanych zadań (tasks), działających jak zwykłe systemowe skróty i tworzonych w ten sam sposób (interfejs IShellLink
). Szczegóły i przykładowy kod można znaleźć na przykład w tym artykule na CodeProject.
Wszyscy znamy doskonale funkcję Sleep
, która w Windows API służy do zawieszania działania wątku na określony czas (podawany w milisekundach). Wydawałoby się, że musi to być najprostsza funkcja z tego API, jaką tylko można sobie wyobrazić – bo co może być skomplikowanego w “zwykłej pauzie”? A okazuje się, że jak najbardziej może :)
Używając Sleep
– zwłaszcza w swej zwykłej wersji – musimy bowiem pamiętać przynajmniej o dwóch sprawach:
Sleep(INFINITE);
sprawi, że właściwie możemy ów wątek wyrzucić do kosza, gdyż nie da się już go odwiesić (funkcja ResumeThread
wywołana z innego wątku nic tu nie pomoże).MsgWaitForMultipleObjectsEx
albo po prostu GetTickCount
wraz z wewnętrzną pętlą komunikatów).Ex
funkcji Sleep
. Różni się on od oryginału tym, że rozpoczęte przez niego oczekiwanie można przerwać, jeśli sobie tego zażyczymy. Wątek uśpiony przez SleepEx
może być przedwcześnie obudzony, gdy otrzyma informacje o zakończeniu asynchronicznej operacji I/O lub asynchronicznego wywołania procedury (APC).Sleep
. Jest on bowiem determinowany przez długość tzw. kwantów czasu (time slices), jakie system operacyjny przydziela kolejnym wątkom, by zapewnić złudzenie ich jednoczesnego wykonania. Działanie Sleep
polega w rzeczywistości na oddaniu systemowi reszty kwantu czasu, który został przydzielony wątkowi; przestawienie wątku w stan “nieuruchamialności” na podaną ilość milisekund; a następnie na wznowieniu jego pracy, gdy ponownie otrzyma czas procesora od systemowego schedulera. W sumie więc czas uśpienia będzie równy:PozostałyKwantCzasu + ParametrSleep + CzasDoUaktywnieniaWątku
Stąd wynikają dwa wnioski. Po pierwsze, nie powinniśmy nigdy używać Sleep
jako sposobu na mierzenie czasu – już poczciwy GetTickCount
sprawi się tu znacznie lepiej. Po drugie, wywołanie Sleep(0);
jest jak najbardziej dopuszczalne i oznacza przedwczesne zrzeczenie się kwantu czasu, jaki wątek dostał od systemu. W czasach 16-bitowych wersji Windows i wielowątkowości bez wywłaszczała była od tego specjalna funkcja Yield
, którą należało często wywoływać, aby przełączanie wątków w ogóle było możliwe. Teraz rzecz jasna nie jest to konieczne, ale nadal może być przydatne dla zasygnalizowania, że nasz wątek nie robi nic pożytecznego, a tylko w brzydki sposób na coś czeka (tzw. busy waiting).
O tych dwóch szczegółach odnośnie funkcji Sleep
dobrze jest pamiętać, jeśli nasze wątki chcemy usypiać. Jako programiści Windows możemy się aczkolwiek podbudować tym, że nie mamy przy tym takich problemów jak koderzy piszący pod Linuksem. Tam sleep
może być potencjalnie zaimplementowany na sygnałach, co wymaga ostrożności przy stosowaniu go razem z funkcjami alarm
i signal
.
Parę dni temu przyszło mi w końcu napisać coś, co wielu programistów grafiki pewnie już nie jeden raz miało okazję kodować. Chodzi o generowanie siatki terenu na podstawie predefiniowanej mapy wysokości (heightmap), zapisanej w pliku jako obrazek w odcieniach szarości.
Cała procedura nie jest bardzo skomplikowana. W sumie sprowadza się ona do równomiernego próbkowania obrazka mapy i tworzenia wierzchołków leżących na ustalonej płaszczyźnie, z uwzględnieniem odczytanej wysokości.
Pozycje tych wierzchołków wyznaczyć jest prosto, chociaż zależy to trochę od przyjętej reprezentacji płaszczyzny (a właściwie prostokąta) “poziomu morza”. Trochę więcej zabawy jest natomiast z wektorami normalnymi, które niewątpliwie przydadzą się, jeśli nasz teren będziemy chcieli oświetlić. Właśnie o ich znajdowaniu chciałem napisać.
Jak wiadomo, wierzchołki dowolnej siatki możemy wyposażyć w normalne, posługując się niezwykle przydatną operacją iloczynu wektorowego. Przy jego pomocy można obliczyć normalne dla poszczególnych trójkątów; w przypadku wierzchołków należy po prostu uśrednić wyniki dla sąsiadujących z nimi face‘ów (oznaczonych niżej jako T(v)):
Konieczną normalizację niektórzy przeprowadzają tu na końcu, a inni dla poszczególnych trójkątów. Prawdę mówiąc nie wiem, które podejście jest właściwsze – jeśli którekolwiek.
W powyższy sposób można oczywiście wyliczać normalne również dla utworzonego terenu, bo przecież czym on jest, jak właśnie siatką :) Jednak w tym przypadku mamy dostęp do większej liczby informacji o nim. Mamy przecież źródłową mapę wysokości, z której na wierzchołki przerobiliśmy tylko niektóre piksele (plus ew. jakieś ich otoczenia). Czemu by nie wykorzystać jej w większym stopniu, generując być może lepsze przybliżenie normalnych?
Ano właśnie, dlaczego nie :) W tym celu można by wprowadzić nieco wyższej (dosłownie) matematyki i zauważyć, że nasza heightmapa jest zbiorem wartości pewnej funkcji i że wobec tego normalną w jakimś punkcie x0, y0 da się wyliczyć jako:
o ile tylko rzeczone pochodne istnieją. Można by – ale przecież nie będziemy tego robili ;-) Zamiast tego wystarczy zastanowić się, co by było, gdybyśmy wygenerowali skrajnie gęstą siatkę dla naszego terenu: tak gęstą, że każdemu pikselowi heightmapy odpowiadałby dokładnie jeden wierzchołek tej siatki. Wówczas opisana wyżej metoda liczenia normalnych korzystałaby ze wszystkich informacji zawartych w mapie.
Nie musimy jednak generować tych wszystkich wierzchołków. Do obliczenia wektora normalnego w punkcie wystarczą tylko dwa, odpowiadające – na przykład – pikselowi heightmapy położonemu bezpośrednio na prawo i u dołu tego, z którego “wzięliśmy” dany wierzchołek siatki. Z tych trzech punktów możemy następnie złożyć trójkąt, obliczyć wektor normalny i zapisać go w wierzchołku siatki:
Tutaj oznacza odpowiedni piksel mapy wysokości, a funkcja pos3d jest tą, która dla owego pikseli potrafi wyliczyć pozycję odpowiadającego mu wierzchołka w wynikowej siatce. (Taką funkcję mamy, bo przecież jakoś generujemy tę siatkę, prawda? :])
Z podanych sposobów obliczania normalnych terenu można oczywiście korzystać niezależnie od tego, z jaką biblioteką graficzną pracujemy. Jak to jednak często bywa, w DirectX sporo rzeczy mamy zaimplementowanych od ręki w postaci biblioteki D3DX i nie inaczej jest z liczeniem normalnych.
I tak funkcja D3DXComputeNormals
potrafi wyliczyć wektory normalne dla dowolnej siatki – warunkiem jest to, żeby była ona zapisana w postaci obiektu ID3DXMesh
, więc w razie potrzeby musielibyśmy takowy obiekt stworzyć. Z kolei D3DXComputeNormalMap
potrafi stworzyć mapę normalnych na podstawie mapy wysokości; tę pierwszą możemy później indeksować w celu pobrania “wektorów normalnych pikseli”.
Jedną z rzeczy, która na początku programowania w DirectX może wydawać się dziwna, jest tajemniczy parametr Pool
. Pojawia się on w każdej funkcji, która tworzy jakiś zasób graficzny: teksturę, bufor wierzchołków, siatkę modelu (ID3DXMesh
), bufor głębokości, itp.
Rolą tego parametru jest określenie, w której puli pamięci znajdzie się tworzony zasób. DX wyróżnia bowiem ich kilka, co jest związane przede wszystkim z (przynajmniej) dwoma rodzajami pamięci, z jakimi możemy mieć do czynienia programując grafikę: zwykłym systemowym RAM-em oraz pamięcią karty graficznej.
W jakiej puli powinniśmy więc umieszczać swoje obiekty? To zależy od kilku czynników. Na początek na pewno warto przyjrzeć możliwościom:
D3DPOOL_DEFAULT
, jak wskazuje na to nazwa, oznacza pulę domyślną. Gdy użyjemy tej flagi, DirectX umieści nasz zasób w najlepszym – pod względem wydajności – miejscu, bazując przy tym na innym parametrze, Usage
(określa on, mówiąc w skrócie, sposób wykorzystania danego zasobu). Tym najlepszym miejscem jest prawie zawsze pamięć karty graficznej.D3DUSAGE_DYNAMIC
), ich blokowanie wiąże się ze stratą szybkości albo jest wręcz niemożliwe (w przypadku tekstur).D3DPOOL_MANAGED
to pula zarządzana. Oznacza to, że pieczę nad nią sprawują sam DirectX i to on decyduje, w którym rodzaju pamięci zasoby z tej puli zostaną umieszczone. Zazwyczaj oznacza to, że w pamięci operacyjnej trzymana jest kopia obiektu, znajdującego się też w pamięci graficznej. Dzięki temu nie trzeba go tworzyć ponownie w przypadku straty urządzenia, a także można go blokować i modyfikować niezależnie od typu i flag Usage
; w tym przypadku DX zadba o odpowiednią synchronizację.D3DPOOL_SYSTEMMEM
oznacza pulę pamięci systemowej. Zasoby tu stworzone będą znajdowały się w zwykłym RAM-ie i nie będą mogły być bezpośrednio renderowane. Dane z nich mogą jednak być kopiowane do zasobów znajdujących się w puli domyślnej, jak chociażby poprzez funkcję UpdateTexture
.D3DPOOL_SCRATCH
jest również usytuowana w pamięci systemowej (RAM). W odróżnieniu od poprzedniej, zasoby z tej puli nie są jednak w żaden sposób (także pośredni) dostępne dla urządzenia i nie mogą być używane podczas renderowania. Oznacza to też, że nie podlegają one ograniczeniom związanym z kartą graficzną. Można więc, przykładowo, tworzyć w tej puli tekstury o rozmiarach niebędących potęgami dwójki także wtedy, gdy karta nie wspiera takich tekstur.Spotkałem się z dwiema ogólnymi wytycznymi dotyczącymi tego, z których pul pamięci należy korzystać:
D3DPOOL_DEFAULT
, bo to zapewnia największą wydajność, jako że wtedy zasoby generalnie umieszczane są w pamięci karty graficznej.D3DPOOL_MANAGED
, gdyż wtedy pozwalamy DirectX-owi zdecydować, w jakim rodzaju pamięci trzymać nasz zasób – a już on powinien o tym wiedzieć lepiej.W sumie więc wygląda na to, że nic nie wiadomo :) Oczywiście są przypadki, gdy wyboru nie mamy, jak to się dzieje choćby dla tekstur typu render target, które muszą być stworzone w D3DPOOL_DEFAULT
. Zawsze jednak będziemy mieli takie zasoby, które “równie dobrze” dałoby się umieścić w puli domyślnej, jak i zarządzanej. Co wtedy?
Otóż wydaje mi się, że to zależy od wielkości naszej aplikacji (w sensie rozmiaru używanych zasobów) oraz… stopnia zaawansowania w programowaniu w DirectX. Na początek bowiem D3DPOOL_MANAGED
jest pulą bezpieczniejszą: nie musimy się w niej martwić o stratę urządzenia i możemy każdy zasób blokować i zmieniać. Ceną za to jest wzrost zużycia pamięci przez aplikację, spowodowany trzymaniem przez DX kopii obiektów w pamięci systemowej.
Jeśli jednak nasza gra (nie bójmy się użyć tego słowa ;)) używa dużej ilości zasobów, to takie marnotrawstwo jest zwykle nie do przyjęcia. Wtedy przynajmniej część z nich należy przenieść do D3DPOOL_DEFAULT
. Istnieje szansa, że na tym etapie będziemy już wiedzieć lepiej, którą część ;-)
Na koniec pozwolę sobie też wspomnieć o – często pomijanej – puli D3DPOOL_SCRATCH
. Jej przeznaczeniem są wszelkiego rodzaju zasoby pomocnicze, których nie renderujemy, ale mimo to wykorzystujemy do jakiegoś celu – na przykład tworzenia innych zasobów. Typowym przykładem są wszelkiego rodzaju pomocnicze, narzędziowe tekstury – jak choćby mapy wysokości (heightmap), na podstawie których generujemy ukształtowanie terenu.
Najlepszą pulą dla takich obiektów jest właśnie D3DPOOL_SCRATCH
. Użycie jakiejkolwiek innej spowodowałoby uszczerbek na wydajności lub wręcz błędy, jak np. niezamierzone przeskalowanie tekstury do rozmiaru 2n.