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

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

  Обновл. 23 Сен 2021  | 

 18502

 ǀ   21 

Теперь пришло время погрузиться в дебри Assimp и приступить к созданию кода, отвечающего за загрузку модели. Целью данного урока является создание класса, представляющего всю модель целиком, то есть модель, содержащую несколько мешей с, возможно, несколькими текстурами. Дом с деревянным балконом, башней и бассейном — всё это может быть загружено в виде единой модели. Мы загрузим модель через 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-версиями приложения в вашей IDE.

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

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

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

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

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

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

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

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

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

Дополнительные ресурсы

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

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

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

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

  1. Powarito:

    При попытке скомпилировать программу после этого урока Visual Studio 2022 выдает ошибку, что backpack.obj — file is invalid or corrupted.

    Пробовал и с моделью из этого урока, пробовал и в оригинальной книге на английском найти ссылку на репозиторий автора книги, и там взять модель, но там вообще её нет (что вообще странно); пробовал и сайта sketchlab скачать оригинальную модель, но там не было .obj версии, поэтому .fbx файл конвертировал в .obj с помощью онлайн-сервиса.

    Во всех случаях исход один и тот же — файл неправильный или повреждённый…

  2. flychepel:

    Разобрался, оказывается создал огромную сферу и смотрел на неё из-нутри)

  3. flychepel:

    Здравствуйте. Спасибо за уроки. К сожалению файл рюкзачка по ссылке не доступен.. Попробовал загрузить сферу с простым материалом diffuse типа Phong, созданную в 3dmax и конвертированную в .obj. При сборке проекта ошибок не возникло, но сферы нет.. только чёрный фон.. никак не могу решить в чём дело. Файлы модели лежат в папке Models/shpere.obj и sphere.mtl. При загрузке модели указываю этот путь: Model ourModel(«Models/sphere.obj»); Может кто-нибудь подскажет, что может быть..

  4. Сергей Федоров:

    Спасибо за урок.

    Пришлось повозиться с освещением: избавиться от структуры Material (хотя можно было бы изменить названия текстур в классах Mesh и Model). Рюкзачёк сразу заиграл красками.
    Загружается конечно довольно долго (около 12 секунд), пробовал release, но что-то не пошло.

    Удалось найти робота в формате .obj (хотя файла .mtl не было). Сообразил взять mtl от рюкзака (открываетя обычным текстовым редактором) и чуток его поковырять. У робота был файл эмиссии (светился глаз) но почему-то формат jpg портил всю картину. Переписал в png тогда глаз (и некоторые детали) чётко засветились. Да… и пришлось его подгрузить вместо текстуры нормалей (просто устал искать формат файла mtl и какой идентификатор цеплять на эмиссию).

    Долго морочил голову с картой нормалей. Хорошо что google вернул на сайт ravesli урок 34 (отложу на потом).

  5. Ruslan:

    Здравствуйте, скажите, пожалуйста, будет ли в будущих уроках показан импорт файлов формата .fbx так как это сейчас очень популярный формат 3Д моделей, хотелось бы понимать как работать именно с ним.

  6. Никита:

    Здравствуйте, у меня возникла проблема с касательными векторами модели:
    Вызвано необработанное исключение: нарушение доступа для чтения. mesh->mTangents было 0x1110112

  7. Андрей:

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

  8. Accellinker:

    Здравствуйте
    Столкнулся с проблемой. При добавлении точечного источника света, отраженный свет по какой то причине отображается только в красном спектре. Когда экспериментировал с шейдером обнаружил, что красный оттенок исходит от карты отраженного света. На на этой карте вообще нет красного оттенка.
    При этом фоновый и рассеянный свет работают без нареканий.
    В чем может быть проблема?

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

      Тяжело что-то подсказать, не имея перед собой исходный код вашего шейдера…

      1. Accellinker:

        Я использую следующий фрагментный шейдер:

        1. Владимир:

          Дело в том, что текстура specular имеет только первый и единственный канал (вы можете проверить в любом редакторе, что на самом деле это GrayScale текстура).

          Вы используете vec3(texture(material.diffuse, TexCoords)), поэтому у вас берется красный из текстуры [0.0 : 1.0], а зеленый и синий — это всегда 0, потому что их нет текстуре.

          Как вариант можно написать так:

          или сокращенно:

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

  9. Иван:

    Здравствуйте, а как загрузить модель формата stl одним файлом?

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

      Немного не понял ваш вопрос, в смысле "одним файлом"?

      1. Иван:

        Ну помимо .obj файла нужен еще .mtl файл модели, а у формата .stl только один .stl нету .mtl. Как вместо .obj файла загрузить .stl?

  10. Валерий:

    А можно ли попросить небольшой пример того, как правильно к мешу применять источники света?
    Как должен выглядеть фрагментный шейдер в данном случае и как правильно изменять параметры света
    Потому что на данный момент я ловлю черный рюкзак с небольшими вкраплениями нужной текстуры

  11. freelogger:

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

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

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

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

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

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

  12. Евгений:

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

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

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

      1. Евгений:

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

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

          Пожалуйста 🙂

Добавить комментарий для Accellinker Отменить ответ

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