Введение
Формат .OBJ является «устаревшим», так как не поддерживает анимации и использует старый формат материалов, требующий конвертацию к PBR-материалам. Данная заметка предлагает замену — формат glTF, поддерживаемый KhronosGroup.
Важное замечание: существует две версии стандарта для glTF, но в заметке рассматривается только вторая версия.
Содержание заметки:
- формат моделей .gltf;
- библиотека tinyglTF;
- доработка класса Model;
- доработка класса Camera;
- функция-загрузчик.
Формат моделей .gltf
Идея формата glTF заключается в том, что данные хранятся в готовом для загрузки на видеокарту формате с указанием параметров конфигурации атрибутов.
Спецификация формата второй версии доступна на сайте разработчика Khronos Group в двух форматах: HTML и PDF. Данная заметка не будет переводом спецификации, а лишь упрощенным разбором формата с некоторыми возможными подводными камнями.
Данный стандарт использует JSON для описания сцен и используемых в их составе элементов. JSON состоит из двух основных «элементов-контейнеров»:
- словарь — объект, состоящий из пар ключ-значение, который оборачивается в фигурные скобки;
- массив — упорядоченный список значений, который оборачивается квадратными скобками.
В данных «элементах-контейнерах» могут содержаться как другие контейнеры, так и более простые данные: строки (в двойных кавычках), числа, true/false, null.
Теперь, когда формат JSON понятен, можно рассмотреть формат файлов glTF. Если открыть файл со сценой в текстовом редакторе, поддерживающем сворачивание JSON блоков (например VSCode или Notepad++), то можно увидеть основные компоненты, описывающие сцены и их содержимое. В качестве примера на рисунке 1 представлен файл cube.gltf.
Рассмотрим предназначения данных блоков:
- asset — информация об экспорте и авторе;
- scene — сцена по умолчанию;
- scenes — информация о сценах;
- nodes — информация о узлах сцены, каждый может содержать:
- массив индексов других узлов,
- массив индексов сеток (meshes);
- materials — информация о материалах, обычно в формате PBR metallic-roughness;
- meshes — информация о трехмерных сетках (состоящих из полигонов);
- textures — информация о текстурах, ссылающаяся на изображения;
- images — информация о используемых изображениях в качестве текстур — их расположение и формат;
- accessors (средство доступа) — информация о используемых привязках вершинных атрибутов (для функции glVertexAttribPointer);
- bufferViews (границы буфера) — информация о границах вершинных и индексных буферов в одном бинарном буфере:
- buffer — индекс в массиве бинарных буферов,
- byteLength — длина в байтах,
- byteOffset — отступ в байтах,
- target — тип (вершинный или индексный);
- samplers — определяет wrapping (поведение при выходе за границы текстуры) и фильтрацию (filtering — Magnification и Minification)
- buffers — буферы с бинарными данными.
Примечание: про wrapping и filtering можно прочесть в 4 заметке про текстуры.
Содержимое файла следует рассматривать в виде графа (набор связанных между собой вершин). Пример связей в файле до уровня сетки (Mesh) изображен на рисунке 2.
Узлы (nodes) могут быть общими между сценами, как и трехмерные сетки (mesh) по отношению к узлам. Узлы могут ссылаться на сетку, а так же содержать в себе массив других узлов, например для групповых трансформаций. Каждая сетка состоит из примитивов, к которым в свою очередь применяются различные буферы и материалы. Пример рассмотрения сеток и примитивов представлен на рисунке 3.
Важное замечание: в качестве узла может выступать камера или источник света, загрузка которых в рамках данной заметки не рассматривается.
Каждый узел может (не обязан) содержать в себе следующие элементы, отвечающие за трансформацию:
- вектор смещения (translation);
- вектор поворота в виде кватерниона (rotation);
- вектор масштабирования (scale);
- матрица трансформации (matrix).
Примечание: с теорией о кватернионах можно ознакомиться в соответствующей заметке.
Каждая сетка (mesh) состоит из одного или нескольких примитивов, которые в свою очередь определяются вершинными буферами (позиция вершин, текстурные координаты, нормали), индексным буфером и материалом. каждый вершинный или индексный буфер определяется через средство доступа (accessors), которое определяет параметры привязки вершинного буфера и ссылается на границы бинарного буфера (BufferViews). Границы бинарного буфера задают границы и тип данных, хранящихся в бинарном буфере. Бинарные буферы — набор бинарных данных, которые должны быть загружены на видеокарту.
Материал и связанные с ним элементы изображены на рисунке 4.
Каждый материал включает в себя параметры PBR, которые по умолчанию задаются в формате MetallicRoughness (металличность-шероховатость), а так же используемые текстуры. Текстура ссылается на параметры сэмплирования (sampler) и изображение (image). Изображение может быть записано в один (формат «glTF со встраиванием») из буферов или в отдельный файл (формат «Разделенный glTF»).
Для простоты работы с данными можно загрузить вершинные буферы и текстуры перед началом формирования сцены в память видеокарты.
Библиотека tinyglTF
Для того, чтобы сэкономить время разработки функции-загрузчика, будет использована библиотека-парсер для формата glTF с названием tinyglTF, доступная в github репозитории.
Для работы библиотеки понадобятся библиотека json (файл json.hpp) и, если потребуется записывать текстуры в файл, расширение библиотеки stb_image: stb_image_write.h (не используется в заметке). Расположим данные файлы в папке dependencies/tinyglTF.
Если вы используете Makefile, то необходимо дополнить переменную CFLAGS и изменить стандарт на gnu++11:
CFLAGS += -I../dependencies/tinyglTF
# Опции линкера
LDFLAGS += --std=gnu++11
Если вы работаете со сборщиком VS Code, то в файле .vscode/tasks.json необходимо указать папку с заголовочными файлами для двух задач сборки:
{
"tasks": [
{
"type": "cppbuild",
"label": "C/C++: g++.exe сборка активного файла",
"command": "C:/MinGW/bin/g++.exe",
"args": [
...
"-I${workspaceFolder}/../dependencies/tinyobjloader",
"-I${workspaceFolder}/../dependencies/tinyglTF",
"-static",
...
},
{
"type": "cppbuild",
"label": "C/C++ x64: g++.exe сборка активного файла",
"command": "C:/MinGW64/bin/g++.exe",
"args": [
...
"-I${workspaceFolder}/../dependencies/tinyobjloader",
"-I${workspaceFolder}/../dependencies/tinyglTF",
"-static",
...
А так же для компилятора MinGW x32 следует изменить параметр, определяющий версию стандарта языка C++:
"--std=c++11",
"-std=gnu++11",
В файле .vscode/c_cpp_properties.json добавим путь до созданной папки:
{
"configurations": [
{
"name": "some_name",
"includePath": [
"${workspaceFolder}/include",
"${workspaceFolder}/../dependencies/GLFW/include",
"${workspaceFolder}/../dependencies/glad/include",
"${workspaceFolder}/../dependencies/glm",
"${workspaceFolder}/../dependencies/stb",
"${workspaceFolder}/../dependencies/tinyobjloader",
"${workspaceFolder}/../dependencies/tinyglTF"
],
...
Для работы с данной библиотекой необходимо использовать следующие директивы препроцессора:
#define TINYGLTF_IMPLEMENTATION
#define TINYGLTF_NO_STB_IMAGE_WRITE
#define TINYGLTF_NOEXCEPTION
#define JSON_NOEXCEPTION
#include "tiny_gltf.h"
Разберем четыре директивы define, используемые перед подключением заголовочного файла:
- TINYGLTF_IMPLEMENTATION — указывает, что далее будет следовать определение функций, вложенных в заголовочный файл (объявляется только один раз в одном файле);
- TINYGLTF_NO_STB_IMAGE_WRITE — указывает, что компонент библиотеки stb_image, отвечающий за сохранение текстур в файл, не требуется;
- TINYGLTF_NOEXCEPTION — отключает выдачу исключений библиотекой-загрузчиком в случае ошибок;
- JSON_NOEXCEPTION — отключает выдачу исключений библиотекой-парсером формата JSON.
Доработка класса Model
По умолчанию в методе Model::render в аргументах функции glDrawElements используется GL_UNSIGNED_INT в качестве типа данных элементов массива индексов. Иногда в файлах формата glTF в индексных буферах может использоваться GL_UNSIGNED_SHORT. Для учета типа данных добавим к классу Model приватное поле и модифицируем метод Model::set_index_range в файле include/Model.h:
class Model : public Node
{
public:
...
void set_index_range(size_t first_byteOffset, size_t count, size_t type = GL_UNSIGNED_INT); // Ограничение диапазона из буфера индексов
...
size_t first_index_byteOffset, indices_count, indices_datatype; // Сдвиг в байтах для первого, количество индексов и тип данных индексов
...
};
В файле src/Model.cpp метод Model::set_index_range имеет следующий вид:
// Ограничение диапазона из буфера индексов
void Model::set_index_range(size_t first_byteOffset, size_t count, size_t type)
{
first_index_byteOffset = first_byteOffset;
indices_count = count;
indices_datatype = type;
}
Дополним конструкторы и метод Model::render новым полем:
// Конструктор без параметров
Model::Model(Node *parent) : Node(parent), verteces_count(0), first_index_byteOffset(0), indices_count(0), indices_datatype(GL_UNSIGNED_INT),
...
// Конструктор копирования
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), indices_datatype(copy.indices_datatype),
...
// Вызов отрисовки без uniform-даных
void Model::render()
{
// Подключаем VAO
vao.use();
// Если есть индексы - рисуем с их использованием
if (indices_count)
{
index_vbo.use();
glDrawElements(GL_TRIANGLES, indices_count, indices_datatype, (void*)(first_index_byteOffset));
}
// Если есть вершины - рисуем на основании массива вершин
else if (verteces_count)
glDrawArrays(GL_TRIANGLES, 0, verteces_count);
}
Так как формат буферов в файле glTF подразумевает загрузку данных напрямую в память видеокарты, то логично создавать буфер и прикреплять его к существующей модели. Для реализации такой функциональности добавим два публичных метода к классу модели в файле include/Model.h:
class Model : public Node
{
public:
...
void setBO(int attribute, BO & bo); // Замена вершинного буфера по номеру его привязки
void setIndicesBO(BO & data); // Замена индексного буфера
...
};
Реализация новых методов в файле src/Model.cpp:
// Замена вершинного буфера по номеру его привязки
void Model::setBO(int attribute, BO & bo)
{
switch(attribute)
{
case 0:
vertex_vbo = bo;
break;
case 1:
texCoords_vbo = bo;
break;
case 2:
normals_vbo = bo;
break;
case 3:
tangent_vbo = bo;
break;
case 4:
bitangent_vbo = bo;
break;
default:
throw std::runtime_error("Unknown attribute buffer");
};
}
// Замена индексного буфера
void Model::setIndicesBO(BO & data)
{
index_vbo = data;
}
Текущая версия доступна на теге v0.1 в репозитории 19.
Доработка класса Camera
В формате GLTF камера может являться элементом сцены. Среди параметров камеры, доступных при загрузке сцены присутствуют ближняя и дальняя плоскости усеченной пирамиды проекции (или прямоугольника в случае ортогональной проекции).
В имеющейся реализации класса Camera данные параметры константно заданы на этапе компиляции. Для полноценной работы с библиотекой дополним класс камеры в файле include/Camera.h:
class Camera : public Node
{
public:
Camera(float aspect, const glm::vec3 &position = glm::vec3(0.0f), const glm::vec3 &initialRotation = CAMERA_DEFAULT_ROTATION, float fovy = CAMERA_FOVy, float near = CAMERA_NEAR, float far = CAMERA_FAR); // Конструктор камеры с проекцией перспективы
Camera(float width, float height, const glm::vec3 &position = glm::vec3(0.0f), const glm::vec3 &initialRotation = CAMERA_DEFAULT_ROTATION, float near = CAMERA_NEAR, float far = CAMERA_FAR); // Конструктор ортографической камеры
...
void setPerspective(float fov, float aspect, float near = CAMERA_NEAR, float far = CAMERA_FAR); // Устанавливает заданную матрицу перспективы
void setOrtho(float width, float height, float near = CAMERA_NEAR, float far = CAMERA_FAR); // Устанавливает заданную ортографическую матрицу
...
};
Изменения конструкторов и методов в файле src/Camera.cpp:
// Конструктор камеры с проекцией перспективы
Camera::Camera(float aspect, const glm::vec3 &position, const glm::vec3 &initialRotation, float fovy, float near, float far)
: Camera(position, initialRotation)
{
setPerspective(fovy, aspect, near, far);
}
// Конструктор ортографической камеры
Camera::Camera(float width, float height, const glm::vec3 &position, const glm::vec3 &initialRotation, float near, float far)
: Camera(position, initialRotation)
{
setOrtho(width, height, near, far);
}
// Устанавливает заданную матрицу перспективы
void Camera::setPerspective(float fovy, float aspect, float near, float far)
{
projection = glm::perspective(glm::radians(fovy), aspect, near, far);
requiredRecalcVP = true;
for (int cascade = 0; cascade < CAMERA_CASCADE_COUNT; cascade++)
cascade_proj[cascade] = glm::perspective(glm::radians(fovy), aspect, camera_cascade_distances[cascade], camera_cascade_distances[cascade+1]);
}
// Устанавливает заданную ортографическую матрицу
void Camera::setOrtho(float width, float height, float near, float far)
{
const float aspect = width / height;
projection = glm::ortho(-1.0f, 1.0f, -1.0f/aspect, 1.0f/aspect, near, far);
requiredRecalcVP = true;
for (int cascade = 0; cascade < CAMERA_CASCADE_COUNT; cascade++)
cascade_proj[cascade] = glm::ortho(-1.0f, 1.0f, -1.0f/aspect, 1.0f/aspect, camera_cascade_distances[cascade], camera_cascade_distances[cascade+1]);
}
Важное замечание: на данный момент дистанции каскадов задаются относительно стандартных значений и требуют доработок для нестандартных параметров камеры.
Текущая версия доступна на теге v0.2 в репозитории 19.
Функция-загрузчик
Приложение поддерживает работу со сценами, содержащими в себе сложную иерархию узлов с отношениями родитель-потомок для наследования трансформации объектов в пространстве. В файле формата glTF могут содержаться несколько сцен, но данная реализация функции-загрузчика будет возвращать одну сцену.
Перед реализацией функции добавим в include/Model.h заголовочные файлы для glm, отвечающие за работу с кватернионами и их приведению к углам Эйлера:
#include <GLM/glm.hpp>
#include <GLM/gtc/type_ptr.hpp>
#include <GLM/gtc/quaternion.hpp>
#include <GLM/gtx/quaternion.hpp>
#include <GLM/gtx/euler_angles.hpp>
Функции-загрузчику достаточно одного адреса — с именем файла. Добавим в тот же файл объявление функции-загрузчика:
class Scene loadOBJtoScene(const char* filename, const char* mtl_directory = DEFAULT_MTL_DIR, const char* texture_directory = DEFAULT_MTL_DIR);
class Scene loadGLTFtoScene(std::string filename);
В файле src/Model.cpp подключим заголовочный файл tiny_gltf.h с необходимыми директивами препроцессора перед определением функции:
#define TINYGLTF_IMPLEMENTATION
#define TINYGLTF_NO_STB_IMAGE_WRITE
#define TINYGLTF_NOEXCEPTION
#define JSON_NOEXCEPTION
#include "tiny_gltf.h"
Scene loadGLTFtoScene(std::string filename)
{
...
Создадим объект, который будет возвращен в конце функции, две строки для ошибок и предупреждений, а так же объекты загрузчика и модели в формате загрузчика:
Scene result;
tinygltf::TinyGLTF loader; // Объект загрузчика
tinygltf::Model in_model; // Модель в формате загрузчика
std::string err; // Строка под ошибки
std::string warn; // Строка под предупреждения
tinygltf::TinyGLTF loader; // Объект загрузчика
tinygltf::Model in_model; // Модель в формате загрузчика
std::string err; // Строка под ошибки
std::string warn; // Строка под предупреждения
Метод tinygltf::tinyGLTF::LoadASCIIFromFile (объекта loader) принимает в качестве аргументов адреса модели в формате загрузчика, строк под предупреждения и ошибки, а так же строку с именем файла. Данный метод возвращает логическую переменную, определяющую результат работы:
bool success = loader.LoadASCIIFromFile(&in_model, &err, &warn, filename); // Загрузка из файла
Важное замечание: для бинарных файлов следует использовать
Если есть предупреждения или ошибки — выдадим исключение, а в случае если файл успешно считан — продолжим загрузку:
// Если есть ошибки или предупреждения - выдадим исключение
if (!err.empty() || !warn.empty())
throw std::runtime_error(err + '\n' + warn);
// Если все успешно считано - продолжаем загрузку
if (success)
{
...
Так как формат файла подразумевает, что данные хранятся в готовом для записи в буфер формате — пройдемся по всем границам доступа и подготовим буферные объекты (класс BO):
// Загрузим данные в вершинные и индексные буферы
std::vector<BO> BOs;
for (auto & bufferView : in_model.bufferViews)
{
auto & buffer = in_model.buffers[bufferView.buffer];
BOs.push_back(BO((BUFFER_TYPE)bufferView.target, buffer.data.data() + bufferView.byteOffset, bufferView.byteLength));
}
Здесь используется параметры:
- отступ с начала файлового буфера в байтах (bufferView.byteOffset);
- длина буфера в байтах (bufferView.byteLength);
- адрес начала массива данных (buffer.data.data() — метод у std::vector<char>);
- тип буфера (bufferView.target — соответствует спецификации OpenGL), который требуется явно привести к перечислению (enum) BUFFER_TYPE из-за особенностей конструктора класса BO.
Так же заранее подготовим используемые текстуры, загрузив их в цикле по изображениям. Текстура может хранится в отдельном файле по указанному адресу или быть частью файлового буфера. В первом случае достаточно вызвать конструктор класса Texture, передав ему адрес изображения. Во втором случае следует определить формат и тип данных пикселя, после чего можно вызвать конструктор класса Texture, передав ему параметры загруженного в оперативную память изображения. В выборе нужного подхода можно отталкиваться от длинны строки с адресом изображения (image.uri.size() > 0)
Важное замечание: адрес изображения в формате glTF хранится в относительном виде от основного файла, так что следует отделить адрес директории от имени файла (filename.substr(0,filename.find_last_of(«/\\»)+1)).
// Адрес директории для относительных путей изображений
std::string dir = filename.substr(0,filename.find_last_of("/\\")+1);
// Загрузим используемые текстуры
std::vector<Texture> textures;
for (auto & image : in_model.images)
{
// Если длинна файла больше 0, то текстура в отдельном файле
if (image.uri.size() > 0)
{
Texture tmp(TEX_AVAILABLE_COUNT,(dir + image.uri).c_str());
textures.push_back(tmp);
}
else // иначе она является частью буфера
{
GLuint format = GL_RGBA;
GLenum type = GL_UNSIGNED_BYTE;
// Формат пикселя
if (image.component == 1)
format = GL_RED;
else if (image.component == 2)
format = GL_RG;
else if (image.component == 3)
format = GL_RGB;
// Тип данных
if (image.bits == 16)
type = GL_UNSIGNED_SHORT;
else if (image.bits == 32)
type = GL_UNSIGNED_INT;
Texture tmp( image.width, image.height
, image.image.data()
, 0, format, format
, type
);
textures.push_back(tmp);
}
}
Для восстановления иерархии сцены сделаем два массива: по указателям на созданные узлы (соответствие с индексами узлов glTF) и по индексам на родительские узлы:
// Указатели на узлы для построения иерархии родитель-потомок
std::vector<Node *> pNodes(in_model.nodes.size(), NULL);
// Индексы родителей (-1 - корневой узел сцены)
std::vector<int> parents_id(in_model.nodes.size(), -1);
Запустим цикл по сценам:
// Цикл по сценам
for (auto &scene : in_model.scenes)
{
В формате glTF сцена имеет только узлы, являющиеся потомками для корневого. В целях упрощения работы с узлами выполним рекурсивный обход потомков и соберем их в массив (std::vector):
// Так как у нас есть информация о потомках корневого узла сцены - пройдем рекурсивно и соберем все узлы из этой сцены:
std::vector<int> scene_nodes;
// Цикл по узлам рассматриваемой сцены с рекурсивным проходом потомков
for (auto &node_id : scene.nodes)
collectGLTFnodes(node_id, scene_nodes, in_model);
Реализация функции collectGLTFnodes:
void collectGLTFnodes(int node_id, std::vector<int> &nodes, tinygltf::Model &in_model)
{
nodes.push_back(node_id);
for (auto& child : in_model.nodes[node_id].children)
collectGLTFnodes(child, nodes, in_model);
}
Вернемся к функции-загрузчику: далее пройдем по индексам узлов, относящимся к этой сцене (массив scene_nodes):
// Цикл по всем узлам рассматриваемой сцены
for (auto &node_id : scene_nodes)
{
auto &node = in_model.nodes[node_id];
Каждый узел в формате glTF может иметь камеру и несколько примитивов в одном меше. В представлении иерархии приложения данные узлы являются сложными и требуют разбиения на более простые узлы:
Node *tmpParent = &result.root; // Указатель на родителя, используется если узел сложный (несколько мешей или камера-меш)
// Запишем текущий узел как родительский для потомков
for (auto& child : node.children)
parents_id[child] = node_id;
// Проверим наличие сложной сетки
bool complex_mesh = false;
// Если у узла есть полигональная сетка
if (node.mesh > -1)
if (in_model.meshes[node.mesh].primitives.size() > 1)
complex_mesh = true;
// Если узел составной: имеет и камеру, и полигональную сетку
// или узел пустой
// или имеет сложную полигональную сетку (примитивов больше одного)
if (node.camera > -1 && node.mesh > -1
|| node.camera == -1 && node.mesh == -1
|| complex_mesh)
{
// Создадим вспомогательный родительский узел для трансформаций
result.nodes.push_back(Node(&result.root));
pNodes[node_id] = tmpParent = &result.nodes.back(); // Сохраним в массив узлов и как родителя
// В противном случае дополнительный узел не требуется
}
Если у узла есть полигональная сетка (меш) — обработаем её:
// Обработаем полигональную сетку
if (node.mesh > -1)
{
auto &mesh = in_model.meshes[node.mesh];
// Для каждого примитива связанного с полигональной сеткой
for (auto &primitive : mesh.primitives)
{
Model model(tmpParent); // Тут используется либо корневой узел сцены, либо вспомогательный узел
Для каждого примитива обработаем атрибуты, индексы и материалы:
// Цикл по атрибутам примитива
for (auto &attribute : primitive.attributes)
{
// Средство доступа
auto &accessor = in_model.accessors[attribute.second];
// Границы буфера
auto &bufferView = in_model.bufferViews[accessor.bufferView];
// Индекс привязки на шейдере
int attribute_index;
if (attribute.first.compare("POSITION") == 0)
attribute_index = 0;
else if (attribute.first.compare("TEXCOORD_0") == 0)
attribute_index = 1;
else if (attribute.first.compare("NORMAL") == 0)
attribute_index = 2;
else
continue;
// Подключаем вершинный буфер
model.setBO(attribute_index, BOs[accessor.bufferView]);
BOs[accessor.bufferView].use();
glEnableVertexAttribArray(attribute_index);
// Определим спецификацию атрибута
glVertexAttribPointer( attribute_index // индекс атрибута, должен совпадать с Layout шейдера
, tinygltf::GetNumComponentsInType(accessor.type) // количество компонент одного элемента
, accessor.componentType // тип
, accessor.normalized ? GL_TRUE : GL_FALSE // нормализованность значений
, accessor.ByteStride(bufferView) // шаг
, ((char *)NULL + accessor.byteOffset) // отступ с начала массива
);
}
// Если есть индексы
if (primitive.indices > -1)
{
// Средство доступа для индексов
auto &accessor = in_model.accessors[primitive.indices];
// Границы индексного буфера
auto &bufferView = in_model.bufferViews[accessor.bufferView];
model.setIndicesBO(BOs[accessor.bufferView]);
model.set_index_range(accessor.byteOffset, accessor.count, accessor.componentType);
}
// Если есть материал
if (primitive.material > -1)
{
// Параметры материалов
auto &material = in_model.materials[primitive.material];
model.material.base_color = {material.pbrMetallicRoughness.baseColorFactor[0], material.pbrMetallicRoughness.baseColorFactor[1], material.pbrMetallicRoughness.baseColorFactor[2]};
model.material.metallic = material.pbrMetallicRoughness.metallicFactor;
model.material.roughness = material.pbrMetallicRoughness.roughnessFactor;
model.material.emitted = {material.emissiveFactor[0], material.emissiveFactor[1], material.emissiveFactor[2]};
if (material.pbrMetallicRoughness.baseColorTexture.index > -1)
{
textures[material.pbrMetallicRoughness.baseColorTexture.index].setType(TEX_ALBEDO);
model.set_texture(textures[material.pbrMetallicRoughness.baseColorTexture.index]);
}
if (material.pbrMetallicRoughness.metallicRoughnessTexture.index > -1)
{
textures[material.pbrMetallicRoughness.metallicRoughnessTexture.index].setType(TEX_METALLIC);
model.set_texture(textures[material.pbrMetallicRoughness.metallicRoughnessTexture.index]);
model.material.roughness = -2;
}
if (material.emissiveTexture.index > -1)
{
textures[material.emissiveTexture.index].setType(TEX_EMITTED);
model.set_texture(textures[material.emissiveTexture.index]);
}
auto specular_ext = material.extensions.find("KHR_materials_specular");
if (specular_ext != material.extensions.end())
{
if (specular_ext->second.Has("specularColorFactor"))
{
auto &specular_color = specular_ext->second.Get("specularColorFactor");
model.material.specular = (specular_color.Get(0).GetNumberAsDouble() + specular_color.Get(1).GetNumberAsDouble() + specular_color.Get(2).GetNumberAsDouble()) / 3;
}
if (specular_ext->second.Has("specularColorTexture"))
{
auto &specular_texture = specular_ext->second.Get("specularColorTexture");
int index = specular_texture.Get("index").GetNumberAsInt();
if (index > -1)
{
textures[index].setType(TEX_SPECULAR);
model.set_texture(textures[index]);
}
}
}
}
Важное замечание: в формате glTF в качестве текстур шероховатости и металличности может использоваться одна двухканальная текстура — в таком случае значение шероховатости задается равным -2 для удобства определения на шейдере.
Полученную модель добавим к моделям сцены:
result.models.push_back(model); // Добавляем к сцене
// Если ещё не сохранили
if (!pNodes[node_id])
pNodes[node_id] = &result.models.back(); // Сохраним адрес созданного узла
}
}
После обработки полигональной сетки нужно обработать камеру, если таковая имеется у рассматриваемого узла:
// Обработаем камеру
if (in_model.nodes[node_id].camera > -1)
{
auto &in_camera = in_model.cameras[in_model.nodes[node_id].camera];
// Если камера использует проекцию перспективы
if (in_camera.type == "perspective")
{
Camera camera(in_camera.perspective.aspectRatio, glm::vec3(0.0f), CAMERA_DEFAULT_ROTATION, in_camera.perspective.yfov, in_camera.perspective.znear, in_camera.perspective.zfar);
result.cameras.push_back(camera);
}
// Иначе ортографическую проекцию
else
{
Camera camera(in_camera.orthographic.xmag, in_camera.orthographic.ymag, glm::vec3(0.0f), CAMERA_DEFAULT_ROTATION, in_camera.orthographic.znear, in_camera.orthographic.zfar);
result.cameras.push_back(camera);
}
// Если у узла есть полигональная сетка - сделаем камеру потомком модели, адрес которой записан в вектор
if (in_model.nodes[node_id].mesh > -1)
result.cameras.back().setParent(pNodes[node_id]);
// Иначе узел является камерой сам по себе
else
{
result.cameras.back().setParent(&result.root);
pNodes[node_id] = &result.cameras.back(); // Сохраним адрес созданного узла
}
}
}
}
Последним этапом будет выстроена иерархия узлов и заданы их трансформации. Запустим цикл по всем имеющимся индексам узлов, проверяя что указатель не указывает на NULL:
// Зададим трансформацию и родителей для узлов
// Цикл по всем индексам узлов
for (int node_id = 0; node_id < in_model.nodes.size(); node_id++)
{
// Проверка на нулевой указатель
if (pNodes[node_id])
{
Есть два пути хранения данных о трансформации в glTF:
- в виде матрицы — уже готовые преобразования;
- в виде векторов смещения и масштабирования, а так же кватерниона поворота.
В случае с матрицей — разложим её на преобразования:
// Если есть матрица трансформации - разберем её на составляющие
if (in_model.nodes[node_id].matrix.size() == 16)
{
glm::mat4 transform = glm::make_mat4(in_model.nodes[node_id].matrix.data());
pNodes[node_id]->e_position() = glm::vec3(transform[3][0], transform[3][1], transform[3][2]);
pNodes[node_id]->e_scale() = {glm::length(glm::vec3(transform[0][0], transform[1][0], transform[2][0])), glm::length(glm::vec3(transform[0][1], transform[1][1], transform[2][1])), glm::length(glm::vec3(transform[0][2], transform[1][2], transform[2][2]))};
for (int i = 0; i < 3; i++)
transform[i] = glm::normalize(transform[i]);
pNodes[node_id]->e_rotation() = glm::quat_cast(transform);
}
В противном случае возьмем информацию о преобразованиях из соответствующих полей при условии совпадения длин векторов:
else
{
// Если есть параметры трансформации
if (in_model.nodes[node_id].translation.size() == 3)
pNodes[node_id]->e_position() = glm::vec3(in_model.nodes[node_id].translation[0], in_model.nodes[node_id].translation[1], in_model.nodes[node_id].translation[2]);
if (in_model.nodes[node_id].rotation.size() == 4)
pNodes[node_id]->e_rotation() = glm::quat(in_model.nodes[node_id].rotation[3], glm::vec3(in_model.nodes[node_id].rotation[0], in_model.nodes[node_id].rotation[1], in_model.nodes[node_id].rotation[2]));
if (in_model.nodes[node_id].scale.size() == 3)
pNodes[node_id]->e_scale() = glm::vec3(in_model.nodes[node_id].scale[0], in_model.nodes[node_id].scale[1], in_model.nodes[node_id].scale[2]);
}
Осталось установить родителя для узла, если таковой имеется.
// Если индекс родителя > -1, то родитель создан и это не корневой узел сцены
if (parents_id[node_id] > -1)
pNodes[node_id]->setParent(pNodes[parents_id[node_id]]);
}
}
}
В фрагментном шейдере G-буфера (отложенный рендер) shaders/gshader.frag необходимо изменить сохранение параметров материалов с учетом двойной текстуры:
// Если используется двухканальная текстура
if (roughness < -1)
{
// Сохранение шероховатости и металличности
gRMS.rg = texture(tex_metallic, new_texCoord).bg;
}
else
{
// Сохранение шероховатости
gRMS.r = roughness<0?texture(tex_roughness, new_texCoord).r:roughness;
// Сохранение металличности
gRMS.g = metallic<0?texture(tex_metallic, new_texCoord).r:metallic;
}
В качестве примера можно взять модель из публичного репозитория Khronos Group / glTF-Sample-Models, либо использовать модель капли из репозитория resources. В качестве примера будет загружена модель капли в файле src/main.cpp:
// Загрузка сцены из glTF файла
std::vector<Scene> scenes = loadGLTFtoScene("../resources/models/blob.gltf");
Scene& scene = scenes[0];
Текущая версия доступна на теге v0.3 в репозитории 19.
Заключение
В рамках данной заметки рассмотрен формат моделей glTF 2.0, а так же написана функция для загрузки статических сцен.
Проект доступен в публичном репозитории: 19.
Библиотеки: dependencies
Ресурсы: resources