Введение
Скелетная анимация (skeletal animation) — метод анимации в компьютерной графике, который используется для придания движения трехмерным объектам.
В современном мире компьютерной графики скелетные анимации стали неотъемлемой частью процесса создания реалистичных и выразительных персонажей. Они обеспечивают аниматоров и разработчиков игр мощным инструментом для придания жизни моделям.
Основная идея скелетной анимации заключается в создании структуры скелета, состоящей из костей (bones), которые связаны в иерархическую структуру и оказывают влияние на различные части объектов. Иерархическая структура в форме дерева обеспечивает влияние родительских костей на дочерние.
Примечание: в предметной области трехмерного моделирования принято использовать англицизмы, но автор постарается давать синонимы, указывая англицизмы или английские термины в скобках при первых упоминаниях.
Содержание заметки:
- теория по скелетной анимации;
- скелетные анимации в формате glTF;
- классы Bone и Skeleton;
- доработка сцены;
- функция-загрузчик glTF;
- шейдеры;
- примеры.
Теория по скелетной анимации
Основные компоненты скелетной анимации:
- кости (bones) — объекты, которые определяют положение и ориентацию частей модели;
- скелет — иерархическая структура в форме дерева, которая обеспечивает отношения между костями скелета;
- веса (weights) — коэффициенты, определяющие степень влияния различных костей на конкретную вершину;
- ключевые кадры (keyframes) — моменты времени, образующие временные отрезки, которые определяют начальное и конечное положение костей и объектов.
Каждая кость обладает параметрами, которые позволяют определять и контролировать их положение, ориентацию и взаимодействие с окружающей геометрией:
- расположение кости (position);
- поворот (rotation);
- масштаб (scale);
- ограничения (constraints).
Создание любой анимации на основе скелета и ключевых кадров можно разделить на четыре пункта:
- создание модели;
- привязка костей к модели (rigging, риггинг);
- присвоение весов костей к вершинам;
- задание поз скелета на нужных ключевых кадрах.
Примечание: этап создания модели в рамках данной заметки не рассматривается — можно ознакомиться с заметкой по скелетной анимации в Blender.
Иерархия костей (bone hierarchy), называемая скелетом — это дерево, где каждая кость является узлом.
Веса костей для вершин определяются в диапазоне [0.0;1.0]. Обычно для визуализации цветов используются цвета в диапазоне от синего (0.0) до красного (1.0).
Для определения положения кости между двумя ключевыми кадрами используется интерполяция (interpolation) — процесс вычисления промежуточных значений между двумя известными значениями во временной последовательности. Интерполяция позволяет создать плавные и непрерывные движения между ключевыми кадрами, что придает анимации реалистичность и плавность. Подробнее про ключевые кадры и интерполяцию было написано в предыдущей заметке по базовым анимациям.
Скелетные анимации в формате glTF
Скелетные анимации в формате glTF основаны на анимациях иерархии пустых узлов, повторяющих скелет. Такой подход позволяет без изменений использовать наработки прошлой заметки по базовым анимациям.
Примечание: кости в данном формате называются JOINTS.
Для описания влияния костей на вершины используются два четырехкомпонентных вектора: индексы костей (целочисленные), веса костей (вещественные). Для хранения этих данных следует использовать объекты вершинных буферов (VBO).
Важное замечание: для конфигурации атрибута с целочисленными данными (индексы костей) следует использовать функцию glVertexAttribIPointer.
Внутри класса tinygltf::Model есть массив структур (std::vector<Skin>) skins, которые содержат информацию о привязке костей к моделям (скиннинг):
- name — имя скина;
- inverseBindMatrices — индекс доступа (accessor index) к буферу, содержащему массив обратных матриц привязки для каждой кости;
- skeleton — индекс узла, который служит корнем скелета;
- joints — список индексов узлов (Node), каждый из которых представляет кость в скелете.
Примечание: поле tinygltf::Skin::skeleton является не обязательным и в данной заметке не используется.
Обратные матрицы привязки используются для перевода вершин в локальное пространство кости. В рамках данных заметок автор хранит данные матрицы в оперативной памяти. Для того чтобы анимация с учетом влияния костей была корректной, необходимо уметь переводить координаты вершин из мирового пространства в пространство, локальное для каждой кости. Это достигается путем умножения координат вершин на обратную матрицу привязки соответствующей кости.
Стандарт glTF не запрещает использование одного узла в качестве кости для двух разных скинов. В такой ситуации для узла будут использованы разные матрицы обратной привязки.
Классы Bone и Skeleton
В файле include/Model.h добавим классы кости и скелета:
// Кость скелета
class Bone
{
public:
Bone(Node* node = NULL, const glm::mat4& inverseBindMatrix = glm::mat4(1)); // Конструктор с параметрами по умолчанию
Node* node; // Узел, выступающий костью
glm::mat4 inverseBindMatrix; // Матрица обратного связывания
};
// Скелет
class Skeleton
{
public:
std::vector<Bone*> bones; // Вектор указателей на кости (хранятся в сцене)
void upload(UBO *bones_buffer); // Формирует массив матриц и загружает его в буфер
};
Дополнительно в начало заголовочного файла добавим максимально допустимое число костей для одной модели:
#define MAX_BONES 100
Реализация конструктора и метода загрузки в файле src/Model.cpp:
// Конструктор с параметрами по умолчанию
Bone::Bone(Node* pNode, const glm::mat4& invBindMatrix) : node(pNode), inverseBindMatrix(invBindMatrix)
{
}
// Формирует массив матриц и загружает его на шейдер
void Skeleton::upload(UBO *bones_buffer)
{
glm::mat4 result[MAX_BONES]; // Массив итоговых матриц трансформации для костей
// Если требуется выполнить загрузку
if (bones_buffer)
{
// Цикл по костям
for (int i = 0; i < bones.size() && i < MAX_BONES; i++)
{
if (bones[i] && bones[i]->node)
result[i] = bones[i]->node->getTransformMatrix() * bones[i]->inverseBindMatrix;
else
result[i] = glm::mat4(1.0f);
}
// Отправка на шейдер
bones_buffer->loadSub(result, sizeof(glm::mat4)*bones.size());
}
}
В методе Skeleton::upload используется указатель на UBO буфер с матрицами трансформации костей для загрузки только для тех частей цикла отрисовки, где это требуется. В цикле по костям вычисляются матрицы трансформации костей, количество которых ограничено MAX_BONES.
Так как кости и модели являются частью одного дерева возникает проблема в повторном применении трансформаций скелета. В текущей реализации метод Node::getTransformMatrix возвращает матрицу трансформации модели с учетом родительских. Для решения данной проблемы добавим метод Node::getIsolatedTransformMatrix, который возвращает изолированную матрицу трансформации модели (без учета родительских). Данный метод будет вызываться, если у модели есть скелет.
// Возвращает матрицу трансформации модели без учета родительских
const glm::mat4& Node::getIsolatedTransformMatrix()
{
// Если требуется - пересчитаем матрицу
recalcMatrices();
return transform;
}
В файле include/Model.h:
// Класс узла сцены
class Node
{
public:
...
virtual const glm::mat4& getTransformMatrix(); // Возвращает матрицу трансформации модели
virtual const glm::mat4& getIsolatedTransformMatrix(); // Возвращает матрицу трансформации модели без учета родительских
...
};
Теперь дополним класс модели (Model) указателем на скелет и буферами с данными для костей:
// Класс модели
class Model : public Node
{
...
void render(); // Вызов отрисовки без uniform-данных
void render(ShaderProgram &shaderProgram, UBO &material_buffer, UBO *bones_buffer = NULL); // Вызов отрисовки
...
void load_bitangent(glm::vec3* bitangent, GLuint count); // Загрузка бикасательных векторов в буфер
void load_bonesData(glm::ivec4* ids, glm::vec4 *weights, GLuint count); // Загрузка индексов и весов костей
void set_index_range(size_t first_byteOffset, size_t count, size_t type = GL_UNSIGNED_INT); // Ограничение диапазона из буфера индексов
...
Material material; // Материал модели
Skeleton* skeleton; // Указатель на скелет
ID id; // ID модели
private:
...
BO tangent_vbo, bitangent_vbo; // буферы с касательными и бикасательными векторами
BO bonesIds_vbo, bonesWeights_vbo; // буферы с индексами и весами костей
...
};
Реализация конструкторов, оператора присваивания и метода Model::render в файле src/Model.cpp:
// Конструктор по умолчанию
Model::Model(Node *parent) : Node(parent), verteces_count(0), first_index_byteOffset(0), indices_count(0), indices_datatype(GL_UNSIGNED_INT),
vertex_vbo(VERTEX), index_vbo(ELEMENT), normals_vbo(VERTEX), texCoords_vbo(VERTEX),
tangent_vbo(VERTEX), bitangent_vbo(VERTEX), bonesIds_vbo(VERTEX), bonesWeights_vbo(VERTEX),
skeleton(NULL)
{
// Приведение указателя к целому 8байт
id.value = (GLuint64) this;
id.etc = 0;
}
// Конструктор копирования
Model::Model(const Model& copy) : Node(copy),
vao(copy.vao),
...
bitangent_vbo(copy.bitangent_vbo),
bonesIds_vbo(copy.bonesIds_vbo), bonesWeights_vbo(copy.bonesWeights_vbo),
texture_albedo(copy.texture_albedo),
...
material(copy.material),
skeleton(copy.skeleton)
{
// Приведение указателя к целому 8байт
id.value = (GLuint64) this;
id.etc = copy.id.etc;
}
// Оператор присваивания
Model& Model::operator=(const Model& other)
{
...
bitangent_vbo = other.bitangent_vbo;
bonesIds_vbo = other.bonesIds_vbo;
bonesWeights_vbo = other.bonesWeights_vbo;
...
skeleton = other.skeleton;
return *this;
}
// Вызов отрисовки
void Model::render(ShaderProgram &shaderProgram, UBO &material_buffer, UBO *bones_buffer)
{
// Загрузка идентификатора объекта
glUniform3uiv(shaderProgram.getUniformLoc("ID"), 1, (GLuint*) &id);
// Если есть указатель на скелет и используемый буфер для матриц трансформации
if (skeleton && bones_buffer)
{
// Флаг того, что есть скелет и буфер костей используется
glUniform1i(shaderProgram.getUniformLoc("hasBones"), 1);
glUniformMatrix4fv(shaderProgram.getUniformLoc("model"), 1, GL_FALSE, &this->getIsolatedTransformMatrix()[0][0]);
skeleton->upload(bones_buffer); // Загрузим матрицы трансформации скелета
}
else
{
// Флаг того, что буфер костей НЕ используется
glUniform1i(shaderProgram.getUniformLoc("hasBones"), 0);
glUniformMatrix4fv(shaderProgram.getUniformLoc("model"), 1, GL_FALSE, &this->getTransformMatrix()[0][0]);
}
...
}
Для загрузки индексов и весов костей добавим вспомогательные функции для конфигурации атрибутов вершинного буфера, а так же реализуем метод и Model::load_bonesData:
// Функция для конфигурации атрибута вершинного буфера
void bonesIds_attrib_config()
{
// Включаем необходимый атрибут у выбранного VAO
glEnableVertexAttribArray(2);
// Устанавливаем связь между VAO и привязанным VBO
glVertexAttribIPointer( 4 // индекс атрибута, должен совпадать с Layout шейдера
, 4 // количество компонент одного элемента
, GL_UNSIGNED_INT // тип
, 0 // шаг
, (void *)0 // отступ с начала массива
);
}
// Функция для конфигурации атрибута вершинного буфера
void bonesWeights_attrib_config()
{
// Включаем необходимый атрибут у выбранного VAO
glEnableVertexAttribArray(2);
// Устанавливаем связь между VAO и привязанным VBO
glVertexAttribPointer( 5 // индекс атрибута, должен совпадать с Layout шейдера
, 4 // количество компонент одного элемента
, GL_FLOAT // тип
, GL_FALSE // нормализованность значений
, 0 // шаг
, (void *)0 // отступ с начала массива
);
}
// Загрузка индексов и весов костей
void Model::load_bonesData(glm::ivec4* ids, glm::vec4 *weights, GLuint count)
{
if (count)
{
// Подключаем VAO
vao.use();
bonesIds_vbo.use();
// Загрузка данных в память буфера
bonesIds_vbo.load(ids, sizeof(glm::ivec4)*count);
bonesIds_attrib_config();
bonesIds_vbo.use();
// Загрузка данных в память буфера
bonesWeights_vbo.load(weights, sizeof(glm::vec4)*count);
bonesWeights_attrib_config();
bonesWeights_vbo.use();
}
}
Важное замечание: данные для использования костей компонуются по 4, что накладывает ограничение на количество костей, оказывающих влияние на одну вершину.
Индексы костей будут будут представлены в виде целых чисел в шейдере (для использования в индексах массива буфера матриц трансформаций), что требует использования функции glVertexAttribIPointer при конфигурации буфера, в котором хранятся индексы.
Помимо этого необходимо дополнить метод Model::setBO для работы с новыми буферами:
// Замена вершинного буфера по номеру его привязки
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;
case 5:
bonesIds_vbo = bo;
break;
case 6:
bonesWeights_vbo = bo;
break;
default:
throw std::runtime_error("Unknown attribute buffer");
};
}
Доработка сцены
Для хранения костей и скелетов добавим к сцене два списка (std::list) и метод для перестройки указателей при копировании, а так же дополним метод Scene::render:
// Класс сцены
class Scene
{
public:
Scene(); // Конструктор пустой сцены
Scene(const Scene ©); // Конструктор копирования
Scene& operator=(const Scene& other); // Оператор присваивания
void render(ShaderProgram &shaderProgram, UBO &material_buffer, UBO *bones_buffer = NULL, bool recalc_animations = false); // Рендер сцены
void set_group_id(GLuint64 id, GLuint etc = 0); // Изменение флага записи идентификатора для всех моделей
Node root; // Корневой узел
// Списки объектов, выступающих узлами
std::list<Node> nodes; // Список пустых узлов
std::list<Model> models; // Список моделей для рендера
std::list<Camera> cameras; // Список камер
std::list<Bone> bones; // Узлы, выступающие в роли костей
std::list<Skeleton> skeletons; // Скелеты
std::vector<Animation> animations; // Список анимаций
std::map<std::string, size_t> animation_names; // Имя анимации - индекс
protected:
...
void rebuld_bones(const Scene &from); // Перестройка указателей на кости и узлы, которые выступают в роли костей
};
В файле src/Scene.cpp в методе Scene::render просто передадим указатель на буфер матриц трансформаций костей моделям:
// Рендер сцены
void Scene::render(ShaderProgram &shaderProgram, UBO &material_buffer, UBO *bones_buffer, bool recalc_animations)
{
// Если требуется пересчитаем анимации
if (recalc_animations)
for (auto & animation : animations)
if (animation.isEnabled())
animation.process();
// Рендер моделей
for (auto & model : models)
model.render(shaderProgram, material_buffer, bones_buffer);
}
Так же требуется дополнить конструктор копирования и оператор присваивания:
// Конструктор копирования
Scene::Scene(const Scene ©): root(copy.root),
nodes(copy.nodes), models(copy.models), cameras(copy.cameras),
animations(copy.animations), animation_names(copy.animation_names),
bones(copy.bones) // скелеты (skeletons) перестраиваются в методе Scene::rebuild_bones
{
rebuld_tree(copy);
}
// Оператор присваивания
Scene& Scene::operator=(const Scene& other)
{
root = other.root;
nodes = other.nodes;
models = other.models;
cameras = other.cameras;
animations = other.animations;
animation_names = other.animation_names;
bones = other.bones; // скелеты (skeletons) перестраиваются в методе Scene::rebuild_bones
rebuld_tree(other);
return *this;
}
Важное примечание: список скелетов не копируется, так как автор посчитал, что проще собрать новый в методе Scene::rebuild_bones. Реализация данного метода:
// Перестройка указателей на кости и узлы, которые выступают в роли костей
void Scene::rebuld_bones(const Scene &from)
{
// Цикл по костям
for (auto bone = bones.begin(); bone != bones.end(); ++bone)
{
// Если целевой узел - оригинальный корневой узел, то меняем на собственный корневой узел
if (bone->node == &from.root)
{
bone->node = &root;
continue;
}
// Если можно привести к модели, то ищем родителя среди моделей
if (dynamic_cast<Model*>(bone->node))
move_animation_target(bone->node, from.models, this->models);
else
// Иначе проверяем на принадлежность к камерам
if (dynamic_cast<Camera*>(bone->node))
move_animation_target(bone->node, from.cameras, this->cameras);
// Иначе это пустой узел
else
move_animation_target(bone->node, from.nodes, this->nodes);
// Не нашли узел - значит он не часть этой сцены
// и изменений по каналу не требуется
}
// Построим скелеты с нуля
// Цикл по скелетам оригинала
for (auto & f_skeleton : from.skeletons)
{
Skeleton tmp;
// Цикл по костям в конкретном скелете оригинала
for (Bone* fs_bones : f_skeleton.bones)
{
auto it_bone = this->bones.begin();
// Цикл по общему списку костей оригинала
for (auto f_bone = from.bones.begin(); f_bone != from.bones.end(); ++f_bone, ++it_bone)
// Если адрес объекта, на который указывает итератор, равен кости в скелете
if (&(*f_bone) == fs_bones)
tmp.bones.push_back(&(*it_bone));
}
skeletons.push_back(tmp);
}
// Обновим указатели в моделях для скелета
for (auto & model : models)
{
// Если есть скелет
if (model.skeleton)
{
auto it_skeleton = this->skeletons.begin();
// Цикл по скелетам оригинала
for (auto f_skeleton = from.skeletons.begin(); f_skeleton != from.skeletons.end(); ++f_skeleton, ++it_skeleton)
// Если адрес оригинального скелета, на который указывает итератор, равен используемому в модели
if (&(*f_skeleton) == model.skeleton)
model.skeleton = &(*it_skeleton);
}
}
}
Данный метод сначала перестраивает указатели на узлы, выступающие костями, в объектах класса Bone, затем строит скелеты с правильными указателями на кости, а в конце проходит по массиву моделей и обновляет указатели на скелеты.
Вызовем его в методе Scene::rebuld_tree:
// Перестройка дерева после копирования или присваивания
void Scene::rebuld_tree(const Scene& from)
{
// Восстановим родителей в пустых узлах для копии
rebuild_Nodes_list(nodes, from);
rebuild_Nodes_list(models, from);
rebuild_Nodes_list(cameras, from);
// Восстановим указатели на узлы для каналов анимаций
for (auto & animation : animations)
for (auto & channel : animation.channels)
{
...
}
// Восстановим указатели на кости и перестроим скелеты
rebuld_bones(from);
}
Функция-загрузчик glTF
В файле src/Scene.cpp добавим обработку загруженных скинов (tinygltf::Skin).
После успешного чтения, перед циклом по сценам зарезервируем место под скелеты:
Scene loadGLTFtoScene(std::string filename)
{
...
// Если все успешно считано - продолжаем загрузку
if (success)
{
...
// Зарезервируем место под скелеты
result.skeletons.resize(in_model.skins.size());
// Цикл по сценам
for (auto &scene : in_model.scenes)
{
...
В цикле по примитивам добавим скелеты для создаваемой модели, если такие есть в узле. Для пустых узлов смысла сохранять скелеты нет:
...
// Обработаем полигональную сетку
if (node.mesh > -1)
{
auto &mesh = in_model.meshes[node.mesh];
// Для каждого примитива связанного с полигональной сеткой
for (auto &primitive : mesh.primitives)
{
Model model(tmpParent); // Тут используется либо корневой узел сцены, либо вспомогательный узел
// Скелеты записываются только для моделей (не для узлов), если есть
if (node.skin > -1)
model.skeleton = &(*std::next(result.skeletons.begin(), node.skin));
Далее в цикле по атрибутам для каждого примитива добавим загрузку новых буферов, связанных с костями (индексов и весов):
// Цикл по атрибутам примитива
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 if (attribute.first.compare("JOINTS_0") == 0)
attribute_index = 5;
else if (attribute.first.compare("WEIGHTS_0") == 0)
attribute_index = 6;
else
continue;
// Подключаем вершинный буфер
model.setBO(attribute_index, BOs[accessor.bufferView]);
BOs[accessor.bufferView].use();
glEnableVertexAttribArray(attribute_index);
// Определим спецификацию атрибута
// Если это индексы костей - привяжем как int
if (attribute_index == 5)
glVertexAttribIPointer( attribute_index // индекс атрибута, должен совпадать с Layout шейдера
, tinygltf::GetNumComponentsInType(accessor.type) // количество компонент одного элемента
, accessor.componentType // тип
, accessor.ByteStride(bufferView) // шаг
, ((char *)NULL + accessor.byteOffset) // отступ с начала массива
);
// Иначе как float
else
glVertexAttribPointer ( attribute_index // индекс атрибута, должен совпадать с Layout шейдера
, tinygltf::GetNumComponentsInType(accessor.type) // количество компонент одного элемента
, accessor.componentType // тип
, accessor.normalized ? GL_TRUE : GL_FALSE // нормализованность значений
, accessor.ByteStride(bufferView) // шаг
, ((char *)NULL + accessor.byteOffset) // отступ с начала массива
);
}
Важное замечание: для индексов костей необходимо использовать функцию glVertexAttribIPointer по аналогии с Model::load_bonesData.
Осталось лишь построить скелеты: в конце функции — после цикла по анимациям добавим цикл для построения скелетов:
// Цикл по анимациям
for (auto &in_animation : in_model.animations)
{
...
}
auto result_skeleton = result.skeletons.begin(); // Итератор по скелетам в нашей сцене
// Цикл по скелетам (зарезервированы в начале)
for (auto &in_skin : in_model.skins)
{
// Цикл по костям скелета
for (int i = 0; i < in_skin.joints.size(); i++)
{
Bone bone;
// Если есть матрица обратного преобразования связывания
if (in_skin.inverseBindMatrices > 0)
{
// Получение матриц обратного преобразования связывания
const auto& accessor = in_model.accessors[in_skin.inverseBindMatrices];
const auto& bufferView = in_model.bufferViews[accessor.bufferView];
const auto& buffer = in_model.buffers[bufferView.buffer];
const glm::mat4* matrix = reinterpret_cast<const glm::mat4*>(&(buffer.data[bufferView.byteOffset + accessor.byteOffset]));
bone.inverseBindMatrix = matrix[i];
}
// Узел, выступающий в роли кости
bone.node = pNodes[in_skin.joints[i]];
// Добавим в список костей сцены
result.bones.push_back(bone);
// Добавим кость к скелету
result_skeleton->bones.push_back(&(result.bones.back()));
}
++result_skeleton; // Двигаем итератор
}
// Зададим трансформацию и родителей для узлов
...
В файле src/main.cpp добавим новый буфер для матриц трансформаций костей перед циклом рисования сцены:
// Буфер для костей
UBO bones_matrices_data(NULL, sizeof(glm::mat4)*MAX_BONES, 5);
Для каждого вызова отрисовки сцены передадим наш буфер под матрицы трансформаций костей:
// Пока не произойдет событие запроса закрытия окна
while(!glfwWindowShouldClose(window))
{
...
// Тут производится рендер
scene.render(gShader, material_data, &bones_matrices_data, true);
...
// Рендерим геометрию в буфер глубины
scene.render(sunShadowShader, material_data, &bones_matrices_data);
...
// Для каждого источника вызывается рендер сцены
for (int i = 0; i < Light::getCount(); i++)
{
glUniform1i(pointShadowShader.getUniformLoc("light_i"), i);
// Рендерим геометрию в буфер глубины
scene.render(pointShadowShader, material_data, &bones_matrices_data);
rectangle.render(pointShadowShader, material_data);
}
...
Осталось лишь поменять загружаемую модель. В качестве примера будет использована модель человека из репозитория resources:
Примечание: осталось дополнить шейдеры, а если сейчас запустить приложение, то персонаж будет стоять на камере без учета трансформации сцены, так как он имеет скелет и на шейдер передается матрица трансформации без учета родителей.
Шейдеры
В файле shaders/gshader.vert добавим описание вершинных буферов с индексами и весами костей:
layout(location = 5) in ivec4 boneIds;
layout(location = 6) in vec4 boneWeights;
Важное замечание: так как при настройке вершинного буфера использовалась функция glVertexAttribIPointer — необходимо использовать тип данных ivec4 для индексов костей.
А так же добавим описание uniform-буфера с матрицами трансформаций костей и uniform-переменную для флага о наличии костей:
layout(std140, binding = 5) uniform BonesMatrices
{
mat4 bonesMatrices[100];
};
uniform bool hasBones = false;
Далее, используя флаг о наличии костей, посчитаем итоговую трансформацию модели. Если присутствуют кости, то вклад каждой кости (представлен матрицей трансформации кости) определяется её весом. Для получения правильной матрицы трансформации следует использовать индексы костей (boneIds). После получения итоговой матрицы трансформации для всех костей необходимо добавить изолированную матрицу трансформации модели (без учета родительской):
void main()
{
mat4 totalTransform;
if (!hasBones)
totalTransform = model;
else
{
totalTransform = mat4(0.0);
for (int i = 0; i < 4; i++)
{
if (boneIds[i] == -1 || boneIds[i] >= 100)
continue;
totalTransform += bonesMatrices[boneIds[i]] * boneWeights[i];
}
totalTransform = totalTransform * model;
}
В оставшейся части шейдера поменяем матрицу model на totalTransform:
vec4 P = totalTransform * vec4(pos, 1.0); // трансформация вершины
vertex = P.xyz;
N = normalize(mat3(totalTransform) * normals); // трансформация нормали
texCoord = inTexCoord; // Текстурные координаты
T = normalize(mat3(totalTransform) * tangent);
B = normalize(mat3(totalTransform) * bitangent);
view = camera.position - vertex;
if (displacementmapped)
{
float height = texture(tex_heights, texCoord).r * displacement_heightScale;
P.xyz += mat3(T, B, N) * vec3(0, 0, height);
}
gl_Position = camera.projection * camera.view * P;
}
В вершинном шейдере для теней (shaders/sun_shadow.vert) проделаем аналогичные манипуляции:
#version 420 core
layout (location = 0) in vec3 pos;
layout(location = 5) in ivec4 boneIds;
layout(location = 6) in vec4 boneWeights;
layout(std140, binding = 5) uniform BonesMatrices
{
mat4 bonesMatrices[100];
};
uniform bool hasBones = false;
uniform mat4 model;
void main()
{
mat4 totalTransform;
if (!hasBones)
totalTransform = model;
else
{
totalTransform = mat4(0.0);
for (int i = 0; i < 4; i++)
{
if (boneIds[i] == -1 || boneIds[i] >= 100)
continue;
totalTransform += bonesMatrices[boneIds[i]] * boneWeights[i];
}
totalTransform = totalTransform * model;
}
gl_Position = totalTransform * vec4(pos, 1.0);
}
Текущая версия доступна на теге v0.1 в репозитории 21.
Примеры
На рисунке 1 изображен пример модели LowpolyHuman_Hi из репозитория resources.
В качестве примера можно взять модели из публичного репозитория Khronos Group / glTF-Sample-Models. На рисунке 2 изображена модель RiggedFigure с незакольцованной анимацией поднятия рук.
Важное замечание: блендер упаковывает анимацию для разных моделей в разные анимации. В примере включается первая анимация из файла.
Заключение
В рамках данной заметки рассмотрен формат скелетов в glTF 2.0, а так же написана функция для загрузки скелетов на сцене, которые анимируются наработками из прошлой заметки.
Проект доступен в публичном репозитории: 21.
Библиотеки: dependencies
Ресурсы: resources