模型的骨骼动画技术讲解.docx
- 文档编号:27500415
- 上传时间:2023-07-02
- 格式:DOCX
- 页数:18
- 大小:486.32KB
模型的骨骼动画技术讲解.docx
《模型的骨骼动画技术讲解.docx》由会员分享,可在线阅读,更多相关《模型的骨骼动画技术讲解.docx(18页珍藏版)》请在冰豆网上搜索。
模型的骨骼动画技术讲解
模型的骨骼动画技术讲解
骨骼动画实际上是两部分的过程。
第一个由美术执行,第二个由程序员(或者你写的引擎)执行。
第一部分发生在建模软件中,称为建模。
这里发生的是术定义了网格下面骨骼的骨架。
网格代表物体(无论是人类,怪物还是其他物体)的皮肤,骨骼用于移动网格物体,以模拟现实世界中的实际运动,这通过将每个顶点分配给一个或多个骨头来完成。
当顶点被分配给骨骼时,定义了权重,该权重确定骨骼在移动时对顶点的影响量。
通常的做法是使所有权重的总和1(每个顶点)。
例如,如果一个顶点位于两个骨骼之间,我们可能希望将每个骨骼的权重分配为0.5,因为我们希望骨骼在顶点上的影响相等。
然而,如果顶点完全在单个骨骼的影响之内,那么权重将为1(这意味着骨骼自主地控制顶点的运动)。
这是一个在混合器中创建的骨骼结构的例子:
我们上面看到的是动画的重要组成部分,美术将骨骼结构组合在一起,并为每个动画类型(“步行”,“跑步”,“死亡”等)定义了一组关键帧。
关键帧包含沿着动画路径的关键点的所有骨骼的变换。
图形引擎在关键帧的变换之间进行插值,并在它们之间创建平滑的运动。
用于骨骼动画的骨骼结构通常是继承的,这意味着骨骼有一个孩子/父母关系,所以创建了一根骨头。
除了根骨之外,每个骨骼都有一个父母。
例如,在人体的情况下,您可以将后骨分配为具有诸如手臂和腿部以及手指骨的儿童骨骼的根部。
当父骨骼移动时,它也移动其所有的孩子,但是当孩子的骨骼移动时,它不会移动它的父母(我们的手指可以移动而不移动手,但是当手移动它移动所有的手指)。
从实践的角度来看,这意味着当我们处理骨骼的变换时,我们需要将它与从它引导到根的所有父骨骼的转换结合起来。
我们不会再进一步讨论装备,它是一个复杂的主题,并且在图形程序员的领域之外。
建模软件有先进的工具来帮助美术做这项工作,你需要成为一个很好的美术来创造一个好看的网格和骨架。
让我们看看图形引擎需要做什么才能制作骨架动画。
第一阶段是用顶点骨骼信息来提取顶点缓冲区。
有几个选项可用,但我们将要做的很简单。
对于每个顶点,我们将添加一个插槽阵列,其中每个插槽包含骨骼ID和权重。
为了使我们的生活更简单,我们将使用具有四个插槽的数组,这意味着没有顶点可以受到四个以上的骨骼的影响。
如果您要加载更多骨骼的模型,则需要调整阵列大小,但是对于作为本博文一部分的Doom3模型,四个骨骼就足够了。
所以我们的新顶点结构将如下所示:
骨骼ID是骨转换数组的索引,这些变换将被应用在WVP矩阵之前的位置和正常(即它们将顶点从“骨空间”转换成局部空间)。
权重将用于将几个骨骼的变换组合成单个变换,并且在任何情况下,总权重必须正好为1(建模软件的事情)。
通常,我们将在动画关键帧之间进行插值,并在每个帧中更新骨骼变换数组。
骨骼转换阵列的创建方式通常是棘手的部分。
变换被设置在一个历史结构(即树)中,通常的做法是在树中的每个节点中具有缩放向量,旋转四元数和平移向量。
实际上,每个节点都包含这些项目的数组。
数组中的每个条目都必须有一个时间戳。
应用时间与其中一个时间戳完全匹配的情况可能很少,因此我们的代码必须能够插值缩放/旋转/转换,以便在应用程序的时间点获得正确的转换。
我们对每个节点从当前骨到根进行相同的过程,并将这个变换链相加在一起以获得最终结果。
我们为每个骨骼做这些,然后更新着色器。
到目前为止,我们谈到的一切都是非常通用的。
但是这是一个关于使用Assimp的骨骼动画的博文,所以我们需要再次进入该库,读者可以自行下载一个Assimp库,看看如何使用它进行皮肤化。
Assimp的好处是它支持从多种格式加载骨骼信息。
不好的是,您仍然需要对其创建的数据结构进行相当多的工作,以生成您为着色器所需的骨骼转换。
让我们从根的骨骼信息开始吧,以下是Assimp数据结构中的相关内容:
后面给读者介绍一下关于Assimp类的加载,一切都包含在aiScene类中(当我们导入网格文件时我们得到的对象),aiScene包含一组aiMesh对象。
aiMesh是模型的一部分,并在顶点级别包含位置,法线,纹理坐标等内容。
现在我们看到aiMesh还包含一个aiBone对象的数组。
毫无疑问,aiBone代表网格骨架中的一个骨骼,每个骨骼都有一个名字,通过它可以在骨骼层级(见下文),顶点权重数组和4x4偏移矩阵中找到,我们需要这个矩阵的原因是因为顶点存储在通常的本地空间中,这意味着即使没有骨架动画,我们现有的代码库也可以加载模型并正确渲染。
但是,骨干变化在骨骼空间中发挥作用(每个骨骼都有自己的空间,这就是为什么我们需要将变换加在一起)。
因此,偏移矩阵的工作将顶点位置从网格的局部空间移动到该特定骨骼的骨空间。
顶点权重数组是事物开始变得有趣的地方,该数组中的每个条目都包含aiMesh中顶点数组的索引(请注意,顶点分布在几个长度相同的数组中)和权重。
所有顶点权重的总和必须为1,但是要找到它们,您需要遍历所有骨骼,并将权重累加到每个特定顶点的列表中。
在我们的顶点级别构建骨骼信息之后,我们需要处理骨骼变换层级并生成将加载到着色器中的最终转换,下图显示相关数据结构:
再次,我们从aiScene开始,aiScene对象包含一个指向aiNode类对象的指针,该对象是一个节点层级的根(换句话说-一棵树),树中的每个节点都有一个指向其父项的指针以及指向其子节点的数组,这样我们可以方便地来回遍历树。
另外,节点执行从节点空间变换到其父节点空间的变换矩阵。
最后,节点可能有也可能没有一个名字。
如果一个节点表示父进制中的骨骼,则节点名称必须与骨骼名称相匹配。
但是有时节点没有名称(这意味着没有相应的骨骼),而且他们的工作只是帮助模型分解模型并且沿着一些中间变换。
最后一块拼图是aiAnimation数组,它也存储在aiScene对象中,单个aiAnimation对象表示一系列动画帧,例如“walk”,“run”,“shoot”等。
通过在帧之间进行内插,我们得到与动画名称相匹配的所需视觉效果。
动画的持续时间为每秒钟的秒数(例如每秒100个刻度和25个刻度,代表4秒动画),这有助于我们对进程进行时间调整,以使动画在每个硬件上看起来相同。
另外,动画还有一个名为通道的aiNodeAnim对象的数组。
每个通道实际上都是骨骼,全部是它的转变。
该通道包含一个名称,该名称必须与其他一个节点在层级和三个转换数组中匹配。
为了计算特定时间点的最终骨骼变换,我们需要在这三个阵列中的每一个中找到与时间匹配的两个入口,并在它们之间插值。
那么我们需要将转换组合成一个矩阵。
做完之后,我们需要在根中找到相应的节点。
然后我们需要相应的通道为父,并进行相同的插值过程。
我们把这两个变化相乘合起来,直到我们达到根的层级。
加载模型的源代码实现如下:
[cpp]viewplaincopy在CODE上查看代码片派生到我的代码片
boolMesh:
:
LoadMesh(conststring&Filename)
{
//Releasethepreviouslyloadedmesh(ifitexists)
Clear();
//CreatetheVAO
glGenVertexArrays(1,&m_VAO);
glBindVertexArray(m_VAO);
//Createthebuffersfortheverticesattributes
glGenBuffers(ARRAY_SIZE_IN_ELEMENTS(m_Buffers),m_Buffers);
boolRet=false;
m_pScene=m_Importer.ReadFile(Filename.c_str(),aiProcess_Triangulate|aiProcess_GenSmoothNormals|
aiProcess_FlipUVs);
if(m_pScene){
m_GlobalInverseTransform=m_pScene->mRootNode->mTransformation;
m_GlobalInverseTransform.Inverse();
Ret=InitFromScene(m_pScene,Filename);
}
else{
printf("Errorparsing'%s':
'%s'\n",Filename.c_str(),m_Importer.GetErrorString());
}
//MakesuretheVAOisnotchangedfromtheoutside
glBindVertexArray(0);
returnRet;
}
这是更新到Mesh类的入口点,更改标记为粗体,有一些我们需要注意的变化。
一个是导入和aiScene对象现在是类成员,而不是堆栈变量。
(关于阿Assimp模型的加载会在后面博客中讲解)原因是在运行时,我们将一次又一次地返回到aiScene对象,因此我们需要扩展导入器和场景的范围。
在一个真实的游戏中,您可能想要复制所需的东西,并以更优化的格式存储。
第二个变化是提取,反转和存储了根的层级转换矩阵,我们继续看下去。
请注意,矩阵逆的代码已从Assimp库复制到我们的Matrix4f类中。
源代码的实现如下所示:
[cpp]viewplaincopy在CODE上查看代码片派生到我的代码片
(mesh.h)
structVertexBoneData
{
uintIDs[NUM_BONES_PER_VEREX];
floatWeights[NUM_BONES_PER_VEREX];
}
(mesh.cpp)
boolMesh:
:
InitFromScene(constaiScene*pScene,conststring&Filename)
{
...
vector
...
Bones.resize(NumVertices);
...
glBindBuffer(GL_ARRAY_BUFFER,m_Buffers[BONE_VB]);
glBufferData(GL_ARRAY_BUFFER,sizeof(Bones[0])*Bones.size(),&Bones[0],GL_STATIC_DRAW);
glEnableVertexAttribArray(BONE_ID_LOCATION);
glVertexAttribIPointer(BONE_ID_LOCATION,4,GL_INT,sizeof(VertexBoneData),(constGLvoid*)0);
glEnableVertexAttribArray(BONE_WEIGHT_LOCATION);
glVertexAttribPointer(BONE_WEIGHT_LOCATION,4,GL_FLOAT,GL_FALSE,sizeof(VertexBoneData),(constGLvoid*)16);
...
}
上面的结构包含了我们在顶点级别所需要的一切,默认情况下,我们有足够的存储空间用于四个骨骼(每个骨骼的ID和权重)。
VertexBoneData的结构就像这样,使之简单的传递给着色器。
我们已经分别在位置0,1和2处获得了位置,纹理坐标和法线。
因此,我们配置的VAO来绑定位置3处的骨骼ID和位置4处的权重。
请注意,我们使用glVertexAttribIPointer而不是glVertexAttribPointer来绑定ID非常重要。
原因是ID是整数而不是浮点。
注意这一点,否则您将在着色器中收到损坏的数据。
[cpp]viewplaincopy在CODE上查看代码片派生到我的代码片
(mesh.cpp)
voidMesh:
:
LoadBones(uintMeshIndex,constaiMesh*pMesh,vector&Bones)
{
for(uinti=0;i
uintBoneIndex=0;
stringBoneName(pMesh->mBones[i]->mName.data);
if(m_BoneMapping.find(BoneName)==m_BoneMapping.end()){
BoneIndex=m_NumBones;
m_NumBones++;
BoneInfobi;
m_BoneInfo.push_back(bi);
}
else{
BoneIndex=m_BoneMapping[BoneName];
}
m_BoneMapping[BoneName]=BoneIndex;
m_BoneInfo[BoneIndex].BoneOffset=pMesh->mBones[i]->mOffsetMatrix;
for(uintj=0;j
uintVertexID=m_Entries[MeshIndex].BaseVertex+pMesh->mBones[i]->mWeights[j].mVertexId;
floatWeight=pMesh->mBones[i]->mWeights[j].mWeight;
Bones[VertexID].AddBoneData(BoneIndex,Weight);
}
}
}
上述函数加载单个aiMesh对象的顶点骨骼信息。
它由Mesh:
:
InitMesh()调用。
除了填充VertexBoneData结构之外,此功能还可以更新骨骼名称和骨骼ID(由此功能管理的运行索引)之间的映射,并将偏移矩阵存储在基于骨骼ID的向量中。
注意如何计算顶点ID。
由于顶点ID与单个网格相关,并且我们将所有网格存储在单个向量中,因此将当前aiMesh的基本顶点ID从mWeights数组中添加到顶点ID以获取绝对顶点ID。
[cpp]viewplaincopy在CODE上查看代码片派生到我的代码片
voidMesh:
:
VertexBoneData:
:
AddBoneData(uintBoneID,floatWeight)
{
for(uinti=0;i if(Weights[i]==0.0){ IDs[i]=BoneID; Weights[i]=Weight; return; } } //shouldnevergethere-morebonesthanwehavespacefor assert(0); } 此功能函数在VertexBoneData结构中找到一个空闲插槽,并将骨骼ID和权重放在其中。 某些顶点将受到少于四个骨骼的影响,但是由于非现有骨骼的权重保持为零,这意味着我们可以对任意数量的骨骼使用相同的权重计算。 [cpp]viewplaincopy在CODE上查看代码片派生到我的代码片 Matrix4fMesh: : BoneTransform(floatTimeInSeconds,vector { Matrix4fIdentity; Identity.InitIdentity(); floatTicksPerSecond=m_pScene->mAnimations[0]->mTicksPerSecond! =0? m_pScene->mAnimations[0]->mTicksPerSecond: 25.0f; floatTimeInTicks=TimeInSeconds*TicksPerSecond; floatAnimationTime=fmod(TimeInTicks,m_pScene->mAnimations[0]->mDuration); ReadNodeHeirarchy(AnimationTime,m_pScene->mRootNode,Identity); Transforms.resize(m_NumBones); for(uinti=0;i Transforms[i]=m_BoneInfo[i].FinalTransformation; } } 在程序启动期间加载网格时,在前面看到的顶点级别的骨骼信息的加载仅完成一次。 现在我们来到第二部分,它计算每一帧进入着色器的骨骼变换,上述功能是切入点。 我们找到动画周期内的相对时间,并处理节点层级,最终是将一组转换返回给调用函数。 [cpp]viewplaincopy在CODE上查看代码片派生到我的代码片 voidMesh: : ReadNodeHeirarchy(floatAnimationTime,constaiNode*pNode,constMatrix4f&ParentTransform) { stringNodeName(pNode->mName.data); constaiAnimation*pAnimation=m_pScene->mAnimations[0]; Matrix4fNodeTransformation(pNode->mTransformation); constaiNodeAnim*pNodeAnim=FindNodeAnim(pAnimation,NodeName); if(pNodeAnim){ //Interpolatescalingandgeneratescalingtransformationmatrix aiVector3DScaling; CalcInterpolatedScaling(Scaling,AnimationTime,pNodeAnim); Matrix4fScalingM; ScalingM.InitScaleTransform(Scaling.x,Scaling.y,Scaling.z); //Interpolaterotationandgeneraterotationtransformationmatrix aiQuaternionRotationQ; CalcInterpolatedRotation(RotationQ,AnimationTime,pNodeAnim); Matrix4fRotationM=Matrix4f(RotationQ.GetMatrix()); //Interpolatetranslationandgeneratetranslationtransformationmatrix aiVector3DTation; CalcInterpolatedPosition(Translation,AnimationTime,pNodeAnim); Matrix4fTranslationM; TranslationM.InitTranslationTransform(Translation.x,Translation.y,Translation.z); //Combinetheabovetransformations NodeTransformation=TranslationM*RotationM*ScalingM; } Matrix4fGlobalTransformation=ParentTransform*NodeTransformation; if(m_BoneMapping.find(NodeName)! =m_BoneMapping.end()){ uintBoneIndex=m_BoneMapping[NodeName]; m_BoneInfo[BoneIndex].FinalTransformation=m_GlobalInverseTransform*GlobalTransformation* m_BoneInfo[BoneIndex].BoneOffset; } for(uinti=0;i ReadNodeHeirarchy(AnimationTime,pNode->mChildren[i],GlobalTransformation); } } 此函数遍历节点树,并根据指定的动画时间生成每个节点/骨骼的最终变换。 它的意义在于它假定网格只有一个动画序列并且是有限的。 如果你想支持多个动画,你需要告诉它的动画名称并在m_pScene->mAnimations[]数组中搜索它,上面的代码对于我们使用的演示网格是足够好的。 从节点中的mTransformation成员初始化节点变换,如果节点不对应于骨骼,那么这是其最终的转换。 如果我们用生成的矩阵来覆盖它,这样做如下: 首先我们在动画的通道数组中搜索节点名称,然后我们基于动画时间内插缩放矢量,旋转四元数和平移矢量。 我们将它们组合成一个矩阵,并将其与我们得到的矩阵相乘(称为GlobablTransformation),此函数是递归的,并且以GlobalTransformation参数为单位矩阵为根节点进行调用。 每个节点递归地为其所有子节点调用此函数,并将其自身的变换作为GlobalTransformation传递。 我们从顶部开始会得到每个节点的组合转换链。 m_BoneMapping数组将节点名称映射到我们生成的索引中,我们将该索引用作存储m_BoneInfo数组,最后的变换计算如下: 我们从节点偏移矩阵开始,将顶点从其局部空间位置引入其节点空间,然后,我们将所有节点父节点的组合变换加上我们根据动画时间为节点计算的特定变换进行多次迭代。 请注意,我们在这里使用Assimp代码处理数学的东西,我没有看到将其复制到我们自己的代码库中,所以我只是使用Assimp。 [cpp]viewplaincopy在CODE上查看代码片派生到我的代码片 voidMesh: : CalcInterpolatedRotation(aiQuaternion&Ou
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 模型 骨骼 动画 技术 讲解