1 前言
前面一篇文章 android OpenGL渲染3D模型文件 介绍了渲染3D模型的方式,但是,它还是静态的,模型本身不会动,还是不够炫酷。所以本文来讨论一下如何让模型自己动起来。
想要动起来,就需要传说中的骨骼动画了。 一般大部分模型文件都支持带骨骼动画的数据,例如fbx, dae,但也有个别不支持,例如obj。
本文分两部分讨论,一是捋一下骨骼动画的背景知识,二是在android上怎么用openGL ES渲染。当然了,渲染骨骼动画还是比较麻烦的,大部分场景下,还是走游戏引擎,例如unity,裸写openGL的还是比较少的,但这有注意理解openGL,理解游戏引擎的实现。
先上图,给个效果,吸引一下大家的注意力。
2 骨骼动画
骨骼动画(Skeletal animation),也叫骨骼蒙皮(Skinning)。它包含2个词语,对应两件事情,一个是骨骼Bone,一个是动画Animation。 美术同学做好一个模型后,只有顶点和纹理信息,是不会动的,想要动起来,就需要有什么介质,带动模型一起动,这个介质就是骨骼。怎么个动法,就是为骨骼添加一些动画,例如移动1cm并旋转30度。
骨骼有3个基本元素: "开始的关节 "叫 首端(root) 或 头部(head) 。 “body(身体)”部分是骨骼的主体。 “结束关节” 部分叫 顶端(tip) 或 尾端 (tail) 。 基本上是,一根骨骼的root关节会连着另一根骨骼的tail关节。所有的骨骼连在一起,叫骨骼树。骨骼树需要有一个根节点。
例如对于人体骨骼,我们可能会设置后背骨头作为根节点,然后手臂、腿、手指骨骼等作为下一层级的子节点骨骼。当父节点骨头运动的时候同时会带动所有子节点骨头运动,但是当子节点骨头运动的时候并不会反过来带动父节点骨头运动 (例如我们的手指头可以在手掌不动的时候自己活动,但是当手掌移动的时候手指会跟着移动)。
来,我们感受一下骨头树到底是啥样子。
下图是Blend软件,正在制作模型文件。 左边是美术同学辛苦做了一天的模型。这个模型包含了多个网格(Mesh),例如头发,脸,衣服,脚,但它不会动。 于是,美术同学制作了右边的一个骨骼树(当然了,骨骼树也有现成的模板,可以直接导入使用,修改,不需要每次重新制作一个骨骼)。
可以把骨骼树拖到人身上,把每一块Mesh都绑定到骨骼上(一个Mesh可以对应多个骨骼,一个骨骼也可能被多个mesh绑定,例如手,脚,都包含了几块骨骼)。这部分工作叫做骨骼绑定(Rigging)。
下图是把mesh和骨骼绑定后的一个样子。 骨骼和mesh绑定后,还是不会动,想要动,就要为骨骼添加动画Animation了。例如**“行走”,“奔跑”,“死亡”**等。 每一种动画,都可以定义了一组关键帧。关键帧包含沿动画路径的关键点中所有骨骼的变换。这样在渲染的时候,在关键帧之间进行插值,并在关键帧之间创建平滑的运动。 例如动画1秒,定义2个关键帧,位移从0.5 到1.5。1秒内动画20次,则每一次的位移是0.5 + (1.5 - 0.5)/20。
有了动画,骨头就会动,mesh就可以跟着动了。下图就是美术同学开始为骨骼添加动画,让骨骼动起来,于是脚就可以动起来了。 可以预知,绑定后,每个顶点都有对应的骨骼影响它。在两个骨骼的连接处的顶点,还会被2个骨骼同时影响。于是就有一个很重要的概念,是权重(weights)。通常一个顶点如果被多个骨骼影响,则这些骨骼,对该顶点的权重之和为1。另外,一般规范,一个顶点最多被4个骨骼影响 。
3 OpenGL ES渲染
如果没有骨骼,则vertex shader很简单:
gl_Position = u_MVPMatrix * position;
也就是乘于MVP转换矩阵,把顶点在模型空间,转换到裁减空间中。
现在有了骨骼,可以猜想,先要把position做一些偏移,然后再乘于MVP矩阵。
这个偏移,是骨骼对顶点产生的影响,数学上就是一个矩阵,有4个骨骼影响,则是4个矩阵。 可以猜想shader的代码如下:
new_position = M1 * position * W1 + M2 * position * W2 + M3 * position * W3 + M4 * position * W4; gl_Position = u_MVPMatrix * new_position;
其中M1 ~M4是顶点对应的4个骨骼的转换矩阵 ,W1~W4是对应的权重。
下文分别就如何提取权重 和转换矩阵 ,来展开说明。
3.1 骨骼的权重数据提取
对模型文件的解析,我们用assimp ,更多assimp的使用细节,在这篇文章 android OpenGL渲染3D模型文件 已经讨论过,本文不会过多展开。
我们定义一个Vertex数据结构,来存顶点数据,以及顶点所关联的骨骼+权重数据。
struct Vertex {
glm::vec3 Position;
glm::vec3 Normal;
glm::vec2 TexCoords;
int m_BoneIDs[4];
float m_Weights[4];
};
和这篇文章想比,android OpenGL渲染3D模型文件,多的就是m_BoneIDs和m_Weights。代表该顶点被哪些骨骼影响,以及对应的权重。 m_Weights数组的加和,必然为1。
接下来看下怎么提取权重数据。
下图是骨骼在assimp 中的数据结构。 aiScene 存放了模型的所有数据,它包含了aiMesh 数组。 每个aiMesh都包含了aiBone数组 。 每个aiBone 都包含了名字,一个offset矩阵 ,一个aiVertexWeight数组,该数组存放所有被当前骨骼影响的顶点,和对应的权重。
来看下如何提取:
如下函数,专门提取一个mesh下的骨骼数据。 其中参数vertices代表当前mesh的所有顶点数据结构Vertex数组。
void ExtractBoneWeightForVertices(std::vector<Vertex>& vertices, aiMesh* mesh)
{
LOGCATE("ExtractBoneWeightForVertices, mesh->mNumBones %d", mesh->mNumBones);
auto& boneInfoMap = m_BoneInfoMap;
int& boneCount = m_BoneCounter;
for (int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex)
{
int boneID = -1;
aiBone* aiBonePtr = mesh->mBones[boneIndex];
std::string boneName = aiBonePtr->mName.C_Str();
if (boneInfoMap.find(boneName) == boneInfoMap.end())
{
BoneInfo newBoneInfo;
newBoneInfo.id = boneCount;
newBoneInfo.offset = AssimpGLMHelpers::ConvertMatrixToGLMFormat(aiBonePtr->mOffsetMatrix);
boneInfoMap[boneName] = newBoneInfo;
boneID = boneCount;
boneCount++;
}
else
{
boneID = boneInfoMap[boneName].id;
}
LOGCATE("boneName %s, boneID %d, boneCount %d", boneName.c_str(), boneID, boneCount);
assert(boneID != -1);
auto weightsArray = aiBonePtr->mWeights;
int numWeights = aiBonePtr->mNumWeights;
LOGCATE("numWeights %d", numWeights);
for (int weightIndex = 0; weightIndex < numWeights; ++weightIndex)
{
int vertexId = weightsArray[weightIndex].mVertexId;
float weight = weightsArray[weightIndex].mWeight;
assert(vertexId <= vertices.size());
SetVertexBoneData(vertices[vertexId], boneID, weight);
}
}
}
void SetVertexBoneData(Vertex& vertex, int boneID, float weight)
{
for (int i = 0; i < 4; ++i)
{
if (vertex.m_BoneIDs[i] < 0)
{
vertex.m_Weights[i] = weight;
vertex.m_BoneIDs[i] = boneID;
break;
}
}
}
上面已经加了很多注释,不再重复说明了。 最终就是每个顶点数据,都添加了所对应的骨骼(不超过4个),以及骨骼的权重。
另外还把每个骨骼的id和offset矩阵存到了一个map,在后面渲染时使用。
3.2 动画数据提取
提取的目标,就是生成一个转换矩阵 ,把某个顶点的坐标,转换到动画之后的新的坐标。
3.2.1 assimp中的数据结构分析
动画数据在assimp中的存储结构如下: 一个aiAnimation 代表一种动画,例如“奔跑” 。 aiAnimation的mTicksPerSecond ,代表一秒钟几次动画。 mDuration 代表总共多少次电话。 举个例子,如果mTicksPerSecond=25, mDuration = 100,则表示动画总时间为4秒。
mChannels 代表动画所包含的骨骼节点列表。 来看一下一个channel的类定义:
struct aiNodeAnim {
aiString mNodeName;
aiVectorKey* mPositionKeys;
aiQuatKey* mRotationKeys;
aiVectorKey* mScalingKeys;
}
可见aiNodeAnim 包括骨骼名字,和对应的关键帧的位移,旋转,缩放 参数。 来看一下位移数组的类aiVectorKey 定义是啥
struct aiVectorKey
{
double mTime;
aiVector3D mValue;
}
发现很简单,一个是关键的时间,一个是具体值。
假如总共定义4个关键帧。那么,对于mTicksPerSecond=25, mDuration = 100,我们程序要做的,就是在非关键帧的时间点,做一下插值,估算这个时间点,mValues大概是多少。
现在清楚assimp怎么存的了,我们就定义一些类,来把这些数据提取出来。
3.2.2 提取准备
首先,定义三个类,来存关键帧的数据,具体如下:
struct KeyPosition
{
glm::vec3 position;
float timeStamp;
};
struct KeyRotation
{
glm::quat orientation;
float timeStamp;
};
struct KeyScale
{
glm::vec3 scale;
float timeStamp;
};
接着,定义一个类Bone ,管理关键帧
class Bone {
private:
std::vector<KeyPosition> m_Positions;
std::vector<KeyRotation> m_Rotations;
std::vector<KeyScale> m_Scales;
int m_NumPositions;
int m_NumRotations;
int m_NumScalings;
glm::mat4 m_LocalTransform;
std::string m_Name;
int m_ID;
public:
Bone(const std::string& name, int ID, const aiNodeAnim* channel);
void Update(float animationTime);
}
一个非常重要的函数,是Update ,用于根据时间戳,计算矩阵。这个函数在每次onDraw 时调用。
现在来看一下怎么把这些数据提取出来。
3.2.3 提取函数
void ReadMissingBones(const aiAnimation* animation, ModelAnim& model)
{
int size = animation->mNumChannels;
m_BoneInfoMap = model.GetBoneInfoMap();
LOGCATE("ReadMissingBones, m_BoneInfoMap address %p, size %d, animation->mNumChannels %d", &m_BoneInfoMap,m_BoneInfoMap.size(), animation->mNumChannels);
int& boneCount = model.GetBoneCount();
for (int i = 0; i < size; i++)
{
auto channel = animation->mChannels[i];
std::string boneName = channel->mNodeName.data;
if (m_BoneInfoMap.find(boneName) == m_BoneInfoMap.end())
{
m_BoneInfoMap[boneName].id = boneCount;
boneCount++;
}
m_Bones.push_back(Bone(channel->mNodeName.data,
m_BoneInfoMap[channel->mNodeName.data].id, channel));
}
}
从上面的代码可见,m_Bones 数组,记录了所有骨骼的动画信息。
Bone对象的构造函数,做了实际的提取工作:
Bone(const std::string& name, int ID, const aiNodeAnim* channel)
:
m_Name(name),
m_ID(ID),
m_LocalTransform(1.0f)
{
m_NumPositions = channel->mNumPositionKeys;
LOGCATE("Bone created, m_NumPositions %d", m_NumPositions);
for (int positionIndex = 0; positionIndex < m_NumPositions; ++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.push_back(data);
LOGCATE("get one key frame's position %c, timeStamp %f", glm::to_string(data.position).c_str(), data.timeStamp);
}
m_NumRotations = channel->mNumRotationKeys;
for (int rotationIndex = 0; rotationIndex < m_NumRotations; ++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.push_back(data);
}
m_NumScalings = channel->mNumScalingKeys;
for (int keyIndex = 0; keyIndex < m_NumScalings; ++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.push_back(data);
}
}
3.3 逐帧绘制数据
上面的数据全部准备好了,接下来就看每次onDraw时要怎么让模型动起来了。
3.3.1 一次绘制的全流程
下面是Draw函数。
void Model3DAnimSample::Draw(int screenW, int screenH)
{
if(m_pModel == nullptr || m_pShader == nullptr) return;
float deltaTime = 0.03f;
m_pAnimator->UpdateAnimation(deltaTime);
LOGCATE("Draw start");
glClearColor(1.0f, 1.0f, 1.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_DEPTH_TEST);
UpdateMVPMatrix(m_MVPMatrix, m_AngleX, m_AngleY, (float)screenW / screenH);
m_pShader->use();
m_pShader->setMat4("u_MVPMatrix", m_MVPMatrix);
m_pShader->setMat4("u_ModelMatrix", m_ModelMatrix);
m_pShader->setVec3("lightPos", glm::vec3(0, 0, m_pModel->GetMaxViewDistance()));
m_pShader->setVec3("lightColor", glm::vec3(1.0f, 1.0f, 1.0f));
m_pShader->setVec3("viewPos", glm::vec3(0, 0, m_pModel->GetMaxViewDistance()));
auto transforms = m_pAnimator->GetFinalBoneMatrices();
LOGCATE("Draw, transform size %d", transforms.size());
for (int i = 0; i < transforms.size(); ++i)
m_pShader->setMat4("finalBonesMatrices[" + std::to_string(i) + "]", transforms[i]);
m_pModel->Draw((*m_pShader));
LOGCATE("Draw done");
}
和这篇文章android OpenGL渲染3D模型文件不同的,就2个地方: 一个是 m_pAnimator->UpdateAnimation(deltaTime); 用于根据时间戳,计算转换矩阵 。
一个是 auto transforms = m_pAnimator->GetFinalBoneMatrices(); m_pShader->setMat4("finalBonesMatrices[" + std::to_string(i) + "]", transforms[i]); 把转换矩阵拿出来,上传到vertex shader,用于计算动画之后的新顶点坐标。
我们先不关心finalBonesMatrices转换矩阵怎么生成的,先来看在shader中怎么用的,在第三节开头已经提到了,这里给出具体实现代码:
"#version 300 es
precision mediump float;
layout (location = 0) in vec3 a_position;
layout (location = 1) in vec3 a_normal;
layout (location = 2) in vec2 a_texCoord;
//骨骼id,最多4个
layout (location = 5) in ivec4 boneIds;
//相应的骨骼的权重
layout (location = 6) in vec4 weights;
out vec2 v_texCoord;
uniform mat4 u_MVPMatrix;
const int MAX_BONES = 100;//最多有100个骨骼
const int MAX_BONE_INFLUENCE = 4;//该顶点最多被4个骨骼影响
uniform mat4 finalBonesMatrices[MAX_BONES];
out vec3 specular;
void main()
{
v_texCoord = a_texCoord;
vec4 position = vec4(0.0f);
//把所有影响的骨骼的换算矩阵,乘于原始的顶点坐标,加和,得到动画之后的新的顶点坐标
for(int i = 0 ; i < MAX_BONE_INFLUENCE ; i++)
{
vec4 localPosition = finalBonesMatrices[boneIds[i]] * vec4(a_position,1.0f);
position += localPosition * weights[i];
}
//乘于MVP矩阵,得到gl_Position
gl_Position = u_MVPMatrix * position;
//....代码省略
}
首先,入参多了boneIds & weights 以及finalBonesMatrices ,即该顶点被哪些骨骼影响,以及对应的权重和转换矩阵。 接着,一个for循环,计算第i根骨骼产生的影响: vec4 localPosition = finalBonesMatrices[boneIds[i]] * vec4(a_position,1.0f); 然后加上权重 position += localPosition * weights[i];
for循环退出后,position 就代表一帧动画之后的新的顶点位置。
注意,这个顶点仍然是在模型空间内。所以,还需要乘于MVP矩阵,得到最终的gl_Position ,即裁减空间下的坐标。
好了,基本上绘制的逻辑已经完成了!!
3.3.2 动画矩阵的计算过程
接下来,回过头来看一下 m_pAnimator->UpdateAnimation(deltaTime); 的实现。
void UpdateAnimation(float dt)
{
m_DeltaTime = dt;
if (m_CurrentAnimation)
{
m_CurrentTime += m_CurrentAnimation->GetTicksPerSecond() * dt;
m_CurrentTime = fmod(m_CurrentTime, m_CurrentAnimation->GetDuration());
CalculateBoneTransform(&m_CurrentAnimation->GetRootNode(), glm::mat4(1.0f));
}
}
dt的值,可以是1/fps,例如30帧率的话,是0.03。 例如TicksPerSecond = 25, Duration = 100,则绘制第一帧, mCurrentTime = 25 * 0.03 = 0.75。 fmod函数很简单,是求余函数,保证m_CurrentTime 一直不会超过Duration,超过的话就从0开始。说人话就是,动画播放结束,从头开始。
接着,就是CalculateBoneTransform 函数了,这是一个递归的函数。 首次传参是动画的第一个骨骼节点。然后递归,算出动画所影响的所有骨骼的矩阵。
来看一下具体实现:
void CalculateBoneTransform(const AssimpNodeData* node, glm::mat4 parentTransform)
{
std::string nodeName = node->name;
glm::mat4 nodeTransform = node->transformation;
LOGCATE("CalculateBoneTransform nodeName %s", nodeName.c_str());
Bone* Bone = m_CurrentAnimation->FindBone(nodeName);
if (Bone)
{
LOGCATE("CalculateBoneTransform Bone->Update %.4f", m_CurrentTime);
Bone->Update(m_CurrentTime);
nodeTransform = Bone->GetLocalTransform();
}
glm::mat4 globalTransformation = parentTransform * nodeTransform;
std::map<std::string,BoneInfo> boneInfoMap = m_CurrentAnimation->GetBoneIDMap();
if (boneInfoMap.find(nodeName) != boneInfoMap.end())
{
int index = boneInfoMap[nodeName].id;
glm::mat4 offset = boneInfoMap[nodeName].offset;
m_FinalBoneMatrices[index] = m_GlobalTransform * globalTransformation * offset;
LOGCATE("m_FinalBoneMatrices[%d]: %s, offset %s", index, glm::to_string(m_FinalBoneMatrices[index]).c_str(), glm::to_string(offset).c_str());
}
for (int i = 0; i < node->childrenCount; i++)
CalculateBoneTransform(&node->children[i], globalTransformation);
}
一个很重要的调用,是 Bone->Update(m_CurrentTime); 这个就是前面说很多次的插值计算,怎么个插值计算,先不管,反正最后是得到了插值后的一个矩阵。 因为父骨骼的动画会影响子骨骼的动画,所以需要乘于parentTransform。
glm::mat4 globalTransformation = parentTransform * nodeTransform;
首次调用parentTransform 为单位矩阵。 后面递归调用,parentTransform 就是globalTransformation 了。
接着,终于开始计算m_FinalBoneMatrices 了,这个是要传递到shader的! 来看一下公式:
m_FinalBoneMatrices[index] = m_GlobalTransform * globalTransformation * offset;
offset矩阵 是当前从骨骼空间转换到mesh空间的矩阵。在前面的aiBone结构中读取的。更多细节见What does mOffsetMatrix actually do in Assimp?
m_GlobalTransform 从是根节点的矩阵的逆矩阵,通过这样获得:
m_GlobalTransformation = scene->mRootNode->mTransformation;
m_GlobalTransformation = m_GlobalTransformation.Inverse();
之所以要依赖根结点的矩阵,是因为骨骼树结构中,每个结点都包含一个mat4 Transform矩阵,用于描述自己相对于父结点的方位变化。子结点代表的骨骼,其绝对方位由根结点的Transform逐步地乘到自己的Transform来得到。“绝对方位”指的就是在Model Space中的方位。
4 再次探讨骨骼
我们回到3.1节,那里一笔带过的踢了aiBone的offset矩阵,该矩阵也在3.3.2节使用了。
那么问题来了,为什么一个aiBone,可以用一个4x4的offset矩阵来表示? 长度没有,位置也没有,是不是有点寒酸?
实际上每块骨骼可理解为一个坐标空间 ,关节 可理解为骨骼坐标空间的原点 。关节的位置由它在父骨骼坐标空间中的位置描述。
上图中有三块骨骼,分别是上臂,前臂和手指。锁骨关节,它是上臂的原点,同样肘关节是前臂的原点,腕关节是手指骨骼的原点。关节既决定了骨骼空间的位置,又是骨骼空间的旋转和缩放中心。
回到上面的问题: 为什么用一个4X4矩阵就可以表达一个骨骼?
因为4X4矩阵中含有的平移分量 决定了关节的位置 ,旋转和缩放 分量决定了骨骼空间的旋转和缩放。
我们来看前臂这个骨骼,其原点位置是位于上臂上某处的,对于上臂来说,它知道自己的坐标空间某处(即肘关节所在的位置)有一个子空间 ,那就是前臂,至于前臂里面是啥就不考虑了。当前臂绕肘关节旋转时,实际是前臂坐标空间在旋转,从而其中包含的子空间也在绕肘关节旋转,在这个例子中是手指骨骼。
再总结一下: 骨骼就是坐标空间,骨骼层次就是嵌套的坐标空间。关节只是描述骨骼的位置即骨骼自己的坐标空间原点在其父空间中的位置,绕关节旋转是指骨骼坐标空间(包括所有子空间)自身的旋转。
在骨骼树中,每一块骨骼的位置都依赖于其父骨骼的位置,而根骨骼没有父节点,他的位置就是整个骨骼体系在世界坐标系中的位置。
5 源码
最最后,上链接了!!!卖货了!!!^^ 源码 newchenxf/OpenGLESDemo
6 参考文献
骨骼蒙皮动画(SkinnedMesh)的原理解析 3D骨骼动画(一):原理 Assimp库实现骨骼蒙皮动画 Csharp实现骨骼动画
|