android OpenGL渲染带骨骼动画的3D模型
Posted 长江很多号
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了android OpenGL渲染带骨骼动画的3D模型相关的知识,希望对你有一定的参考价值。
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
// position
glm::vec3 Position;
// normal
glm::vec3 Normal;
// texCoords
glm::vec2 TexCoords;
//bone indexes which will influence this vertex
int m_BoneIDs[4];
//weights from each bone
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;//start from 0
//一个Mesh可以有多个骨骼
for (int boneIndex = 0; boneIndex < mesh->mNumBones; ++boneIndex)
//1. 为这根骨骼分配一个id,方便后续计算
int boneID = -1;
aiBone* aiBonePtr = mesh->mBones[boneIndex];//接下来针对这根骨骼提取数据
std::string boneName = aiBonePtr->mName.C_Str();
if (boneInfoMap.find(boneName) == boneInfoMap.end())
BoneInfo newBoneInfo;
//分配id
newBoneInfo.id = boneCount;
//提取offset矩阵
newBoneInfo.offset = AssimpGLMHelpers::ConvertMatrixToGLMFormat(aiBonePtr->mOffsetMatrix);
boneInfoMap[boneName] = newBoneInfo;
boneID = boneCount;//assign an id
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;//骨骼的权重数组,用指针表示,数组长度为numWeights
int numWeights = aiBonePtr->mNumWeights;
LOGCATE("numWeights %d", numWeights);
//2. 遍历所有的权重数组,提取出weight,来放到顶点数据结构中
//一根骨骼,可以影响多个顶点,通过权重参数来影响,不同的顶点的权重不同
//一个顶点,也可以被多个骨骼影响,特别是关节处(2个骨骼交界处),但最多4个
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)//如果第N个骨骼还没填充权重数据,则填充,填充完break
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
/** The time of this key */
double mTime;
/** The value of this key */
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);//构造函数,提取aiNodeAnim的数据
void Update(float animationTime);//根据时间,计算一个m_LocalTransform换算矩阵
一个非常重要的函数,是Update
,用于根据时间戳,计算矩阵。这个函数在每次onDraw
时调用。
现在来看一下怎么把这些数据提取出来。
3.2.3 提取函数
void ReadMissingBones(const aiAnimation* animation, ModelAnim& model)
int size = animation->mNumChannels;
//获得之前解析权重时所记录的骨骼map,其中key为骨骼名字
m_BoneInfoMap = model.GetBoneInfoMap();//getting m_BoneInfoMap from Model class
LOGCATE("ReadMissingBones, m_BoneInfoMap address %p, size %d, animation->mNumChannels %d", &m_BoneInfoMap,m_BoneInfoMap.size(), animation->mNumChannels);
//获得骨骼计数器,用于分配id
int& boneCount = model.GetBoneCount(); //getting the m_BoneCounter from Model class
//reading channels(bones engaged in an animation and their keyframes)
//读取通道列表,每个通道包括所有被该动画影响的骨骼,以及对应的关键帧
for (int i = 0; i < size; i++)
auto channel = animation->mChannels[i];//一个channel代表某个骨骼
std::string boneName = channel->mNodeName.data;//拿到骨骼名字
if (m_BoneInfoMap.find(boneName) == m_BoneInfoMap.end())
//如果万一map不包括这个骨骼,则记录下来
m_BoneInfoMap[boneName].id = boneCount;
boneCount++;
//创建一个Bone对象,添加到m_Bones数组
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;
//1. 提取关键帧的位移参数,放到m_Positions列表,后面可以用于计算插值
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);
//2. 提取关键帧的旋转
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);
//3. 提取关键帧的缩放
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;
//update animation firstly
float deltaTime = 0.03f;//base on seconds, 30fps, each frame is about 0.03 seconds
//根据时间戳,计算 动画矩阵
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);
//更新MVP矩阵
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());
//传递给vertex shader, 用于计算动画之后的新顶点坐标
for (int i = 0; i < transforms.size(); ++i)
m_pShader->setMat4("finalBonesMatrices[" + std::to_string(i) + "]", transforms[i]);
//调用DrawCall,逐网格绘制
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
函数了,这是一个递归的函数。
首次传参是动画的第一个骨骼节点。然后递归,算出动画所影响的所有骨骼的矩阵。
来看一下具体实现:
/**
* 计算某个骨骼 影响顶点的换算矩阵
* @param node 存骨骼名字,矩阵
* @param parentTransform 父节点的换算矩阵
*/
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对象根据时间,计算一个矩阵
Bone->Update(m_CurrentTime);
//得到矩阵
nodeTransform = Bone-><以上是关于android OpenGL渲染带骨骼动画的3D模型的主要内容,如果未能解决你的问题,请参考以下文章
带有 Assimp 的 OpenGL (GLSL) 中的骨骼动画