NIne3 – stan aktualny

W tej notce prezentuję aktualny stan mojego frameworka NIne3, którego strukturę opisałem jakiś czas temu. Jest to swego rodzaju ‘proof of concept’, czyli że to jednak działa. Z głównych feature’ów chciałbym wymienić:

  • shadowmapy (nowość :))
  • oświetlenie per-pixel
  • pliki wykonywalne dla platfrom x64 i x86 z obsługą SSE i bez
  • stały krok update’u logiki (tego akurat można nie zauważyć)
  • obsługa heightmap

I to chyba tyle, a tutaj link do aplikacji (wymaga redistów DX Feb 2010).

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

Fenomen SSE

Na warsztacie pojawił się ostatnio bardzo ciekawy temat dotyczący wykorzystania instrukcji procesora przetwarzających dane potokowo. Jak wiadomo każdemu programiście, każdy procesor wspiera różny zestaw funkcji (ponieważ są one dołączane do poprzednich). O to lista tych rozszerzeń:

  • MMX (operacje na liczbach całkowitych)
  • SSE (operacje zmiennoprzecinkowe pojedynczej precyzji)
  • SSE2 (operacje zmiennoprzecinkowe podwójnej precyzji)
  • SSE3 (konwersje i dodawanie w poziomie)
  • SSSE3 (dodatkowe działania na liczbach całkowitych)
  • SSE4 (dodatkowe instrukcje wektorowe, w tym upragniony iloczyn skalarny dwóch wektorów)

O ile rozszerzenie MMX korzysta z rejestrów koprocesora (co czyni go niewydajnym, ponieważ przełączanie między MMX a koprocesorem trwa trochę czasu), o tyle pozostałe rozszerzenia wprowadzają dodatkowe rejestry xmm (8 dla procesorów 32-bitowych oraz 16 dla procesorów 64-bitowych) po 128 bitów każdy. Rejestry te są traktowane tak jak jest to wymagane do danej funkcji, zatem dla liczb całkowitych jest całe 128-bitowe słowo lub można dzielić na pół, aż do uzyskania 16 osobnych bajtów. W przypadku liczb zmiennoprzecinkowych są to 4 liczby typu float lub 2 liczby typu double.

Dostępne funkcje pozwalają wykonywać obliczenia zarówno skalarnie jak i wektorowo. Oczywiście najlepiej jest gdy przeprowadzane operacje są głównie wektorowe, ponieważ pozwala to zaoszczędzić mnóstwo cennych cykli procesora. Największy problem to oczywiście pisanie operacji przy użyciu tych funkcji, ponieważ wymagana jest podstawowa znajomość Assemblera, chociaż dzięki tzw. funkcjom intrinsics wcale nie trzeba robić w kodzie wstawek asemblerowych, gdyż funkcje robią to zamiast programisty.

Przyznam szczerze, mnie również  bardzo interesują te funkcje, dlatego postanowiłem z ich pomocą napisać własną implementacje wektorów i macierzy (również dlatego, że te w D3DX, nie mają tego wsparcia). Jest to dość żmudna robota, ponieważ trzeba jakoś zagwarantować to, żeby moje wektory ruszyły na starszych komputerach (a nuż trzeba będzie), ale mimo wszystko uważam, że warta zachodu. Mogę już powiedzieć, że funkcja obliczająca długość wektora, dzięki instrukcją z rozszerzenia SSE, jest szybsza od wersji z D3DX.

SSE i SSE 2 w Visual 2008

Przeglądając opcje projektu w Visual C++ 2008 Express Edition natrafiłem na opcję, która umożliwia kompilatorowi użycie dodatkowych instrukcji procesora z zestawów SSE i SSE2. Niestety nie wiem, które co i jak kompilator może zoptymalizować, ale myślę, że warto ustawić tę opcję na minimum SSE. A nuż coś pomoże. Aby uaktywnić tę opcję należy wejść do opcji projektu -> C++ -> Code Generation -> Enable Enhanced Instruction Set.

Próba uruchomienia programu zoptymalizowanego pod instrukcje wyższego zestawu po prostu się nie powiedzie, ale w dzisiejszych czasach kiedy dostępny jest zestaw instrukcji SSE3, można sobie pozwolić na minimum SSE.