Рубрики
3D мир Программирование

OpenGL 5: загрузка .OBJ моделей

Данная заметка посвящена теории об устройстве OBJ файлов и разработке функции для их загрузки

Введение

В рамках третьей заметки по OpenGL был создан класс для работы с моделями, но не был реализован интерфейс для их загрузки с жесткого диска.

У сложных проектов обычно реализована своя система хранения игровых компонентов, но для простоты работы в заметках каждая модель будет храниться в .OBJ формате.

Содержание заметки:

Формат моделей .OBJ

OBJ — простой и открытый формат файлов описания геометрии, разработанный Wavefront Technologies.

Строки, начинающиеся с символа #, считаются комментариями:

# Закомментированная строка

Каждая строка начинается с определенного символа, определяющего предназначение последующих данных. Файл имеет следующую структуру:

  • v X Y Z W — координаты вершины (w является необязательной и по умолчанию равна 1.0);
  • vt U V W — текстурные координаты (w является необязательной и по умолчанию равна 0.0);
  • vn X Y Z — нормали к вершинам;
  • vp U V W — параметры вершин в пространстве;
  • s off — сглаживание полигонов;
  • f v1/vt1/vn1 v2/vt2/vn2 v3/vt3/vn3 — определение поверхностей (совокупность трех или более вершин);
  • g Group1 — имя группы;
  • o Object1 — имя объекта.

Для задания информации об используемых материалах модели используются файлы библиотеки материалов. Для подключения такой библиотеки используется директива:

mtllib filename.mtl

Файл библиотеки имеет следующий формат:

  • newmtl material1_name — заголовок нового материала, задающий его имя;
  • Ka R G B — цвет окружающего освещения;
  • Kd R G B — диффузный цвет;
  • Ks R G B — цвет зеркального отражения;
  • Ns K — коэффициент зеркального отражения [0; 1000];
  • d A — прозрачность;
  • Tr A’ — другое обозначение для прозрачности (Tr = 1 — d);
  • Ni K — коэффициент оптической плотности (показатель преломления);
  • map_Ka filename — карта текстур окружения (ambient);
  • map_Kd filename — карта диффузной текстуры;
  • map_Ks filename — карта зеркальной текстуры;
  • map_Ns filename — компонент зеркального блика;
  • map_d filename — карта прозрачности текстуры;
  • map_bump filename — карта выпуклостей на модели;
  • bump filename — синоним для map_bump.

Данный формат достаточен для хранения сложных не анимированных сцен, использующих сложные материалы.

Библиотека tinyobjloader

Для загрузки моделей для хранения которых используется формат, рассмотренный выше будет использована библиотека tinyobjloader, так как она поставляется одним заголовочным файлом.

Скачать библиотеку можно из репозитория github.

Примечание: в заметке используется версия 1.0.6.

Важное замечание: используемая в заметке версия 1.0.6 требует компилятор стандарта не ниже C++03. Можно использовать tinyobjloader-c, если проект не использует код C++.

Добавим скачанный заголовочный файл в директорию ../dependencies/tinyobjloader и укажем её в настройках среды и компилятора.

Необходимо дополнить переменную CFLAGS, если вы используете Makefile:

CFLAGS += -I../dependencies/tinyobjloader

Либо дополнить

...
            "args": [
                "-I${workspaceFolder}/../dependencies/tinyobjloader",
...

Файл .vscode/c_cpp_properties.json после 10 строки (необходимо добавить запятую после stb):

...
                "${workspaceFolder}/../dependencies/stb",
                "${workspaceFolder}/../dependencies/tinyobjloader"
...

В одном из .cpp файлов (далее в заметке) необходимо перед подключением заголовочного файла задать макроконстанту:

#define TINYOBJLOADER_IMPLEMENTATION
#include "tiny_obj_loader.h"

Для загрузки модели с диска потребуется функция tinyobj::LoadObj, которая возвращает значения ПРАВДА/ЛОЖЬ в результате загрузки на основании следующих аргументов:

  • attrib — адрес объекта атрибутов вершины (контейнер, содержащий координаты вершины, её нормали и текстурные координаты);
  • shapes — адрес вектора (std::vector), предназначенного для хранения объектов (контейнер с отдельными объектами и их поверхностями);
  • materials — адрес вектора (std::vector), предназначенного для хранения материалов;
  • err — адрес строки (std::string) для записи сообщения об ошибке;
  • filename — адрес си-строки, содержащей адрес файла для загрузки;
  • mtl_basedir (по умолчанию NULL) — опционально указывает где искать файлы библиотек материалов;
  • triangulate (по умолчанию true) — триангуляция полигонов.

Примечание: под триангуляцией полигонов понимается декомпозиция сложного многоугольника на множество треугольников.

Важное замечание: в более старых версиях в аргументах присутствовал адрес warn для предупреждений и большинство примеров используют старый формат.

Структура attrib_t содержит в себе три std::vector с координатами вершин (verticies), нормалями (normals) и текстурными координатами (textcoords). Каждый элемент является числом с плавающей точкой, так что в процессе загрузки необходимо будет скомпоновать их в соответствующие массивы для соответствия индексам.

Структура shape_t хранит имя объекта (name) и вложенную структуру mesh_t, которая в свою очередь содержит информацию о индексах, количестве вершин в фрагменте и т.п.

Важное замечание: загрузчик имеет три различных индекса — для вершин, нормалей и текстурных координат, что образует необходимость переложить вершины в отдельные массивы.

Структура material_t хранит данные о конкретном материале. Индекс из shape::mesh::material_ids позволяет однозначно сопоставить материал к вершине.

Расширение класса модели

Для работы с нормалями добавим к классу модели публичный метод Model::load_normals и приватное поле Model::normals_vbo:

class Model
{
    public:

...
        void load_normals(glm::vec3* normals, GLuint count); // Загрузка нормалей в буфер

...
    private:

...
        BO normals_vbo, texCoords_vbo; // буферы с нормалями и текстурными координатами

...
};

Необходимо изменить конструкторы:

// Конструктор по умолчанию
Model::Model(Node *parent) : Node(parent), verteces_count(0), first_index_byteOffset(0), indices_count(0), 
vertex_vbo(VERTEX), index_vbo(ELEMENT), normals_vbo(VERTEX), texCoords_vbo(VERTEX)
{

}

// Конструктор копирования
Model::Model(const Model& copy) : Node(copy),
vao(copy.vao), 
verteces_count(copy.verteces_count), first_index_byteOffset(copy.first_index_byteOffset), indices_count(copy.indices_count), 
vertex_vbo(copy.vertex_vbo), index_vbo(copy.index_vbo), normals_vbo(copy.normals_vbo), texCoords_vbo(copy.texCoords_vbo),
texture_diffuse(copy.texture_diffuse)
{

}

Реализация метода Model::load_normals:

// Функция для конфигурации атрибута вершинного буфера
void normals_attrib_config()
{
    // Включаем необходимый атрибут у выбранного VAO
    // Устанавливаем связь между VAO и привязанным VBO
    glVertexAttribPointer(  2         // индекс атрибута, должен совпадать с Layout шейдера
                          , 3         // количество компонент одного элемента
                          , GL_FLOAT  // тип
                          , GL_FALSE  // необходимость нормировать значения
                          , 0         // шаг
                          , (void *)0 // отступ с начала массива
                         );
    // Включаем необходимый атрибут у выбранного VAO
    glEnableVertexAttribArray(2);
} 

// Загрузка нормалей в буфер
void Model::load_normals(glm::vec3* normals, GLuint count) 
{
    // Подключаем VAO
    vao.use();

    normals_vbo.use();

    // Загрузка вершин в память буфера
    normals_vbo.load(normals, sizeof(glm::vec3)*count);
    normals_attrib_config();
}

Теперь класс модели может хранить в себе данные о нормалях вершин, которые будут использованы в заметке об освещении.

Добавим в вершинный шейдер нормали вершины shaders/shader.vert:

layout(location = 0) in vec3 pos; 
layout(location = 1) in vec2 inTexCoord;
layout(location = 2) in vec3 normals; 
...

Текущая версия доступна на теге v0.1 репозитория 05.

Функция-загрузчик

Функция-загрузчик, представленная в данной заметке, создает объекты класса Model в рамках сцены класса Scene, которые разделяются на основании используемых материалов.

Важное примечание: на данный момент загрузчик берет только диффузные текстуры (базовая текстура задающая цвет фрагмента) игнорируя прочие параметры материалов, которые будут использоваться в последующих заметках.

На рисунке 1 представлены компоновки tinyOBJloader и необходимая для работы в OpenGL. Необходимо сложить уникальные комбинации координат вершин, нормалей и текстурных координат в соответствующие векторы.

Рисунок 1 — Компоновки данных (изображение можно открыть в новой вкладке для более детального рассмотрения)

Примечание: можно хранить все данные по конкретной вершине в одной структуре и складывать в один VBO с последующей расстановкой атрибутов, но автор предпочитает такой способ для наглядности данных.

В файле src/Scene.cpp определим функцию:

#define TINYOBJLOADER_IMPLEMENTATION
#include "tiny_obj_loader.h"

Scene loadOBJtoScene(const char* filename, const char* mtl_directory, const char* texture_directory)
{
...

Данная функция принимает в качестве аргументов имя OBJ файла и два адреса, уточняющие где искать библиотеки материалов и текстуры. В результате выполнения в точку вызова возвращается объект сцены

Создадим объект модели и необходимые для вызова функции из библиотеки переменные, после чего можно вызвать функцию:

Scene loadOBJtoScene(const char* filename, const char* mtl_directory, const char* texture_directory)
{
    Scene result;
    Model model;
    // Все модели образованные на основании этой модели будут иметь общего родителя
    model.setParent(result.root); 

    tinyobj::attrib_t attrib;
    std::vector<tinyobj::shape_t> shapes;
    std::vector<tinyobj::material_t> materials;

    std::string err;

    // Если в процессе загрузки возникли ошибки - выдадим исключение
    if (!tinyobj::LoadObj(&attrib, &shapes, &materials, &err, filename, mtl_directory))
        throw std::runtime_error(err);

В начале функции создадим хранилища для перегруппировки данных:

    std::vector<GLuint>    indices;  // индексы модели
    std::vector<glm::vec3> verteces; // вершины
    std::vector<glm::vec3> normals; // нормали
    std::vector<glm::vec2> texCords; // текстурные координаты
    size_t hash; // Для уникальных вершин
    std::map <int, int> uniqueVerteces; // словарь для уникальных вершин: ключ - хеш, значение - индекс вершины

Уникальность комбинации v/n/t обеспечивается подсчетом хеша для каждого индекса из tinyOBJloader.

А так же создадим вспомогательные переменные и контейнеры для разделения объектов:

    int last_material_index = 0; // индекс последнего материала (для группировки моделей)
    int count = 0, offset; // для индексов начала и конца в индексном буфере
    std::vector<int> materials_range; // хранилище индексов
    std::vector<int> materials_ids; // индексы материалов

В рамках следующего алгоритма объекты дополнительно делятся по используемым материалам для последующей привязки текстур.

Положим нулевой индекс в материалы и запустим цикл по объектам:

    materials_range.push_back(count); // Закидываем начало отрезка в индексном буфере
    // Цикл по считанным моделям
    for (const auto& shape : shapes) 
    {
        offset = count;  // Переменная для 
        last_material_index = shape.mesh.material_ids[(count - offset)/3]; // Запоминаем индекс материала

Отступ используется для разделения по объектам из оригинального файла.

Далее следует цикл по индексам объектов и подсчет хеша для последующей проверки уникальности:

        // Цикл по индексам модели
        for (const auto& index : shape.mesh.indices) 
        {
            hash = 0;
            hash_combine( hash
                        , attrib.vertices[3 * index.vertex_index + 0], attrib.vertices[3 * index.vertex_index + 1], attrib.vertices[3 * index.vertex_index + 2]
                        , attrib.normals[3 * index.normal_index + 0], attrib.normals[3 * index.normal_index + 1], attrib.normals[3 * index.normal_index + 2]
                        , attrib.texcoords[2 * index.texcoord_index + 0], attrib.texcoords[2 * index.texcoord_index + 1]);
    

Для подсчета хеша потребуется вспомогательная функция с переменным числом аргументов:

#include <functional>

inline void hash_combine(std::size_t& seed) { }

template <typename T, typename... Rest>
inline void hash_combine(std::size_t& seed, const T& v, Rest... rest) {
    std::hash<T> hasher;
    seed ^= hasher(v) + 0x9e3779b9 + (seed<<6) + (seed>>2);
    hash_combine(seed, rest...);
}

Если такой хеш не известен, то необходимо добавить данные в соответствующие массивы. После проверки необходимо запомнить индекс вершины по хешу:

            if (!uniqueVerteces.count(hash))
            {
                uniqueVerteces[hash] = verteces.size();
                
                // группируем вершины в массив на основании индексов
                verteces.push_back({  attrib.vertices[3 * index.vertex_index + 0]
                                                , attrib.vertices[3 * index.vertex_index + 1]
                                                , attrib.vertices[3 * index.vertex_index + 2]
                                            });
                // группируем нормали в массив на основании индексов
                normals.push_back({  attrib.normals[3 * index.normal_index + 0]
                                                , attrib.normals[3 * index.normal_index + 1]
                                                , attrib.normals[3 * index.normal_index + 2]
                                            });
                // группируем текстурные координаты в массив на основании индексов
                texCords.push_back({  attrib.texcoords[2 * index.texcoord_index + 0]
                                                , 1-attrib.texcoords[2 * index.texcoord_index + 1]
                                            });
            }
            // Сохраняем индекс в массив
            indices.push_back(uniqueVerteces[hash]);

Важное замечание: текстурные координаты используются в перевернутом виде по оси Y (выделенно жирным в коде)

После требуется проверить необходимость разделения модели по материалам:

            // Если индекс последнего материала изменился, то необходимо сохранить его
            if (last_material_index != shape.mesh.material_ids[(count - offset)/3])
            {
                materials_range.push_back(count); // как конец отрезка
                materials_ids.push_back(last_material_index); // как используемый материал
                last_material_index = shape.mesh.material_ids[(count - offset)/3];
            }
            count++; 
        } // for (const auto& index : shape.mesh.indices) 

После цикла по индексам необходимо проверить что последний материал был учтен для рассматриваемого объекта (shape):

        // Если последний материал не загружен - загружаем его
        if (materials_range[materials_range.size()-1] != count-1)
        {
            materials_range.push_back(count); // последний конец отрезка
            materials_ids.push_back(last_material_index); // последний используемый материал
        }
    } // for (const auto& shape : shapes) 

Теперь можно загрузить данные в буферы созданной ранее модели:

    // Загрузка в буферы
    model.load_verteces (&verteces[0], verteces.size());
    model.load_normals  (&normals[0],  normals.size());
    model.load_texCoords(&texCords[0], texCords.size());
    // Загрузка индексного буфера
    model.load_indices  (&indices[0],  indices.size());

На основании данной модели создаются копии с заданным диапазоном индексов и загружаются текстуры для них:

    // Создаем копии модели, которые будут рендериться в заданном диапазоне
    // И присваиваем текстуры копиям на основании материала
    for (int i = 0; i < materials_range.size()-1; i++)
    {
        result.models.push_back(model); // Создание копии с общим VAO
        auto s = --result.models.end();
        s->set_index_range(materials_range[i]*sizeof(GLuint), materials_range[i+1]-materials_range[i]);

        Texture diffuse(TEX_DIFFUSE, texture_directory + materials[materials_ids[i]].diffuse_texname);
        s->set_texture(diffuse);
    }    

    return result;
}

Данную функцию требуется объявить в файле include/Scene.h с параметрами по умолчанию:

#define DEFAULT_MTL_DIR "./"
class Scene loadOBJtoScene(const char* filename, const char* mtl_directory = DEFAULT_MTL_DIR, const char* texture_directory = DEFAULT_MTL_DIR);

Для тестов будет использоваться модель с двумя кубами под названием cubes.obj, библиотека материалов cubes.mtl, а так же текстуры box_wood.png и box_green.png. Данный набор доступен в репозитории с ресурсами.

Вызовем новую функцию в src/main.cpp:

    // Загрузка сцены из obj файла
    Scene scene = loadOBJtoScene("../resources/models/cubes.obj", "../resources/models/", "../resources/textures/"); 

Для работы с классом Scene нужно заменить подключаемые файлы:

#include "Camera.h"
#include "Model.h"
#include "Texture.h"
#include "Scene.h"

А так же вызовем рендер сцены в цикле while:

        // Тут производится рендер
        scene.render(model_uniform);

Результат работы программы представлен на рисунке 2.

Рисунок 2 — Коробки, загруженные из файла cubes.obj

Текущая версия доступна на теге v0.2 репозитория 05.

Буфер глубины

Рисунок 2 демонстрирует неправильный порядок отрисовки вершин. Из увиденного можно сделать вывод, что порядок рисования стенок следующий: левая, задняя, правая, передняя, нижняя или верхняя. Если повертеть загруженный объект, то можно получить корректную картинку, но такие манипуляции не допустимы для трехмерного приложения с активной камерой и подвижными объектами. Для решения этой проблемы используется проверка «по буферу глубины»: в процессе рендера вершина проверяется на приближенность к камере и рисуется только тот фрагмент, который ближе ранее нарисованных.

На рисунке 3 изображен буфер глубины (Z-буфер) при отключенной проверке. По данному рисунку можно сделать вывод, что буфер глубины перезаписывается вершинами в порядке их рисования.

Рисунок 3 — Буфер глубины без проверки

По рисунку 3 можно сделать вывод, что буфер глубины имеет значения в диапазоне от [0;1], где значение уменьшается по мере приближения к камере.

Для включения проверки по буферу глубины необходимо вызвать функцию glEnable с параметром GL_DEPTH_TEST после вызова загрузчика glad в функции src/main.cpp, в противном случае программа будет выполнять аварийное завершение работы из-за вызова пустого указателя на функцию. Пример вызова:

...
    // Загрузка функций OpenGL с помощью GLAD
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
        std::cout << "GLAD load GL error\n";
        glfwTerminate(); // Завершение работы с GLFW3 в случае ошибки
        return -1;
    }
    
    // Включаем проверку по буферу глубины
    glEnable(GL_DEPTH_TEST);
...

Для корректной работы теста глубины необходимо очищать буфер глубины вначале рисования кадра:

...
    // Пока не произойдет событие запроса закрытия окна
    while(!glfwWindowShouldClose(window))
    {
        // Очистка буфера цвета
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        // Тут производится рендер
...

Результат работы теста глубины на примере Z-буфера изображен на рисунке 4.

Рисунок 4 — Результат работы теста глубины на примере Z-буфера

Результат корректного рендера коробок представлен на рисунке 5.

Рисунок 5 — Правильный результат рендера

Текущая версия доступна на теге v0.3 репозитория 05.

Заключение

В данной заметке была рассмотрена теория об устройстве .OBJ файлов с геометрией моделей, написана функция-загрузчик с помощью библиотеки tinyOBJloader, а так же рассмотрена теория по тестам Z-буфера.

Проект доступен в публичном репозитории: 05
Библиотеки: dependencies
Ресурсы: resources

2 ответа к “OpenGL 5: загрузка .OBJ моделей”

Отличная заметка, помогла разобраться с форматом файлов obj и их использованием в OpenGL проектах, но я заметил, что программа падает, если загружать obj модели, состоящие из нескольких объектов (такие модели делает например 3д редактор BlockBench) все ваши модели содержали только один объект, но там могли использоваться несколько библиотек материалов, текстур и так далее. Хотел бы узнать, будет ли в будущих заметках (после 17-й) расширение загрузчика 3д моделей? Например поддержка моделей с костями, например для создания анимации сцены?

Спасибо за комментарий. Ошибка была в том, что я забыл учесть offset для last_material_index. Дополнительно их нужно поменять местами, а так же условный оператор «Если последний материал не загружен — загружаем его» требуется поднять в конец цикла по объектам (shapes).
Репозитории я обновил. Будьте внимательны — при вытягивании изменений HEAD может быть отделен.
Модели с костями и анимации планируются в ближайшие пару месяцев.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.