Shaderami ostatni raz zajmowałem się (dość teoretycznie) parę lat temu, więc od tego czasu minęła już cała epoka – albo i kilka. W ramach przypomnienia oraz nauki HLSLa (bardzo prostego i przyjemnego języka swoją drogą) przeglądam więc implementację różnych technik graficznych, głównie oświetlenia i cieniowania.
Przy okazji natrafiłem tam na pewnego rodzaju shaderową sztuczkę. Bardziej zaawansowanym programistom grafiki pewnie nie będzie się ona wydawała niczym nadzwyczajnym. Dla mnie jednak jej pomysłowość i prostota była – że użyję modnego ostatnio słowa – porażająca :)
Otóż, jak powszechnie wiadomo, programowalny potok grafiki to przede wszystkim dwie części: vertex i pixel shader. W tym pierwszym transformujemy wierzchołki i wyliczamy inne ich dane, które potem trafiają do shadera pikseli. Ten zaś z racji częstości wykonywania musi być jak najlżejszy i dlatego jak najwięcej operacji trzeba wykonywać per vertex. Z drugiej strony, aby cokolwiek ładnie wyglądało (oświetlenie, nierówności, itd.) musi być liczone per pixel.
I tutaj pomaga część potoku włączająca się między shaderami, czyli interpolator. Jego głównym zadaniem jest interpolacja wartości koloru rozproszenia i współrzędnych tekstur na poszczególnych pikselach. To dzięki niemu piksel w środku trójkąta może mieć właściwy kolor rozproszenia i mieć przypisany odpowiedni teksel.
Trik polega na tym, że w interpolować możemy też inne dane. Typowy przykład to choćby pozycja w przestrzeni 3D – już przetransformowana, ale jeszcze nie zrzutowana na płaszczyznę projekcji. Podobnie może być z wektorami normalnymi. Już te dwie dane (plus kierunek światła) wystarczają, by otrzymać proste oświetlenie per pixel, które wygląda realistycznie, jeżeli tylko powierzchnia nie wymaga mapowania nierówności.
Żeby to wszystko było możliwe, wystarczy nieco oszukać sprzęt i oznaczyć określone pole wyjścia vertex shadera – zawierające np. wspomnianą normalną czy pozycję – jako… współrzędne tekstury. Wtedy zostaną poddane interpolacji i wyliczone w ten sposób wartości będą dostępne w pixel shaderze. A tam już wcale nie musimy ich traktować jako koordynaty tekstury i znów mogą być pozycją lub normalną.
W sumie wspomniane proste oświetlenie per pixel to kwestia użycia takich dwóch shaderów:
Różnica między oświetleniem wierzchołkowym a pikselowym
Źródło
Oczywiście nie ma tutaj wygaszania ani kontroli kształtu światła, ale to i tak długi przykład ;) Widać jednak, że to co kosztowne – przekształcenia macierzowe – są wykonywane dla wierzchołków, a nie pikseli. W pixel shaderze liczymy tylko oświetlenie, a to – jak wiemy i widzimy powyżej – tylko kwestia odpowiedniego iloczynu skalarnego. Możemy go obliczyć, bo w wyniku interpolacji mamy zarówno pozycję piksela w przestrzeni, jak i jego normalną.
A efekty wyglądają mniej więcej tak:
Eksperymentując z grafiką 3D bardzo często zachodzi potrzeba napisania i przetestowania programu testującego jakąś konkretną technikę zrobienia czegoś – na przykład powierzchni odbijającej światło czy rzucania cieni. Można w tym celu stworzyć jakiś szablon przykładowego programu, który będziemy odpowiednio modyfikować. Nie będzie to zwykle wygodne, chyba że naprawdę się postaramy (czytaj: poświęcimy dużo czasu na rzecz nie do końca potrzebną).
Istnieje aczkolwiek inne rozwiązanie i jest nim skorzystanie z wyspecjalizowanej aplikacji, jak na przykład RenderMonkey stworzonej przez ATI i dostępnej za darmo. Jest to coś w rodzaju wirtualnego środowiska dla programowania grafiki (czyli shaderów) działającego jednak na zupełnie realnym urządzeniu – przy użyciu DirectX lub OpenGL.
Ma ono całkiem spore możliwości oraz udostępnia sporą wygodę w testowaniu różnych efektów opierających się na ustalonych parametrach. O ile w kodzie zmiana jakiejś wartości wymagałaby zwykle rekompilacji, o tyle tutaj można taką wartość (zmienną) odpowiednio oznaczyć i modyfikować jej wartość przy pomocy graficznego interfejsu, natychmiast podglądając rezultat.
Rzeczone zmienne ostatecznie mają po prostu odpowiedniki w rejestrach shaderów (pisanych dla DX w HLSL). Oprócz edytowania ich RenderMonkey umożliwia też dodawanie do projektów różnego rodzaju zasobów (modeli, tekstur, itp.) oraz ustawianie ich różnych stanów. Na dodatek w pakiecie z programem jest całkiem sporo takich właśnie przykładowych zasobów.
A wady? Cóż… Mogę wspomnieć o tym, że obecnie program wykorzystuje tylko DX9, czyli niestety nie pobawimy się tymi jakże użytecznymi geometry shaderami (oczywiście przy założeniu, że nasza karta potrafi się nimi bawić :)) Ale jest i dobra strona – nie trzeba instalować Visty ;P
Rysując grafikę dwuwymiarową dość często chcemy, aby była ona wpasowana tylko w ograniczony obszar ekranu. Jeśli większość obrazu ma być wyrenderowaną trójwymiarowa scena, grafika HUDa nie może zajmować zbyt dużo miejsca. Wielkość rysowanych sprite’ów można kontrolować na wiele sposobów, począwszy od zabawy z macierzami (projekcji i/lub świata), a skończywszy na programowym skalowaniu wielokątów.
Podobnie jest z innym aspektem tego ‘ograniczania’ wyświetlanej grafiki, czyli przycinaniem. Tutaj jednak dużo zależy od tego, jakie kształty chcemy przycinać oraz czym jest region przycinania.
Najprostszy przypadek dotyczy ograniczenia prostokąta przez inny prostokąt. Przycinanie, czyli wyznaczenie ich części wspólnej, to w tym przypadku tylko kilka porównań współrzędnych tych prostokątów:
Ponadto trzeba jeszcze tylko sprawdzić, czy powstały prostokąt jest poprawny – czyli czy spełnione są warunki: left
mniejsze od right
i top
mniejsze niż bottom
. W przeciwnym wypadku wyjściowe prostokąty nie mają części wspólnej.
Kiedy prostokątem chcemy uciąć coś o innym kształcie, wówczas sprawa znacznie się komplikuje z matematycznego punktu widzenia. Można jednak skorzystać z przydatnego narzędzia wbudowanego w Direct3D i znanego jako scissor test. Jest to specjalna, późna faza potoku renderowania, która odrzuca wszystkie piksele poza tymi mieszczącymi w podanym regionie (w przypadku DirectX 9 chodzi o prostokąt). Skorzystanie z tego mechanizmu jest bardzo proste i ogranicza się do:
D3DRS_SCISSORTESTENABLE
na TRUE
SetScissorRect
urządzenia (współrzędne prostokąta liczone są w pikselach według viewportu)A co zrobić w najbardziej skomplikowanym przypadku: gdy zarówno przycinane figury, jak i region przycinania są niestandardowymi kształtami? Cóż, DirectX to nie GDI i nie można oczekiwać, że będzie miał wszystko :) Nie jest to aczkolwiek sytuacja bez wyjścia, gdyż można ją rozwiązać przy pomocy stencil buffera (‘bufora szablonu’ lub ‘bufora matrycy’, że wymienię co ciekawsze tłumaczenia, jakie dotąd spotkałem). Wymaga to jednak nieco zabawy ze stanami urządzenia, a przede wszystkim renderowania regionu przycinania w postaci prymitywów od nowa w każdej klatce.
Wczoraj naszła mnie niesamowita ochota, aby napisać tekst. Taki zwyczajny programistyczny artykuł, w którym można poruszyć ciekawe sprawy i nauczyć czegoś czytelników. Podobne natchnienie ostatnio nie zdarza mi się często, więc żal byłoby go nie wykorzystać :)
Efektem jest dość spory tekst traktujący o pisaniu prostego modułu do grafiki 2D z użyciem DirectX – czyli czegoś, czym zajmowałem się kilka tygodni temu. Jak sądzę wyszedł z tego całkiem przejrzysty i przydatny opis, w którym udało mi się też poruszyć kilka różnych tematów z zakresu DXa ze szczególnym uwzględnieniem dynamicznych buforów wierzchołków.
Zaprogramowanie w miarę porządnego, elastycznego i wydajnego silnika grafiki 3D jest oczywiście dość trudne. W końcu trzeba zadbać o właściwą organizację sceny, wczytywanie modeli, system materiałów, oświetlenie, cienie i pewnie jeszcze mnóstwo innych rzeczy. Trzeci wymiar nie ułatwia nam wcale życia.
Wydawałoby się więc, że gdy go usuniemy, sprawy nabiorą znacznie przyjemniejszego (i prostszego) kształtu. No cóż, kiedyś życie było łatwiejsze – ale głównie dlatego, że mieliśmy mniejsze wymagania. Mieliśmy na przykład całkiem znośną bibliotekę 2D zaimplementowaną jako część DirectXa – czyli DirectDraw. Chociaż obcowanie z nią na początku mogło być dość odstręczające (zwłaszcza jeśli wcześniej korzystało np. tylko z windowsowego GDI czy arcyprostego SDL), to jednak i tak stworzenie powierzchni do rysowania z podwójnych buforowaniem jest w DD znacznie prostsze niż chociażby poprawne zainicjowanie urządzenia Direct3D…
Fakty są jednak takie, że 3D ma się dobrze i coraz lepiej, a grafika dwuwymiarowa jest najczęściej tylko dodatkiem (prawda, że niezbędnym) do renderowanej sceny. Dlatego też najlepiej używać do niej tego samego DirectXa, z którego korzystamy w trakcie wyświetlania scen.
I chociaż jest to generalnie trudniejsze, daje o wiele większe możliwości i lepszą wydajność. Można oczywiście korzystać z należącego do D3DX interfejsu ID3DXSprite, lecz jego możliwości są raczej ograniczone i do wygodnego stosowania należałoby zapakować go w bardziej gustowne klasy.
Stąd też lepiej, w moim odczuciu samemu zatroszczyć się o odpowiednie funkcje i klasy do obsługi rysowania 2D. Muszę przy tym przyznać, że jestem trochę może za bardzo przyzwyczajony do eleganckich interfejsów bibliotek w rodzaju GDI+, VCL czy Graphics32. Chodzi tu przede wszystkim o dosyć intuicyjną koncepcję “płótna” (canvas), czyli obiektu kontrolującego całe rysowanie (w zwykłym GDI nazywa się to kontekstem urządzenia). Płótno, jak sama nazwa wskazuje, jest to taki obiekt, na którym rysujemy inne obiekty – jak na przykład obrazy czy tekst. Jest to nieco inne rozwiązanie projektowe niż chociażby te stosowane w D3DX, w którym to same obiekty (sprite’y, czcionki, itp.) wiedzą, jak się narysować.
Jak zapewne nietrudno się domyślić, piszę o tym dlatego, iż obecnie zajmuję się właśnie implementacją modułu grafiki 2D opartego z grubsza o podane wyżej założenia. Naturalnie, zakodowanie wszystkich funkcji dostępnych w GDI(+) itp. byłoby strasznie pracochłonne i w większości przypadków zupełnie niepotrzebne. Dlatego ograniczam się przede wszystkim do wyświetlania prostokątnych obrazków, a później także do wypisywania tekstu.
Zadanie to może wydawać się proste, ale w gruncie rzeczy wcale takie nie jest. Weźmy chociażby pod uwagę to, że dla efektywności należy podczas rysowania obrazków (będących oczywiście oteksturowanymi prostokątami) unikać częstego przełączania tekstur. A z tego wynika, że pojedynczy “obrazek” to tak naprawdę określony fragment pewnej tekstury, dla którego trzeba chociażby wygenerować określone współrzędne. A to już wymaga odpowiedniego opisu takich obrazków (konsekwentnie unikam słowa sprite, które uważam za sztuczne ;P) – na przykład w zewnętrznym pliku tekstowym. Dla wygody czy chociażby dla celów animacji obrazki wypadałoby ponadto układać w kolekcje. W sumie wychodzi więc całkiem sporo pracy, nieprawdaż? :)
Zresztą samo rysowanie też nie należy do najłatwiejszych. Jedną z pojawiających się tu kwestii jest na przykład przycinanie (clipping), czyli ograniczenie rysowania do wybranego obszaru – w najprostszej wersji prostokąta. Direct3D od wersji 9 posiada wprawdzie narzędzie nazywane scissor test (“test nożyczkowy”), które to właśnie robi, jednak jego użycie praktycznie wykluczałoby możliwość buforowania rysowanych kształtów. Znaczy to, że każda zmiana obszaru przycinania wymuszałaby renderowanie zebranych trójkątów – co w dość skuteczny sposób obniżyłoby ogólną wydajność. Tak więc przycinanie trzeba robić – tadam! – ręcznie :P Na szczęście nie jest to bardzo trudne, jeżeli dotyczy wyłącznie prostokątów wyrównanych do osi układu współrzędnych (czyli wtedy, gdy wykluczamy obroty).
Tak mniej więcej przedstawia się początek tematu pt. grafika dwuwymiarowa. Czy ktoś nadal uważa, że jest to rzecz znacznie prostsza niż 3D? :)
Żeby zwiększyć szansę na to, by moje devlogowanie nie było tylko pobożnym życzeniem, od razu spróbuję przejść do rzeczy. Zaprezentuję mianowicie to, co udało mi się napisać w ostatni weekend.
Jako sposób na przypomnienie sobie DirectX’a, postanowiłem zrealizować prostą, ale efektowną “sztuczkę” znaną jako system cząsteczkowy. Dla mniej zorientowanych mogę wyjaśnić, że jest to sposób na generowanie ciekawych efektów graficznych stosunkowo małym wysiłkiem przy pomocy dużej ilości tzw. cząstek – albo też “partikli”, bo całość to przecież particle system. Takie cząstki mają zwykle dość prozaiczne właściwości, jak pozycja i prędkość (odpowiednio aktualizowane), a w praktyce są renderowane jako kwadraty zwrócone do kamery i pokryte określoną teksturą.
Swój system oparłem w dużym stopniu na zawartości 18. i (zwłaszcza) 19. rozdziału książki Programowanie gier w DirectX – doskonałej zresztą. Jak przystało na porządny system jest więc opisywanie właściwości cząstek za pomocą zewnętrznych skryptów. Za najbardziej pomysłowy uważam jednak opisany w rzeczonej książce mechanizm zdarzeń, czyli sposób na opisanie życia każdej cząstki – od poczęcia do naturalnej śmierci ;)
Weźmy na przykład taki dym. Po utworzeniu cząsteczka dymu na kolor jasnoszary, ale z czasem robi się ciemniejsza i coraz bardziej przezroczysta. Ponadto dym rzadko unosi się pionowo do góry, zatem początkowy wektor prędkości powinien co jakiś czas mieć losowo zmienianą składową poziomą.
Wszystko to można opisać w skrypcie, ustanawiając zdarzenia – czyli mówiąc, co spotka cząstkę np. w 3 sekundzie życia. Może to być na przykład coś takiego:
co – jak nietrudno się domyślić – jest ustawieniem składowej X prędkości na wartość z zakresu [-1;1]. Ciekawsza jest interpolacja liniowa parametrów:
W ten sposób cząsteczka będzie się stawać coraz ciemniejsza i coraz bardziej przezroczysta, bo i alfie, i trzem składowym koloru każemy liniowo zmniejszać się do zera. Całe zdarzenie nie ma przypisanego stałego czasu, a jedynie oznaczenie, iż ma się wykonać w chwili śmierci cząsteczki. W tym przypadku aczkolwiek znaczy to tylko tyle, że w tym momencie cząsteczka będzie już całkiem czarna i zupełnie niewidoczna, lecz interpolacja jej koloru będzie przeprowadzana przez cały czas (od ew. poprzedniego zdarzenia).
Zaprogramowanie tego mechanizmu było swoją drogą nieco kłopotliwe. W książkowym oryginale zdarzenia mogły zmieniać tylko jeden parametr cząstki, więc implementacja płynnego przejścia nie była trudna. W mojej wersji można naraz zmieniać dowolną ilość właściwości oraz mieszać ze sobą natychmiastowe (set) i płynne (lerp) zmiany. Ostatecznie skończyło się na trochę pokręconym sprawdzaniu różnicy między czasem, w którym zdarzenie ma wystąpić, a aktualnym zegarem cząsteczki.
Ano właśnie – nie czasem życia, tylko zegarem. Bo cząstkę można też zapętlić, dzięki czemu będzie wiele razy przechodziła przez te same zdarzenia. Wystarczy “oszukać” jej zegar:
i już mamy taki oto śnieg:
Przy okazji muszę stwierdzić, że mechanizm publikowania obrazków na Bloggerze to czysta poezja. Nie dość, że można uploadować własne pliki, to jeszcze miniaturki tworzone są automatycznie. Tak tak – nie krępuj się i kliknij :)
Może nie wygląda on zbyt realistycznie, ale jego tekstura była robiona bardzo na szybko ;P Zresztą, w efektach cząsteczkowych partikle (coraz bardziej lubię to słowo :D) są zwykle bardzo małe, a końcowe wrażenie jest oparte na wyglądzie całej tej “masy”, a nie pojedynczych cząstek. Tak czy owak widać jednak, że systemy cząsteczkowe to całkiem przydatny mechanizm tworzenia ładnego dymu, śniegu, kurzu, efektów magicznych, że nie wspomnę o błyskach towarzyszących zbieraniu różnych power-upów :) Mała acz zgrabna rzecz.
Przy czym ‘mała’ to pojęcie dość względne, jako że ostatecznie system zamknął się w ok. 1500 linijkach kodu – i to nie licząc parsera skryptów (który oparłem na opracowanym jakiś czas temu formacie XVL) i używanego też parsera wyrażeń matematycznych. Całkiem niezły wynik jak na dwa dni kodowania :)