Урок №18. Загрузка моделей в OpenGL

  Дмитрий Бушуев  | 

  Обновл. 24 Июл 2020  | 

 1384

 ǀ   6 

Теперь пришло время погрузиться в дебри Assimp и приступить к созданию кода, отвечающего за загрузку модели. Цель данного урока — это создать класс, представляющий всю модель целиком, то есть модель, содержащую несколько mesh-ей (в дальнейшем мы будем использовать перевод «меш») с, возможно, несколькими текстурами. Дом с деревянным балконом, башней и бассейном — всё это может быть загружено в виде единой модели. Мы загрузим модель через Assimp и переведем её в набор Mesh-объектов, которые мы создали в предыдущем уроке.

Структура класса Model

Без лишних слов я представляю вам структуру класса Model:

Закрытый (private) раздел класса Model содержит вектор, состоящий из объектов типа Mesh. Далее, при создании объекта класса, конструктор запрашивает расположение файла модели. Затем он сразу же загружает файл с помощью функции loadModel(), которая вызывается в нём же. Все функции закрытого раздела класса предназначены для обработки процедуры импорта моделей Assimp-ом, они будут рассмотрены чуть позже. Чуть не забыл, также необходимо сохранить путь к папке с файлом модели, чтобы позже воспользоваться им при загрузке текстур.

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

Импортирование 3D-модели в OpenGL


Чтобы импортировать модель и перевести её в нашу собственную структуру данных, нам, прежде всего, необходимо подключить соответствующие заголовочные файлы библиотеки Assimp:

Первая функция, которую мы используем, — это loadModel(), она вызывается непосредственно из конструктора. Внутри loadModel() мы задействуем библиотеку Assimp для загрузки модели в структуру данных Assimp, называемую объектом сцены. Как только мы получим объект сцены, то будем иметь доступ ко всем необходимым данным загруженной модели.

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

Сначала мы объявляем объект importer типа Importer из пространства имен Assimp, а затем вызываем у созданного объекта функцию ReadFile(). В качестве аргументов функция ожидает получить путь к файлу модели и несколько параметров постобработки. Assimp позволяет нам указать несколько параметров, которые заставляют библиотеку выполнять дополнительные вычисления/операции с импортированными данными. Задавая параметр aiProcess_Triangulate, мы сообщаем Assimp, что, если модель не состоит полностью из треугольников, то необходимо сначала преобразовать все примитивные формы модели в треугольники. Параметр aiProcess_FlipUVs переворачивает во время обработки координаты текстуры на оси Y, где это необходимо. Но это не все опции, есть ещё несколько штук:

   aiProcess_GenNormals — создает нормальные векторы для каждой вершины, если модель не содержит нормальных векторов.

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

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

Assimp предоставляет нам большой набор опций постобработки, с полным списком которых вы можете ознакомиться здесь. Загрузка модели через Assimp (как вы можете видеть) удивительно проста. Самая сложная часть работы заключается в том, чтобы, используя возвращаемый объект сцены, преобразовать загруженные данных в массив объектов типа Mesh.

Ниже представлен полный исходный код функции loadModel():

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

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

Как вы помните из строения структуры данных в Assimp, каждый узел содержит набор индексов мешей, указывающих на определенный меш в объекте сцены. Таким образом, сначала нам необходимо получить меш-индексы, затем получить каждый отдельный меш, обработать его, и, под конец, снова выполнить все эти действия для каждого из дочерних узлов. Содержание функции processNode() показано ниже:

Сначала мы проверяем каждый меш-индекс выбранного узла и извлекаем соответствующий меш, индексируя массив mMeshes сцены. Затем полученный меш передается в функцию processMesh(), которая возвращает объект типа Mesh, который теперь мы можем сохранить в переменной meshes типа список/вектор.

После того, как все меши будут обработаны, мы начнём перебирать дочерние элементы выбранного узла и вызывать одну и ту же функцию processNode() для каждого дочернего элемента. Как только мы переберем все дочерние элементы узла, рекурсия прекращается.

Примечание: Внимательный читатель, возможно, заметил, что мы могли бы и не обрабатывать узлы, а просто напрямую пройтись по всем мешам сцены, не делая все эти сложные вещи с индексами. Причина, по которой мы всё это делаем, заключается в том, что идея использования узлов задаёт между мешами отношение типа родитель-потомок. Рекурсивно перебирая эти цепочки, мы можем определить те меши, которые являются родительскими элементами по отношению к другим мешам. Пример использования такой системы — это когда вы хотите преобразовать меш автомобиля и убедиться, что все его дочерние элементы (например, меш двигателя, меш рулевого колеса и меш шин) также преобразуются; такая система легко создается с использованием отношений типа родитель-потомок.

Однако, сейчас мы не используем такую систему, но рекомендуется придерживаться данного подхода всякий раз, когда вы хотите получить дополнительный контроль над своими меш-данными.

Следующий шаг — это обработка Assimp-данных и перевод их в Mesh-класс, описанный в предыдущем уроке.

Из Assimp в Mesh

Перевод объекта aiMesh в наш собственный меш-объект не слишком сложен. Всё, что нам нужно сделать, — это получить доступ к каждому из соответствующих свойств меша и сохранить их в нашем собственном объекте. Общая структура функции processMesh() следующая:

Обработка меша — это процесс, состоящий из 3-х частей:

   извлечение всех вершинных данных;

   извлечение индексов меша;

   извлечение соответствующих данных о материале.

Обработанные данные хранятся в 3-х векторах, из которых, впоследствии, создается объект типа Mesh, и возвращается вызывающему объекту функции.

Извлечение вершинных данных — дело довольно простое: мы определяем структуру Vertex, которую добавляем в массив vertices после каждой итерации цикла. Количество итераций цикла зависит от количества вершин, содержащихся в меше (эту информацию можно получить через mesh -> mNumVertices). В рамках итерации мы заполняем структуру всеми соответствующими данными. Для вершинных координат это делается следующим образом:

Обратите внимание, что мы определяем временную переменную типа vec3 для передачи Assimp-данных. Это необходимо, так как библиотека Assimp поддерживает свои собственные типы данных для вектора, матрицы, строки и т.п., и они не очень хорошо преобразуются в типы данных GLM.

Assimp именует свой массив координат вершин как mVertices. Согласитесь, что это не самое интуитивное название.

Нижеследующий код не должен вызывать у вас удивление:

С текстурными координатами происходит примерно то же самое, но при этом стоит заметить, что Assimp позволяет модели иметь до 8 различных координат текстуры на вершину. Мы не собираемся использовать все 8, нас волнует только первый набор текстурных координат. Также необходимо проверить, действительно ли меш содержит текстурные координаты (их может и не быть вовсе):

Теперь структура vertex полностью заполнена необходимыми атрибутами вершин, и на заключительном этапе итерации мы можем добавить её в конец вектора vertices. Данный процесс будет повторяться для каждой вершины меша.

Индексы


Интерфейс Assimp определяет каждый меш как объект, содержащий массив граней, при этом каждая грань представляет собой отдельный примитив, который, в нашем случае (из-за опции aiProcess_Triangulate), всегда является треугольником. Грань содержит индексы вершин примитива в том порядке, в каком мы должны их отрисовывать. Так что, если мы переберем все грани и сохраним индексы в векторе indices, то у нас всё будет готово:

Теперь, после завершения внешнего цикла for, мы будем иметь полный набор вершин и индексов, необходимый для отрисовки меша с помощью функции glDrawElements(). Однако это ещё не всё; чтобы добавить к мешу немного деталей, необходимо обработать данные о материале меша.

Материал

Подобно узлам, меш содержит только индекс объекта материала. Чтобы получить сам материал меша, нам нужно проиндексировать массив mMaterials сцены. Индекс материала меша задается в его свойстве mMaterialIndex, которое мы также можем запросить, чтобы проверить, содержит ли меш материал или нет:

Сначала мы извлекаем объект aiMaterial из массива mMaterials сцены. Затем загружаем диффузные и/или зеркальные текстуры меша. Объект материала хранит внутри себя массив местоположений текстуры для каждого типа текстуры. Все различные типы текстур имеют префикс aiTextureType_. Для извлечения, загрузки и инициализации текстуры из материала мы используем вспомогательную функцию loadMaterialTextures(). Она возвращает вектор, содержащий элементы в виде структур Textures, которые будут сохранены в конце вектора модели textures.

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

Сначала мы проверяем количество текстур, хранящихся в материале, с помощью функции GetTextureCount(), которая ожидает получить в качестве параметра один из переданных нами типов текстур. Далее извлекаем каждое расположение файла текстуры с помощью функции GetTexture(), которая сохраняет результат в переменной типа aiString. Затем мы используем другую вспомогательную функцию TextureFromFile(), которая загружает текстуру (с помощью заголовочного файла stb_image.h) и возвращает её идентификатор.

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

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

И это всё, что нужно сделать для реализации импорта модели с помощью библиотеки Assimp.

Оптимизация


Но это ещё не конец, так как есть довольно большая (но может не совсем необходимая) оптимизация, которую стоило бы провести. Большинство сцен повторно используют несколько своих текстур. Представьте себе ещё раз дом, который имеет текстуру гранита для своих стен. Данная текстура также может быть применена и к полу, и к потолку, и к лестнице, и, возможно, даже к столу и к небольшому колодцу поблизости. Загрузка текстур — это очень затратная операция, и в нашей текущей реализации для каждого меша загружается и генерируется новая текстура, хотя точно такая же текстура уже могла быть загружена ранее. В результате получаем узкое место в реализации загрузки нашей модели.

Поэтому необходимо добавить одну небольшую поправку к коду модели, сохранив все загруженные текстуры глобально. В том месте, где мы хотим загрузить текстуру, сначала нужно проверить, не была ли она уже загружена ранее. Если это так, то мы берем эту текстуру и пропускаем всю процедуру загрузки, экономя вычислительные ресурсы компьютера. Чтобы иметь возможность сравнивать текстуры нам нужно также сохранить их путь:

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

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

Примечание: Некоторые версии Assimp, при использовании режима отладки Debug вашей IDE, как правило, загружают модели довольно медленно, поэтому обязательно протестируйте работу Assimp с Release-версиями приложения, если вы столкнетесь c большим временем загрузки моделей.

Полный исходный код класса Model

Больше никаких контейнеров!

Ну что же, давайте придадим новый импульс нашей реализации, импортировав модель, созданную настоящими 3D-дизайнерами. Давайте загрузим удивительный гитарный рюкзак выживальщика от Berk Gedik. Я немного изменил материал и пути, чтобы они работали непосредственно с тем, как мы настроили загрузку модели. Модель экспортируется в виде файла .obj вместе с файлом .mtl, который ссылается на диффузные, зеркальные и нормальные карты модели (мы вернемся к ним позже). Вы можете скачать подкорректированную модель здесь. Обратите внимание, что есть несколько дополнительных типов текстур, которые мы пока не будем использовать, и что все текстуры и файлы моделей должны быть расположены в одном каталоге для загрузки текстур.

Примечание: Модифицированная версия рюкзака использует локальные относительные пути текстуры и у неё переименованы текстуры альбедо и металла в диффузные и зеркальные, соответственно.

Теперь объявите объект типа Model и передайте ему путь к файлу модели. Затем модель должна автоматически загрузиться и (если не было ошибок) в цикле рендеринга нам нужно будет визуализировать объект, используя функцию Draw(), и это всё! Больше никаких выделений памяти под буферы, указателей атрибутов и команд рендеринга, только однострочный код загрузки модели. Если вы создадите простой набор шейдеров, в котором фрагментный шейдер выводит исключительно диффузную текстуру объекта, результат будет выглядеть примерно так:

  Google Drive / Исходный код — Урок №18. Загрузка моделей в OpenGL

  GitHub / Исходный код — Урок №18. Загрузка моделей в OpenGL

Обратите внимание, что мы сообщаем stb_image.h перевернуть текстуры вертикально (если вы этого ещё не сделали до загрузки модели). В противном случае, текстуры будут выглядеть совершенно испорченными.

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

Даже я должен признать, что он выглядит, возможно, немного более причудливо, чем контейнеры, которые мы использовали до сих пор. С помощью Assimp вы можете загрузить тонны различных моделей, найденных в Интернете. Существует довольно много сайтов, которые предлагают бесплатные 3D-модели для загрузки в разных форматах. Обратите внимание, что некоторые модели всё ещё не загружаются должным образом, имеют пути текстур, которые не будут работать, или просто экспортируются в формате, который даже библиотека Assimp не в состоянии прочитать.

Дополнительно


   How-To Texture Wavefront (.obj) Models for OpenGL — это отличное видео-руководство Мэтью Эрли о том, как настроить 3D-модели в Blender, чтобы они напрямую работали с текущим загрузчиком моделей (поскольку выбранная нами настройка текстуры не всегда работает из коробки).

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (3 оценок, среднее: 5,00 из 5)
Загрузка...

Комментариев: 6

  1. Аватар freelogger:

    В вашем коде на этой строке у меня сегфолт:

    Пересобрал assimp сам — не помогло. Платформа GNU/Linux

    1. Дмитрий Бушуев Дмитрий Бушуев:

      Об этом как раз я и писал в Уроке №16
      https://ravesli.com/urok-16-biblioteka-importa-3d-modelej-assimp-v-opengl/#toc-4

      "К сожалению, под данную платформу мне не удалось заставить стабильно работать собранную из исходников библиотеку Assimp. С ней постоянно возникали какие-то проблемы […]

      Поэтому пока пользователям Linux приходится рассчитывать только на свои собственные силы. Увы…"

  2. Аватар Евгений:

    Возникла проблема с загрузкой текстур: при использовании функции TextureFromFile(); пишет "необъявленный идентификатор"
    stb_image.h подключал в точности как в уроке 6, все другие функции работают
    Не подскажете, в чем дело?

    1. Дмитрий Бушуев Дмитрий Бушуев:

      "Необъявленный идентификатор" чего? Он ругается, что не может найти функцию TextureFromFile()? Или какую-то переменную? Если функцию — то она определена в заголовочном файле model.h.
      Проверьте как у вас подключаются заголовочные файлы в файле main.cpp. У меня они идут в следующем порядке:

      1. Аватар Евгений:

        А, да, извиняюсь. Проверил model.h , оказалось, проблема там
        Спасибо большое!

        1. Дмитрий Бушуев Дмитрий Бушуев:

          Пожалуйста 🙂

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

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