资源描述
模型的骨骼动画技术讲解骨骼动画实际上是两部分的过程。第一个由美术执行,第二个由程序员(或者你写的引擎)执行。第一部分发生在建模软件中,称为建模。这里发生的是术定义了网格下面骨骼的骨架。网格代表物体(无论是人类,怪物还是其他物体)的皮肤,骨骼用于移动网格物体,以模拟现实世界中的实际运动,这通过将每个顶点分配给一个或多个骨头来完成。当顶点被分配给骨骼时,定义了权重,该权重确定骨骼在移动时对顶点的影响量。通常的做法是使所有权重的总和1(每个顶点)。例如,如果一个顶点位于两个骨骼之间,我们可能希望将每个骨骼的权重分配为0.5,因为我们希望骨骼在顶点上的影响相等。然而,如果顶点完全在单个骨骼的影响之内,那么权重将为1(这意味着骨骼自主地控制顶点的运动)。这是一个在混合器中创建的骨骼结构的例子:我们上面看到的是动画的重要组成部分, 美术将骨骼结构组合在一起,并为每个动画类型(“步行”,“跑步”,“死亡”等)定义了一组关键帧。 关键帧包含沿着动画路径的关键点的所有骨骼的变换。 图形引擎在关键帧的变换之间进行插值,并在它们之间创建平滑的运动。用于骨骼动画的骨骼结构通常是继承的, 这意味着骨骼有一个孩子/父母关系,所以创建了一根骨头。 除了根骨之外,每个骨骼都有一个父母。 例如,在人体的情况下,您可以将后骨分配为具有诸如手臂和腿部以及手指骨的儿童骨骼的根部。 当父骨骼移动时,它也移动其所有的孩子,但是当孩子的骨骼移动时,它不会移动它的父母(我们的手指可以移动而不移动手,但是当手移动它移动所有的手指)。 从实践的角度来看,这意味着当我们处理骨骼的变换时,我们需要将它与从它引导到根的所有父骨骼的转换结合起来。我们不会再进一步讨论装备, 它是一个复杂的主题,并且在图形程序员的领域之外。 建模软件有先进的工具来帮助美术做这项工作,你需要成为一个很好的美术来创造一个好看的网格和骨架。 让我们看看图形引擎需要做什么才能制作骨架动画。第一阶段是用顶点骨骼信息来提取顶点缓冲区。 有几个选项可用,但我们将要做的很简单。 对于每个顶点,我们将添加一个插槽阵列,其中每个插槽包含骨骼ID和权重。 为了使我们的生活更简单,我们将使用具有四个插槽的数组,这意味着没有顶点可以受到四个以上的骨骼的影响。 如果您要加载更多骨骼的模型,则需要调整阵列大小,但是对于作为本博文一部分的Doom 3模型,四个骨骼就足够了。 所以我们的新顶点结构将如下所示:骨骼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 view plain copy 在CODE上查看代码片派生到我的代码片bool Mesh:LoadMesh(const string& Filename) / Release the previously loaded mesh (if it exists) Clear(); / Create the VAO glGenVertexArrays(1, &m_VAO); glBindVertexArray(m_VAO); / Create the buffers for the vertices attributes glGenBuffers(ARRAY_SIZE_IN_ELEMENTS(m_Buffers), m_Buffers); bool Ret = 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(Error parsing %s: %sn, Filename.c_str(), m_Importer.GetErrorString(); / Make sure the VAO is not changed from the outside glBindVertexArray(0); return Ret; 这是更新到Mesh类的入口点,更改标记为粗体,有一些我们需要注意的变化。 一个是导入和aiScene对象现在是类成员,而不是堆栈变量。(关于阿Assimp模型的加载会在后面博客中讲解) 原因是在运行时,我们将一次又一次地返回到aiScene对象,因此我们需要扩展导入器和场景的范围。 在一个真实的游戏中,您可能想要复制所需的东西,并以更优化的格式存储。第二个变化是提取,反转和存储了根的层级转换矩阵, 我们继续看下去。 请注意,矩阵逆的代码已从Assimp库复制到我们的Matrix4f类中。源代码的实现如下所示:cpp view plain copy 在CODE上查看代码片派生到我的代码片(mesh.h) struct VertexBoneData uint IDsNUM_BONES_PER_VEREX; float WeightsNUM_BONES_PER_VEREX; (mesh.cpp) bool Mesh:InitFromScene(const aiScene* pScene, const string& Filename) . vector Bones; . Bones.resize(NumVertices); . glBindBuffer(GL_ARRAY_BUFFER, m_BuffersBONE_VB); glBufferData(GL_ARRAY_BUFFER, sizeof(Bones0) * Bones.size(), &Bones0, GL_STATIC_DRAW); glEnableVertexAttribArray(BONE_ID_LOCATION); glVertexAttribIPointer(BONE_ID_LOCATION, 4, GL_INT, sizeof(VertexBoneData), (const GLvoid*)0); glEnableVertexAttribArray(BONE_WEIGHT_LOCATION); glVertexAttribPointer(BONE_WEIGHT_LOCATION, 4, GL_FLOAT, GL_FALSE, sizeof(VertexBoneData), (const GLvoid*)16); . 上面的结构包含了我们在顶点级别所需要的一切, 默认情况下,我们有足够的存储空间用于四个骨骼(每个骨骼的ID和权重)。 VertexBoneData的结构就像这样,使之简单的传递给着色器。 我们已经分别在位置0,1和2处获得了位置,纹理坐标和法线。 因此,我们配置的VAO来绑定位置3处的骨骼ID和位置4处的权重。请注意,我们使用glVertexAttribIPointer而不是glVertexAttribPointer来绑定ID非常重要。 原因是ID是整数而不是浮点。 注意这一点,否则您将在着色器中收到损坏的数据。cpp view plain copy 在CODE上查看代码片派生到我的代码片(mesh.cpp) void Mesh:LoadBones(uint MeshIndex, const aiMesh* pMesh, vector& Bones) for (uint i = 0 ; i mNumBones ; i+) uint BoneIndex = 0; string BoneName(pMesh-mBonesi-mName.data); if (m_BoneMapping.find(BoneName) = m_BoneMapping.end() BoneIndex = m_NumBones; m_NumBones+; BoneInfo bi; m_BoneInfo.push_back(bi); else BoneIndex = m_BoneMappingBoneName; m_BoneMappingBoneName = BoneIndex; m_BoneInfoBoneIndex.BoneOffset = pMesh-mBonesi-mOffsetMatrix; for (uint j = 0 ; j mBonesi-mNumWeights ; j+) uint VertexID = m_EntriesMeshIndex.BaseVertex + pMesh-mBonesi-mWeightsj.mVertexId; float Weight = pMesh-mBonesi-mWeightsj.mWeight; BonesVertexID.AddBoneData(BoneIndex, Weight); 上述函数加载单个aiMesh对象的顶点骨骼信息。 它由Mesh : InitMesh()调用。 除了填充VertexBoneData结构之外,此功能还可以更新骨骼名称和骨骼ID(由此功能管理的运行索引)之间的映射,并将偏移矩阵存储在基于骨骼ID的向量中。 注意如何计算顶点ID。 由于顶点ID与单个网格相关,并且我们将所有网格存储在单个向量中,因此将当前aiMesh的基本顶点ID从mWeights数组中添加到顶点ID以获取绝对顶点ID。cpp view plain copy 在CODE上查看代码片派生到我的代码片void Mesh:VertexBoneData:AddBoneData(uint BoneID, float Weight) for (uint i = 0 ; i ARRAY_SIZE_IN_ELEMENTS(IDs) ; i+) if (Weightsi = 0.0) IDsi = BoneID; Weightsi = Weight; return; / should never get here - more bones than we have space for assert(0); 此功能函数在VertexBoneData结构中找到一个空闲插槽,并将骨骼ID和权重放在其中。 某些顶点将受到少于四个骨骼的影响,但是由于非现有骨骼的权重保持为零,这意味着我们可以对任意数量的骨骼使用相同的权重计算。cpp view plain copy 在CODE上查看代码片派生到我的代码片Matrix4f Mesh:BoneTransform(float TimeInSeconds, vector& Transforms) Matrix4f Identity; Identity.InitIdentity(); float TicksPerSecond = m_pScene-mAnimations0-mTicksPerSecond != 0 ? m_pScene-mAnimations0-mTicksPerSecond : 25.0f; float TimeInTicks = TimeInSeconds * TicksPerSecond; float AnimationTime = fmod(TimeInTicks, m_pScene-mAnimations0-mDuration); ReadNodeHeirarchy(AnimationTime, m_pScene-mRootNode, Identity); Transforms.resize(m_NumBones); for (uint i = 0 ; i mName.data); const aiAnimation* pAnimation = m_pScene-mAnimations0; Matrix4f NodeTransformation(pNode-mTransformation); const aiNodeAnim* pNodeAnim = FindNodeAnim(pAnimation, NodeName); if (pNodeAnim) / Interpolate scaling and generate scaling transformation matrix aiVector3D Scaling; CalcInterpolatedScaling(Scaling, AnimationTime, pNodeAnim); Matrix4f ScalingM; ScalingM.InitScaleTransform(Scaling.x, Scaling.y, Scaling.z); / Interpolate rotation and generate rotation transformation matrix aiQuaternion RotationQ; CalcInterpolatedRotation(RotationQ, AnimationTime, pNodeAnim); Matrix4f RotationM = Matrix4f(RotationQ.GetMatrix(); / Interpolate translation and generate translation transformation matrix aiVector3D Translwww.sm136.comation; CalcInterpolatedPosition(Translation, AnimationTime, pNodeAnim); Matrix4f TranslationM; TranslationM.InitTranslationTransform(Translation.x, Translation.y, Translation.z); / Combine the above transformations NodeTransformation = TranslationM * RotationM * ScalingM; Matrix4f GlobalTransformation = ParentTransform * NodeTransformation; if (m_BoneMapping.find(NodeName) != m_BoneMapping.end() uint BoneIndex = m_BoneMappingNodeName; m_BoneInfoBoneIndex.FinalTransformation = m_GlobalInverseTransform * GlobalTransformation * m_BoneInfoBoneIndex.BoneOffset; for (uint i = 0 ; i mNumChildren ; i+) ReadNodeHeirarchy(AnimationTime, pNode-mChildreni, GlobalTransformation); 此函数遍历节点树,并根据指定的动画时间生成每个节点/骨骼的最终变换。 它的意义在于它假定网格只有一个动画序列并且是有限的。 如果你想支持多个动画,你需要告诉它的动画名称并在m_pScene- mAnimations 数组中搜索它, 上面的代码对于我们使用的演示网格是足够好的。从节点中的mTransformation成员初始化节点变换,如果节点不对应于骨骼,那么这是其最终的转换。 如果我们用生成的矩阵来覆盖它, 这样做如下:首先我们在动画的通道数组中搜索节点名称, 然后我们基于动画时间内插缩放矢量,旋转四元数和平移矢量。 我们将它们组合成一个矩阵,并将其与我们得到的矩阵相乘(称为GlobablTransformation), 此函数是递归的,并且以GlobalTransformation参数为单位矩阵为根节点进行调用。 每个节点递归地为其所有子节点调用此函数,并将其自身的变换作为GlobalTransformation传递。 我们从顶部开始会得到每个节点的组合转换链。m_BoneMapping数组将节点名称映射到我们生成的索引中,我们将该索引用作存储m_BoneInfo数组, 最后的变换计算如下:我们从节点偏移矩阵开始,将顶点从其局部空间位置引入其节点空间, 然后,我们将所有节点父节点的组合变换加上我们根据动画时间为节点计算的特定变换进行多次迭代。请注意,我们在这里使用Assimp代码处理数学的东西, 我没有看到将其复制到我们自己的代码库中,所以我只是使用Assimp。cpp view plain copy 在CODE上查看代码片派生到我的代码片void Mesh:CalcInterpolatedRotation(aiQuaternion& Out, float AnimationTime, const aiNodeAnim* pNodeAnim) / we need at least two values to interpolate. if (pNodeAnim-mNumRotationKeys = 1) Out = pNodeAnim-mRotationKeys0.mValue; return; uint RotationIndex = FindRotation(AnimationTime, pNodeAnim); uint NextRotationIndex = (RotationIndex + 1); assert(NextRotationIndex mNumRotationKeys); float DeltaTime = pNodeAnim-mRotationKeysNextRotationIndex.mTime - pNodeAnim-mRotationKeysRotationwww.whfengjun.comIndex.mTime; float Factor = (AnimationTime - (float)pNodeAnim-mRotationKeysRotationIndex.mTime) / DeltaTime; assert(Factor = 0.0f & Factor mRotationKeysRotationIndex.mValue; const aiQuaternion& EndRotationQ = pNodeAnim-mRotationKeysNextRotationIndex.mValue; aiQuaternion:Interpolate(Out, StartRotationQ, EndRotationQ, Factor); Out = Out.Normalize(); 该方法基于动画时间插入指定频道的旋转四元数(请记住,频道包含关键四元数组), 首先,我们找到正好在所需动画时间之前的关键四元数的索引。 我们计算从动画时间到它之前的键的距离与该键和下一个键之间的距离之间的比率。 我们需要使用这个系数在这两个键之间插值, 我们使用Assimp代码进行插值并对结果进行归一化, 相应的位置和缩放方法非常相似,所以在这里没有引用。cpp view plain copy 在CODE上查看代码片派生到我的代码片uint Mesh:FindRotation(float AnimationTime, const aiNodeAnim* pNodeAnim) assert(pNodeAnim-mNumRotationKeys 0); for (uint i = 0 ; i mNumRotationKeys - 1 ; i+) if (AnimationTime mRotationKeysi + 1.mTime) return i; assert(0); 此实用程序方法找到紧接在动画时间之前的按键旋转, 如果我们有N个键旋转,结果可以是0到N-2, 动画时间总是包含在频道的持续时间内,所以最后一个键(N-1)永远不会是一个有效的结果。下面展示的是蒙皮Shader 代码如下:cpp view plain copy 在CODE上查看代码片派生到我的代码片(skinning.vs) #version 330 layout (location = 0) in vec3 Position; layout (location = 1) in vec2 TexCoord; layout (location = 2) in vec3 Normal; layout (location = 3) in ivec4 BoneIDs; layout (location = 4) in vec4 Weights; out vec2 TexCoord0; out vec3 Normal0; out vec3 WorldPos0; const int MAX_BONES = 100; uniform mat4 gWVP; uniform mat4 gWorld; uniform mat4 gBonesMAX_BONES; void main() mat4 BoneTransform = gBonesBoneIDs0 * Weights0; BoneTransform += gBonesBoneIDs1 * Weights1; BoneTransform += gBonesBoneIDs2 * Weights2; BoneTransform += gBonesBoneIDs3 * Weights3; vec4 PosL = BoneTransform * vec4(Position, 1.0); gl_Position = gWVP * PosL; TexCoord0 = TexCwww.edu800.cnoord; vec4 NormalL = BoneTransform * vec4(Normal, 0.0); Normal0 = (gWorld * NormalL).xyz; WorldPos0 = (gWorld * PosL).xyz; 现在我们已经完成了mesh类中的更改,我们来看看我们在着色器级别需要做的, 首先,我们将骨骼ID和权重数组添加到VSInput结构中。 接下来,有一个新的统一数组包含骨转换, 在着色器本身,我们将最终骨骼转换计算为顶点的骨骼转换矩阵及其权重的组合。 该最终矩阵用于将位置和法线从其骨骼空间转换为局部空间。cpp view plain copy 在CODE上查看代码片派生到我的代码片float RunningTime = (float)(double)GetCurrentTimeMillis() - (double)m_startTime) / 1000.0f; m_mesh.BoneTransform(RunningTime, Transforms); for (uint i = 0 ; i SetBoneTransform(i, Transformsi); 我们需要做的最后一件事是将所有这些东西整合到应用程序代码中, 这是在上面的简单代码中完成的。 函数GetCurrentTimeMillis()返回自应用程序启动以来的毫秒数(注意浮点数以适应分数)的时间。
展开阅读全文