Search code examples
c++animationopenglassimpskeletal-animation

How do I correctly blend between skeletal animations in OpenGL from a walk animation to a run animation?


I finished the skeletal animation tutorial from learnopengl.com (link).

When I play another animation, it "jumps" to the first frame of that animation in a very jarring way instead of smoothly transitioning to it.

Here's what I wrote so far:

// Recursive function that sets interpolated bone matrices in the 'm_FinalBoneMatrices' vector
void CalculateBlendedBoneTransform(
        Animation* pAnimationBase,  const AssimpNodeData* node,
        Animation* pAnimationLayer, const AssimpNodeData* nodeLayered,
        const float currentTimeBase, const float currentTimeLayered,
        const glm::mat4& parentTransform,
        const float blendFactor)
{
    const std::string& nodeName = node->name;

    glm::mat4 nodeTransform = node->transformation;
    Bone* pBone = pAnimationBase->FindBone(nodeName);
    if (pBone)
    {
        pBone->Update(currentTimeBase);
        nodeTransform = pBone->GetLocalTransform();
    }

    glm::mat4 layeredNodeTransform = nodeLayered->transformation;
    pBone = pAnimationLayer->FindBone(nodeName);
    if (pBone)
    {
        pBone->Update(currentTimeLayered);
        layeredNodeTransform = pBone->GetLocalTransform();
    }

    // Blend two matrices
    const glm::quat rot0 = glm::quat_cast(nodeTransform);
    const glm::quat rot1 = glm::quat_cast(layeredNodeTransform);
    const glm::quat finalRot = glm::slerp(rot0, rot1, blendFactor);
    glm::mat4 blendedMat = glm::mat4_cast(finalRot);
    blendedMat[3] = (1.0f - blendFactor) * nodeTransform[3] + layeredNodeTransform[3] * blendFactor;

    const glm::mat4 globalTransformation = parentTransform * blendedMat;

    const auto& boneInfoMap = pAnimationBase->GetBoneInfoMap();
    if (boneInfoMap.find(nodeName) != boneInfoMap.end())
    {
        const int index = boneInfoMap.at(nodeName).id;
        const glm::mat4& offset = boneInfoMap.at(nodeName).offset;
        const glm::mat4& offsetLayerMat = pAnimationLayer->GetBoneInfoMap().at(nodeName).offset;
            
        // Blend two matrices... again
        const glm::quat rot0 = glm::quat_cast(offset);
        const glm::quat rot1 = glm::quat_cast(offsetLayerMat);
        const glm::quat finalRot = glm::slerp(rot0, rot1, blendFactor);
        glm::mat4 blendedMat = glm::mat4_cast(finalRot);
        blendedMat[3] = (1.0f - blendFactor) * offset[3] + offsetLayerMat[3] * blendFactor;

        m_FinalBoneMatrices[index] = globalTransformation * blendedMat;
    }

    for (size_t i = 0; i < node->children.size(); ++i)
        CalculateBlendedBoneTransform(pAnimationBase, &node->children[i], pAnimationLayer, &nodeLayered->children[i], currentTimeBase, currentTimeLayered, globalTransformation, blendFactor);
}

This next function runs every frame:

pAnimator->BlendTwoAnimations(pVampireWalkAnim, pVampireRunAnim, animationBlendFactor, deltaTime);

Which contains:

void BlendTwoAnimations(Animation* pBaseAnimation, Animation* pLayeredAnimation, float blendFactor, float dt)
{
    static float currentTimeBase = 0.0f;
    currentTimeBase += pBaseAnimation->GetTicksPerSecond() * dt;
    currentTimeBase = fmod(currentTimeBase, pBaseAnimation->GetDuration());

    static float currentTimeLayered = 0.0f;
    currentTimeLayered += pLayeredAnimation->GetTicksPerSecond() * dt;
    currentTimeLayered = fmod(currentTimeLayered, pLayeredAnimation->GetDuration());

    CalculateBlendedBoneTransform(pBaseAnimation, &pBaseAnimation->GetRootNode(), pLayeredAnimation, &pLayeredAnimation->GetRootNode(), currentTimeBase, currentTimeLayered, glm::mat4(1.0f), blendFactor);
}

And here's what it looks like: https://i.sstatic.net/71534.jpg

The run and walk animations look perfectly fine when the "blend factor" is at 0.0 and 1.0, but anything in-between has a sort of discontinuity... There are entire milliseconds that look like he's running with both feet raised at the same time. How can I get them to blend correctly? I was expecting to see a smooth transition between walking and running, like when you incrementally move the analog stick on a controller in most 3rd person games.

The animations are from mixamo.com (same as the model), in FBX format, loaded using Assimp 5.1.0rc1. I have tested them in Unreal Engine 4 with a Blend Space, and the "crossfading" between them looks great. So it's not the animation files themselves, they are correct.


Solution

  • Ok, I solved it: https://i.sstatic.net/Gcvgi.jpg

    I googled around and found this animation example where there are 3 transitions: walk -> jog -> run. If you look closely they all start and finish at the same time, almost like they have the same duration. The duration of the jog starts off with a "speed multiplier" (of sorts) set to 0.6, the run animation starts off with one at 0.5. If you play them individually, of course the run animation is shorter than a laisurely walk. It's those speed multipliers that make them play at the same time. Think of them like "scaling" the duration of each animation.

    So how do you get those speed multipliers? Well, there's a .GetDuration() member function for the Animation class. If you divide the duration of the base animation by the duration of "layered" animation, you get a number. A float. Same if you divide it the other way around. For me, it was:

    WalkAnim Duration:  1266.67
    RunAnim  Duration:   766.667
    
    1266.67  /  766.667  =  1.6521775425315032471725012293473
     766.667 / 1266.67   =  0.6052618282583466885613458911950
    

    To have the same duration (and end up syncronized) you have to increase/decrease the playback speed of each one, as a proportion (read: a lerp), by multiplying the 'deltaTime' parameter for each specific animation with one of the above quotients. In other words, you have to both increase the speed of the walk AND decrease the speed of the run at the same time, depending on what value the "blend factor" is set to (between 0 and 1).

    For this to work, the animations kind of have to match. If you open them in Blender, they should start with the same foot raised, take exactly one step with the left, one step with the right, and finish with the same position and rotation that they start with, for a seamless loop. And their tick rate has to match (usually either 30 fps or 60 fps, but Mixamo also allows downloading them at 24 fps for some reason). 30 is fine. You'll get terrible blending if one is using 30 and the other is using 60.

    So here's the code.

    Note: The * 1.0f and 1.0f * from the speed multipliers can be left out, but I chose to leave them in because it makes the lerp formula more identifiable, easier to read. The compiler will probably optimize them away anyway.

    void Animator::BlendTwoAnimations(Animation* pBaseAnimation, Animation* pLayeredAnimation, float blendFactor, float deltaTime)
    {
        // Speed multipliers to correctly transition from one animation to another
        float a = 1.0f;
        float b = pBaseAnimation->GetDuration() / pLayeredAnimation->GetDuration();
        const float animSpeedMultiplierUp = (1.0f - blendFactor) * a + b * blendFactor; // Lerp
    
        a = pLayeredAnimation->GetDuration() / pBaseAnimation->GetDuration();
        b = 1.0f;
        const float animSpeedMultiplierDown = (1.0f - blendFactor) * a + b * blendFactor; // Lerp
    
        // Current time of each animation, "scaled" by the above speed multiplier variables
        static float currentTimeBase = 0.0f;
        currentTimeBase += pBaseAnimation->GetTicksPerSecond() * deltaTime * animSpeedMultiplierUp;
        currentTimeBase = fmod(currentTimeBase, pBaseAnimation->GetDuration());
    
        static float currentTimeLayered = 0.0f;
        currentTimeLayered += pLayeredAnimation->GetTicksPerSecond() * deltaTime * animSpeedMultiplierDown;
        currentTimeLayered = fmod(currentTimeLayered, pLayeredAnimation->GetDuration());
    
        CalculateBlendedBoneTransform(pBaseAnimation, &pBaseAnimation->GetRootNode(), pLayeredAnimation, &pLayeredAnimation->GetRootNode(), currentTimeBase, currentTimeLayered, glm::mat4(1.0f), blendFactor);
    }
    
    
    // Recursive function that sets interpolated bone matrices in the 'm_FinalBoneMatrices' vector
    void Animator::CalculateBlendedBoneTransform(
        Animation* pAnimationBase,  const AssimpNodeData* node,
        Animation* pAnimationLayer, const AssimpNodeData* nodeLayered,
        const float currentTimeBase, const float currentTimeLayered,
        const glm::mat4& parentTransform,
        const float blendFactor)
    {
        const std::string& nodeName = node->name;
    
        glm::mat4 nodeTransform = node->transformation;
        Bone* pBone = pAnimationBase->FindBone(nodeName);
        if (pBone)
        {
            pBone->Update(currentTimeBase);
            nodeTransform = pBone->GetLocalTransform();
        }
    
        glm::mat4 layeredNodeTransform = nodeLayered->transformation;
        pBone = pAnimationLayer->FindBone(nodeName);
        if (pBone)
        {
            pBone->Update(currentTimeLayered);
            layeredNodeTransform = pBone->GetLocalTransform();
        }
    
        // Blend two matrices
        const glm::quat rot0 = glm::quat_cast(nodeTransform);
        const glm::quat rot1 = glm::quat_cast(layeredNodeTransform);
        const glm::quat finalRot = glm::slerp(rot0, rot1, blendFactor);
        glm::mat4 blendedMat = glm::mat4_cast(finalRot);
        blendedMat[3] = (1.0f - blendFactor) * nodeTransform[3] + layeredNodeTransform[3] * blendFactor;
    
        glm::mat4 globalTransformation = parentTransform * blendedMat;
    
        const auto& boneInfoMap = pAnimationBase->GetBoneInfoMap();
        if (boneInfoMap.find(nodeName) != boneInfoMap.end())
        {
            const int index = boneInfoMap.at(nodeName).id;
            const glm::mat4& offset = boneInfoMap.at(nodeName).offset;
    
            m_FinalBoneMatrices[index] = globalTransformation * offset;
        }
    
        for (size_t i = 0; i < node->children.size(); ++i)
            CalculateBlendedBoneTransform(pAnimationBase, &node->children[i], pAnimationLayer, &nodeLayered->children[i], currentTimeBase, currentTimeLayered, globalTransformation, blendFactor);
    }
    

    And instead of pAnimator->UpdateAnimation(deltaTime), I run this every frame:

    pAnimator->BlendTwoAnimations(pVampireWalkAnim, pVampireRunAnim, animationBlendFactor, deltaTime * 30.0f); // 30.0f intentional here, otherwise they play too slowly