Tutorial 38:

Skeletal Animation With Assimp

最终,我们来到了这里。有数百万的读者都要求这一教程(我可能夸大了一些,但确实有不少)。骨骼动画(skeletion animation),同样也称作skinning,它使用了Assimp库。

骨骼动画事实上有两部分。第一部分是由设计师完成的,而第二部分是由你,程序员完成的(或者说,你写的引擎)。第一部分发生在模型软件中,它被称作骨骼装配(rigging)。在这里,设计师指定了皮肤下的骨架。mesh就是指对象的皮肤(无论它是人,怪物或者是其它)而骨骼是用于移动mesh以模拟现实世界中的运动。这是通过指定某个点到一块或多块骨头完成的。当一个顶点指定了一个骨头的时候,我们给出在移动中这块骨头对顶点影响的权重。对于每个顶点,实践中要保证权重总和为1(每个顶点)。例如,如果一个顶点恰处在两块骨头之间,我们可能会赋予每块骨头0.5的权值,因为我们希望每块骨头对顶点有相等的影响。但是,如果一个顶点完全收到一块骨头的影响,那么权值应当是1(这意味着骨头自主控制了顶点的运动)。

这里是一个blender中创建的骨骼:

我们在上面看到的事实上是动画的一个重要部分。设计师装配了骨架结构,并且为每个动画类型(走路,跑步,死亡等)定义了关键帧集合。关键帧包含了所有骨头在动画路径上的关键点的变换。图形引擎在关键帧变换中插值,并构造出它们之间的流畅运动。骨骼动画中的骨架结构通常是分层次的。这意味着骨头之间有孩子/父母的关系,所以形成了一棵骨头树。每个骨头都有一个父节点,除了根节点。在人类身体的情况下,例如,你把后背的骨头设为根,在下一层次,手臂、脚、手指头的骨骼作为其孩子。当一个父结点移动的时候,它的所有孩子也会移动,但是当一个孩子移动的时候,父结点不会移动。(我们的手指头可以在手不移动的情况下移动)。从练习的角度来看,这意味着当我们运行骨骼变换的时候,我们需要结合其到根所有的父节点。

接下来我们不会继续讨论骨骼装配了。它是非常复杂的课题,并且超出了图形程序员的范围。造型软件有高级的技术来帮助设计师完成这一工作,你需要一个好的设计师来创建一个好的皮肤以及骨架。让我们来看看图形引擎需要为骨骼动画做些什么。

第一步是利用每个顶点的骨头信息填充顶点缓冲区。有几个可选的变量,但是我们打算做的非常直接。对于每个顶点我们加入一个槽的数组,每个槽包含了一个骨骼Id以及它的权重。为了简化我们将会使用四个槽的数组,这意味着一个顶点不会被四块以上的骨头控制。如果你想要加载有更多骨头的模型,你需要调整一下数组的大小,但是对于这一教程使用的Doom3模型的demo而言四块骨头已经足够了。所以我们的新顶点结构看起来是这样的:

骨头的IDs是一个骨头变换数组的索引。这些变换将会在WVP矩阵(它们把顶点从骨头空间转换到本地空间)之前应用到位置和法线上。权值将用于结合不同骨头的变换为一个变换,在任何情况下,权值总和都应该是1(模型软件会负责)。通常情况下,我们会在关键帧之间插值并在每一帧更新骨头变换的数组。

骨头数组的变换创建方法通常是具有迷惑性的部分。变换被放置在一个分层结构体里(i.e.树)。普通的练习里使用尺度向量,树中每个结点有一个旋转四元数和一个平移向量。事实上,每个结点都包含这些项的一个数组。数组的每一项都应当有一个时间戳。应用的时间恰好和时间戳联系上的可能很小,所以我们的代码必须能够插值缩放/平移/旋转使得应用能够及时得到正确的变换。对于从当前骨头到根的每一结点我们完成相同的过程,并且把这一串变换相乘得到最终结果。我们对每个骨头都这么做,然后更新着色器。

目前为止,我们讨论的一切都已经有了好的封装。但是这是一个使用Assimp的骨骼动画教程,所以我们需要再次进入库中来看看我们怎么实现skining。关于Assimp的一个好消息是它支持加载一些格式的骨骼信息。坏消息是你仍然需要一些工作量,创建数据结构来生成着色器所需的骨骼变换。

让我们从顶点层次的骨骼信息开始。这是Assimp数据结构中相关的片段:

你可能回忆起了Assimp教程中,所有东西都包含在aiScene类中(我们导入mesh文件时得到的对象)。aiScene包含了aiMesh对象数组。一个aiMesh是模型的一部分,它包含了每个顶点层次的信息,如位置,法线,纹理坐标等等。现在我们看到aiMesh同样包含了aiBone对象。很显然,一个aiBone代表了骨架下的一块骨头。每个骨头都有一个名称,依靠这个名称它们可以在层次中被找到(看下方),也就是一个顶点权值数组和一个4X4的偏移量矩阵。你需要这个矩阵的原因是顶点存储在本地空间。这意味着就算不支持骨骼动画,我们已有的代码框架也可以加载模型并正确渲染它。但是层次中的骨骼变换是在骨骼空间中完成的(每块骨头有属于自己的空间,所以我们需要把变换相乘)。所以偏移量矩阵的工作就将顶点位置从mesh的本地空间变换到特定骨头的骨骼空间。

我们感兴趣的是顶点权值数组。数组中的每一项都包含aiMesh顶点数组中的一个索引(记得顶点是以相同长度跨越了几个数组)和一个权值。所有顶点的权值和必须为1,但是为了找到它们,你需要遍历所有的骨头并且累加和到每个特定定点的某一种链表中。

我们在顶点层次创建完可以运行骨骼变换层次以及产生最终变换的骨骼信息后,我们将其加载到着色器。接下来的图片显示了相关数据结构:

我们再一次从aiScene开始。aiScene对象包含了一个aiNode的指针,其中aiNode类是分层(树)的根。树上的每个结点都有一个指针指向了它们的父母,一个指针数组指向了它们的孩子。这允许我们方便的来回遍历树。例外,结点存储了从结点空间到父空间的变换矩阵。最后,结点可能有名字,也可能没有名字。如果一个结点代表分层中的一块骨头,那么这个节点的名字必须和骨头对应。但是有时候结点没有名字(这意味着没有对应的骨头),它们的工作仅仅是帮助建模师分解模型并执行一些中间变换。

难题的最后一部分是aiAnimation数组,它同样也存储在aiScene对象中。一个单一的aiAnimation对象代表着一序列动画帧,例如“走路”,“奔跑”,“射击”等等。通过在帧之间插值我们得到了如动画名所示的想要的可见效果。一个动画有它持续的ticks以及每秒的ticks(共100ticks,25ticks每分钟代表着一个四秒的动画)这帮助我们计时程序,这样的话在不同硬件上动画表现才能一样。另外,一个动画有一个aiNodeAnim对象的数组,叫做通道。每个通道事实上包含了其所有变换的骨头。通道包含的名字必须与分层中的一个结点和三个变换数组对应。

为了在特定点及时计算最终的骨骼变换,我们需要在每个点的三个数组中找到两个时间并且插值对应的数组。然后我们需要结合变换到一个矩阵中。完成了这些后,我们需要找到分层中的一个对应结点,然后访问其父亲结点。接下来我们需要父节点对应的通道,并执行相同的插值操作。我们将两个变换相乘,继续如此,直到到达分层的根。

Source walkthru

(mesh.cpp:75)

  1. bool Mesh::LoadMesh(const string& Filename)

  2. {

  3. // Release the previously loaded mesh (if it exists)

  4. Clear();

  5. // Create the VAO

  6. glGenVertexArrays(1, &m_VAO);

  7. glBindVertexArray(m_VAO);

  8. // Create the buffers for the vertices attributes

  9. glGenBuffers(ARRAY_SIZE_IN_ELEMENTS(m_Buffers), m_Buffers);

  10. bool Ret = false;

  11. m_pScene = m_Importer.ReadFile(Filename.c_str(), aiProcess_Triangulate | aiProcess_GenSmoothNormals |

  12. aiProcess_FlipUVs);

  13. if (m_pScene) {

  14. m_GlobalInverseTransform = m_pScene->mRootNode->mTransformation;

  15. m_GlobalInverseTransform.Inverse();

  16. Ret = InitFromScene(m_pScene, Filename);

  17. }

  18. else {

  19. printf("Error parsing '%s': '%s'\n", Filename.c_str(), m_Importer.GetErrorString());

  20. }

  21. // Make sure the VAO is not changed from the outside

  22. glBindVertexArray(0);

  23. return Ret;

  24. }

用粗体标记的地方是Mesh类的更新点(注:代码格式化后就没有粗体了,可以在原文中看粗体部分)。这里有一些我们需要注意的变化。一个是导入器和aiScene对象现在是类成员,而不再是栈变量。原因在于运行时我们会多次返回到aiScene对象,因此我们需要扩展导入器和场景的范围。在实时游戏中,你也许想要拷贝你需要的资料并且将其存储在一个更优的格式中,但是出于教程的目的这已经足够了。

第二个变化是层次中根的变换矩阵被选取、求逆以及存储。我们将继续采取这样的方式。注意到我们从Assimp库中拷贝了矩阵求逆的代码到Matrix4f类中。

  1. (mesh.h:69)

  2. struct VertexBoneData

  3. {

  4. uint IDs[NUM_BONES_PER_VEREX];

  5. float Weights[NUM_BONES_PER_VEREX];

  6. }

  1. (mesh.cpp:107)

  2. bool Mesh::InitFromScene(const aiScene* pScene, const string& Filename)

  3. {

  4. ...

  5. vector<VertexBoneData> Bones;

  6. ...

  7. Bones.resize(NumVertices);

  8. ...

  9. glBindBuffer(GL_ARRAY_BUFFER, m_Buffers[BONE_VB]);

  10. glBufferData(GL_ARRAY_BUFFER, sizeof(Bones[0]) * Bones.size(), &Bones[0], GL_STATIC_DRAW);

  11. glEnableVertexAttribArray(BONE_ID_LOCATION);

  12. glVertexAttribIPointer(BONE_ID_LOCATION, 4, GL_INT, sizeof(VertexBoneData), (const GLvoid*)0);

  13. glEnableVertexAttribArray(BONE_WEIGHT_LOCATION);

  14. glVertexAttribPointer(BONE_WEIGHT_LOCATION, 4, GL_FLOAT, GL_FALSE, sizeof(VertexBoneData), (const GLvoid*)16);

  15. ...

  16. }

上面的结构包含了我们在顶点层次需要的所有东西。默认下,我们有足够的空间存储4个骨头(Id + 每个骨头的权值)。VertexBoneData是能让其更简单地传送到着色器的结构。我们已经得到了处在0,1,2的位置,纹理坐标以及法线。所以,我们可以设置我们的VAO在3处绑定骨头ID,在4处绑定权值。一个重要的注意事项是我们使用glVertexAttriblPointer儿不是glVertexAttribPointer来绑定IDs。原因在于ID是整数而不是浮点数。要关注着一点,否则你可能在着色器中得到错误的数据。

  1. (mesh.cpp:213)

  2. void Mesh::LoadBones(uint MeshIndex, const aiMesh* pMesh, vector& Bones)

  3. {

  4. for (uint i = 0 ; i < pMesh->mNumBones ; i++) {

  5. uint BoneIndex = 0;

  6. string BoneName(pMesh->mBones[i]->mName.data);

  7. if (m_BoneMapping.find(BoneName) == m_BoneMapping.end()) {

  8. BoneIndex = m_NumBones;

  9. m_NumBones++;

  10. BoneInfo bi;

  11. m_BoneInfo.push_back(bi);

  12. }

  13. else {

  14. BoneIndex = m_BoneMapping[BoneName];

  15. }

  16. m_BoneMapping[BoneName] = BoneIndex;

  17. m_BoneInfo[BoneIndex].BoneOffset = pMesh->mBones[i]->mOffsetMatrix;

  18. for (uint j = 0 ; j < pMesh->mBones[i]->mNumWeights ; j++) {

  19. uint VertexID = m_Entries[MeshIndex].BaseVertex + pMesh->mBones[i]->mWeights[j].mVertexId;

  20. float Weight = pMesh->mBones[i]->mWeights[j].mWeight;

  21. Bones[VertexID].AddBoneData(BoneIndex, Weight);

  22. }

  23. }

  24. }

上面的函数对一个单一的aiMesh对象加载了顶点骨骼信息。其调用了Mesh::InitMesh()。除了设定VertexBoneData结构外,这一函数还更新了骨头名字和骨头IDs的映射(一个由函数管理的运行中的索引)并且存储基于骨骼ID的偏移量矩阵到一个向量中。注意这个顶点ID是如何被计算出来的。因为顶点IDs和单一mesh有关系,而且我们把所有的mesh存储到了一个向量中,我们可把当前aiMesh基本顶点ID加到mWeights数组的顶点ID上,来得到绝对的顶点ID。

  1. (mesh.cpp:29)

  2. void Mesh::VertexBoneData::AddBoneData(uint BoneID, float Weight)

  3. {

  4. for (uint i = 0 ; i < ARRAY_SIZE_IN_ELEMENTS(IDs) ; i++) {

  5. if (Weights[i] == 0.0) {

  6. IDs[i] = BoneID;

  7. Weights[i] = Weight;

  8. return;

  9. }

  10. }

  11. // should never get here - more bones than we have space for

  12. assert(0);

  13. }

这个有用的函数找到了一个VertexBoneData结构中的空槽,并且存入骨头ID和权重。一些顶点可能会受到少于四个骨头的影响,但是因为一个不存在的骨头的权值保持为0(可以看VertexBoneData的构造器),这意味着我们可以对任意数量的骨头使用相同的权值计算。

  1. (mesh.cpp:473)

  2. Matrix4f Mesh::BoneTransform(float TimeInSeconds, vector<Matrix4f>& Transforms)

  3. {

  4. Matrix4f Identity;

  5. Identity.InitIdentity();

  6. float TicksPerSecond = m_pScene->mAnimations[0]->mTicksPerSecond != 0 ?

  7. m_pScene->mAnimations[0]->mTicksPerSecond : 25.0f;

  8. float TimeInTicks = TimeInSeconds * TicksPerSecond;

  9. float AnimationTime = fmod(TimeInTicks, m_pScene->mAnimations[0]->mDuration);

  10. ReadNodeHeirarchy(AnimationTime, m_pScene->mRootNode, Identity);

  11. Transforms.resize(m_NumBones);

  12. for (uint i = 0 ; i < m_NumBones ; i++) {

  13. Transforms[i] = m_BoneInfo[i].FinalTransformation;

  14. }

  15. }

我们之前看到的顶点层次的骨骼信息的加载仅在开始加载mesh的时候完成。现在我们来到了第二部分,也就是计算传入着色器每一帧的骨骼变换。上面的函数是这一活动的入口。调用者给出了按秒计算的当前时间(它可以是一个分数),并且提供了我们需要更新的矩阵向量。我们在动画循环中找到相关时间,并且运行结点层次。得到的结果是一个变换矩阵,它将返回给调用者。

  1. void Mesh::ReadNodeHeirarchy(float AnimationTime, const aiNode* pNode, const Matrix4f& ParentTransform)

  2. {

  3. string NodeName(pNode->mName.data);

  4. const aiAnimation* pAnimation = m_pScene->mAnimations[0];

  5. Matrix4f NodeTransformation(pNode->mTransformation);

  6. const aiNodeAnim* pNodeAnim = FindNodeAnim(pAnimation, NodeName);

  7. if (pNodeAnim) {

  8. // Interpolate scaling and generate scaling transformation matrix

  9. aiVector3D Scaling;

  10. CalcInterpolatedScaling(Scaling, AnimationTime, pNodeAnim);

  11. Matrix4f ScalingM;

  12. ScalingM.InitScaleTransform(Scaling.x, Scaling.y, Scaling.z);

  13. // Interpolate rotation and generate rotation transformation matrix

  14. aiQuaternion RotationQ;

  15. CalcInterpolatedRotation(RotationQ, AnimationTime, pNodeAnim);

  16. Matrix4f RotationM = Matrix4f(RotationQ.GetMatrix());

  17. // Interpolate translation and generate translation transformation matrix

  18. aiVector3D Translation;

  19. CalcInterpolatedPosition(Translation, AnimationTime, pNodeAnim);

  20. Matrix4f TranslationM;

  21. TranslationM.InitTranslationTransform(Translation.x, Translation.y, Translation.z);

  22. // Combine the above transformations

  23. NodeTransformation = TranslationM * RotationM * ScalingM;

  24. }

  25. Matrix4f GlobalTransformation = ParentTransform * NodeTransformation;

  26. if (m_BoneMapping.find(NodeName) != m_BoneMapping.end()) {

  27. uint BoneIndex = m_BoneMapping[NodeName];

  28. m_BoneInfo[BoneIndex].FinalTransformation = m_GlobalInverseTransform * GlobalTransformation *

  29. m_BoneInfo[BoneIndex].BoneOffset;

  30. }

  31. for (uint i = 0 ; i < pNode->mNumChildren ; i++) {

  32. ReadNodeHeirarchy(AnimationTime, pNode->mChildren[i], GlobalTransformation);

  33. }

  34. }

这一函数遍历了结点树并且根据特定动画时间产生了最终每块骨头的变换。它的局限在于它假定mesh仅有单一的动画序列。如果你想要支持更多的动画,你需要告诉它动画的名字,并且在m_pScene->mAnimations[]数组中找到它。上面的代码对于我们使用的demo mesh已经足够好了。

结点变换是根据结点中的变换成员初始化的。如果结点没有对应着骨头,那么这就是它最终的变换。如果有的话,我们用生成的矩阵重写它。这是按以下步骤完成的:首先在动画通道数组里搜索节点名字。然后在基于动画时间的缩放向量,旋转四元数以及平移向量中进行插值。我们将这些结合到一个矩阵中,并做为参数与我们得到的矩阵相乘(名为GlobalTransformation)。这一函数是递归的,由以GlobalTransformation为单位矩阵的根节点调用。每个结点都对它的所有孩子递归调用这一函数,并且传送他自己的变换以作为GlobalTransformation。由于我们是从顶开始而向下运行,我们得到了每个结点的变换链。

m_BoneMapping数组映射一个结点名字到一个我们生成的索引,我们使用索引作为m_BoneInfo数组的入口,在那里存储了最终的变换。最终的变换是这样计算的:我们从结点偏移量矩阵(它把顶点从本地空间位置转换到结点空间)开始。然后我们乘以所有结点父母的变换加上我们根据动画时间计算的结点特定变换。

注意到我们在这里使用了Assimp的代码来进行数学运算。我觉得没有必要在我们的代码中重复这一工作,所以我就使用了Assimp。

  1. (mesh.cpp:387)

  2. void Mesh::CalcInterpolatedRotation(aiQuaternion& Out, float AnimationTime, const aiNodeAnim* pNodeAnim)

  3. {

  4. // we need at least two values to interpolate...

  5. if (pNodeAnim->mNumRotationKeys == 1) {

  6. Out = pNodeAnim->mRotationKeys[0].mValue;

  7. return;

  8. }

  9. uint RotationIndex = FindRotation(AnimationTime, pNodeAnim);

  10. uint NextRotationIndex = (RotationIndex + 1);

  11. assert(NextRotationIndex < pNodeAnim->mNumRotationKeys);

  12. float DeltaTime = pNodeAnim->mRotationKeys[NextRotationIndex].mTime - pNodeAnim->mRotationKeys[RotationIndex].mTime;

  13. float Factor = (AnimationTime - (float)pNodeAnim->mRotationKeys[RotationIndex].mTime) / DeltaTime;

  14. assert(Factor >= 0.0f && Factor <= 1.0f);

  15. const aiQuaternion& StartRotationQ = pNodeAnim->mRotationKeys[RotationIndex].mValue;

  16. const aiQuaternion& EndRotationQ = pNodeAnim->mRotationKeys[NextRotationIndex].mValue;

  17. aiQuaternion::Interpolate(Out, StartRotationQ, EndRotationQ, Factor);

  18. Out = Out.Normalize();

  19. }

这一方法插值了基于动画时间的特定通道的旋转四元数(记得通道包含了关键帧数组)。首先我们在需要的动画时间前找到关键帧的索引。我们计算从动画时间到关键帧的距离和关键帧到下一帧的距离之间的比例。我们需要使用这一因子在两个关键帧之间插值。我们使用Assimp代码来完成插值,并标准化结果。位置以及缩放对应的方法非常类似,所以我们在这里不再赘述。

  1. (mesh.cpp:335)

  2. uint Mesh::FindRotation(float AnimationTime, const aiNodeAnim* pNodeAnim)

  3. {

  4. assert(pNodeAnim->mNumRotationKeys > 0);

  5. for (uint i = 0 ; i < pNodeAnim->mNumRotationKeys - 1 ; i++) {

  6. if (AnimationTime < (float)pNodeAnim->mRotationKeys[i + 1].mTime) {

  7. return i;

  8. }

  9. }

  10. assert(0);

  11. }

这一有用的方法找到了关键帧的旋转,它恰在动画时间之前。如果我们有N个关键帧旋转,结果可以是0-N-2。动画时间通常包含在通道的区间内,所以最后的关键帧(N-1)从来都不是有效的结果。

  1. (skinning.vs)

  2. #version 330

  3. layout (location = 0) in vec3 Position;

  4. layout (location = 1) in vec2 TexCoord;

  5. layout (location = 2) in vec3 Normal;

  6. layout (location = 3) in ivec4 BoneIDs;

  7. layout (location = 4) in vec4 Weights;

  8. out vec2 TexCoord0;

  9. out vec3 Normal0;

  10. out vec3 WorldPos0;

  11. const int MAX_BONES = 100;

  12. uniform mat4 gWVP;

  13. uniform mat4 gWorld;

  14. uniform mat4 gBones[MAX_BONES];

  15. void main()

  16. {

  17. mat4 BoneTransform = gBones[BoneIDs[0]] * Weights[0];

  18. BoneTransform += gBones[BoneIDs[1]] * Weights[1];

  19. BoneTransform += gBones[BoneIDs[2]] * Weights[2];

  20. BoneTransform += gBones[BoneIDs[3]] * Weights[3];

  21. vec4 PosL = BoneTransform * vec4(Position, 1.0);

  22. gl_Position = gWVP * PosL;

  23. TexCoord0 = TexCoord;

  24. vec4 NormalL = BoneTransform * vec4(Normal, 0.0);

  25. Normal0 = (gWorld * NormalL).xyz;

  26. WorldPos0 = (gWorld * PosL).xyz;

  27. }

现在我们完成了mesh类的改变。让我们看看在着色器层次我们要做些什么。首先,我们已经添加了骨骼IDs以及权值数组到VSinput结构。接下来,有一个新形式的数组,它包含了骨骼变换。在着色器自身我们计算了最终的骨骼变换,它结合了顶点骨骼变换矩阵和它们的权值。最终的矩阵用于从骨骼空间变换位置和法线到本地空间。从这里开始所有事情都是一样的了。

  1. (tutorial38.cpp:140)

  2. float RunningTime = (float)((double)GetCurrentTimeMillis() - (double)m_startTime) / 1000.0f;

  3. m_mesh.BoneTransform(RunningTime, Transforms);

  4. for (uint i = 0 ; i < Transforms.size() ; i++) {

  5. m_pEffect->SetBoneTransform(i, Transforms[i]);

  6. }

最终我们要做的事情是把所有过程统一。这是由以上简单的代码完成的。函数GetCurrentTimeMillis()返回了从应用开始时的毫秒时间。

如果你正确的完成了所有事情,最终的结果看起来和这个会很类似。(注:在墙外)

[OpenGL] 使用Assimp库的骨骼动画相关推荐

  1. CSharpGL(50)使用Assimp加载骨骼动画

    CSharpGL(50)使用Assimp加载骨骼动画 在(http://ogldev.atspace.co.uk/www/tutorial38/tutorial38.html)介绍了C++用Asism ...

  2. cocosd-x 下 2D 骨骼动画编辑器选择的闲聊

    前言:没想到好多年没来,CSDN 竟然支持了 Markdown 的编辑,而且还是直接 fork 了 StackEdit 过来的,开始我还在想是否要换个写博客的地方,毕竟我不是一个很勤快的人,而且我也想 ...

  3. OpenGL基础26:Assimp库

    一.模型文件 游戏中有很多复杂的模型往往都是美术通过3D建模工具构建出来的,当然不是程序将顶点写死在代码里的,想想看一个简单的人物模型可能就有上千个顶点,这个时候按之前"生成木箱子" ...

  4. opengl实现骨骼动画-(1)原理

    教学知识文档 骨骼动画 背景 骨骼动画实际上是一个由两部分组成的过程.第一个由艺术家执行,第二个由程序员(或者更确切地说,你编写的引擎)执行.第一部分发生在建模软件内部,称为索具.这里发生的事情是,艺 ...

  5. 一步一步的使用C++和OPENGL实现COLLADA骨骼动画 第一部分

    一步一步的使用C++和OPENGL实现COLLADA骨骼动画 第一部分   英文原作者:waZim 原文标题:Step by Step Skeletal Animation in C++ and Op ...

  6. 安卓上的 3D 模型加载 和骨骼动画 库 SceneView

    如果你要加载3D 模型,比如Maya 3D max 生成的 3d 模型文件,你会发现基本没有好用的快捷的库, github上是有一个比较出名的3d 库 , https://github.com/the ...

  7. OpenGL绘制罗纳尔多三维模型-Assimp库

    本文主要讲解assimp库的使用. 先看效果-资源在文末 正视图: 后视图: 侧视图: 主要使用assimp库进行加载obj模型: 核心代码: 通过加载完的scene对象解析模型的数据: void l ...

  8. DirectX12(D3D12)基础教程(十七)——让小姐姐翩翩起舞(3D骨骼动画渲染【1】)

    目录 1.前言 2.本章代码简要说明 1.前言   经过了一系列比较枯燥的命令行式的"外篇"系列教程后,这一篇起回归主干,继续我们的D3D12之旅,本章就利用已经学习的assimp ...

  9. DirectX12(D3D12)基础教程(十七)——让小姐姐翩翩起舞(3D骨骼动画渲染【2】)

    目录 3.Assimp的导入标志 4.网格(Mesh) 5.骨骼动画基础 3.Assimp的导入标志   一般的模型文件中,大多数情况下在建模时默认都保存成了OpenGL的右手坐标系,即z轴坐标垂直屏 ...

最新文章

  1. 如何使用 Python 或 Bash动态生成 Jekyll 配置文件
  2. Github无法加载或不显示图片问题
  3. wedo巡线机器人编程教程_这是一个机器人和编程的时代
  4. flume采集数据报错问题解决
  5. java创建临时文件_用Java创建一个临时文件
  6. wpf开源ui引用步骤_吸引开源社区的5个步骤
  7. php new static 效率,对比 PHP 中 new static() 与 new self()
  8. .Net应用程序打包部署总结
  9. 【早报】这届程序员要做好会Python的准备了!
  10. python三角网格代码_python中shapely的多多边形三角网格/网格
  11. 广数980系统锁解除密码
  12. matlab语言fcm,【上海校区】FCM算法原理及MATLAB实现
  13. C#iText7对PDF进行签章
  14. C语言#include的用法
  15. SpringMVC基础入门
  16. JavaScript03
  17. 初识安卓--简单计算器(上)
  18. mescroll在vue中的应用
  19. 直流屏的作用及其工作原理
  20. 配置化表单FormRender初尝试

热门文章

  1. 哲理故事300篇(上)
  2. APP抓不到包及问题解决方法
  3. heigh,clientHeight , scrollHeight , offsetHeight介绍
  4. 用程序来模拟Alt+PrtSc的键盘事件,实现截屏功能
  5. zuul+SpringSession的session不一致问题
  6. Windows远程桌面连接后被连接的桌面锁屏
  7. JavaScript - 将 Allegro 坐标文件转为嘉立创坐标文件(CSV 格式)的工具
  8. 中国农业大学专业学位计算机,中国农业大学(专业学位)计算机技术研究生考试科目和考研参考书目...
  9. CAN收发器TJA1040简介
  10. 智能营销文本生成项目知识点总结