Posts tagged ‘graphics programming’

Magiczna interpolacja

2007-10-07 16:36

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.

Interpolacja normalnychTrik 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:

  1. float4 vLightPos; // pozycja światła
  2.  
  3. // macierze przekształceń
  4. float4x4 mtxRotation;  // obrót (dla normalnych)
  5. float4x4 mtxTranslationAndScaling; // translacja i obrót
  6. float4x4 mtxViewProjection; // widok i projekcja
  7.  
  8. // wyjście VS i wejście PS
  9. struct Vertex2Pixel
  10. {
  11.    // zwykłe dane wierzchołka
  12.    float4 Pos   : POSITION;
  13.    float4 Diffuse : COLOR0;
  14.    float2 Tex0   : TEXCOORD0;
  15.    
  16.    // dane dla oświetlenia per pixel
  17.    float3 Normal : TEXCOORD1;
  18.    float3 Pos3D : TEXCOORD2;
  19. };
  20.  
  21.  
  22. // vertex shader
  23. Vertex2Pixel VS(float4 inPos : POSITION, float4 inDiffuse : COLOR0,
  24. float4 inNormal : NORMAL, float2 inTex0 : TEXCOORD0)
  25. {
  26.    Vertex2Pixel v2p = (Vertex2Pixel)0;
  27.  
  28.    // liczenie macierzy transformacji (preshader)
  29.    float4x4 mtxWorld = mul(mtxRotation, mtxTranslationAndScaling);
  30.    float4x4 mtxWVP = mul(mtxWorld, mtxViewProjection);
  31.  
  32.    // transformacja wierzchołka
  33.    v2p.Pos = mul(inPos, mtxWVP);
  34.    v2p.Pos3D = v2p.Pos;   // dla interpolacji
  35.  
  36.    // transformacja (obrót) normalnych
  37.    v2p.Normal = mul(inNormal, mtxRotation); // też będzie interpolowane
  38.  
  39.    // reszta
  40.    v2p.Tex0 = inTex0;
  41.    v2p.Diffuse = inDiffuse;
  42.    return v2p;
  43. }
  44.  
  45. // pixel shader
  46. sampler Tex;
  47. float4 PS(Vertex2Pixel inV2P) : COLOR0
  48. {
  49.    Pixel px = (Pixel)0;
  50.  
  51.    // obliczenie oświetlenia dla piksela
  52.    // (korzystamy z interpolowanej pozycji piksela i z interpolowanej normalnej)
  53.    float3 vPixel2Light = normalize(vLightPos - inV2P.Pos3D);
  54.    float fLightFactor = dot(vPixel2Light, inV2P.Normal);
  55.  
  56.    // liczymy kolor (tekstura + kolor rozproszenia + oświetlenie)
  57.    float4 clDiffuse = tex2D(Tex, inV2P.Tex0) * inV2P.Diffuse * fLightFactor;
  58.    return clDiffuse;
  59. }

Oświetlenie per pixel
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:

Screen prostego oświetlenia per pixel Screen prostego oświetlenia per pixel

Pomocna małpa

2007-10-04 16:56

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ą).

Logo RenderMonkeyIstnieje 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.

Screen z RenderMonkey Screen z RenderMonkey

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

Ten straszny rendering

2007-09-28 17:27

Mogę bronić się rękami i nogami, mogę starać się obchodzić temat ze wszystkich stron, ale w końcu przyjdzie taki czas, że po prostu trzeba będzie zająć się esencją silnikologii – czyli grafiką 3D :) Zwiększenie liczby wymiarów o 50% powoduje mniej więcej podobny przyrost potencjalnych powodów bólu głowy. Dlatego też nie należy się wybierać w tę wyprawę bez odpowiedniego przygotowania i planu.

Plan natomiast jest generalnie dość prosty, lecz jak wiemy diabeł tkwi w szczegółach. W grafice 3D mamy oczywiście do czynienia ze sceną, w której mogą się znaleźć przeróżne jej elementy – zwane też węzłami lub encjami. Takim elementem może być instancja modelu, teren oparty na mapie wysokości, emiter cząsteczek czy jeszcze coś innego. Ważne jest, że każdy taki element zajmuje się w przestrzeni określoną pozycję i miejsce; są one najprościej definiowane przez otaczający prostopadłościan równoległy do osi układu współrzędnych, czyli axis-aligned bounding box (AABB).

Zadaniem obiektu sceny jest między innymi szybka odpowiedź na pytanie, czy dany węzeł znajduje się w polu widzenia kamery. Jako że pole widzenia jest najczęściej perspektywiczne i ma kształt ściętego ostrosłupa, czynność ta (eliminowanie niewidocznych obiektów) jest znana jako frustum culing. Można ją przeprowadzać, organizując odpowiednio przestrzeń sceny, dzieląc ją na sektory – na przykład przy pomocy drzewa ósemkowego.

Naturalnie wszystkiego wyeliminować się nie da i w końcu trzeba będzie coś narysować :) I tutaj znowu mamy kolejną dość skomplikowaną kwestię. Stosunkowo łatwo jest zaprogramować rysowanie każdego rodzaju obiektów tak, aby każdy odpowiadał tylko za siebie i nie zakładał nic chociażby o stanach renderowania przed i po tej operacji. Rzecz w tym, że o ile wygląda to bardzo ładnie z punktu widzenia zasad programowania obiektowego (jak okiem sięgnąć – hermetyzacja!), to w praktyce efektywność takiego rozwiązania byłaby co najmniej wątpliwa. Niestety dla karty graficznej przypisanie konkretnego wierzchołka do konkretnego obiektu w scenie nie ma żadnego znaczenia. Liczy się bowiem to, jakie stany renderowania, mieszania tekstur, itp. trzeba ustawić, aby ów wierzchołek narysować.

Stąd potrzebne jest pojęcie materiału, znane z edytorów grafiki 3D. Tutaj jednak oznacza ono nie tyle wygląd powierzchni, co wszystkie właściwości wpływające na wygląd geometrii, które nie są zapisane w danych wierzchołków. Może to być więc zarówno tekstura, jak i właściwości świetlne czy nawet określenie, czy dana powierzchnia jest półprzezroczysta czy nie.
Aby efektywnie wyrenderować scenę, trzeba więc grupować fragmenty jej geometrii nie względem obiektu, ale materiału. Dodatkowo trzeba też pamiętać o tym, że pewne zmiany są bardziej kosztowane niż inne (taniej jest zmienić choćby teksturę niż shader) i uwzględniać to przy sortowaniu.

Na koniec pozostaje jeszcze ostatni etap, gdy dane o wierzchołkach trafiają już do karty graficznej i muszą być przetworzone przez shader, aby mogły zostać odpowiednio pokazane. Napisanie takie shadera, a potem sterowanie nim (np. włączanie lub wyłączanie pewnych jego fragmentów) to też nie jest lekki orzech do zgryzienia. Jest to solidny kawałek matematyki i geometrii połączonej z kombinowaniem, jak to wszystko zmieścić w limicie instrukcji, który jest nieubłagany :)

To oczywiście nie wszystko – nie wspomniałem na przykład w ogóle o oświetleniu czy cieniach, które wymagają renderowania potraktowanych nimi fragmentów więcej niż raz. Ale już z obecnego opisu widać, że jedna literką ‘D’ więcej to jednocześnie sporo dodatkowych literek ‘P’ – jak ‘problemy’ ;P

Tags: ,
Author: Xion, posted under Programming » 2 comments

Prawie wszystko o przycinaniu 2D

2007-09-10 23:19

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.

Część wspólna dwóch prostokątów

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:

  1. const RECT Intersection(const RECT& rc2, const RECT& rc2)
  2. {
  3.    RECT out;
  4.    out.left = max(rc1.left, rc2.left);
  5.    out.top = max(rc1.top, rc2.top);
  6.    out.right = min(rc1.right, rc2.right);
  7.    out.bottom = min(rc1.bottom, rc2.bottom);
  8.    return out;
  9. }

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:

  • ustawienia stanu renderowania D3DRS_SCISSORTESTENABLE na TRUE
  • ustawienia prostokąta przycinania za pomocą metody 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.

Tags: , ,
Author: Xion, posted under Programming » Comments Off on Prawie wszystko o przycinaniu 2D

Dziś pomysł, jutro artykuł

2007-08-30 11:04

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.

Czcionki i tekst(ury)

2007-07-30 9:08

Ż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:

Linie pisma

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:

Tekstura czcionki Arial stworzona programem Bitmap Font Builder Tekstura czcionki Verdana stworzona programem Bitmap Font Generator

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 :)

Jedno D mniej

2007-07-16 16:20

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? :)

Tags: , ,
Author: Xion, posted under Programming » 13 comments
 


© 2017 Karol Kuczmarski "Xion". Layout by Urszulka. Powered by WordPress with QuickLaTeX.com.