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

Za pomocą biblioteki Assimp możemy załadować wiele różnych modeli do aplikacji, ale po załadowaniu, wszystkie są przechowywane w strukturach danych Assimp. Ostatecznie chcemy przekształcić te dane w format zrozumiały dla OpenGL, abyśmy mogli renderować obiekty. Z poprzedniego samouczka dowiedzieliśmy się, że siatka reprezentuje pojedynczy byt do rysowania, zacznijmy od zdefiniowania własnej klasy Mesh.

Przyjrzyjmy się nieco temu, czego dotychczas się nauczyliśmy, aby zastanowić się, co minimalnie powinna mieć minimalna klasa Mesh. Siatka powinna przynajmniej potrzebować zestawu wierzchołków, gdzie każdy wierzchołek zawiera wektor położenia, wektor normalny i wektor współrzędnych tekstury. Siatka powinna również zawierać indeksy do rysowania indeksowego i dane materiałów w postaci tekstur (mapy diffuse/specular).

Teraz, gdy ustawiliśmy minimalne wymagania dla klasy Mesh, możemy zdefiniować strukturę wierzchołka:

    struct Vertex {
        glm::vec3 Position;
        glm::vec3 Normal;
        glm::vec2 TexCoords;
    };

Przechowujemy każdy z wymaganych wektorów w strukturze o nazwie Vertex, której możemy użyć do zaindeksowania każdego z atrybutów wierzchołków. Oprócz struktury Vertex chcemy również zorganizować dane tekstur w strukturze Texture:

    struct Texture {
        unsigned int id;
        string type;
    };  

Przechowujemy identyfikator tekstury i jej typ, np. tekstura diffuse lub tekstura specular.

Znając reprezentację wierzchołka i tekstury możemy zacząć definiować strukturę klasy Mesh:

    class Mesh {
        public:
            /*  dane klasy Mesh  */
            vector<Vertex> vertices;
            vector<unsigned int> indices;
            vector<Texture> textures;
            /*  Funkcje  */
            Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures);
            void Draw(Shader shader);
        private:
            /*  Dane renderowania  */
            unsigned int VAO, VBO, EBO;
            /*  Funkcje    */
            void setupMesh();
    };  

Jak widać, klasa nie jest zbyt skomplikowana. W konstruktorze podajemy wszystkie niezbędnych dane, inicjujemy bufory w funkcji setupMesh i na koniec rysujemy siatkę za pomocą funkcji Draw. Zauważ, że jako parametr funkcji Draw przekazujemy obiekt shader’a; przekazując shader, przed rysowaniem możemy ustawić kilka uniformów (np. połączyć samplery z jednostkami tekstur).

Zawartość funkcji konstruktora jest dość prosta. Po prostu ustawiamy publiczne zmienne klasy z odpowiednimi zmiennymi argumentów konstruktora. W konstruktorze wywołujemy również funkcję setupMesh:

    Mesh(vector<Vertex> vertices, vector<unsigned int> indices, vector<Texture> textures)
    {
        this->vertices = vertices;
        this->indices = indices;
        this->textures = textures;

        setupMesh();
    }

Nic specjalnego tutaj się nie dzieje. Zajrzyjmy teraz do funkcji setupMesh.

Inicjalizacja

Dzięki konstruktorowi mamy teraz duże tablice zawierające dane siatki, których możemy użyć do renderowania. Musimy jednak ustawić odpowiednie bufory i określić układ atrybutów za pośrednictwem wskaźników atrybutów wierzchołków. Do tej pory nie powinieneś mieć problemów z tymi pojęciami, ale tym razem trochę to skomplikowaliśmy, wprowadzając strukturę Vertex:

    void setupMesh()
    {
        glGenVertexArrays(1, &VAO);
        glGenBuffers(1, &VBO);
        glGenBuffers(1, &EBO);

        glBindVertexArray(VAO);
        glBindBuffer(GL_ARRAY_BUFFER, VBO);

        glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), &vertices[0], GL_STATIC_DRAW);  

        glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
        glBufferData(GL_ELEMENT_ARRAY_BUFFER, indices.size() * sizeof(unsigned int), 
                     &indices[0], GL_STATIC_DRAW);

        // pozycje wierzchołków
        glEnableVertexAttribArray(0);	
        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)0);
        // wektory normalne wierzchołków
        glEnableVertexAttribArray(1);	
        glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));
        // współrzedne tekstury wierzchołków
        glEnableVertexAttribArray(2);	
        glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, TexCoords));

        glBindVertexArray(0);
    }  

Kod nie różni się zbytnio od tego, czego mogliśmy się spodziewać, ale użyto kilku małych sztuczek przy pomocy struktury Vertex.

Struktury mają świetną właściwość w C++, że ich układ w pamięci jest sekwencyjny. Gdybyśmy mieli reprezentować strukturę jako tablicę danych, zawierałaby ona tylko zmienne struktury w porządku sekwencyjnym, co bezpośrednio przekłada się na tablicę float (w rzeczywistości bajtów), którą chcemy przekazać buforowi wierzchołków. Na przykład, jeśli mamy wypełnioną strukturę Vertex, jej układ pamięci będzie równy:

    Vertex vertex;
    vertex.Position  = glm::vec3(0.2f, 0.4f, 0.6f);
    vertex.Normal    = glm::vec3(0.0f, 1.0f, 0.0f);
    vertex.TexCoords = glm::vec2(1.0f, 0.0f);
    // = [0.2f, 0.4f, 0.6f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f];

Dzięki tej użytecznej właściwości możemy bezpośrednio przekazać wskaźnik do dużej listy struktur Vertex jako danych bufora i doskonale przekłada się na to, czego funkcja glBufferData oczekuje jako argumentu:

    glBufferData(GL_ARRAY_BUFFER, vertices.size() * sizeof(Vertex), vertices[0], GL_STATIC_DRAW);    

Oczywiście operator sizeof może być również użyty na strukturze dla uzyskania odpowiedniego rozmiaru w bajtach. Powinno to być 32 bajtów (8 floatów po * 4 bajty każdy).

Innym świetnym zastosowaniem struktur jest dyrektywa preprocesora o nazwie offsetof(s, m), która jako pierwszy argument przyjmuje strukturę, a jako drugi argument nazwę zmiennej tej struktury. Makro zwraca offset (przesunięcie) w bajtach tej zmiennej względem początku struktury. Jest to idealne rozwiązanie do definiowania parametru offsetu funkcji glVertexAttribPointer:

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), (void*)offsetof(Vertex, Normal));  

Przesunięcie jest teraz zdefiniowane za pomocą makra offsetof, które w tym przypadku ustawia offset w bajtach dla wektora normalnego, który równy jest offsetowi w bajtach, który wynosi 3 float’y, a więc 12 bajtów. Zauważ, że ustawiliśmy również parametr kroku (stride) równy rozmiarowi struktury Vertex.

Używanie takiej struktury nie tylko zapewnia bardziej czytelny kod, ale także pozwala na łatwe rozszerzanie struktury. Jeśli chcemy mieć kolejny atrybut wierzchołków, możemy po prostu dodać go do struktury i ze względu na jej elastyczny charakter, kod renderujący nie zostanie popsuty.

Renderowanie

Ostatnią funkcją, którą musimy zdefiniować dla klasy Mesh, jest jej funkcja Draw. Przed faktycznym renderowaniem siatki, musimy najpierw powiązać odpowiednie tekstury z samplerami przed wywołaniem funkcji glDrawElements. Jest to jednak trochę trudne, ponieważ nie wiemy, ile (jeśli w ogóle) tekstur ma siatka i jaki może mieć typ. Jak więc ustawić jednostki tekstur i samplery w shader’ach?

Aby rozwiązać ten problem, przyjmiemy pewną konwencję nazewnictwa: każda tekstura diffuse ma nazwę texture_diffuseN, a każda tekstura specular powinna mieć nazwę texture_specularN, gdzie N jest dowolną liczbą od 1 do maksymalnej liczby dozwolonych samplerów tekstur. Powiedzmy, że mamy 3 tekstury diffuse i 2 tekstury specular dla konkretnej siatki, wtedy ich samplery tekstury powinny zostać nazwane tak:

    uniform sampler2D texture_diffuse1;
    uniform sampler2D texture_diffuse2;
    uniform sampler2D texture_diffuse3;
    uniform sampler2D texture_specular1;
    uniform sampler2D texture_specular2;

Dzięki tej konwencji możemy zdefiniować tyle samplerów tekstur, ile chcemy w shader’ach i jeśli siatka faktycznie zawiera (tyle) tekstur, wiemy, jakie będą ich nazwy. Zgodnie z tą konwencją możemy przetwarzać dowolną ilość tekstur na pojedynczej siatce, a programista może swobodnie korzystać z wielu potrzebnych elementów, po prostu definiując odpowiednie samplery (chociaż zdefiniowanie mniejszej ilości byłoby tylko marnowaniem wywołań powiązania (bind) i uniformów).

Istnieje wiele rozwiązań takich problemów, a jeśli nie podoba ci się to konkretne rozwiązanie, to zależy od ciebie, abyś użył swojej kreatywności i opracował własne rozwiązanie.

Powstały kod renderowania wygląda tak:

    void Draw(Shader shader) 
    {
        unsigned int diffuseNr = 1;
        unsigned int specularNr = 1;
        for(unsigned int i = 0; i < textures.size(); i++)
        {
            glActiveTexture(GL_TEXTURE0 + i); // aktywuj odpowiednią jednostkę teksturowania przed powiązaniem
            // pobierz numer tekstury (N dla diffuse_textureN)
            string number;
            string name = textures[i].type;
            if(name == "texture_diffuse")
                number = std::to_string(diffuseNr++);
            else if(name == "texture_specular")
                number = std::to_string(specularNr++);

            shader.setFloat(("material." + name + number).c_str(), i);
            glBindTexture(GL_TEXTURE_2D, textures[i].id);
        }
        glActiveTexture(GL_TEXTURE0);

        // narysuj siatkę
        glBindVertexArray(VAO);
        glDrawElements(GL_TRIANGLES, indices.size(), GL_UNSIGNED_INT, 0);
        glBindVertexArray(0);
    }  

Najpierw obliczamy komponent N dla typu tekstury i łączymy go z ciągiem znaków typu tekstury, aby uzyskać odpowiednią nazwę uniforma. Następnie lokalizujemy odpowiedni sampler, nadając mu wartość lokalizacji odpowiadającą aktualnie aktywnej jednostce teksturowania i wiążemy teksturę. Z tego też powodu potrzebujemy shader’a w funkcji Draw. Dodaliśmy również słowo "material" do wynikowej nazwy uniforma, ponieważ zwykle przechowujemy tekstury w strukturze materiałów (może się to różnić w zależności od implementacji).

Zauważ, że zwiększamy liczniki diffuse i specular w momencie, gdy przekształcimy je w std::string. W C++ wywołanie inkrementacji: variable++ zwraca zmienną taką jaka jest i następnie inkrementuje zmienną, a ++variable najpierw inkrementuje zmienną, a następnie ją zwraca. W naszym przypadku wartość przekazana do std::string jest oryginalną wartością licznika. Następnie wartość jest zwiększana w kolejnym przebiegu pętli.

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

Klasa Mesh, którą właśnie zdefiniowaliśmy, jest czystą abstrakcją dla wielu tematów, o których mówiliśmy we wcześniejszych tutorialach. W następnym samouczku utworzymy klasę Model, który będzie działał jako kontener dla kilku obiektów Mesh i faktycznie zaimplementuje interfejs ładowania biblioteki Assimp.