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。

扫描二维码关注公众号,回复: 13498673 查看本文章

有了动画,骨头就会动,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->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;//骨骼的原始矩阵
			//某个骨骼影响顶点的换算矩阵,该矩阵将传递给vertex shader
			//需要再乘于根节点的m_GlobalTransform,根节点影响所有子节点的换算
			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实现骨骼动画

猜你喜欢

转载自blog.csdn.net/newchenxf/article/details/121790111