Всем привет! Сегодня на обзоре Скелетная анимационная система, её организация и упорядочивание.
Скелетная анимация в 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
изначальный пример взят отсюда
