This is the Polish translation of Model-Loading/Model article of learnopengl.com tutorial series.

Nadszedł czas, aby zabrać się za Assimpa i zacząć pisać rzeczywisty kod ładowania modeli z zewnętrznych plików. Celem tego samouczka jest utworzenie innej klasy reprezentującej model w całości, czyli model zawierający wiele siatek, prawdopodobnie z wieloma obiektami. Dom, który zawiera drewniany balkon, wieżę i basen, może zostać załadowany jako pojedynczy model. Załadujemy model przez Assimp i przekonwertujemy go na wiele obiektów typu Mesh, które stworzyliśmy w poprzednim tutorialu.

Bez dalszych wstępów przedstawiam strukturę klas Model:

    class Model 
    {
        public:
            /*  Funkcje   */
            Model(char *path)
            {
                loadModel(path);
            }
            void Draw(Shader shader);	
        private:
            /*  Dane modelu  */
            vector<Mesh> meshes;
            string directory;
            /*  Funkcje   */
            void loadModel(string path);
            void processNode(aiNode *node, const aiScene *scene);
            Mesh processMesh(aiMesh *mesh, const aiScene *scene);
            vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, 
                                                 string typeName);
    };

Klasa Model zawiera wektor obiektów typu Mesh i wymaga od nas podania w jego konstruktorze ścieżki pliku. Następnie od razu ładuje plik za pomocą funkcji loadModel, która jest wywoływana w konstruktorze. Wszystkie funkcje prywatne są zaprojektowane tak, aby przetworzyć część potoku ładowania Assimp, co wkrótce omówimy. Przechowujemy również katalog ścieżki pliku, który będziemy później potrzebować podczas ładowania tekstur.

Funkcja Draw nie jest niczym specjalnym i zasadniczo wykonuje pętlę dla każdej siatki, aby wywołać funkcję Draw:

    void Draw(Shader shader)
    {
        for(unsigned int i = 0; i < meshes.size(); i++)
            meshes[i].Draw(shader);
    }  

Importowanie modelu 3D do OpenGL

Aby zaimportować model i przekonwertować go do naszej struktury, musimy najpierw dołączyć odpowiednie nagłówki biblioteki Assimp, aby kompilator na nas nie krzyczał:

    #include <assimp/Importer.hpp>
    #include <assimp/scene.h>
    #include <assimp/postprocess.h>

Pierwsza funkcja, którą wywołujemy, to loadModel, która jest wywoływana bezpośrednio z konstruktora. W ramach loadModel używamy Assimp do załadowania modelu do struktury Assimp nazywanego obiektem scene. Być może pamiętasz z pierwszego samouczka z serii ładowania modeli, że jest to główny obiekt interfejsu danych Assimp. Po uzyskaniu obiektu sceny możemy uzyskać dostęp do wszystkich potrzebnych danych załadowanego modelu.

Wspaniałą rzeczą w Assimp jest to, że ukrywa wszystkie techniczne szczegóły ładowania różnych formatów plików i robi to wszystko za pomocą jednej linijki kodu:

    Assimp::Importer importer;
    const aiScene *scene = importer.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs); 

Najpierw zadeklarujemy obiekt Importer z przestrzeni nazw Assimp, a następnie wywołamy funkcję ReadFile. Funkcja oczekuje jako pierwszego argumentu ścieżki do pliku i jako drugiego argumentu opcji post-processingu. Oprócz zwykłego ładowania pliku, Assimp pozwala nam określić kilka opcji, które zmuszają Assimp do wykonywania dodatkowych obliczeń/operacji na zaimportowanych danych. Ustawiając aiProcess_Triangulate mówimy Assimpowi, że jeśli model nie składa się (w całości) z trójkątów, powinien przekształcić wszystkie prymitywy w trójkąty. aiProcess_FlipUVs odwraca współrzędne tekstury na osi Y, gdy jest to konieczne podczas przetwarzania (możesz pamiętać z samouczka Tekstury, że większość obrazów w OpenGL jest odwrócona na osi Y, więc ta opcja post-processingu rozwiązuje ten problem za nas). Kilka innych przydatnych opcji to:

  • aiProcess_GenNormals : w rzeczywistości tworzy normalne dla każdego wierzchołka, jeśli model nie zawiera wektorów normalnych.
  • aiProcess_SplitLargeMeshes : dzieli duże siatki (meshe) na mniejsze siatki, co jest przydatne, jeśli przekroczyłeś już maksymalną liczbę wierzchołków i możesz przetwarzać tylko mniejsze siatki.
  • aiProcess_OptimizeMeshes : robi odwrotność tego, co opcja wyżej, próbując połączyć kilka siatek do jednej większej siatki, zmniejszając liczbę wywołań funkcji glDraw*().

Assimp daje duży zestaw instrukcji prost-processingu, które możesz znaleźć tutaj. Ładowanie modelu za pomocą Assimp jest (jak widać) zaskakująco łatwe. Ciężka praca polega na użyciu zwróconego obiektu sceny do przekonwertowania załadowanych danych na tablicę obiektów Mesh.

Pełna funkcja loadModel znajduje się tutaj:

    void loadModel(string path)
    {
        Assimp::Importer import;
        const aiScene *scene = import.ReadFile(path, aiProcess_Triangulate | aiProcess_FlipUVs);	

        if(!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE || !scene->mRootNode) 
        {
            cout << "ERROR::ASSIMP::" << import.GetErrorString() << endl;
            return;
        }
        directory = path.substr(0, path.find_last_of('/'));

        processNode(scene->mRootNode, scene);
    }  

Po załadowaniu modelu sprawdzamy, czy scena i root node nie mają wartości null i sprawdzamy jedną z jej flag, aby sprawdzić, czy zwrócone dane są kompletne. Jeśli którykolwiek z tych warunków zostanie spełniony, pobieramy błąd za pośrednictwem funkcji GetErrorString importera i ją wyświetlamy. Jeżeli wszystkie warunki były fałszywe (czyli model został wczytany poprawnie), pobieramy nazwę katalogu z podanej ścieżki do pliku.

Następnie, chcemy przetworzyć wszystkie węzły sceny, więc przekazujemy pierwszy węzeł (root node) do funkcji rekursywnej processNode. Ponieważ każdy węzeł (ewentualnie) zawiera zestaw potomków, chcemy najpierw przetworzyć dany węzeł, a następnie kontynuować przetwarzanie wszystkich dzieci węzła i tak dalej. To pasuje do struktury rekursywnej, więc będziemy zdefiniujemy funkcję rekursywną. Funkcja rekursywna jest funkcją, która wykonuje pewne operacje i rekursywnie - wywołuje samą siebie ale z różnymi parametrami, dopóki nie zostanie spełniony określony warunek. W naszym przypadku warunek zakończenia jest spełniony, gdy wszystkie węzły zostaną przetworzone.

Jak zapewne pamiętasz ze struktury Assimpa, każdy węzeł zawiera zestaw indeksów siatki, gdzie każdy indeks wskazuje na konkretną siatkę znajdującą się w obiekcie sceny. Chcemy zatem pobrać te indeksy siatki, pobrać każdą siatkę, przetworzyć każdą siatkę, a następnie wykonać to wszystko ponownie dla każdego z węzłów podrzędnych węzła. Zawartość funkcji processNode pokazano poniżej:

    void processNode(aiNode *node, const aiScene *scene)
    {
        // przetwórz wszystkie węzły siatki (jeśli istnieją)
        for(unsigned int i = 0; i < node->mNumMeshes; i++)
        {
            aiMesh *mesh = scene->mMeshes[node->mMeshes[i]]; 
            meshes.push_back(processMesh(mesh, scene));			
        }
        // następnie wykonaj to samo dla każdego z jego dzieci
        for(unsigned int i = 0; i < node->mNumChildren; i++)
        {
            processNode(node->mChildren[i], scene);
        }
    }  

Najpierw sprawdzamy każdy z indeksów siatki węzła i pobieramy odpowiednią siatkę, indeksując tablicę mMeshes. Zwrócona siatka jest następnie przekazywana do funkcji processMesh, która zwraca obiekt Mesh, który możemy przechowywać w liście/wektorze obiektów Mesh.

Po przetworzeniu wszystkich siatek, iterujemy po wszystkich dzieciach węzła i wywołujemy tę samą funkcję processNode dla każdego z dzieci węzła. Gdy węzeł nie ma już żadnych potomków, funkcja przestaje działać.

Uważny czytelnik może zauważyć, że możemy zasadniczo zapomnieć o przetwarzaniu któregokolwiek z węzłów i po prostu przechwycić wszystkie siatki sceny bezpośrednio, nie robiąc tych skomplikowanych operacji za pomocą indeksów. Powodem, dla którego to robimy jest to, że początkową ideą korzystania z takich węzłów jest to, że definiuje ona relację rodzic-dziecko między siatkami. Dzięki przetwarzaniu rekursywnemu możemy zdefiniować relacje pomiędzy siatkami. Jednym z przypadków użycia dla takiego podejścia jest to, kiedy chcesz przetworzyć siatkę samochodu i upewnić się, że wszystkie jego dzieci (podobnie jak siatka silnika, siatka kierownicy i siatka opon) również zostaną przetworzone; taki system można łatwo utworzyć za pomocą relacji rodzic-dziecko.

W tej chwili jednak nie używamy takiego systemu, ale generalnie zaleca się trzymanie się takiego podejścia, na wypadek gdybyś chciał uzyskać dodatkową kontrolę nad danymi siatki. Te relacje pomiędzy węzłami są zdefiniowane przez artystów, którzy stworzyli modele.

Następnym krokiem jest faktyczne przetworzenie danych Assimp i zapisanie ich w obiekcie klasy Mesh, którą stworzyliśmy w ostatnim tutorialu.

Zapisywanie danych w obiekcie klasy Mesh

Przetwarzanie obiektu aiMesh na własny obiekt Mesh nie jest zbyt trudne. Wszystko, co musimy zrobić, to uzyskać dostęp do odpowiednich właściwości siatki i zapisać je w naszym własnym obiekcie. Ogólna struktura funkcji processMesh wygląda tak:

    Mesh processMesh(aiMesh *mesh, const aiScene *scene)
    {
        vector<Vertex> vertices;
        vector<unsigned int> indices;
        vector<Texture> textures;

        for(unsigned int i = 0; i < mesh->mNumVertices; i++)
        {
            Vertex vertex;
            // przetwórz pozycje wierzchołków, normalne i współrzędne tekstury
            ...
            vertices.push_back(vertex);
        }
        // przetwórz indeksy
        ...
        // przetwórz materiały
        if(mesh->mMaterialIndex >= 0)
        {
            ...
        }

        return Mesh(vertices, indices, textures);
    }  

Przetwarzanie siatki składa się zasadniczo z 3 sekcji: pobierania wszystkich danych wierzchołków, pobierania indeksów siatki i wreszcie pobierania odpowiednich danych materiałowych. Przetworzone dane są przechowywane w jednym z 3 wektorów, a z nich tworzony jest obiekt Mesh i zostaje on zwrócony do funkcji wywołującej.

Pobieranie danych wierzchołków jest dość proste: definiujemy strukturę Vertex, którą dodajemy do tablicy vertices po każdej iteracji. Iterujemy po wszystkich wierzchołkach jakie istnieją w siatce (pobranych przez mesh->mNumVertices). W ramach iteracji chcemy wypełnić tę strukturę wszystkimi odpowiednimi danymi. Dla pozycji wierzchołków jest to wykonywane w następujący sposób:

    glm::vec3 vector; 
    vector.x = mesh->mVertices[i].x;
    vector.y = mesh->mVertices[i].y;
    vector.z = mesh->mVertices[i].z; 
    vertex.Position = vector;

Zauważ, że definiujemy obiekt tymczasowy vec3 do zapisania danych Assimp. Potrzebujemy tego obiektu tymczasowego, ponieważ Assimp ma własne typy danych dla wektorów, macierzy, łańcuchów znaków itp. I nie konwertują się one dobrze do typów danych glm.

Assimp nazywa swoją tablicę pozycji wierzchołków jako mVertices, która to nazwa nie jest zbyt intuicyjna.

Procedura dla wektorów normalnych nie powinna teraz dziwić:

    vector.x = mesh->mNormals[i].x;
    vector.y = mesh->mNormals[i].y;
    vector.z = mesh->mNormals[i].z;
    vertex.Normal = vector;  

Współrzędne tekstury są przetwarzane mniej więcej tak samo, ale Assimp pozwala modelowi posiadać do 8 różnych zestawów współrzędnych tekstury na wierzchołek, których nie użyjemy, więc zależy nam tylko na pierwszym zestawie współrzędnych tekstury. Będziemy chcieli również sprawdzić, czy siatka rzeczywiście zawiera współrzędne tekstury (co nie zawsze musi tak być):

    if(mesh->mTextureCoords[0]) // czy siatka zawiera współrzędne tekstury?
    {
        glm::vec2 vec;
        vec.x = mesh->mTextureCoords[0][i].x; 
        vec.y = mesh->mTextureCoords[0][i].y;
        vertex.TexCoords = vec;
    }
    else
        vertex.TexCoords = glm::vec2(0.0f, 0.0f);  

Struktura vertex jest teraz całkowicie wypełniona wymaganymi danymi wierzchołków i możemy ją wstawić na koniec wektora vertices. Ten proces powtarza się dla każdego z wierzchołków siatki.

Indeksy

Interfejs biblioteki Assimp zdefiniował każdą siatkę, aby posiadała tablicę ścianek (ang. face), gdzie każda ścianka reprezentuje pojedynczy prymityw, który w naszym przypadku (ze względu na opcję aiProcess_Triangulate) jest zawsze trójkątem. Ścianka zawiera indeksy, które definiują wierzchołki, które musimy narysować, w jakiej kolejności, dla każdego prymitywu, więc jeśli wykonujemy iteracje po wszystkich ściankach, to przechowujemy wszystkie indeksy ścianek w wektorze indices:

    for(unsigned int i = 0; i < mesh->mNumFaces; i++)
    {
        aiFace face = mesh->mFaces[i];
        for(unsigned int j = 0; j < face.mNumIndices; j++)
            indices.push_back(face.mIndices[j]);
    }  

Po zakończeniu pętli zewnętrznej, mamy teraz pełny zestaw wierzchołków i danych indeksowych do narysowania siatki za pomocą glDrawElements. Jednak, aby dodać trochę szczegółów do siatki, chcemy przetworzyć również materiały siatki.

Materiały

Podobnie jak w przypadku węzłów, siatka zawiera tylko indeks do obiektu materiału i aby pobrać rzeczywisty materiał siatki, musimy zaindeksować tablicę mMaterials. Indeks materiału siatki jest ustawiony we właściwości mMaterialIndex, którą możemy również wykorzystać, aby sprawdzić, czy siatka rzeczywiście zawiera materiał, czy nie:

    if(mesh->mMaterialIndex >= 0)
    {
        aiMaterial *material = scene->mMaterials[mesh->mMaterialIndex];
        vector<Texture> diffuseMaps = loadMaterialTextures(material, 
                                            aiTextureType_DIFFUSE, "texture_diffuse");
        textures.insert(textures.end(), diffuseMaps.begin(), diffuseMaps.end());
        vector<Texture> specularMaps = loadMaterialTextures(material, 
                                            aiTextureType_SPECULAR, "texture_specular");
        textures.insert(textures.end(), specularMaps.begin(), specularMaps.end());
    }  

Najpierw pobieramy obiekt aiMaterial z tablicy mMaterials. Następnie chcemy załadować tekstury diffuse i/lub specular. Obiekt materiału wewnętrznie przechowuje tablicę lokalizacji tekstury dla każdego typu. Typy tekstur są poprzedzone prefiksem aiTextureType_. Używamy funkcji pomocniczej o nazwie loadMaterialTextures w celu pobrania tekstur z materiału. Funkcja zwraca wektor obiektów Texture, które następnie przechowujemy w wektorze textures.

Funkcja loadMaterialTextures iteruje po wszystkich lokalizacjami tekstur danego typu, pobiera ścieżkę pliku tekstury, a następnie ładuje i generuje obiekt tekstury oraz przechowuje informacje w strukturze Vertex. Wygląda to tak:

    vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
    {
        vector<Texture> textures;
        for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
        {
            aiString str;
            mat->GetTexture(type, i, &str);
            Texture texture;
            texture.id = TextureFromFile(str.C_Str(), directory);
            texture.type = typeName;
            texture.path = str;
            textures.push_back(texture);
        }
        return textures;
    }  

Najpierw sprawdzamy ilość tekstur przechowywanych w materiale za pomocą funkcji GetTextureCount, która oczekuje jednego z podanych typów tekstur. Następnie pobieramy każdą ze ścieżek plików tekstury za pomocą funkcji GetTexture, która zapisuje wynik w aiString. Następnie używamy funkcji pomocniczej o nazwie TextureFromFile, która ładuje dla nas teksturę i zwraca identyfikator tekstury. Możesz sprawdzić kompletny kod na końcu tego samouczka, jeśli nie masz pewności, jak taka funkcja jest napisana.

Zauważ, że zakładamy, że ścieżki plików tekstur w plikach modeli są względem lokalizacji rzeczywistego pliku modelu, np. w tym samym katalogu. Możemy następnie po prostu połączyć ciąg znaków ścieżki tekstury i pobranego wcześniej łańcucha znaków katalogów (w funkcji loadModel), aby uzyskać pełną ścieżkę pliku tekstury (dlatego GetTexture wymaga również nazwy katalogu).

Niektóre modele znalezione w Internecie wciąż używają bezwzględnych ścieżek dla ich lokalizacji plików tekstur, które nie będą działać na każdym komputerze. W takim przypadku prawdopodobnie musisz ręcznie edytować plik, aby użyć lokalnych ścieżek dla tekstur (jeśli jest to możliwe).

To wystarczy, aby zaimportować model za pomocą Assimp.

Duża optymalizacja

Jeszcze nie skończyliśmy, ponieważ wciąż potrzebujemy dużej (ale nie całkowicie niezbędnej) optymalizacji, którą teraz wprowadzimy. Większość scen ponownie wykorzystuje te same tekstury na kilku siatkach; pomyśl jeszcze raz o domu, który ma granitową fakturę na ścianach. Tę fakturę można również zastosować do podłogi, sufitu, schodów, może do stołu, a może nawet do małej studni w pobliżu. Ładowanie tekstur nie jest tanią operacją, a w naszej obecnej implementacji nowa tekstura jest ładowana i generowana dla każdej siatki, mimo że dokładnie ta sama tekstura została już załadowana wcześniej. To szybko staje się wąskim gardłem w implementacji ładowania modelu.

Tak więc, dodamy jedno małe ulepszenie do kodu ładowania modelu, przechowując wszystkie załadowane tekstury i wszędzie tam, gdzie chcemy załadować teksturę, najpierw sprawdzimy, czy nie została ona już załadowana. Jeśli tak, pobieramy tę teksturę i pomijamy całą procedurę ładowania, oszczędzając dużo mocy obliczeniowej. Aby móc faktycznie porównywać tekstury, musimy również przechowywać ich ścieżkę:

    struct Texture {
        unsigned int id;
        string type;
        string path;  // przechowujemy ścieżkę tekstury w celu porównania z innymi teksturami
    };

Następnie przechowujemy wszystkie załadowane tekstury w innym wektorze, zadeklarowanym u góry pliku klasy Model jako zmienna prywatna:

    vector<Texture> textures_loaded; 

Następnie w funkcji loadMaterialTextures chcemy porównać ścieżkę tekstury ze wszystkimi ścieżkami tekstur w wektorze textures_loaded, aby sprawdzić, czy bieżąca tekstura nie została już przypadkiem załadowana. Jeśli tak, pomijamy część ładującą/generującą teksturę i po prostu używamy znalezionego obiektu tekstury. Funkcja (zaktualizowana) jest pokazana poniżej:

    vector<Texture> loadMaterialTextures(aiMaterial *mat, aiTextureType type, string typeName)
    {
        vector<Texture> textures;
        for(unsigned int i = 0; i < mat->GetTextureCount(type); i++)
        {
            aiString str;
            mat->GetTexture(type, i, &str);
            bool skip = false;
            for(unsigned int j = 0; j < textures_loaded.size(); j++)
            {
                if(std::strcmp(textures_loaded[j].path.data(), str.C_Str()) == 0)
                {
                    textures.push_back(textures_loaded[j]);
                    skip = true; 
                    break;
                }
            }
            if(!skip)
            {   // jeśli tekstura nie została jeszcze załadowana, załaduj ją
                Texture texture;
                texture.id = TextureFromFile(str.C_Str(), directory);
                texture.type = typeName;
                texture.path = str.C_Str();
                textures.push_back(texture);
                textures_loaded.push_back(texture); // dodaj do załadowanych wektora textures_loaded
            }
        }
        return textures;
    }  

Teraz nie tylko mamy wszechstronny system ładowania modeli, ale jest on również zoptymalizowany, który ładuje obiekty dość szybko.

Niektóre wersje biblioteki Assimp mają tendencję do powolnego ładowania modeli podczas korzystania z wersji Debug i/lub trybu debugowania w IDE, więc upewnij się, że przetestowałeś to także w wersji Release, jeśli masz problemy z długim czasem ładowania.

Możesz znaleźć pełny kod źródłowy zoptymalizowanej klasy Model tutaj.

Koniec z pojemnikami!

Warto przetestować naszą implementację, importując model stworzony przez prawdziwych artystów. Tym razem załadujemy oryginalną zbroję nanosuit używanego przez grę Crysis. Model jest eksportowany jako plik .obj razem z plikiem .mtl, który zawiera tekstury diffuse, specular i mapę normalnych (w kolejnych tutorialach będzie więcej o mapach normalnych). Możesz pobrać (nieco zmodyfikowany) model tutaj, który jest łatwiejszy do zaimportowania. Zwróć uwagę, że wszystkie tekstury i pliki modeli powinny znajdować się w tym samym katalogu, w którym będą ładowane tekstury.

Wersja, którą można pobrać z tej witryny, jest zmodyfikowaną wersją oryginalnego pliku, w której każda ścieżka pliku tekstury została zmieniona na lokalną ścieżkę względną (zamiast ścieżki bezwzględnej).

Teraz w kodzie stwórz obiekt Model i przekaż ścieżkę do pliku modelu. Klasa Model powinna następnie automatycznie załadować i (jeśli nie było żadnych błędów) narysować obiekt za pomocą funkcji Draw i to wszystko. Koniec z alokacją buforów, wskaźnikami atrybutów i komendami renderowania, po prostu jedna linijka kodu. Następnie, jeśli utworzysz prosty zestaw shaderów, gdzie Fragment Shader tylko używa mapę rozproszoną, wynik będzie wyglądał mniej więcej tak:

Możesz znaleźć kompletny kod źródłowy tutaj.

Moglibyśmy również być bardziej kreatywni i wprowadzić dwa światła punktowe do równania renderingu, o czym dowiedzieliśmy się z samouczków dotyczących oświetlenia, a wraz z mapami specular można uzyskać niesamowite wyniki:

Muszę przyznać, że jest to może trochę bardziej wymyślne niż pojemniki, których używaliśmy do tej pory. Za pomocą programu Assimp można załadować wiele modeli znalezionych w Internecie. Istnieje sporo witryn, które oferują bezpłatne modele 3D do pobrania w kilku formatach plików. Zwróć uwagę, że niektóre modele nadal nie ładują się poprawnie, mają ścieżki tekstur, które nie będą działały lub mogą być po prostu wyeksportowane w formacie, którego nawet nie można odczytać.