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_BoneIDsm_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) 中的骨骼动画

骨骼动画—从基础建模到Threejs渲染

Unity3D之Mecanim动画系统学习笔记:模型导入

进击3D游戏界!Cocos Creator快速实现骨骼动画交互!

进击3D游戏界!Cocos Creator快速实现骨骼动画交互!