Data-Oriented-Design – SceneGraph proof of concept

Jakiś czas temu postanowiłem do swojego silnika dołączyć SceneGraph, którego zadaniem byłaby hierarchizacja modeli (w relacji rodzic-dzieci) występujących na scenie oraz aktualizacja ich macierzy świata. Generalnie taki graf zawiera nieco więcej informacji, takich jak na przykład bryły otaczające wraz z drzewem ósemkowym, na podstawie których można odrzucić modele niebędące w zasięgu kamery, jeszcze przed renderowaniem całej sceny. Jednak aktualnie zależało mi na zaimplementowaniu samej podstawy, czyli macierzy świata, ale w sposób zorientowany na dane, jak ma to miejsce w prezentacji Pitfalls of Object-Oriented Programming.

Hierarchizacja sceny polega na zebraniu wszystkich elementów sceny i połączeniu ich w drzewo, które składa się z korzenia oraz węzłów potomnych. W takim drzewie każdy rodzic oddziałuje bezpośrednio na swoje dziecko, czyli w tym przypadku transformacja, którą przechowuje rodzic ma wpływ na transformację przechowywaną przez dziecko, dla D3DX wygląda to tak:

D3DXMatrixMultiply(&childWorldMatrix, &childLocalMatrix, &parentWorldMatrix);

Istnieje kilka sposobów na powiązanie węzłów SceneGraph z elementami sceny. Najprostszym z nich jest struktura SGNode, której instancję można umieścić w klasie reprezentującej jakiś element sceny. Taka struktura wygląda następująco:

1
2
3
4
5
struct SGNode
{
   SGNode* pSibling, pChild;
   D3DXMATRIX localMatrix, worldMatrix;
};

Ponieważ sceny zwykle nie są statyczne, każdy taki węzeł powinien być aktualizowany co klatkę. Do tego celu powinna służyć odpowiednia funkcja klasy zarządzającej drzewem, której zadaniem będzie przejście rekursywnie po całym drzewie i zaktualizowanie danych. Oto przykład takiej funkcji:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void updateNodes(SGNode* pRoot)
{
   SGNode* pNode = pRoot->pChild;
 
   while(pNode != null)
   {
      D3DXMatrixMultiply(&pNode->worldMatrix, &pNode->localMatrix, &pRoot->worldMatrix);
      updateNodes(pNode);
      pNode->pSibling;
   }
}
 
// Wywołanie na korzeniu
updateNodes(m_pRoot);

Ten prosty przykład wystarczy by zauważyć problem, który został poruszony w wymienionej prezentacji. Chodzi przede wszystkim o skakanie po pamięci wskaźnikami, które są dość obficie używane w takiej implementacji. Powoduje to cache-missy, zwłaszcza w przypadku, gdy każdy węzeł jest alokowany osobno.

Rozwiązaniem tego, jak można się domyślić, jest umieszczenie tych danych w sposób liniowy, tak jak ma to miejsce w wymienionej prezentacji. Ważne jest również, aby dane były rozdzielone i uszeregowane w kolejności ich późniejszej aktualizacji.

W swoim silniku rozwiązałem to wykorzystując uchwyty, a węzły traktując jak każdy inny zasób, który można tworzyć i niszczyć. Wszystko to znajduje się w jednej klasie, która wygląda następująco:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
class NSceneGraph
{
   struct NSceneGraphNode
   {
      NSize_t   uChild,
            uSibling;
      NSize_t uIndex;            // Indeks do tablic danych
      NSize_t uNext;             // Indeks następnego węzła
   };
 
public:
   NRESULT InitSceneGraph();
 
   void Release();
 
   NRESULT CreateNode(NHSceneGraphNode& hNode);
 
   void ReleaseNode(NHSceneGraphNode hNode);
 
   void ChangeParent(NHSceneGraphNode hNode);
 
   void ChangeParent(NHSceneGraphNode hNode, NHSceneGraphNode hParent);
 
   NMMatrixPP GetLocalMatrix(NHSceneGraphNode hNode);
 
   void SetLocalMatrix(NHSceneGraphNode hNode, NMMatrixPP matrix);
 
   NMMatrixPP GetWorldMatrix(NHSceneGraphNode hNode);
 
   const NMMatrix* GetWorldMatrixPtr(NHSceneGraphNode hNode);
 
   // Funkcja aktualizuje macierze world i inne dane
   void Update();
 
   // Funkcja porządkuje drzewo tak, by przechodzenie wszerz było liniowe i cache-friendly
   NRESULT ApplyChanges();
 
private:
   NRESULT ResizeArrays(NSize_t uSize);
 
   void AddSubtree(NSize_t uNode, NSize_t uNewParent);
 
   void RemoveSubtree(NSize_t uNode);
 
   void SetChildIndex(NSize_t uNode, NSize_t uChild)
   {
      m_aSceneNodes[uNode].uChild = uChild;
   }
 
   NSize_t GetChildIndex(NSize_t uNode)
   {
      return m_aSceneNodes[uNode].uChild;
   }
 
   void SetSiblingIndex(NSize_t uNode, NSize_t uSibling);
 
   NSize_t GetSiblingIndex(NSize_t uNode);
 
   void SetDataIndex(NSize_t uNode, NUint32 uIndex);
 
   NSize_t GetDataIndex(NSize_t uNode);
 
   void SetNextIndex(NSize_t uNode, NSize_t uNext);
 
   NSize_t GetNextIndex(NSize_t uNode);
 
   bool RemoveSubtree(NSize_t uRoot, NSize_t uNode);
 
private:
   NStableDataVector<NSceneGraphNode> m_aSceneNodes;
 
   bool m_bDirty;
   NSize_t m_uArraysSize;
   NAlignedDynamicTable<NMMatrix> m_aLocalMatrices;
   NAlignedDynamicTable<NMMatrix> m_aWorldMatrices;
};

Teraz przydałoby się wyjaśnienie co do tego kodu. Po pierwsze nie ma tutaj wskaźników, ponieważ jednym z założeń silnika jest wykorzystywanie większych obszarów pamięci zamiast pojedynczych alokacji. Z tego wynika założenie o uchwytach, co implikuje wykorzystanie w przedstawionym kodzie indeksów. NStableDataVector reprezentuje kontener, którego kluczowym założeniem jest niezmienność pozycji przechowywanych danych, dzięki czemu uchwyty pozostają ważne przez cały czas działania aplikacji. NAlignedDynamicTable jest natomiast klasą, która odpowiada za tworzenie i obsługę tablicy przechowującej różne dane (w tym przypadku macierze), które wymagają wyrównanego adresu.

Wracając jednak do samego drzewa sceny – ponieważ jego głównym założeniem jest implementacja rozwiązań z wymienionej prezentacji, dane zostały podzielone na kilka odpowiednich tablic, a indeksy do tych tablic zostały dodane do węzła. Wszystkie nowo utworzone węzły są potomkami węzła głównego, a zmiana rodzica jest transakcją, która usuwa podany węzeł i od razu dodaje go jako dziecko nowego rodzica (funkcja ChangeParent()).

Kluczową funkcją tej klasy jest ApplyChanges(), jej zadaniem ułożenie danych w taki sposób, aby zredukować liczbę cache-missów do minimum, wygląda ona tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
NRESULT NSceneGraph::ApplyChanges()
{
   NAssert(m_bDirty != false, "Needless call of this function");
 
   NRESULT result;
   // Temp arrays
   NDataQueue<NSize_t> tempQueue;
   NAlignedDynamicTable<NMMatrix> tempLocalMatrices;
   NAlignedDynamicTable<NMMatrix> tempWorldMatrices;
 
   if(NFAILED(result = tempLocalMatrices.Create(m_aLocalMatrices.GetSize(), MATRIX_ALIGNMENT))
   || NFAILED(result = tempWorldMatrices.Create(m_aWorldMatrices.GetSize(), MATRIX_ALIGNMENT))
   || NFAILED(result = tempQueue.Create(m_uArraysSize / 2, 0)))
   {
      tempLocalMatrices.Release();
      tempWorldMatrices.Release();
      tempQueue.Release();
      return result;
   }
 
   tempLocalMatrices[0] = m_aLocalMatrices[0];
   tempWorldMatrices[0] = m_aWorldMatrices[0];
 
   // Główny algorytm
   NSize_t uRootNode = 0;
   tempQueue.PushElement(GetChildIndex(uRootNode));
 
   SetNextIndex(uRootNode, GetChildIndex(uRootNode));
 
   NSize_t uNext = uRootNode;
   for(NUint32 i = 1; !tempQueue.IsEmpty(); )
   {
      NSize_t uNode = *tempQueue.GetFront();
      tempQueue.PopElement();
 
      for(; uNode != NMAX_SIZE_T; ++i)
      {
         SetNextIndex(uNext, uNode);
 
         tempLocalMatrices[i] = m_aLocalMatrices[GetDataIndex(uNode)];
         tempWorldMatrices[i] = m_aWorldMatrices[GetDataIndex(uNode)];
         SetDataIndex(uNode, i);
 
         if(GetChildIndex(uNode) != NMAX_SIZE_T)
         {
            tempQueue.PushElement(GetChildIndex(uNode));
         }
 
         uNext = GetNextIndex(uNext);
         uNode = GetSiblingIndex(uNode);
      }
   }
   SetNextIndex(uNext, NMAX_SIZE_T);
 
   m_aLocalMatrices.Release();
   m_aWorldMatrices.Release();
   tempQueue.Release();
 
   m_aLocalMatrices = tempLocalMatrices;
   m_aWorldMatrices = tempWorldMatrices;
   m_bDirty = false;
 
   Update();
 
   return NRV_SUCCESS;
}

Powyższa funkcja została wyrwana prosto z pliku źródłowego. Jak widać przechodzi ona całe drzewo wszerz porządkując dane (macierze) oraz aktualizując indeks danych i następnego węzła. Najważniejszym faktem dotyczącym tej funkcji jest to, że powinna być wywołana po wszystkich czynnościach związanych z dodawaniem, usuwaniem lub przenoszeniem węzłów. Dane uporządkowane można później wielokrotnie uaktualniać za pomocą funkcji Update(), która nie będzie posiadać narzutu związanego ze skakaniem po danych (pozostaje tylko skakanie po węzłach, które same w sobie nie są duże).

Funkcja ta wygląda w tym przypadku tak:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void NSceneGraph::Update()
{
   NAssert(m_bDirty == false, "Call this function before running updates");
 
   NSize_t uParentNode = 0;
 
   while(uParentNode != NMAX_SIZE_T)
   {
      NSize_t uNode = GetChildIndex(uParentNode);
 
      while(uNode != NMAX_SIZE_T)
      {
         NSize_t uDataIndex = GetDataIndex(uNode);
         // Macierze globalne
         m_aWorldMatrices[uDataIndex] = NMMatrixMul(m_aLocalMatrices[GetDataIndex(uNode)], m_aWorldMatrices[GetDataIndex(uParentNode)]);
 
         uNode = GetSiblingIndex(uNode);
      }
 
      uParentNode = GetNextIndex(uParentNode);
   }
}

Cały algorytm przechodzenia po tym drzewie polega na aktualizacji każdego dziecka dla bieżącego rodzica. Dzięki informacji o następnym węźle przechodzenie po drzewie odbywa się poziom po poziomie.

Jak widać cały problem podejścia zorientowanego na dane udało się umieścić w jednej klasie, co więcej funkcja porządkująca dane nie wymaga częstego wywoływania, ponieważ najprawdopodobniej będzie to miało miejsce w przypadku ładowania/usuwania sceny. Narzut związany z korzystaniem z uchwytów i indeksacją również można zredukować przed dodanie funkcji zwracających wskaźnik na żądane dane, które po każdym wywołaniu ApplyChanges() należy zaktualizować.

NIne3 – założenia i struktura silnika

Witam ponownie. Tym razem napiszę nieco o założeniach i strukturze mojego silnika, którym ma docelowo być NIne3. Numerek ‘3’ świadczy o tym, że jest to już trzecia iteracja, zatem jest to pewna zmiana w stosunku do wcześniejszych postów. Kod został przepisany na nowo, zmieniły się również założenia, ale część z nich oraz różne fragmenty kodu zostały wykorzystane ponownie. Muszę przyznać, że jestem zadowolony z aktualnego stanu kodu i myślę, że ta iteracja będzie jedną z tych dłuższych i dotrwa do czegoś większego :).

Założenia

Podstawowe założenia to:

  • brak wyjątków
  • brak STL (strumienie, kontenery, std::string)
  • brak funkcji wirtualnych (przynajmniej na niskim poziomie)
  • obiektowość
  • modułowość
  • całkowite ukrycie wykorzystywanego API graficznego
  • oddzielenie klas implementacyjnych od interfejsu programistycznego
  • minimalizacja alokacji pamięci
  • bezpieczny kod

Dlaczego takie założenia? Otóż, po pierwsze, wyjątki, mimo że jest to bardzo wygodny feature języka C++, są dość kosztowne, ponieważ wymagają dużo pracy włożonej w napisanie kodu, który zwolni wszystkie zasoby i sam przy tym nie wygeneruje błędów. Z drugiej strony różne kompilatory różnie implementują obsługę wyjątków, więc nie jest do końca pewne czy nie pojawi się dodatkowy narzut związany z obsługą powrotu z funkcji wewnątrz bloku try/catch.

Natomiast STL mimo, że jest biblioteką napisaną w najwydajniejszy możliwy sposób (a przynajmniej powinna być) i jednocześnie jest dość elastyczna, jest dość obszerna i na start dorzuca niecałe pół megabajta danych do pliku .exe, z czego z większości i tak się nie korzysta bezpośrednio. Ostatecznie nie spełnia ona również podstawowego założenia jaką jest brak obsługi wyjątków, którą można co prawda wyłączyć, ale nie jest to łatwe. Dlatego strumienie zostały u mnie zastąpione starą, dobrą funkcją printf, kontener std::vector własną implementacją uwzględniającą jeden z punktów o minimalnej ilości alokacji, a z std::string zrezygnowałem całkowicie, ponieważ nie jest ona konieczna, a poza tym dodatkowo nie spełniają kilku z wymienionych punktów.

Brak funkcji wirtualnych na niskim poziomie wziął się z oczywistego narzutu w postaci vtable, zatem wykorzystywany interfejs został napisany z użyciem PIMPL, dzięki czemu możliwa jest optymalizacja i inline’owanie funkcji (na poziomie konsolidacji). Nie wykluczam natomiast użycia ich w przyszłości na wyższym poziomie kodu, ale póki co staram się je omijać.

Obiektowość wiąże się przede wszystkim z enkapsulacją kodu, co pozwala oddzielić kod obsługi tekstur od kodu obsługi okna, efektów etc. w sposób wyraźny. Dzięki temu kod jest przejrzysty i łatwy w modyfikowaniu. Ten punkt powiązany jest bezpośrednio z punktem następnym czyli modułowością. Chciałbym, żeby kod był łatwo rozszerzalny i umożliwiał wymianę różnych klas oraz ich dodawanie, bez modyfikowania pozostałych. Pokażę to niżej na przykładzie hierarchii modułów.

W tej iteracji postanowiłem również ukryć całkowicie wykorzystywane API graficzne, którym póki co nadal jest Direct3D 9. API jest ukryte nie tylko na zewnątrz biblioteki, ale również wewnątrz dla modułów wyższego rzędu, które nie korzystają bezpośrednio z klas implementacyjnych, ale z interfejsu użytkownika, co zapewnia niezależność.

Docelowy użytkownik biblioteki nie widzi bezpośrednio klas implementacyjnych tylko klasy interfejsowe napisane przy użyciu PIMPL, które są uporządkowane w odpowiedniej hierarchii, którą pokażę nieco niżej. Daje to swobodę działania wewnątrz silnika i uporządkowanie na zewnątrz, jest również mniej błędogenne.

Ostatnie dwa punkty są niejako powiązane, ponieważ zakładam uruchamianie silnika na maszynach z ograniczoną ilością pamięci i możliwość wystąpienia błędu alokacji pamięci, więc obsługa takich błędów jest wymagana. Natomiast w celu minimalizacji tego typu błędów postanowiłem zmniejszyć ilość alokacji do minimum stosując kilka własnych klas do tego celu (bez menadżera pamięci póki co). Dodatkowo zakładam, że wszelkie możliwe błędy wystąpią w przypadku inicjalizacji i ładowania zasobów, więc każdy możliwy błąd zostaje przekazany do użytkownika biblioteki i zapisany w logu. Natomiast w czasie działania aplikacji błędy nie powinny wystąpić  albo będą występować bardzo rzadko, w tym przypadku ich sprawdzenie, o ile to możliwe, będzie występować w jednym ustalonym miejscu.

Hierarchia interfejsów

Jak już wspomniałem wcześniej klasy implementacyjne są oddzielone od użytkownika warstwą interfejsu, który ma za zadanie utworzyć odpowiednią hierarchię po to, aby klasy specjalizowane, zależne od ogólnych, nie mogły być pobrane i użyte przed zainicjalizowaniem tamtych. Dodatkowo daje to możliwość zmian wewnątrz silnika, bez zmiany samego interfejsu. Sama hierarchia wygląda następująco:

  • ICore:
    • IWindowManager:
      • INInput
    • ITimer
    • IRenderer:
      • IMeshManager
      • ITextureManager
      • IEffectManager:
        • IEffect
      • IModelLoader:
        • IModel:
          • IMaterial
      • IMeshLoaderX
      • IMeshLoaderHeightmap

Jak widać podział jest dość wyraźny. Wyszczególniłem całkowicie niezależne od siebie moduły: Timer, WindowManager i Renderer. Każdą z nich można inicjalizować osobno lub zrobić to za pomocą klasy Core, która zainicjalizuje wszystkie moduły i udostępni kilka podstawowych funkcji takich jak: Update(), Present(), ChangeDisplayMode(), które na nich operują.

Największym modułem jest Renderer, który również został wyraźnie podzielony na kilka podmodułów. Podstawowymi są tak naprawdę MeshManager, TextureManager oraz EffectManager (shadery) i to te klasy są odpowiedzialne za obsługę podstawowych zasobów niezbędnych do wyrenderowania czegokolwiek: siatek, tekstur oraz shaderów. Drugą grupą podmodułów są te odpowiedzialne za ładowanie wymienionych zasobów z plików w różnych formatach – aktualnie obsługuję tylko dwa – pliki .x oraz heightmapy. Ostatnią grupą są podmoduły zarządzające, których przedstawicielem jest ModelManager. Jego zadaniem jest zebranie zasobów w jednym miejscu i dodanie im odpowiedniej funkcjonalności tak, aby było mniej roboty przy renderowaniu.

Hierarchia modułów

Sercem silnika jest hierarchia modułów, która składa się z klas bibliotecznych, struktur oraz klas silnika:

  • Klasy biblioteczne:
    • NMath:
      • NMVector
      • NMMatrix
      • NMQuaternion
    • NAssert
    • NLogger
    • NDynamicTable
    • NDataVector
    • NStableDataVector
    • Inne (mniejsze klasy implementujące różną funkcjonalność)
  • Struktury
  • Engine:
    • Core
    • WindowManager:
      • Input
    • Timer
    • Renderer:
      • SubRenderer (Direct3D_9)
        • BuiltInLoader
          • MeshLoaderX
        • EffectManager
        • MeshManager
        • TextureManager
      • BuiltInLoaders
        • MeshLoaderHeightmap
      • ModelManager

W klasach bibliotecznych można znaleźć definicje typów podstawowych, bibliotekę matematyczną, kontenery oraz klasy pomocne w debugowaniu (logger i asercja). Biblioteka matematyczna jest napisana z myślą o wykorzystaniu funkcji intrinsic procesora (SSE) i jej wygląd bazuje w bardzo dużej mierze na XNAMath. Aktualnie obsługiwane są tylko wektory i macierze, ale nie wszystkie operacje zostały zaimplementowane, są tylko te aktualnie potrzebne. Między kontenerami można wyróżnić opakowaną w klasę zwykła tablicę dynamiczną (NDynamicTable), odpowiednik std::vector (NDataVector) oraz ten sam kontener, ale zachowujący wewnętrzne ułożenie danych (NStableDataVector – pomocne przy korzystaniu z uchwytów).

Hierarchia silnika różni się to trochę od hierarchii interfejsów, którą implementuje. Przede wszystkim wyróżnia się tutaj grupa SubRenderer. Jej celem jest zebranie wszystkich klas zależnych od API graficznego w jednym miejscu. Dzięki temu możliwa jest łatwa wymiana tego API przy zachowaniu funkcjonalności silnika. Wszystkie klasy, które znajdują się poza tą grupą korzystają albo z klasy pośredniej SubRenderer, albo z interfejsów użytkownika. Zmiana API będzie możliwa tylko statycznie, za pomocą odpowiedniej dyrektywy preprocesora. Klasy znajdujące się w tej grupie odpowiedzialne są za zarządzanie podstawowymi zasobami oraz samym urządzeniem i nie robią nic poza podstawowymi operacjami.

Wszystkie zasoby są udostępniane za pomocą uchwytów, dzięki którym można łatwo manipulować zasobami wewnątrz silnika bez obawy, że coś zostanie zgubione na zewnątrz. Niektóre z nich udostępniają dodatkowy interfejs (Effect, Model), który umożliwia bardziej szczegółową manipulację danym zasobem, ale klasy wewnętrzne Renderera manipulują wyłącznie uchwytami.

Oprócz tego istnieje cały zestaw pomocnych struktur, w tym właśnie szablon uchwytów, które bazują na odpowiednich akcesorach. Dzięki temu wszystko pozostaje hermetycznie zamknięte wewnątrz silnika.

Co dalej?

Aktualnie zakończyłem restrukturyzację hierarchii modułów do postaci takiej jaką przedstawiłem. Kolejnym moim celem jest dodanie SceneGraph, który będzie zarządzał drzewem modeli oraz przechowywał ich macierze świata i je aktualizował. Chcę przy tym wykorzystać poprawkę z tej prezentacji , czyli zawrzeć całe drzewo macierzy w jednej tablicy, która będzie cache-friendly.
Oprócz tego w związku z wydzieleniem SubRenderera chciałbym dodać drugi, oparty o OpenGL, ale to już będzie większy orzech do zgryzienia. Muszę przyznać, że sam jestem ciekaw wyniku :).

Environmental mapping + skybox

Tym razem będzie mały update dotyczący silnika. Niby nic, ale można się pochwalić.

etuymcrypPo pierwsze Environmental mapping, czyli proste przybliżenie zjawiska odbicia światła na powierzchni ciała oraz załamania światła na granicy dwóch ośrodków. Wymaga dostarczenia tylko dodatkowej tekstury sześciennej (cube-mapa) oraz skorzystania z dwóch dodatkowych funkcji HLSL: refract i reflect. Można go wzbogacić dodatkowo o efekt Fresnela i rozszczepienie chromatyczne. Całość wygląda jak na screenie obok. Efekt ten nie jest trudny do zaimplementowania, więc szczegółów implementacyjnych nie będzie, zresztą są one w książce CG Tutorial.

Kolejną rzeczą jest skybox. Jak nazwa wskazuje, jest to pudełko z tłem nieba, które powoduje, że scena z obiektami nie wygląda po prostu pusto. Poniżej przedstawiam trzy kroki rysowania skyboxa:

  1. Potrzebne jest pudełko wraz z teksturą, którą jest już wspomniana wcześniej cube-mapa. Biblioteka D3DX posiada do tego odpowiednią funkcję: D3DXCreateBox(), która jako parametry przyjmuje wymiary pudełka. Najlepiej jest stworzyć sześcian 1:1:1.
  2. Pudełko powinno obracać się razem z kamerą, ale nie może zmieniać swojej pozycji. Aby to osiągnąć należy użyć tylko macierzy widoku i projekcji. przy czym ostatni wiersz tej pierwszej powinien mieć takie wartości: (0, 0, 0, 1).
  3. Skybox powinien być pierwszym elementem rysowanym na scenie, a dodatkowym warunkiem koniecznym by był on tłem całej sceny jest wyłączenie zapisu do ZBuffera. Dzięki temu każdy kolejny obiekt sceny będzie rysowany zawsze przed skyboxem. Aby odczytać kolor tekstury zamiast funkcji tex2D należy wykorzystać funkcję texCUBE, do której jako współrzędne tekstury przekazuje się pozycję danego pixela.

Jak widać nie jest to skomplikowane, a dzięki temu scena wygląda o wiele lepiej.

SDKMeshToXConverter

Już trochę czasu temu postanowiłem napisać konwerter wspomnianego formatu .sdkmesh do .x i w końcu dopiąłem swego. Mimo tego, że jak już wcześniej wspomniałem, format ten nie nadaje się do modeli animowanych (posiada informacje o animacji, ale nie ma informacji o kościach), można ostatecznie wyciągnąć z niego model statyczny.

To właśnie robi poniższa aplikacja. Wystarczy tylko przeciągnąć plik .sdkmesh na plik aplikacji i w katalogu mesha źródłowego pojawia się wygenerowany plik w formacie .x.

Pobierz

Kilka zmian

rpotStwierdziłem, że wypadałoby się wreszcie odezwać po kolejnej długiej nieobecności. W końcu udało mi się zaimplementować Skinning w silniku, oczywiście bazując na przykładzie SkinnedMesh z DX SDK. W praktyce zrobiłem  to co było w ów sample’u, a jedyną różnicą jest miejsce położenia kości, które u mnie znajdują się w hierarchii zaraz za modelem. Samą hierarchię tworzą modele (zwykłe drzewo, które ustanawia hierarchię na podstawie której tworzone są wynikowe macierze świata dla tych modeli). Póki co mam tylko dwa rodzaje modeli: StaticMesh i SkinnedMesh, ale bez problemu można do niej wstawić innego rodzaju obiekty, które występują na scenie.

Kolejną sprawą są stosowane przeze mnie formaty. Otóż w poprzedniej notce napisałem jakoby format .x był bardzo prostym formatem. Nic bardziej mylnego, jest on bardziej złożony niż myślałem (chodzi mi tylko o format modeli, nie o system zapisu danych). W standardowym pliku .x może znajdować się więcej modeli, które są ułożone w hierarchię, a do ich załadowania służy funkcja:

D3DXLoadMeshHierarchyFromX();

Funkcja ta ładuje nie tyko wspomnianą hierarchię meshy, ale również informacje o kościach i  animacji. Niestety format materiałów dalej nie zawiera nic więcej poza strukturą D3DMATERIAL9 i nazwą tekstury diffuse.

Natomiast format SDKMesh jest jeszcze gorszy (mimo jednej zalety, że posiada nazwy dla tekstur normal i specular), ponieważ nie posiada on żadnych informacji o kościach mimo, że są informacje o animacji. Po prostu wczytywany mesh jest już przekonwertowany i gotowy do wyświetlenia (co blokuje możliwość prostego użycia ID3DXSkinInfo, którym mógłbym software’owo przekształcić model np. do kolizji per vertex).

SDKMeshInfo

Pisząc różne efekty graficzne, w pewnym momencie możemy stwierdzić, że potrzebujemy jakiegoś innego modelu. Wtedy nasuwa się również pytanie “skąd wziąć taki model?”. Tutaj przychodzi z pomocą DirectX SDK, które posiada dużą ilość różnych modeli w formacie .x oraz .sdkmesh. Z formatem .x można sobie poradzić funkcją

D3DXLoadMeshFormX

Funkcja ta wczytuje i parsuje model, zwracając wskaźnik, z którego można już korzystać.

SDKMeshInfoNiestety format .x jest bardzo prosty, ponieważ zawiera on tylko jeden zestaw materiałów i teksturę diffuse na subset. Problem ten rozwiązano tworząc nowy format z rozszerzeniem .sdkmesh. Powstał on i jest wykorzystywany na potrzeby przykładów Direct3D 10. Pliki tego formatu mogą zawierać kilka modeli, podzielonych na kilka subsetów. Materiały zostały rozszerzone o tekstury normal i specular. Dodane zostały również informacje o klatkach animacji, natomiast szczegóły danej klatki zawiera osobny plik z rozszerzeniem .sdkmesh_anim. Więcej szczegółów dotyczących tego formatu znajduje się w dokumentacji pod hasłem “Overview of the SDK Mesh File Format”.

Postanowiłem się zainteresować tym formatem nie tylko z powodu tego, że część modeli jest w nim zapisana, ale potrzebowałem modeli, które mógłbym wykorzystać do mojej zabawy z animacją opartą na klatkach kluczowych. Właśnie z tego powodu napisałem aplikację, którą tu zamieszczam. SDKMeshInfo jest programem do podglądu bebechów plików .sdkmesh. Nie posiada on opcji ich wyświetlania, ale pokazuje pełne dane z nagłówków zawartych we wskazanym pliku.

Download

Shaderki

AplikacjaPostanowiłem wreszcie zająć się wyświetlaniem grafiki opartej na shaderach. Stworzyłem sobie prosty framework oparty na moim “silniku”, który jest aktualnie tylko szkieletem aplikacji. Framework składa się z kilku klas, których zadaniem jest uprościć wykonywanie różnych rzeczy. Aktualnie w zestawie jest kamerka FPP, prosty system cząsteczek oraz klasy do wczytywania modeli i zarządzania efektami.

Na dole tej notki znajduje się link do aplikacji, która reprezentuje aktualny stan kodu. Aplikacja wyświetla 4 modele wczytane z plików w formacie .x, 1 model z formatu .sdkmesh oraz cząsteczki, których pozycja obliczana jest w shaderze. Światło użyte w scenie jest punktowe, a obliczenia są wykonywane w pixel shaderze. Materiały modeli pochodzą z ich plików, dlatego nie wszystko wygląda super ;).

Kamerkę obsługuje się za pomocą myszy i klawiszy WSAD, a światło za pomocą strzałek oraz klawiszy PG_UP i PG_DN.

Pobierz

Interfejs IMesh, klasa Mesh i loadery.

Nawiązując do poprzedniej notki chcę tylko zamieścić jak wyglądają klasy, które napisałem i czemu są takie fajne:

// Interfejs
class IMesh
{
public:
	/*
	...
	*/
	// Funkcja aktualizująca mesha
	virtual void SetMesh(LPD3DXMESH mesh) = 0;
	// Funkcja dodająca materiał do wektora
	virtual void AddMaterial(const Material&amp; mat) = 0;
	// Funkcja zwraca materiał o podanym indeksie
	virtual Material GetMaterial(Dword index) = 0;
	/*
	...
	*/
};
 
// Główna klasa Mesh
class Mesh : public IMesh
{
public:
	// Konstruktor
	Mesh();
	// Destruktor
	~Mesh();
 
public:
	/* Funkcje używane do rysowania modelu
	...
	*/
 
protected:
	/*
	...
	*/
	// Funkcja aktualizująca mesha
	virtual void SetMesh(LPD3DXMESH mesh) = 0;
	// Funkcja dodająca materiał do wektora
	virtual void AddMaterial(const Material&amp; mat) = 0;
	// Funkcja zwraca materiał o podanym indeksie
	virtual Material GetMaterial(Dword index) = 0;
	/*
	...
	*/
};
 
// Przykład loadera
class X_file
{
public:
	// Konstruktor
	X_file(IMesh* mesh) { m_mesh = mesh; }
	// Funkcja ładująca
	RESULT LoadMesh(String filename)
	{
		LPD3DXMESH mesh;
		/*
		... cuda-niewidy...
		*/
		m_mesh->AddMesh(mesh); // Wywołanie funkcji interfejsu
	}
 
private:
	// Obiekt na którym działam
	IMesh* m_mesh;
};

Nie lubię się rozpisywać. Jak można zauważyć dzięki interfejsowi IMesh można załadować dowolny model do klasy Mesh, ponieważ są publiczne wszystkie funkcje umożliwiające to zadanie, natomiast klasa Mesh posiada publiczne funkcje służące tylko i wyłącznie do rysowania modelu. Dzięki takiemu rozwiązaniu kod jest przejrzysty.

Programowania ciąg dalszy

Minęło trochę czasu od mojej ostatniej notki, ale co poradzić, studia są trochę wymagające, więc trzeba się czasem pouczyć, zwłaszcza, że jest to styczeń czyli ostatni miesiąc przed sesją. W momencie, gdy piszę tę notkę, upływa właśnie pierwszy tydzień tej najbardziej nielubianej przez studentów pory roku :). Wracając jednak do tematyki tego devBloga należałoby powiedzieć czym ciekawym zajmowałem się przez ten czas, przecież nie samą nauką człowiek żyje.

Postanowiłem dodać do mojego silnika możliwość wczytywania różnego rodzaju plików z meshami. Na początek napisałem prostą klasę opakowującą interfejs ID3DXMesh, w której umieściłem strukturę przechowującą materiały danego mesha. Następnie napisałem funkcje automatyzujące odczyt i zapis plików w formacie .x. Schody zaczęły się, gdy chciałem dodać obsługę dodatkowego formatu – zdecydowałem, że będą to pliki .obj (Object files). OBJ są to pliki tekstowe, przechowujące informacje o całym meshu (fajna specyfikacja tego formatu znajduje się tutaj), z którymi w parze są jeszcze pliki .mtl, gdzie trzymane są informacje o materiałach dla danego subsetu (więcej info tutaj). Najważniejszą sprawą w całym tym przedsięwzięciu było napisanie parsera dla tego formatu. Na szczęście z pomocą przychodzi Microsoft DirectX SDK, w którym znajduje się przykładowy program (“MeshFromOBJ”) wczytujący pliki OBJ.

Pisząc klasy obsługujące różne modele chciałem, aby istniała możliwość konwersji między tymi formatami. Powstała więc koncepcja dwóch rodzajów klas – klasa Mesh oraz klasy obsługujące formaty. Klasy te mają być od siebie w jak największym stopniu niezależne, czyli ma istnieć możliwość dodawania funkcjonalności do klasy Mesh bez konfliktu z klasami wczytującym oraz musi istnieć możliwość pisania dodatkowych klas obsługujących różne formaty. Szczerze mówiąc, pierwszy raz zetknąłem się z takim problemem, ale na szczęście udało mi się znaleźć proste i eleganckie rozwiązanie. Mianowicie zastosowałem prosty interfejs, po którym dziedziczy klasa Mesh, z tym wyjątkiem, że klasa interfejsu posiada funkcje abstrakcyjne public, a klasa Mesh definiuje te funkcje jako protected, bądź private. Dzięki takiemu podejściu klasy korzystające z interfejsu, mogą operować na tych funkcjach, a klasa Mesh ma te funkcje ukryte :).

Sztuczny Device Lost w Viście

Zajmując się ostatnio moim silnikiem, natrafiłem na jedną bardzo ciekawą zaletę Visty. Otóż przypadkiem odkryłem jak sztucznie wywołać utratę urządzenia w DirectGraphics. Wystarczy do tego użyć Ctrl + Alt + Del, gdyż Vista w tym momencie przechodzi na ekran wyboru czynności, więc prawdopodobnie przejmuje całkowitą kontrolę nad urządzeniem, czego skutkiem (pewnie ubocznym) jest utrata urządzenia.

Mnie ta sztuczka się bardzo przydała, ponieważ dzięki temu wykryłem, że miałem problem z odzyskiwaniem urządzenia.