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