Всем привет! Сегодня на обзоре Скелетная анимационная система, её организация и упорядочивание.
Скелетная анимация в 3D - это инструмент для лучшего погружения в повествование.
Часто ли вам приходилось задаваться вопросом: как сделать скелетную систему для 3D? Как организовать данные, как удобнее? Возможно, есть какие-то желания, которые при реализации хотелось бы учесть. Именно таким вопросом я задался, каким-то вечером. И так я начал изучать, что и как сделать. Но всё глубже погружаясь в какие-то туториалы, я обращал внимание на то, как организован код, и всё ускользало, как сквозь пальцы. Казалось бы, вот код, но он как бисер, рассыпан по многочисленным файликам какого-то обзорщика. Так же в какой-то момент, я уже точно знал, как я хочу, чтобы выглядел код, какой мне бы хотелось. Конечно, не совсем так. Узнал о том, какой я хочу код, после некоторых тестов... В общем, предлагаю взглянуть на то, как я смог реализовать анимационную систему в моём стиле. Добро пожаловать в эту статью, кому интересно рассмотреть какие-то нюансы с первых строк кода.
Давайте приступим к обзору.
Буду пользоваться Ubuntu 24.04 LTS, g++-14, assimp, glm, opengl, Blender 4.2.14-LTS, модуль выполнен в 1 файле, чтобы было легко понять и отрефакторить. Так же в сборке может не быть каких-то проверок или флагов при сборке, но по диспетчеру утечки нету. Так же, ключевой момент, в обзоре все анимации из файла Т-позы, а бывают случаи когда скелеты загружают отдельно от Т-позы.
Первый шаг.

Для реализации такой задачи потребуется готовая моделька. Обсуждать, как должна быть организована моделька, не будем. Это первое приближение. Явно выраженного контроллера пока тут нету. Моя модель выполнена 1ой цельной сеткой. Тестовая анимация взята с Mixamo. Но всё тоже самое можно проделать и с нуля.
Контроллер. Я подразумеваю - вспомогательные кости, к которым можно крепить дополнительные модели.
После того как модель готова, я её сохраняю в формате FBX. Далее, пока что на примере Mixamo. Все анимации я импортирую после импорта T-позы и переименовываю клипы анимаций на T-позе под свои имена.

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

Как узнать, какая кость корневая? Надо выбрать эту кость, нажать G, потом X, и вместе с этой костью передвигаться будет вся модель. Чтобы отменить сдвиги Escape.
Так же для теста был выставлен 1 материал. Если выставлять разные материалы, то сетка модели, даже если модель цельная, разобьётся при загрузке на разные сетки по материалам.
Когда клипы готовы, этот файл blend я сохраняю как проект. А Т-позу експортирую!

Второй шаг.
Обычно тут прикидывается куда будет встраиваться модуль анимации, предпочтения, возможно математика, какие-то нюансы.
Мне нужна такая система, которая на верху вырождается в подобие спавнера, загружая модель из абстрактной таблицы. Так как в сцене 1 уникальная модель, модель игрока в нулевом индексе инстанса.
У меня пока всё просто - assimp, opengl, glm.
Третий шаг. Финиш
давайте посмотрим на структуры данных
// vertex of an animated model
struct Vertex
{
glm::vec3 position;
glm::vec3 normal;
glm::vec2 uv;
glm::vec4 boneIds = glm::vec4(0);
glm::vec4 boneWeights = glm::vec4(0.0f);
};
// structure to hold bone tree (skeleton)
struct Bone
{
int id = 0; // position of the bone in final upload array
std::string name = "";
glm::mat4 offset = glm::mat4(1.0f);
std::vector<Bone> children = {};
};
// sturction representing an animation track
struct BoneTransformTrack
{
std::vector<float> positionTimestamps = {};
std::vector<float> rotationTimestamps = {};
std::vector<float> scaleTimestamps = {};
std::vector<glm::vec3> positions = {};
std::vector<glm::quat> rotations = {};
std::vector<glm::vec3> scales = {};
};
// structure containing animation information
struct Animation
{
float duration = 0.0f;
float ticksPerSecond = 1.0f;
std::unordered_map<std::string, BoneTransformTrack> boneTransforms = {};
};
//mesh part
struct ModelVB
{
GLuint vao, vbo, ebo;
GLuint textureID;
};
//mesh part
struct ModelVI
{
std::vector<Vertex> vertices;
std::vector<unsigned int> indices;
};
//animation part
struct Skeletons
{
std::vector<Bone> skeleton;
};
//animation part
struct Animations
{
std::vector<Animation> animations;
};
/////////////////////precompute parts/////////////////////////////
struct Pose
{
std::vector<glm::mat4> pose;
};
struct PrecomputedAnimation
{
int start;
int end;
std::vector<Pose> poses;
};
struct Animator
{
int Number;
std::vector<PrecomputedAnimation> pAnimations;
};
//////////////////////////////////////////////////////////
//locations
struct ModelLocs
{
GLuint *Model;
GLuint *BonesT;
GLuint *Texture;
};
//////////////////////////////////////////////////////////
//model representation part
struct Model
{
std::string name;
ModelVB *modelVB;
ModelVI *modelVI;
Skeletons *skeletons;
Animations *animations;
GLuint *shader;
ModelLocs *locs;
glm::vec3 pos;
glm::mat4 modelMatrix;
unsigned int boneCount = 0;
glm::mat4 globalInverseTransform;
glm::mat4 identity;
std::vector<glm::mat4> currentPose;
Animator *animtor;
unsigned int it = 0;
int frame = 0;
// Время для анимации
float currentTime = 0.0f;
bool playing = true;
float playbackSpeed = 30.f;
bool loop = true;
};
////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////
// table AnimationModel example - db
struct AnimationModel
{
std::vector<Model> models;
};
///////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////
//models on current level
struct ModelOnLevel
{
int n=0;
std::vector<Model> instances;
};
///////////////////////////////////////////////////////////
Здесь видно почти уже всю ситуацию и подход. Намеренно не использую конструктор/деструктор, и члены классов/структур.
Таким образом, получается, нужно будет обеспечить очищение ресурсов в структуре ModelVB. Так же, это налагает на весь подход определенную стратегию. Например, внешняя функция будет работать с последовательностями.
Отсюда следует, что при загрузке надо создать Model. Попутно захватив, отдельную функцию для предрасчета анимаций, обновления состояния.
////////////////////////////////////////////////////////////////////////////////////////////////////////
void CreateModel(Model *model)
{
model->locs = new ModelLocs;
model->modelVB = new ModelVB;
model->modelVI = new ModelVI;
model->skeletons = new Skeletons;
model->animations = new Animations;
model->animtor = new Animator;
}
//update animation frames and states
void updateModel(Model *model, float deltaTime, int h)
{
int t = abs(h);
// model->animtor->pAnimations[t].end;
if (model->currentTime > model->animtor->pAnimations[t].end)
{
if (model->loop && h > 0)
{
model->currentTime = 1.0f;
}
else if (!model->loop && h > 0)
{
model->currentTime = model->animtor->pAnimations[t].end;
model->playing = false;
}
else if (model->loop && h < 0)// проигрывание в обратном порядке
{
model->currentTime = model->animtor->pAnimations[t].end;
}
else if (!model->loop && h < 0)
{
model->currentTime = 1.0f;
model->playing = false;
}
}
if (model->currentTime < 0)
{
if (model->loop)
{
model->currentTime = model->animtor->pAnimations[t].end;
}
else
{
model->currentTime = 1.0f;
model->playing = false;
}
}
if (!model->playing)
return;
// Обновляем время
if (h > 0)
{
model->currentTime += deltaTime * model->playbackSpeed;
model->it = glm::clamp(int(model->currentTime), 0, (int)model->animtor->pAnimations[t].end - 1);
}
else if (h < 0)// проигрывание в обратном порядке
{
model->currentTime -= deltaTime * model->playbackSpeed;
model->it = glm::clamp(int(model->currentTime), 0, (int)model->animtor->pAnimations[t].end - 1);
}
// model->animtor->pAnimations[t].poses[model->it];model->precAnim[t][model->it];
model->currentPose = model->animtor->pAnimations[t].poses[model->it].pose;
}
//update for precompute
void updateModelL(Model *model, float deltaTime, int h)
{
if (model->currentTime > model->animations->animations[h].duration)
{
if (model->loop)
{
model->currentTime = 1.0f;
}
else
{
model->currentTime = model->animations->animations[h].duration;
model->playing = false;
}
}
if (!model->playing)
return;
// Обновляем время
model->currentTime += deltaTime * model->playbackSpeed;
}
//draw
void drawModel(Model *model)
{
glUniformMatrix4fv(*model->locs->BonesT, model->boneCount, GL_FALSE, glm::value_ptr(model->currentPose[0]));
glBindVertexArray(model->modelVB->vao);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, model->modelVB->textureID);
glUniform1i(*model->locs->Texture, 0);
glDrawElements(GL_TRIANGLES, model->modelVI->indices.size(), GL_UNSIGNED_INT, 0);
}
//free
void DeleteModel(Model *model)
{
delete model->locs;
glDeleteBuffers(1, &model->modelVB->vbo);
glDeleteVertexArrays(1, &model->modelVB->vao);
glDeleteBuffers(1, &model->modelVB->ebo);
glDeleteTextures(1, &model->modelVB->textureID);
delete model->modelVB;
delete model->modelVI;
delete model->skeletons;
delete model->animations;
delete model->animtor;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////
Этот блок функций успешно создаст уникальный Model.
Так как я имею таблицу уникальных моделек. Надо показать, как происходит загрузка.
////////////////////////////////////////////////////////////////////////////////////////////////////////
// testFunction
void CreateInstancesOnLevel(ModelOnLevel *ms, AnimationModel *TableAnimationModel, uint &shader,int n)
{
ms->n=n;
// modelsOnLevel.instances = instances;
ms->instances.resize(ms->n);
int l = underS*ms->n;
int tC = 0;
int kC = 0;
int ccc = 0;
for (int i = 0; i < l; ++i)
{
for (int j = 0; j < l; ++j)
{
if (kC == 13)//функция тестовая анимаций всего 13
kC = 0;
Model enemy = TableAnimationModel->models[0];
// берем модельку из таблицы
enemy.shader = &shader;//шейдер
glm::vec3 pos = glm::vec3(i * 5.0f, 0.0f, j * 5.0f);
glm::mat4 modelMatrix(1.0f);
modelMatrix = glm::rotate(modelMatrix, glm::radians(180.0f), glm::vec3(1.0f, 0.0f, 0.0f));
modelMatrix = glm::translate(modelMatrix, pos);
modelMatrix = glm::scale(modelMatrix, glm::vec3(.05f, .05f, .05f));
//верхние три строки пока имеют такой вид,
//потомучто модель импортируется из другой системы координат
//и с другим масштабом
enemy.frame = kC;// каждая анимация на новую модельку по ++
enemy.modelMatrix = modelMatrix;//матрица трансформаций где модель в мире, поворот, масштаб
ms->instances[ccc]= enemy;//появление модельки в инстансах
kC++;
ccc++;
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////
...
void LoadAnimationModel(Model &model, const std::string s, GLuint *modelLoc, GLuint *BonesTLoc, GLuint *TextureLoc)
{
//базовая загрузка
loadModelB(&model, s, modelLoc, BonesTLoc, TextureLoc);
//аниматор
model.animtor->Number = model.animations->animations.size();
model.animtor->pAnimations.resize(model.animtor->Number);
//время для предрасчетов
float lastTime = glfwGetTime();
//цикл по количеству анимаций
for (int h = 0; h < model.animtor->Number; ++h)
{
//создаём хранилище расчитываемых анимаций
PrecomputedAnimation precomputeAnimation;
//начальный кадр 1
precomputeAnimation.start = 1.0f;
//
precomputeAnimation.end = model.animations->animations[h].duration;
precomputeAnimation.poses.resize(model.animations->animations[h].duration);
unsigned int duration = model.animations->animations[h].duration;
//цикл по длительности
for (unsigned int i = 0; i < duration; ++i)
{
// std::cout << "Frame: " << i << std::endl;
float currentTime = glfwGetTime();
deltaTime = currentTime - lastTime;
lastTime = currentTime;
//обновим анимацию
updateModelL(&model, deltaTime, h);
float o = static_cast<float>(i); // int to float
// берем позу по скелету анимации и по времени
//тут и будут интерполяции самой анимации
getPoseSIMD(
model.animations->animations[h],
model.skeletons->skeleton[h],
o,
model.currentPose,
model.identity,
model.globalInverseTransform);
//создадим позу для предрасчетов
Pose pose;
pose.pose = model.currentPose;
precomputeAnimation.poses[i] = pose;
}
//готовая анимация
model.animtor->pAnimations[h] = precomputeAnimation;
}
}
...
Model model;//уникальная модель
AnimationModel TableAnimationModel;// хендлер - моделей
TableAnimationModel.models.push_back(model); // добавим модель
CreateModel(&TableAnimationModel.models[0]); // создадим модель
// укажим путь
std::string str = "Animations/TestCharacter/characterTest3.fbx";
LoadAnimationModel(TableAnimationModel.models[0], str, &modelMatrixLocation, &boneMatricesLocation, &textureLocation);
// загрузка всего что связано с этой уникальной моделькой
ModelOnLevel modelsOnLevel;// хендлер инстансов на уровне или в игре
CreateInstancesOnLevel(&modelsOnLevel, &TableAnimationModel, shader,100);
//создание инстансов
while (!glfwWindowShouldClose(window))
{
//блок захвата игрока
glm::vec3 objectPos = glm::vec3(modelsOnLevel.instances[0].modelMatrix[3]);
glm::vec3 front = glm::normalize(glm::vec3(modelsOnLevel.instances[0].modelMatrix * glm::vec4(0, 0, 1, 0)));
float distanceBehind = 40.0f;
cameraPos = objectPos - front * distanceBehind + glm::vec3(0.0f, -7, 0.0f);
modelsOnLevel.instances[0].frame = animation;
viewMatrix = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
//
viewProjectionMatrix = projectionMatrix * viewMatrix;
float currentTime = glfwGetTime();
deltaTime = currentTime - lastTime;
lastTime = currentTime;
// input
// -----
processInput(window, modelsOnLevel.instances[0].modelMatrix);
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shader);
glUniformMatrix4fv(viewProjectionMatrixLocation, 1, GL_FALSE, glm::value_ptr(viewProjectionMatrix));
//проход по инстансам
for (auto &m : modelsOnLevel.instances)
{
updateModel(&m, deltaTime, m.frame);
glUniformMatrix4fv(modelMatrixLocation, 1, GL_FALSE, glm::value_ptr(m.modelMatrix));
drawModel(&m);
}
glfwSwapBuffers(window);
glfwPollEvents();
}
DeleteModel(&TableAnimationModel.models[0]);
glfwDestroyWindow(window);
glfwTerminate();
...
//простой инпут-контроллер игрока instances[0]
//animation номер анимации
// отрицательная анимация это проигрыш анимации в обратном порядке
// трансформации от и в матрицу
void processInput(GLFWwindow *window, glm::mat4 &p)
{
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
glfwSetWindowShouldClose(window, true);
float cameraSpeed = static_cast<float>(20.5 * deltaTime);
glm::vec3 objectPos = glm::vec3(p[3]);
bool idle = true;
if (glfwGetKey(window, GLFW_KEY_W) == GLFW_PRESS)
{
p = glm::translate(p, cameraFront * (-1.0f));
cameraPos += cameraSpeed * cameraFront;
animation = 12;
idle = false;
}
else if (glfwGetKey(window, GLFW_KEY_S) == GLFW_PRESS)
{
p = glm::translate(p, cameraFront);
cameraPos -= cameraSpeed * cameraFront;
animation = -12;
idle = false;
}
else if (glfwGetKey(window, GLFW_KEY_A) == GLFW_PRESS)
{
p = glm::translate(p, glm::normalize(glm::cross(cameraFront, cameraUp)));
cameraPos += glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
animation = 4;
idle = false;
}
else if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
{
p = glm::translate(p, glm::normalize(glm::cross(cameraFront, cameraUp)) * (-1.0f));
cameraPos -= glm::normalize(glm::cross(cameraFront, cameraUp)) * cameraSpeed;
animation = 8;
idle = false;
}
if (idle)
{
animation = 0;
}
}
Вот что получается в итоге.

В планах на будущее посмотрю ufbx - удобные opts, дают возможность настроить оси при загрузке модели
С полным кодом можно ознакомиться тут
Ссылки на другие источники
https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation
изначальный пример взят отсюда