OpenGL 骨骼动画

在实现了模型加载之后,我们还希望这个模型可以动起来,如果我们直接修改模型矩阵,我们可以让整个模型整体移动,但是我们仍希望实现更为精细的控制,比如模型移动时,我们希望人物的脚也动起来,这就需要用到骨骼动画了。骨骼动画的本质其实还是矩阵变换,但是我们需要对不同顶点应用不同的变换矩阵。

首先我们先把模型加载出来

这里 是我们之前用来加载背包的代码,我们对它进行一些修改,以加载人物模型。人物模型可以在 这里 下载。

cpp 复制代码
Model ourModel("resource/models/vampire/dancing_vampire.dae");

去掉加载多个模型的部分,改为加载 dancing_vampire.dae。

cpp 复制代码
Camera camera(glm::vec3(0.0f, 100.0f, 350.0f), glm::vec3(0.0f, 100.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));

调整相机位置,使得模型显示在窗口中心。

cpp 复制代码
#version 330 core
in vec2 TexCoord;
in vec3 Normal;
in vec3 FragPos;

uniform sampler2D texture_diffuse1;
uniform sampler2D texture_specular1;

uniform vec3 ambient;
uniform vec3 diffuse;
uniform vec3 specular;
uniform float shininess;

uniform vec3 lightPos;
uniform vec3 viewPos;
out vec4 FragColor;

void main()
{
    FragColor = texture(texture_diffuse1, TexCoord);
}

简化一下片段着色器。

cpp 复制代码
 glClearColor(0.5f, 0.5f, 0.5f, 1.0f);

设置清屏颜色为灰色。

cpp 复制代码
//stbi_set_flip_vertically_on_load(true);

注释掉纹理翻转代码,可能是模型规范不一样,有的纹理方向向上,有的向下。

最后,我们看到的效果应该是这样的。

编写着色器代码

为了方便理解,我们从着色器倒推我们需要的数据。

cpp 复制代码
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
layout (location = 2) in vec3 aNormal;
layout (location = 3) in ivec4 boneIds;
layout (location = 4) in vec4 weights;

uniform mat4 model;
uniform mat4 invModel;
uniform mat4 view;
uniform mat4 projection;

// 最大骨骼数量
const int MAX_BONES = 100;
// 每个顶点最后受到 4 个骨骼影响
const int MAX_BONE_INFLUENCE = 4;
// 所有骨骼的变换矩阵
uniform mat4 finalBonesMatrices[MAX_BONES];

out vec2 TexCoord;
out vec3 Normal;
out vec3 FragPos;

void main()
{
   vec4 finalPosition = vec4(0.0);
   for (int i = 0; i < MAX_BONE_INFLUENCE; i++) {
      if (boneIds[i] == -1) {
         break;
      }
      if (boneIds[i] >= MAX_BONES) {
         finalPosition = vec4(aPos, 1.0);
         break;
      }
      int boneIndex = boneIds[i];
      finalPosition += finalBonesMatrices[boneIndex] * vec4(aPos, 1.0) * weights[i];
   }
   gl_Position = projection * view * model * finalPosition;
   TexCoord = aTexCoord;
   Normal = mat3(transpose(invModel)) * aNormal;
   FragPos = vec3(model * vec4(aPos, 1.0));
}

顶点坐标增加了两个属性,boneIds 表示的是影响这个顶点的骨骼对应在 finalBonesMatrices 的索引,weight 表示这个骨骼对这个顶点影响的权重。而 finalBonesMatrices 表示的则是每块骨骼对应的变换矩阵,不同骨骼变换矩阵应用到这个顶点上,再根据权重累加就得到了最终的顶点坐标。

读取顶点的骨骼索引和权重

骨骼的数据存储在 aiMesh 的 mBones 数组里。

cpp 复制代码
for (unsigned int i = 0; i < mesh->mNumVertices; i++) {
	Vertex vertex;
	vertex.position = glm::vec3(mesh->mVertices[i].x, mesh->mVertices[i].y, mesh->mVertices[i].z);
	if (mesh->mNormals) {
	    vertex.normal = glm::vec3(mesh->mNormals[i].x, mesh->mNormals[i].y, mesh->mNormals[i].z);
	}
	
	unsigned int numUVChannels = mesh->GetNumUVChannels();
	if (mesh->mTextureCoords[0]) {
	    vertex.texCoords = glm::vec2(mesh->mTextureCoords[0][i].x, mesh->mTextureCoords[0][i].y);
	}
	
	for (int i = 0; i < MAX_BONE_INFLUENCE; i++) {
	    vertex.boneIDs[i] = -1;
	    vertex.weights[i] = 0.0f;
	}
	vertices.push_back(vertex);
}

先初始化一下索引和权重

cpp 复制代码
for (int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex) {
	int boneID = -1;
	std::string boneName = mesh->mBones[boneIndex]->mName.C_Str();
	
	if (m_boneOffsetMap.find(boneName) == m_boneOffsetMap.end()) {
	    BoneOffset boneOffset;
	    boneOffset.id = m_boneIndex;
	    boneOffset.offsetMatrix = AssimpGLMHelpers::ConvertMatrixToGLMFormat(
	        mesh->mBones[boneIndex]->mOffsetMatrix);
	    m_boneOffsetMap[boneName] = boneOffset;
	    boneID = m_boneIndex;
	    m_boneIndex++;
	} else {
	    boneID = m_boneOffsetMap[boneName].id;
	}
	
	auto weights = mesh->mBones[boneIndex]->mWeights;
	int numWeights = mesh->mBones[boneIndex]->mNumWeights;
	for (int weightIndex = 0; weightIndex < numWeights; ++weightIndex) {
	    int vertexId = weights[weightIndex].mVertexId;
	    float weight = weights[weightIndex].mWeight;
	    assert(vertexId <= vertices.size());
	
	    // 找到第一个空位,设置权重和骨骼ID
	    for (int i = 0; i < MAX_BONE_INFLUENCE; ++i) {
	        if (vertices[vertexId].boneIDs[i] < 0) {
	            vertices[vertexId].weights[i] = weight;
	            vertices[vertexId].boneIDs[i] = boneID;
	            break;
	        }
	    }
	}
}

然后遍历每个网格的骨骼信息,使用 m_boneIndex 进行编号,然后把骨骼的编号和 offset 矩阵存储在 m_boneOffsetMap 中。offset 矩阵用于将顶点坐标从模型空间转换到骨骼空间,我们在文章开头就提到了,骨骼动画的本质其实还是矩阵变换,骨骼动画的变换矩阵是顶点相对于骨骼的相对坐标的变换矩阵,因此需要把骨骼转换到骨骼空间,当然,最终我们还是要把顶点转换到模型空间的,我们可以看下这一小节开头的类图 aiNode 这个数据结构,aiNode 有个成员 mTransformation,这个矩阵用于把节点的坐标转换到父节点的空间,比如一个人物模型,由躯干,四肢,头组成,躯干为父节点,四肢为躯干的子节点,当我们把手部顶点的坐标转换到手这一空间时,我们可以通过 mTransformation 把顶点坐标转到躯干的空间,再通过躯干的 mTransformation 把顶点转到人物模型空间。

这样我们就得到了骨骼变换矩阵的计算公式 m_finalBoneMatrices[index] = parentTransform * nodeTransform * offset; 所以当计算骨骼变换矩阵时,我们需要获取第一个 aiNode 节点,然后递归地计算它的子节点的变换矩阵。

构建模型节点树

cpp 复制代码
struct AssimpNodeData
{
    glm::mat4 transformation;
    std::string name;
    int childrenCount;
    std::vector<AssimpNodeData*> children;
};

定义一个结构体,用于存储模型节点信息。

修改 model 类中 processNode 函数,返回一个 AssimpNodeData* 指针。然后构建 AssimpNodeData 节点。

cpp 复制代码
AssimpNodeData* Model::processNode(aiNode* node, const aiScene* scene)
{
    // 处理当前节点的所有网格
    for (unsigned int i = 0; i < node->mNumMeshes; i++) {
        aiMesh* mesh = scene->mMeshes[node->mMeshes[i]];
        m_meshes.push_back(processMesh(mesh, scene));
    }

    AssimpNodeData* nodeData = new AssimpNodeData;
    nodeData->name = node->mName.data;
    nodeData->transformation = AssimpGLMHelpers::ConvertMatrixToGLMFormat(node->mTransformation);
    nodeData->childrenCount = node->mNumChildren;

    if (node == scene->mRootNode) {
        nodeData->transformation = glm::mat4(1.0f);
    }

    // 递归处理所有子节点
    for (unsigned int i = 0; i < node->mNumChildren; i++) {
        nodeData->children.emplace_back(processNode(node->mChildren[i], scene));
    }
    return nodeData;
}

增加一个 AssimpNodeData* m_root = nullptr; 成员变量,用于存储根节点,已经一个获取根节点的接口 AssimpNodeData* GetRootNode() { return m_root; }

cpp 复制代码
if (node == scene->mRootNode) {
   nodeData->transformation = glm::mat4(1.0f);
}

这段代码可能看着比较奇怪,这是因为我加载完动画后,发现模型的大小不对,我需要把视角移动到很近的位置才能看到模型,最终发现是这里对模型进行了缩放,所以我重置了根节点的 transformation

加载动画

在进行下一步之前,我们先来看下 Bone 类的几个 public 函数,Bone 类用于计算当前骨骼的变换矩阵,它根据时间戳更新 transform 矩阵,GetLocalTransform 返回当前的变换矩阵。

cpp 复制代码
class Bone
{
public:
    Bone() = default;
    Bone(const std::string& name, const aiNodeAnim* channel);
    // 根据时间戳更新变换矩阵
    void Update(float animationTime);
    glm::mat4 GetLocalTransform() { return m_localTransform; }
    std::string GetBoneName() const { return m_name; }
};

Bone 类的具体实现我们稍后再介绍,现在让我们先来看下 Animator 类,Animator 用于计算,更新最终的 m_finalBoneMatrices 矩阵。

cpp 复制代码
class Animator
{   
public:
    Animator(const std::string& animationPath, Model* model);
    void UpdateAnimation(float dt);
    std::vector<glm::mat4> GetFinalBoneMatrices() { 
        return m_finalBoneMatrices;  
    }
    
private:
    void CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform);
    
private:
    std::vector<glm::mat4> m_finalBoneMatrices;
    float m_currentTick;
    float m_totalTicks;
    int m_ticksPerSecond;
    std::map<std::string, Bone> m_bones;
    Model* m_model;
};

m_finalBoneMatrices 用于存储所有骨骼的变换矩阵,m_bones 用于计算某块骨骼某个时刻的变换矩阵(局部),model 用于获取模型节点树。值得注意的是这里动画的时间单位是刻度(tick),m_ticksPerSecond 表示每秒多少个 tickm_totalTicks 表示总刻度数, m_currentTick 表示当前的刻度。
UpdateAnimation 根据当前时间计算刻度,然后调用 CalculateBoneTransform 计算骨骼变换矩阵。

cpp 复制代码
void Animator::UpdateAnimation(float dt)
{
    m_currentTick += m_ticksPerSecond * dt;
    m_currentTick = fmod(m_currentTick, m_totalTicks);
    CalculateBoneTransform(m_model->GetRootNode(), glm::mat4(1.0f));
}

然后更新骨骼变换矩阵

cpp 复制代码
void Animator::CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform)
{
    std::string nodeName = node->name;
    glm::mat4 nodeTransform = node->transformation;
    if (m_bones.find(nodeName) != m_bones.end()) {
        Bone bone = m_bones[nodeName];
        bone.Update(m_currentTick);
        nodeTransform = bone.GetLocalTransform();
    }
    
    auto boneOffset = m_model->GetBoneOffsetMap();
    if (boneOffset.find(nodeName) != boneOffset.end()) {
        int index = boneOffset[nodeName].id;
        glm::mat4 offset = boneOffset[nodeName].offsetMatrix;
        m_finalBoneMatrices[index] = parentTransform * nodeTransform * offset;
    }

    for (int i = 0; i < node->childrenCount; i++) {
        CalculateBoneTransform(node->children[i], parentTransform * nodeTransform);
    }
}

offset 用于把顶点坐标转换到骨骼空间,nodeTranform 则用于把顶点坐标转换到父节点所在的空间,parentTransform 用于把父节点转换到模型空间。

我们可以发现,我们是直接用骨骼的变换矩阵替换 nodeTranform 而不是把变换矩阵应用到顶点,再转换到父节点空间,这是因为骨骼动画数据通常已包含相对于父骨骼的完整变换。

完善我们的 Bone 类

cpp 复制代码
struct KeyPosition
{
    glm::vec3 position;
    float timeStamp;
};

struct KeyRotation
{
    glm::quat orientation;
    float timeStamp;
};

struct KeyScale
{
    glm::vec3 scale;
    float timeStamp;
};
cpp 复制代码
std::vector<KeyPosition> m_positions;
std::vector<KeyRotation> m_rotations;
std::vector<KeyScale> m_scales;

我们需要三个结构体,用于存储从动画中读取到的平移,旋转和缩放信息,当时间变化时,我们只需要根据时间戳找到对应的平移,旋转和缩放信息,然后构建变换矩阵即可。实际操作比这个稍微复杂一点,但总体流程就是这样的。

cpp 复制代码
m_positionNum = channel->mNumPositionKeys;
for (int positionIndex = 0; positionIndex < m_positionNum; ++positionIndex) {
    aiVector3D aiPosition = channel->mPositionKeys[positionIndex].mValue;
    float timeStamp = channel->mPositionKeys[positionIndex].mTime;
    KeyPosition data;
    data.position = AssimpGLMHelpers::GetGLMVec(aiPosition);
    data.timeStamp = timeStamp;
    m_positions.emplace_back(data);
}

m_rotationNum = channel->mNumRotationKeys;
for (int rotationIndex = 0; rotationIndex < m_rotationNum; ++rotationIndex) {
    aiQuaternion aiOrientation = channel->mRotationKeys[rotationIndex].mValue;
    float timeStamp = channel->mRotationKeys[rotationIndex].mTime;
    KeyRotation data;
    data.orientation = AssimpGLMHelpers::GetGLMQuat(aiOrientation);
    data.timeStamp = timeStamp;
    m_rotations.emplace_back(data);
}

m_scaleNum = channel->mNumScalingKeys;
for (int keyIndex = 0; keyIndex < m_scaleNum; ++keyIndex) {
    aiVector3D scale = channel->mScalingKeys[keyIndex].mValue;
    float timeStamp = channel->mScalingKeys[keyIndex].mTime;
    KeyScale data;
    data.scale = AssimpGLMHelpers::GetGLMVec(scale);
    data.timeStamp = timeStamp;
    m_scales.emplace_back(data);
}

aiNodeAnim 中读取这些数据,存储在我们的成员变量中。

cpp 复制代码
void Bone::Update(float animationTime) {
    glm::mat4 translation = InterpolatePosition(animationTime);
    glm::mat4 rotation = InterpolateRotation(animationTime);
    glm::mat4 scale = InterpolateScale(animationTime);
    m_localTransform = translation * rotation * scale;
}

Update 函数用于更新变换矩阵,Interpolate* 则是用于创建平移,旋转和缩放矩阵。由于动画里不会包含每个时刻的变换信息,所以我们需要找到离当前时刻最近的两个关键帧,然后进行插值,幸运的是,glm 已经帮我们实现了插值函数,我们只需要传入两个关键帧的数据以及比例因子,即可生成插值后的结果。

cpp 复制代码
float Bone::GetScaleFactor(float lastTimeStamp, float nextTimeStamp, float animationTime)
{
    float scaleFactor = 0.0f;
    float midWayLength = animationTime - lastTimeStamp;
    float framesDiff = nextTimeStamp - lastTimeStamp;
    scaleFactor = midWayLength / framesDiff;
    return scaleFactor;
}

GetScaleFactor 用于计算当前时间在两个关键帧之间的比例因子。

cpp 复制代码
int Bone::GetPositionIndex(float animationTime)
{
    for (int index = 0; index < m_positionNum - 1; ++index) {
        if (animationTime >=  m_positions[index].timeStamp && animationTime < m_positions[index + 1].timeStamp) {
            return index;
        }
    }
    return -1;
}

GetPositionIndex 用于查找当前时间在哪两个关键帧之间,返回较小关键帧的索引值。GetRotationIndexGetScaleIndex 同理。

cpp 复制代码
glm::mat4 Bone::InterpolatePosition(float animationTime)
{
    if (m_positionNum == 1) {
        return glm::translate(glm::mat4(1.0f), m_positions[0].position);
    }

    // 根据两个关键帧的平移量,计算插值
    int p0Index = GetPositionIndex(animationTime);
    if (p0Index == -1) {
        return glm::mat4(1.0f);
    }
    int p1Index = p0Index + 1;
    float scaleFactor = GetScaleFactor(m_positions[p0Index].timeStamp,m_positions[p1Index].timeStamp, animationTime);
    glm::vec3 finalPosition = glm::mix(m_positions[p0Index].position,m_positions[p1Index].position, scaleFactor);
    return glm::translate(glm::mat4(1.0f), finalPosition);
}

最后生成对应的变换矩阵。

cpp 复制代码
glm::quat finalRotation = glm::slerp(m_rotations[p0Index].orientation,m_rotations[p1Index].orientation, scaleFactor);

旋转四元数(用于描述旋转的数学量)的插值使用 glm::slerp 实现,其他部分也都是类似的。

这里 是完整的代码实现,代码在 learnopengl 的基础上做了一些修改。

相关推荐
智者知已应修善业2 小时前
【51单片机独立按键控制往复流水灯启停】2023-6-13
c++·经验分享·笔记·算法·51单片机
t***5442 小时前
这些设计模式在现代C++中如何应用
java·c++·设计模式
t***5442 小时前
能否给出更多现代C++架构设计模式?
java·开发语言·c++
mjhcsp2 小时前
C++信息论超详解析
开发语言·c++
此生只爱蛋2 小时前
【CAD】Parasolid:CAD的os
c++
门左有棵树3 小时前
蓝桥杯C++组算法知识点整理(考前急救)
c++·算法·蓝桥杯
AIminminHu3 小时前
OpenGL渲染与几何内核那点事-项目实践理论补充(一-3-(2):当你的CAD学会“偷懒”:从“一笔一画”到“一键生成”的OpenGL渲染进化史)
opengl
不爱吃炸鸡柳3 小时前
手撕哈希表(Hash Table):从原理到C++完整实现
c++·哈希算法·散列表
charlie1145141914 小时前
通用GUI编程技术——图形渲染实战(三十一)——Direct2D效果与图层:高斯模糊到毛玻璃
c++·图形渲染·gui·win32