steps3D - Tutorials - Скелетная анимация с ASSIMP

Скелетная анимация с ASSIMP

Традиционным подходом для анимации игровых персонажей уже давно стала скелетная анимация или skinning, впервые предложенная еще в 1988 году. Основной идеей скелетной анимации (в отличии от использовавшихся ранее подходов, задающих отдельные анимации для каждой вершины) было использовании специальной иерархической структуры (скелета) для задания анимации.

Модель фактически состоит из двух частей - собственно геометрии (вершин и граней) и скелета (набора костей). Вся анимация строится только для скелета. Мы просто задаем анимацию для каждой кости скелета. Основным преимуществом данного подхода является то, что костей в скелете намного меньше чем вершин (в сотни раз). Поэтому задавать анимацию для скелета гораздо проще и дешевле чем отдельно для каждой вершины и подобная анимация занимает гораздо меньше памяти.

Рис 1. Скелет для модели

Скелет - это иерархия (дерево) костей, у каждой кости (кроме корня) есть родительская кость и могут быть дочерние кости. Также с каждой костью связано некоторое преобразование (матрица 4х4), которое задается относительно родительской кости. Анимация заключается просто в изменении этих преобразований с течением времени.

При этом каждая кость может оказывать влияние на одну или несколько вершин и у каждой вершины может быть несколько костей, влияющих на нее. Обычно число костей, влияющих на вершину, заранее ограничено и мы будем далее считать что на одну вершину может влиять не более четырех костей.

Пусть на вершину v влияют кости b0, b1, b2 и b3. С каждой костью на самом деле связаны две матрицы. Одну из них мы рассмотрели ранее - это локальная матрица, т.е. матрица преобразования относительно родительской кости. Но у родительской кости есть своя локальная матрица, у ее родителя - свой матрица и т.д. Тем самым с каждой костью можно связать еще и глобальную матрицу - матрицу, полученную перемножением всех родительских матриц до самого корня, она задает преобразование данной кости в системе координат всей модели.

Будем считать что кости b0, b1, b2 и b3 имеют глобальные матрицы преобразования B0, B1, B2 и B3. Тогда все эти матрицы будут участвовать в преобразовании данной вершины. Но при этом они могут вносить разный вклад в преобразование вершины - мы будем считать что у каждой вершины кроме четырех костей еще еще четыре коэффициента (веса) w0, w1, w2 и w3. Они задают насколько каждая кость влияет на анимацию данной вершины (их сумма обычно равна единице). В результате анимация вершины задается следующей формулой:

vout=(w0 B0 + ... + w3 B3) * v

Рис 2. Кости, влияющие на вершину

На практике мы передаем в вершинный шейдер массив глобальных матриц костей (по одной матрице на каждую кость) в виде uniform-массива mat4. Также каждой вершине добавляется два атрибута - один целочисленный, типа ivec4, и один вещественный, типа vec4. Первый атрибут задает индексы костей (в массив матриц), а второй - их веса. Тогда вершинный шейдер для скелетной анимации будет выглядеть следующим образом.

#version 330 core

layout (location = 0) in vec3  pos;
layout (location = 1) in vec2  tex;
layout (location = 2) in vec3  normal;
layout (location = 3) in vec3  tangent;
layout (location = 4) in vec3  binormal;
layout (location = 5) in ivec4 boneIDs;
layout (location = 6) in vec4  weights;

uniform mat4 proj;
uniform mat4 mv;
uniform mat3 nm;
uniform vec3 eye;       // eye position
uniform vec3 lightDir;

const int MAX_BONES = 100;

uniform mat4 bones [MAX_BONES];

out vec3 l;
out vec3 h;
out vec3 v;
out vec3 t;
out vec3 b;
out vec3 n;
out vec2 tx;

void main(void)
{
    mat4    boneTransform;

    boneTransform  = bones [boneIDs [0]] * weights [0];
    boneTransform += bones [boneIDs [1]] * weights [1];
    boneTransform += bones [boneIDs [2]] * weights [2];
    boneTransform += bones [boneIDs [3]] * weights [3];

    vec4    pp    = boneTransform * vec4 ( pos, 1.0 );

    if ( weights[0] + weights[1] + weights[2] + weights[3] < 0.01 )
        pp = vec4 ( pos, 1.0 );

    vec4 p = mv * pp;

    gl_Position  = proj * p;

    tx = tex;
    n  = normalize (nm * normal);
    t  = normalize (nm * tangent);
    b  = normalize (nm * binormal);

    l  = normalize (lightDir);
    v  = normalize (eye   - p.xyz);
    h  = normalize (l + v);
    
                // convert to TBN
    v  = vec3 ( dot ( v, t ), dot ( v, b ), dot ( v, n ) );
    l  = vec3 ( dot ( l, t ), dot ( l, b ), dot ( l, n ) );
    h  = vec3 ( dot ( h, t ), dot ( h, b ), dot ( h, n ) );
}

Соответствующая ему вершина будет задаваться следующим описанием:

#define     NUM_BONES_PER_VERTEX    4

struct  SkinnedVertex
{
    glm::vec3   pos;
    glm::vec2   tex;
    glm::vec3   n;
    glm::vec3   t, b;
    uint32_t    ids     [NUM_BONES_PER_VERTEX];         // bone ids
    float       weights [NUM_BONES_PER_VERTEX];         // bone weights

    SkinnedVertex ()
    {
        memset ( ids, 0, sizeof ( ids ) );
        memset ( weights, 0, sizeof ( weights ) );
    }
                // add new bone/weight pair
    void addBoneData ( uint32_t bone, float weight )
    {
        if ( weight <= 0.0f )
            return;

        uint32_t    index = 0;
        
        while ( index < NUM_BONES_PER_VERTEX - 1 && weights [index] > 0 )
            index++;
        
        assert ( index < NUM_BONES_PER_VERTEX );
        
        if ( index < NUM_BONES_PER_VERTEX )
        {
            ids[index]     = bone;
            weights[index] = weight;
        }
    };

        // ensure bone weights are normalized (sum to 1)
    void    normalize ()
    {
        float   sum = weights [0];
        
        for ( int i = 1; i < NUM_BONES_PER_VERTEX; i++ )
            sum += weights [i];
            
        for ( int i = 0; i < NUM_BONES_PER_VERTEX; i++ )
            weights [i] /= sum;
    }
};

На сайте www.mixamo.com есть большое количество готовых анимаций и набор простых моделей, к которым эти анимации применимы. Обратите внимание, что обычно принято задавать модель изначально в так называемой T-pose, как показано на следующем рисунке.

Рис 3. T-pose

Очень часто для загрузки моделей разных форматов в приложениях используется библиотека ASSIMP. Она поддерживает большое количество различных форматов моделей и также поддерживает загрузку моделей со скелетной анимацией. Но использование ее для работы со скелетной анимацией несет в себе и некоторые нюансы, в частности там используется своя библиотека векторов и матриц, отличающаяся от GLM. Наиболее заметным отличием является то что матрицы в ней по сравнению с GLM хранятся в транспонированном виде и поэтому они перемножаются в обратном порядке.

Далее мы рассмотрим использовании библиотеки ASSIMP для загрузки моделей и анимаций, но при этом мы будем использовать в качестве математической библиотеки более традиционную GLM. В результате мы создадим класс SkinnedModel для загрузки, анимации и рендеринга таких моделей.

Важной особенностью библиотеки ASSIMP является то, что она поддерживает иерархическую организацию моделей без отношения к скелетной анимации. Модель (aiScene) состоит из отдельных мешей (aiMesh), объединенных в дерево при помощи узлов (aiNode).

Экземпляр класса aiNode включает в себя имя, матрицу преобразования (относительно родительского узла) и набор мешей. Также имеется указатель на родительский класс и массив указателей на дочерние узлы. Модель содержит ссылку на корень иерархии. Это позволяет строить сложные, иерархически организованные модели.

Поэтому строить отдельно от этой иерархии еще одну иерархию костей не имеет никакого смысла и для скелета используется существующая иерархия узлов. Сами кости задаются объектами класса aiBone. Каждая кость содержит матрицу преобразования и имя. При помощи этого имени кость привязывается к узлу иерархия (с этим же именем). Каждая кость имеет соответствующий узел в иерархии, но не у каждого узла есть своя кость. На следующем фрагменте кода приводятся используемые классы.


struct  Primitive                   // rendering data for submesh in a common buffer
{
    std::string name;
    uint32_t    numIndices;         // # of indices used in this primitive
    uint32_t    baseVertex; 
    uint32_t    baseIndex;          // starting index for this primtitive
    uint32_t    material;           // material index into model's list of materials
    uint32_t    numBones;
    bbox        bounds;             // bounding box for this primitive

    void    render () const
    {
        if ( numIndices > 0 )
            glDrawElementsBaseVertex ( GL_TRIANGLES, numIndices, GL_UNSIGNED_INT, (void*)(sizeof(uint32_t) * baseIndex), baseVertex );
    }
};

struct  Node
{
    std::string             name;
    glm::mat4               transform = glm::mat4 ( 1.0f );
    Node                  * parent    = nullptr;
    std::vector<Node *>     children;   // child nodes, owns
    std::vector<Primitive*> meshes;     // does not own, just a ref
    bbox                    bounds;
    
    Node ( const std::string& n, const glm::mat4& m ) : name ( n ), transform ( m ) {}
    ~Node ()
    {
        for ( auto * child : children )
            delete child;
    }
    
    const bbox& getBox () const
    {
        return bounds;
    }
    
    void    computeBounds ()
    {
        bounds.reset ();
        
            // get bounds for all meshes
        for ( auto * mesh : meshes )
            bounds.merge ( mesh->bounds );
        
            // collect bounds from children
        for ( auto * child : children )
        {
            child->computeBounds ();
            
            bounds.merge ( child->bounds );
        }
        
            // apply transform to them
        bounds.apply ( transform );
    }
    
    glm::mat4   parentTransform ( glm::mat4& m ) const
    {
        for ( const Node * node = this; node -> parent; node = node -> parent )
            m = node->transform * m;
        
        return m;
    }
};

struct  Bone
{
    std::string name;
    glm::mat4   invBindPose;    //  transforms from bone space to mesh space in bind pose
    glm::mat4   globalPose;
    Bone      * parent;
    Node      * node;
};

Давайте теперь рассмотрим каким образом задается в ASSIMP анимация. Для этого в ASSIMP используется всего два класса - aiAnimation и aiNodeAnim. Класс AiAnimation задает всего одну анимацию, но для всей модели целиком. Он содержит массив отдельных анимаций для узлов (по одной анимации на узел) и параметры, задающие масштабирование времени анимации - mDuration и mTicksPerSecond.

Класс aiNodeAnim задает анимацию отдельного узла и для идентификации этого узла он хранит его имя. Также он хранит массив ключевых кадров - для каждого ключевого кадра он хранит момент времени и соответствующее преобразование. Преобразование хранится не в виде матрицы 4х4, а как три отдельных значения - вектор переноса (translation), вектор масштабирования (scaling) и кватернион, задающий поворот (rotation).

Соответственно при построении преобразования для заданного момента времени мы сперва приводим этот момент к времени анимации, после чего путем интерполяции (линейной для переноса и масштабирования и сферической для кватернионов) определяем параметры анимации него. Далее по этим параметрам строится матрица 4х4.

class   AnimNode                // animation node - animation for a given node
{
    template <typename T>
    struct  KeyValue
    {
        float   t;
        T       value;
    
        KeyValue ( float tt, const T& v ) : t ( tt ), value ( v ) {}
    };
    
    using   Key3 = KeyValue<glm::vec3>;         // (time, vec3) key (pos, scale)
    using   KeyQ = KeyValue<glm::quat>;         // (time, quat) key (rotation)
    
    std::string         name;
    std::vector<Key3>   positions;
    std::vector<KeyQ>   rotations;
    std::vector<Key3>   scalings;
    
public:
    AnimNode ( const aiNodeAnim * node );
    
    const std::string&  getName () const
    {
        return name;
    }
    
    template <typename T>
    uint32_t    findKey ( float time, const T& keys ) const
    {
        for ( uint32_t i = 0; i < keys.size () - 1; i++ )
            if ( time < keys [i+1].t )
                return i;
                
        return keys.size() - 1;      // sometimes we have error with animation duration and key time stamps
    }
    
    glm::vec3   interpolate3 ( float time, const std::vector<Key3>& keys ) const;
    glm::quat   interpolate4 ( float time, const std::vector<KeyQ>& keys ) const;
    glm::mat4   interpolate  ( float time ) const;
};

AnimNode::AnimNode ( const aiNodeAnim * node ) : name ( node->mNodeName.C_Str () )
{
    for ( size_t i = 0; i < node->mNumPositionKeys; i++ )
        positions.push_back ( Key3 ( node->mPositionKeys [i].mTime, toGlmVec3 ( node->mPositionKeys [i].mValue ) ) );
            
    for ( size_t i = 0; i < node->mNumRotationKeys; i++ )
        rotations.push_back ( KeyQ ( node->mRotationKeys [i].mTime, toGlmQuat ( node->mRotationKeys [i].mValue ) ) );
            
    for ( size_t i = 0; i < node->mNumScalingKeys; i++ )
        scalings.push_back ( Key3 ( node->mScalingKeys [i].mTime, toGlmVec3 ( node->mScalingKeys [i].mValue ) ) );
}

glm::vec3   AnimNode::interpolate3 ( float time, const std::vector<Key3>& keys ) const
{
    assert ( keys.size () > 0 );
        
    if ( keys.size () == 1 )        // only one value
        return keys [0].value;
                
    auto    index = findKey ( time, keys );
    auto    next  = (index + 1) % keys.size ();
        
    assert ( next < keys.size () );
        
    float delta  = keys [next].t - keys [index].t;
    float factor = clamp ( (time - keys [index].t ) / delta );
        
    return glm::mix ( keys [index].value, keys [next].value, factor );
}
    
glm::quat   AnimNode::interpolate4 ( float time, const std::vector<KeyQ>& keys ) const
{
    assert ( keys.size () > 0 );
    
    if ( keys.size () == 1 )        // only one value
        return keys [0].value;
                
    auto    index = findKey ( time, keys );
    auto    next  = (index + 1) % keys.size ();
        
    assert ( next < keys.size () );
        
    float delta  = keys [next].t - keys [index].t;
    float factor = clamp ( (time - keys [index].t ) / delta );

    return glm::slerp ( keys [index].value, keys [next].value, factor );
}
    
glm::mat4   AnimNode::interpolate ( float time ) const
{
    auto    pos   = interpolate3 ( time, positions );
    auto    rot   = interpolate4 ( time, rotations );
    auto    scale = interpolate3 ( time, scalings  );
    
    return glm::scale ( glm::mat4 ( 1 ), scale ) * glm::translate ( glm::mat4 ( 1 ), pos ) * glm::mat4_cast ( rot );
}

class   Animation               // single animation for a node
{
    std::string             name;
    float                   duration;
    float                   ticksPerSecond;
    std::vector<AnimNode *> channels;
        
public:
    Animation ( const aiAnimation * anim ) : name ( toString ( anim->mName ) )
    {
        duration       = anim->mDuration;
        ticksPerSecond = anim->mTicksPerSecond;
            
        for ( size_t i = 0; i < anim->mNumChannels; i++ )
            channels.push_back ( new AnimNode ( anim->mChannels [i] ) );
    }
    ~Animation ()
    {
        for ( auto * node : channels )
            delete node;
    }
    
    float   convertTime ( float time ) const
    {
        return fmod ( time * ticksPerSecond, duration );
    }

    const AnimNode * findNode ( const std::string& n ) const
    {
        for ( auto ch : channels )
            if ( ch -> getName () == n )
                return ch;
                
        return nullptr;
    }
};

Рассмотрим теперь класс SkinnedMesh, служащий для представления анимированной модели. Удобнее для всех мешей сразу завести два буфера - вершин и индексов, которые будут хранить в себе геометрию сразу всех мешей. Также удобно сразу завести массив мешей и массив материалов для этих мешей (один материал может использоваться сразу несколькими мешами). Также мы заведем массив костей и массив соответствующих им глобальных матриц. Иерархию узлов мы будем хранить просто как указатель на корень.

class   SkinnedModel
{
    std::vector<SkinnedVertex>  vertices;
    std::vector<uint32_t>       indices;
    std::vector<PbrMaterial *>  materials;
    std::vector<Primitive>      meshes;
    std::vector<Bone>           bones;
    std::vector<Animation*>     animations;
    std::vector<glm::mat4>      boneMatrices;
    Node                      * root = nullptr;
    VertexArray                 vao;                // array with all bindings
    VertexBuffer                vertexBuf;          // vertex data (Skinnedvertex) for all submeshes
    VertexBuffer                indexBuf;           // index buffer for all submeshes

public:
    SkinnedModel () = default;
    ~SkinnedModel ()
    {
        for ( auto * mat : materials )
            delete mat;
        
        for ( auto * anim : animations )
            delete anim;
    }
    
    bool    isOk () const
    {
        return root != nullptr && !meshes.empty () && !materials.empty ();
    }
    
    bool load ( const std::string& fileName, const std::string& texturePath )
    {   
        MeshLoader  loader;
        
        auto * scene = loader.loadScene ( fileName, true );

        if ( scene == nullptr )
            return false;

        assert (_heapchk () == _HEAPOK );

        loadMaterials        ( loader, scene, texturePath );
        loadMeshes           ( loader, scene, 1 );
        loadNodes            ( loader, scene    );
        setBonesParents      ();
        
        assert (_heapchk () == _HEAPOK );
        
        return true;
    }
    
    void render ( const glm::mat4& modelView, Program& program ) 
    {
        program.bind               ();
        program.setUniformMatrices ( "bones", boneMatrices.data (), boneMatrices.size () );

        vao.bind       ();
        renderNode     ( root, modelView, program );
        vao.unbind     ();
        program.unbind ();
    }

    void    renderNode ( Node * node, const glm::mat4& modelView, Program& program )
    {
        glm::mat4   tr   = glm::inverse ( root->transform ) * node -> parentTransform ( glm::mat4 ( 1 ) );
        glm::mat4   mv   = modelView * tr;
        glm::mat3   nm   = normalMatrix ( mv );
        glm::vec3   sz   = root->getBox ().getSize   ();
        float       size = max3 ( sz ) / 7;
        glm::mat4   translate = glm::translate ( glm::mat4(1), -root->getBox ().getCenter () );
        glm::mat4   scale     = glm::scale     ( glm::mat4(1), glm::vec3 ( 1.0 / size ) );

        program.setUniformMatrix ( "mv", scale * translate * mv );
        program.setUniformMatrix ( "nm", nm );

        for ( auto * mesh : node->meshes )
        {
            auto * mat = materials [mesh->material];
            
            mat  -> bind   ( program );
            mesh -> render ();
        }
        
        for ( auto * c : node->children )
            renderNode ( c, modelView, program );
    }
    
    Node * createNode ( const aiNode * n, Node * parent )
    {
        auto node = new Node ( toString ( n->mName ), toGlmMat4 ( n->mTransformation )  );
        
        node->parent = parent;
        
                // add meshes (mNumMeshes, mMeshes (int indices into meshes list)
        for ( int i = 0; i < n->mNumMeshes; i++ )
            node->meshes.push_back ( &meshes [n->mMeshes[i]] );
        
                // add children
        for ( int i = 0; i < n->mNumChildren; i++ )
            node->children.push_back ( createNode ( n->mChildren [i], node ) );
        
        return node;
    }
    
    void boneTransform ( int animation, float timeInSeconds )
    {
        glm::mat4 identity = glm::mat4(1.0f);
        float     time     = animations [animation] -> convertTime ( timeInSeconds );
    
        readHierarchy ( animation, time, root, identity );

        boneMatrices.resize ( bones.size () );
        
        for  ( size_t  i = 0; i < bones.size (); i++ )
            boneMatrices [i] = bones[i].globalPose;
    }

    void    readHierarchy  ( int animation, float time, Node * node, const glm::mat4& parentTransform )
    {
        int boneIndex = findBone ( node->name );

        if ( boneIndex != -1 )
        {
            auto * anim          = animations [animation];
            auto * animNode      = anim -> findNode ( node->name );
            auto   nodeTransform = node->transform;
            
            if ( animNode != nullptr )
                nodeTransform = animNode -> interpolate ( time );

                // Combine with node Transformation with Parent Transformation
            glm::mat4   globalTransform = parentTransform * nodeTransform;

            bones [boneIndex].globalPose = glm::inverse ( root->transform ) *  globalTransform * bones[boneIndex].invBindPose;

            for ( auto * child : node->children )
                readHierarchy ( animation, time, child, globalTransform );
        }
        else
        {
            for ( auto * child : node->children )
                readHierarchy ( animation, time, child, parentTransform );
        }
    }

    void    loadNodes ( MeshLoader& loader, const aiScene * scene )
    {
        root = createNode ( scene->mRootNode, nullptr );

        root->computeBounds (); 
    }
    
    void    loadMaterials ( MeshLoader& loader, const aiScene * scene, const std::string& texturePath )
    {
        printf ( "Found %d embedded textures\n", scene -> mNumTextures );
        printf ( "Found %d materials\n",  scene->mNumMaterials );
        
        std::function<bool(Texture&, const std::string&)> texLoader = [scene] (Texture& texture, const std::string& name)->bool
        {
            auto nm = getFileName ( name );

            for ( size_t i = 0; i < scene -> mNumTextures; i++ )
            {
                auto * pTex = scene -> mTextures [i];

                if ( nm == getFileName ( pTex->mFilename.C_Str () ) )
                {
                    if ( pTex -> mHeight > 0 )      // not compressed data, just raw RGBA bits
                        return texture.load2DRaw ( pTex->mWidth, pTex->mHeight, GL_RGBA, GL_RGBA, GL_UNSIGNED_BYTE, pTex->pcData );     
                                                // it is compressed texture, in pTex->achFormatHint type of texture, mWidth - data size
                    return texture.load2D ( pTex->pcData, pTex->mWidth, pTex->mFilename.C_Str ()  );
                }
            }

            return texture.load2D ( name );
        };
        
        for ( unsigned i = 0; i < scene->mNumMaterials; i++ )
        {
            aiMaterial        * material  = scene->mMaterials[i];
            std::string         name      = toString ( material->GetName() );
            std::string         path      = "";
            glm::vec4           diffColor = glm::vec4 ( 0 );            
            aiColor4D           color;

            std::string diff  = getAiTexture ( material, aiTextureType_DIFFUSE  );
            std::string bump  = getAiTexture ( material, aiTextureType_NORMALS  );
            std::string bump2 = getAiTexture ( material, aiTextureType_HEIGHT   );
            std::string spec  = getAiTexture ( material, aiTextureType_SPECULAR );

            if ( AI_SUCCESS == material->Get ( AI_MATKEY_COLOR_DIFFUSE, color ) )
                diffColor = glm::vec4 ( color.r, color.g, color.b, color.a );

            printf ( "---- Material: %s\n", name.c_str () );
            
            PbrMaterial * mat  = new PbrMaterial ( name, texturePath,  diff, bump, spec, diffColor, texLoader );

            materials.push_back ( mat );
        }
    }
    
    void    loadMeshes ( MeshLoader& loader, const aiScene * scene, float scale )
    {
        bbox        box;
        Primitive   mesh;
        uint32_t    numVertices = 0;
        uint32_t    numIndices  = 0;

                // Count the number of vertices and indices
        for ( uint32_t i = 0 ; i < scene->mNumMeshes ;i++ ) 
        {
            if ( scene->mMeshes [i]->mNormals == nullptr )      // sometimes happen
            {
                meshes.push_back ( { "", 0, 0, 0, 0 } );        // placeholder for empty mesh
                continue;
            }

            mesh.name       = toString ( scene -> mMeshes [i] ->mName );
            mesh.material   = scene -> mMeshes [i] -> mMaterialIndex;
            mesh.numIndices = scene -> mMeshes [i] -> mNumFaces * 3;
            mesh.baseVertex = numVertices;
            mesh.baseIndex  = numIndices;

            meshes.push_back ( mesh );
                
            numVertices += scene -> mMeshes [i] -> mNumVertices;
            numIndices  += scene -> mMeshes [i] -> mNumFaces * 3;
        }
            
        for ( uint32_t i = 0 ; i < scene->mNumMeshes ;i++ ) 
        {
            if ( scene->mMeshes [i]->mNormals == nullptr )      // sometimes happen
                continue;

            loader.loadAiMesh<SkinnedVertex> ( scene->mMeshes[i], scale, vertices, indices, box, 0 );   

            meshes [i].bounds   = box;
            meshes [i].numBones = scene->mMeshes [i]->mNumBones;

            loadBones ( scene, scene->mMeshes [i], meshes [i].baseVertex );
        }   

        boneMatrices.resize ( bones.size () );
        
        loadAnimations ( scene );
        createBuffers  ( vertices, indices );
    }

    void    loadBones ( const aiScene * scene, aiMesh * mesh, int baseVertex )
    {
        for ( size_t i = 0; i < mesh->mNumBones; i++ )
        {
            std::string name  = toString ( mesh -> mBones [i]->mName );
            int         index = findBone ( name );
            
            if ( index < 0 )        // new bone
                index = addBone ( mesh->mBones [i] );

            for ( size_t j = 0 ; j < mesh -> mBones [i] -> mNumWeights ; j++ )
            {
                uint32_t    vertex = mesh->mBones [i]->mWeights [j].mVertexId + baseVertex;
                float       weight = mesh->mBones [i]->mWeights [j].mWeight;
                
                assert ( vertex < vertices.size () );

                vertices [vertex].addBoneData ( index, weight );
            }
        }
    }

    void    loadAnimations ( const aiScene * scene )
    {
        printf ( "Found %d animations\n", scene -> mNumAnimations );
        
        for ( size_t i = 0; i < scene -> mNumAnimations; i++ )
            animations.push_back ( new Animation ( scene -> mAnimations [i] ) );
    }

    int addBone ( const aiBone * bone )
    {
        Bone    info;
    
        info.invBindPose = toGlmMat4 ( bone -> mOffsetMatrix );
        info.globalPose  = glm::mat4 ( 1.0f );
        info.name        = toString ( bone -> mName );
        info.parent      = nullptr;
        info.node        = nullptr;

        bones.push_back ( info );

        return (int) (bones.size () - 1);
    }

    int findBone ( const std::string& name ) const
    {
        for ( int i = 0; i < bones.size (); i++ )
            if ( bones [i].name == name )
                return i;

        return -1;
    }

    Node * findNode ( Node * node, const std::string& name )
    {
        if ( node == nullptr )
            return nullptr;

        if ( node->name == name )
            return node;

        for ( auto * child : node->children )
            if ( auto * ptr = findNode ( child, name ) )
                return ptr;

        return nullptr;
    }

    void    setBonesParents ()
    {
        for ( auto& bone : bones )
        {
            Node * node = findNode ( root, bone.name );
        
            if ( node == nullptr )  // no node for bone, strange
                continue;
                                    // look by name of parent node
            if ( node->parent == nullptr )
                continue;

            auto index = findBone ( node->parent->name );

            bone.parent = index >= 0 ? &bones [index] : nullptr;
            bone.node   = (Node *) node;
        }
    }

    void    createBuffers ( std::vector<SkinnedVertex>& vertices, std::vector<uint32_t>& indices )
    {
        vao.create        ();
        vao.bind          ();
        vertexBuf.create  ();
        vertexBuf.bind    ( GL_ARRAY_BUFFER );
        vertexBuf.setData ( vertices );
            
        size_t  offs = 0;       // attribute offset
            
                            // register all components for locations
                            // 0 -> position
        glVertexAttribPointer ( 0,  
                        3,          // number of values per vertex
                        GL_FLOAT,
                        GL_FALSE,   // normalized
                        sizeof(SkinnedVertex),          // stride (offset to next vertex data)
                        (const GLvoid*) offs );
                
        offs += sizeof ( glm::vec3 );
            
                            // 1 -> texture coordinates
        glVertexAttribPointer ( 1,  
                        2,          // number of values per vertex
                        GL_FLOAT,
                        GL_FALSE,   // normalized
                        sizeof(SkinnedVertex),          // stride (offset to next vertex data)
                        (const GLvoid*) offs );
                
        offs += sizeof ( glm::vec2 );
            
                                    // 2 -> normal
        glVertexAttribPointer ( 2,  
                        3,          // number of values per vertex
                        GL_FLOAT,
                        GL_FALSE,   // normalized
                        sizeof(SkinnedVertex),          // stride (offset to next vertex data)
                        (const GLvoid*) offs );
                
        offs += sizeof ( glm::vec3 );
            
                                    // 3 -> tangent
        glVertexAttribPointer ( 3,  
                    3,          // number of values per vertex
                    GL_FLOAT,
                    GL_FALSE,   // normalized
                    sizeof(SkinnedVertex),          // stride (offset to next vertex data)
                    (const GLvoid*) offs );
                
        offs += sizeof ( glm::vec3 );

                                // 4 -> binormal
        glVertexAttribPointer ( 4,  
                        3,          // number of values per vertex
                        GL_FLOAT,
                        GL_FALSE,   // normalized
                        sizeof(SkinnedVertex),  // stride (offset to next vertex data)
                        (const GLvoid*) offs );
                                    
        offs += sizeof ( glm::vec3 );
                                    
        auto    boneIdOffs     = offs;      // save offset to bone id's
        auto    boneWeightOffs = boneIdOffs + NUM_BONES_PER_VERTEX * sizeof ( uint32_t );
            
                                    // 5 - bone ids
        glVertexAttribIPointer ( 5, 4, GL_INT, sizeof(SkinnedVertex), (const GLvoid*) boneIdOffs );

                                    // 6 -> bone weights
        glVertexAttribPointer ( 6,  
                        4,          // number of values per vertex
                        GL_FLOAT,
                        GL_FALSE,   // normalized
                        sizeof(SkinnedVertex),  // stride (offset to next vertex data)
                        (const GLvoid*) boneWeightOffs );
            
        glEnableVertexAttribArray ( 0 );
        glEnableVertexAttribArray ( 1 );
        glEnableVertexAttribArray ( 2 );
        glEnableVertexAttribArray ( 3 );
        glEnableVertexAttribArray ( 4 );
        glEnableVertexAttribArray ( 5 );
        glEnableVertexAttribArray ( 6 );
            
        indexBuf.create  ();
        indexBuf.bind    ( GL_ELEMENT_ARRAY_BUFFER );
        indexBuf.setData ( indices );
        
        vao.unbind ();
    }
};

https://learnopengl.com/Guest-Articles/2020/Skeletal-Animation

Bones animation with OpenGL, ASSIMP and GLM

Skeletal animation with ASSIMP, COLLADA, and glm

ASSIMP OpenGL collada and Skeletal Animation

Skeletal animation with Assimp and glm

Doing animations in OpenGL

ASSIMP Skeletal Animation Tutorial #1 – Vertex Weights and Indices ASSIMP Skeletal Animation Tutorial #3 – Something about Skeletons

ASSIMP 2: Skelatel Animation

По этой ссылке можно скачать весь исходный код к этой статье.