Unity3D 骨骼动画原理小记
一、骨骼动画、关节动画、关键帧动画
在实际的游戏中,用得较多的是这三种基本的动画。
骨骼动画是进一步的动画类型,原理构成很其简单,但是解决问题很其有优势。将模型分为骨骼Bone和蒙皮Mesh两个部分,其基本的原理可以阐述为:模型的骨骼可分为基本多层父子骨骼,在动画关键帧数据的驱动下,计算出各个父子骨骼的位置,基于骨骼的控制通过顶点混合动态计算出蒙皮网格的顶点。在骨骼动画中,通常包含的是骨骼层次数据,网格Mesh数据, 网格蒙皮数据Skin Info和骨骼的动画关键帧数据。
一、骨骼动画、关节动画、关键帧动画
在实际的游戏中,用得多的是这三种基本的动画。
在关键帧动画中,模型在每个关键帧中都是一个固定的姿势,相当于一个“快照”,通过在不同的关键帧中进行插值平滑计算,可以得到一个较为流畅的动画表现。关键帧动画的一个优势是只需要做插值计算,相对于其他的动画计算量很小,但是劣势也比较明显,基于固定的“快照”进行插值计算,表现大大被限制,同时插值如果不够平滑容易出现尖刺等现象。
关节动画是早期出现的一种动画,在这种动画中,模型整体不是一个Mesh, 而是分为多个Mesh,通过父子的关系进行组织,这样父节点的Mesh就会带动子节点的Mesh进行变换,这样层层的变换关系,就可以得到各个子Mesh在不同关键帧中的位置。关节动画相比于关键帧动画,依赖于各个关键帧的动画数据,可以实时的计算出各个Mesh的位置,不再受限于固定的位置,但是由于是分散的各个Mesh,这样在不同Mesh的结合处容易出现裂缝。
骨骼动画是进一步的动画类型,原理构成很其简单,但是解决问题很其有优势。将模型分为骨骼Bone和蒙皮Mesh两个部分,其基本的原理可以阐述为:模型的骨骼可分为基本多层父子骨骼,在动画关键帧数据的驱动下,计算出各个父子骨骼的位置,基于骨骼的控制通过顶点混合动态计算出蒙皮网格的顶点。在骨骼动画中,通常包含的是骨骼层次数据,网格Mesh数据, 网格蒙皮数据Skin Info和骨骼的动画关键帧数据。
在前面,我们已经提到,顶点需要依附于骨骼进行位置计算,但是建模的时候,顶点的位置是基于Mesh原点进行建模的,通常情况下,Mesh的原点是和模型的骨骼的根骨骼处于同一个坐标空间中,那么 BoneOffsetMatrix就是用来将Mesh中顶点从Mesh空间转换到骨骼所在空间中。
在建模的时候,对于每个骨骼,我们是可以得到其对应的Transform Matrix(用来层层计算到父节点所在空间中),其中根骨骼的Transform Matrix是基于世界空间的转换,所以对于每一个下面的子骨骼,要计算其Transform Matrix,需要进行一个矩阵的连乘操作。*后得到的*终矩阵连乘结果矩阵就是Combined Transform Matrix,基于这个矩阵,就可以将顶点从骨骼所在的空间转换到世界空间中。反过来,这个矩阵的逆矩阵(一般只考虑可以取逆的操作),就是从世界空间中转换到该骨骼的空间中,由于Mesh的定义基于Mesh原点,Mesh原点就在世界空间中,所以这个逆矩阵就是要求的 Offset Matrix,也被称为Inverse Matrix,这个逆矩阵一般实在初始位置中求得,通过取逆即可获得。
在实际的计算中,每个骨骼可能会对应多个顶点,如果每个顶点都保存其对应的骨骼的变换矩阵,那么大量的顶点就会报错比较多的变换矩阵。所以我们只需要保存当前该骨骼在初始位置,对应的从世界空间到其骨骼空间的变换矩阵,那么其对应的每个顶点在每次变换操作的时候,只需要对应的用offset Matrix来操作即可。
对于上面的Transform Matrix和offset Matrix,是纳入了旋转、平移和缩放的。其实offset Matrix取决于骨骼的初始位置,此时一般只包含了平移(此时还没有动画,所以没有旋转和缩放),在动画中,一般也以缩放为主(所以大部分的动画的关键帧用四元数表示)。在矩阵中都包含,是处于兼容性考虑。
这儿就基于平移,做一个基本的蒙皮的计算过程:
三、Unity3D骨骼动画处理
前面讲解的对于骨骼动画中的骨骼变换,蒙皮的计算,都是在CPU中进行的。在实际的游戏引擎中,这些都是分开处理的,较为通用的处理是将骨骼的动画数据驱动放在CPU中,计算出骨骼的变换矩阵,然后传递给GPU中进行蒙皮计算。在DX10的时候,一般的shader给出的寄存器的大小在128的大小,一个变换矩阵为4x4,如果去除*后一行(0,0,0,1)就可以用3个float表示,那么*多可以表示,嗯,42个左右,如果考虑进行性能优化,不完全占用寄存器的大小,那么一般会限制在30根骨骼的大小上。将这些骨骼的变换矩阵在CPU进行计算后,就可以封装成skin info传递到GPU中。
在GPU的计算中,就会取出这些mesh上的顶点进行对应的位置计算,基于骨骼的转换矩阵和骨骼的权重,得到*新的位置,从而进行一次顶点计算和描绘。之所以将骨骼动画的两个部分分开处理,一个原因就是CPU的处理能力相对而言没有GPU快捷,一般一个模型的骨骼数量是较小的,但是mesh上的顶点数量较大,利用GPU的并行处理能力优势,可以分担CPU的计算压力。
在DX11还是DX12之后(记不太清楚),骨骼变换矩阵的计算结果不再存储在寄存器中,而是存储在一个buffer中,这样的buffer大小基于骨骼数量的大小在第一次计算的时候设定,之后每次骨骼动画数据驱动得到新的变换矩阵,就依次更改对应的buffer中存储的变换矩阵,这样就不再受到寄存器的大小而限制骨骼的根数的大小。但是实际的优化中,都会尽量优化模型的骨骼的数量,毕竟数量越多,*是影响顶点的骨骼数量越多,那么计算量就会越大,正常的思维是优化骨骼数量而不是去扩展buffer的大小:D
在文章2中,对于GPU的蒙皮计算做了较大的性能优化,主要的思维也是这样,在CPU中进行骨骼变换,将变换的结果传递到GPU中,从而进行蒙皮计算。基本的思维和前面说的变换思维一致,其基本的优化重点也是想利用一个buffer来缓存变换矩阵,从而优化性能。这儿我就重点分析一下shader部分的代码,其在cpu部分的代码处理基本和前面的代码思想一致:
如果采用CPU的计算骨骼变换,那么GPU的shader:
在实际的游戏中,用得较多的是这三种基本的动画。
在关键帧动画中,模型在每个关键帧中都是一个固定的姿势,相当于一个“快照”,通过在不同的关键帧中进行插值平滑计算,可以得到一个较为流畅的动画表现。关键帧动画的一个优势是只需要做插值计算,相对于其他的动画计算量很小,但是劣势也比较明显,基于固定的“快照”进行插值计算,表现大大被限制,同时插值如果不够平滑容易出现尖刺等现象。
关节动画是早期出现的一种动画,在这种动画中,模型整体不是一个Mesh, 而是分为多个Mesh,通过父子的关系进行组织,这样父节点的Mesh就会带动子节点的Mesh进行变换,这样层层的变换关系,就可以得到各个子Mesh在不同关键帧中的位置。关节动画相比于关键帧动画,依赖于各个关键帧的动画数据,可以实时的计算出各个Mesh的位置,不再受限于固定的位置,但是由于是分散的各个Mesh,这样在不同Mesh的结合处容易出现裂缝。骨骼动画是进一步的动画类型,原理构成很其简单,但是解决问题很其有优势。将模型分为骨骼Bone和蒙皮Mesh两个部分,其基本的原理可以阐述为:模型的骨骼可分为基本多层父子骨骼,在动画关键帧数据的驱动下,计算出各个父子骨骼的位置,基于骨骼的控制通过顶点混合动态计算出蒙皮网格的顶点。在骨骼动画中,通常包含的是骨骼层次数据,网格Mesh数据, 网格蒙皮数据Skin Info和骨骼的动画关键帧数据。
一、骨骼动画、关节动画、关键帧动画
在实际的游戏中,用得多的是这三种基本的动画。
在关键帧动画中,模型在每个关键帧中都是一个固定的姿势,相当于一个“快照”,通过在不同的关键帧中进行插值平滑计算,可以得到一个较为流畅的动画表现。关键帧动画的一个优势是只需要做插值计算,相对于其他的动画计算量很小,但是劣势也比较明显,基于固定的“快照”进行插值计算,表现大大被限制,同时插值如果不够平滑容易出现尖刺等现象。
关节动画是早期出现的一种动画,在这种动画中,模型整体不是一个Mesh, 而是分为多个Mesh,通过父子的关系进行组织,这样父节点的Mesh就会带动子节点的Mesh进行变换,这样层层的变换关系,就可以得到各个子Mesh在不同关键帧中的位置。关节动画相比于关键帧动画,依赖于各个关键帧的动画数据,可以实时的计算出各个Mesh的位置,不再受限于固定的位置,但是由于是分散的各个Mesh,这样在不同Mesh的结合处容易出现裂缝。
骨骼动画是进一步的动画类型,原理构成很其简单,但是解决问题很其有优势。将模型分为骨骼Bone和蒙皮Mesh两个部分,其基本的原理可以阐述为:模型的骨骼可分为基本多层父子骨骼,在动画关键帧数据的驱动下,计算出各个父子骨骼的位置,基于骨骼的控制通过顶点混合动态计算出蒙皮网格的顶点。在骨骼动画中,通常包含的是骨骼层次数据,网格Mesh数据, 网格蒙皮数据Skin Info和骨骼的动画关键帧数据。
class Bone { Bone* m_pFirstChild; Bone* m_pSibling; float m_x, m_y, m_z; // pos in parents' space float m_wx, m_wy, m_wz; // pos in world space // public: Bone(float x, float y, float z): m_pSibling(NULL),m_pFirstChild(NULL),m_pFather(NULL),m_x(x), m_y(y), m_z(z){} // void SetFirstChild(Bone* pChild) { m_pFirstChild = pChild; m_pFirstChild->m_pFather = this; } // void SetSibling(Bone* pSibling) { m_pSibling = pSibling; m_pSibling->m_pFather = m_pFather; } }这样,当父节点骨骼发生变换的时候,子节点的骨骼就会做相应的变换,这样的操作可以称为 UpdateBoneMatrix,这样的操作可以用一个方法ComputeWorldPos来表示,这样可以用递归的方式在Bone中实现:
class Bone { void ComputeWorldPos(float fatherX, float fatherY, float fatherZ) { m_wx = fatherX + m_x; m_wy = fatherY + m_y; m_wz = fatherZ + m_z; //兄弟节点用父节点传递的参数 if(m_pSibling !=NULL) m_pSibling ->ComputeWorldPos(fatherX, fatherY, fatherZ) if(m_pFirstChild!=NULL) m_pFirstChild ->ComputeWorldPos(m_wx, m_wy, m_wz) } }这样,当父节点骨骼发生变换的时候,子节点的骨骼都会做出相应的变换,从而得到新的位置、朝向等信息,骨骼发生变化,从而会带动外在的mesh发生变化,所以整体的模型就表现chu出运动起来。基于此,可以理解为什么骨骼是骨骼动画的核心。
#define MAX_BONE_VERTEX 4 class Vertex { float m_x, m_y, m_z; // local pos in mesh space float m_wx, m_wy, m_wz; // pos in world space //skin info int m_boneNum; Bone* m_bones[MAX_BONE_VERTEX]; float m_boneWeights[MAX_BONE_VERTEX]; }当然,这儿只是一个简单的表述,具体的在引擎中会有规范的设计。那么我们的顶点在跟随骨骼做运动的时候,是如何计算自己的位置的?我们就需要引入BoneOffsetMatrix 和 Transform Matrix的概念。
在前面,我们已经提到,顶点需要依附于骨骼进行位置计算,但是建模的时候,顶点的位置是基于Mesh原点进行建模的,通常情况下,Mesh的原点是和模型的骨骼的根骨骼处于同一个坐标空间中,那么 BoneOffsetMatrix就是用来将Mesh中顶点从Mesh空间转换到骨骼所在空间中。
在建模的时候,对于每个骨骼,我们是可以得到其对应的Transform Matrix(用来层层计算到父节点所在空间中),其中根骨骼的Transform Matrix是基于世界空间的转换,所以对于每一个下面的子骨骼,要计算其Transform Matrix,需要进行一个矩阵的连乘操作。*后得到的*终矩阵连乘结果矩阵就是Combined Transform Matrix,基于这个矩阵,就可以将顶点从骨骼所在的空间转换到世界空间中。反过来,这个矩阵的逆矩阵(一般只考虑可以取逆的操作),就是从世界空间中转换到该骨骼的空间中,由于Mesh的定义基于Mesh原点,Mesh原点就在世界空间中,所以这个逆矩阵就是要求的 Offset Matrix,也被称为Inverse Matrix,这个逆矩阵一般实在初始位置中求得,通过取逆即可获得。
在实际的计算中,每个骨骼可能会对应多个顶点,如果每个顶点都保存其对应的骨骼的变换矩阵,那么大量的顶点就会报错比较多的变换矩阵。所以我们只需要保存当前该骨骼在初始位置,对应的从世界空间到其骨骼空间的变换矩阵,那么其对应的每个顶点在每次变换操作的时候,只需要对应的用offset Matrix来操作即可。
对于上面的Transform Matrix和offset Matrix,是纳入了旋转、平移和缩放的。其实offset Matrix取决于骨骼的初始位置,此时一般只包含了平移(此时还没有动画,所以没有旋转和缩放),在动画中,一般也以缩放为主(所以大部分的动画的关键帧用四元数表示)。在矩阵中都包含,是处于兼容性考虑。
这儿就基于平移,做一个基本的蒙皮的计算过程:
class BoneOffset { public: float m_offx, m_offy, m_offz; //暂时只考虑平移 } class Bone { public : BoneOffset* m_boneOffset; // void ComputeBoneOffset() { m_boneOffset.m_offx -= m_wx; m_boneOffset.m_offy -= m_wy; m_boneOffset.m_offz -= m_wz; if(m_pSibling != NULL) m_pSibling->ComputeBoneOffset(); if(m_pFirstChild !=NULL) m_pFirstChild->ComputeBoneOffset(); } } //顶点类的计算 class Vertex { public: void ComputeWorldPosByBone(Bone* pBone, float &outX, float& outy, float& outz) { //从mesh空间转换到bone空间 outx = m_x + pBone->m_boneOffset.m_offx; outy = m_y + pBone->m_boneOffset.m_offy; outz = m_z + pBone->m_boneOffset.m_offz; //从bone空间转换到世界空间 outx += pBone->m_wx; outy += pBone->m_wy; outz += pBone->m_wz; } //GPU中计算顶点的位置 void BlendVertex() { float m_wx = 0; float m_wy = 0; float m_wz = 0; for(int i=0; i < m_boneNum; i++) { float tx, ty,tz; ComputeWorldPosByBone(m_bones[i], tx, ty,tz); tx *= m_boneWeights[i]; ty *= m_boneWeights[i]; tz *= m_boneWeights[i]; m_wx += tx; m_wy += ty; m_wz += tz; } } }仔细捋一捋上面的代码,就可以理解整体的蒙皮变换的过程,当然,这儿只用了矩阵变换中的平移变换,如果考虑加上旋转和缩放,则回到*初的计算公式中了。至此,对于基本的骨骼动画中的骨骼变换和蒙皮变换,有了一个详细的解释。下面说说Unity中是如何处理骨骼变换的。
三、Unity3D骨骼动画处理
前面讲解的对于骨骼动画中的骨骼变换,蒙皮的计算,都是在CPU中进行的。在实际的游戏引擎中,这些都是分开处理的,较为通用的处理是将骨骼的动画数据驱动放在CPU中,计算出骨骼的变换矩阵,然后传递给GPU中进行蒙皮计算。在DX10的时候,一般的shader给出的寄存器的大小在128的大小,一个变换矩阵为4x4,如果去除*后一行(0,0,0,1)就可以用3个float表示,那么*多可以表示,嗯,42个左右,如果考虑进行性能优化,不完全占用寄存器的大小,那么一般会限制在30根骨骼的大小上。将这些骨骼的变换矩阵在CPU进行计算后,就可以封装成skin info传递到GPU中。
在GPU的计算中,就会取出这些mesh上的顶点进行对应的位置计算,基于骨骼的转换矩阵和骨骼的权重,得到*新的位置,从而进行一次顶点计算和描绘。之所以将骨骼动画的两个部分分开处理,一个原因就是CPU的处理能力相对而言没有GPU快捷,一般一个模型的骨骼数量是较小的,但是mesh上的顶点数量较大,利用GPU的并行处理能力优势,可以分担CPU的计算压力。
在DX11还是DX12之后(记不太清楚),骨骼变换矩阵的计算结果不再存储在寄存器中,而是存储在一个buffer中,这样的buffer大小基于骨骼数量的大小在第一次计算的时候设定,之后每次骨骼动画数据驱动得到新的变换矩阵,就依次更改对应的buffer中存储的变换矩阵,这样就不再受到寄存器的大小而限制骨骼的根数的大小。但是实际的优化中,都会尽量优化模型的骨骼的数量,毕竟数量越多,*是影响顶点的骨骼数量越多,那么计算量就会越大,正常的思维是优化骨骼数量而不是去扩展buffer的大小:D
在文章2中,对于GPU的蒙皮计算做了较大的性能优化,主要的思维也是这样,在CPU中进行骨骼变换,将变换的结果传递到GPU中,从而进行蒙皮计算。基本的思维和前面说的变换思维一致,其基本的优化重点也是想利用一个buffer来缓存变换矩阵,从而优化性能。这儿我就重点分析一下shader部分的代码,其在cpu部分的代码处理基本和前面的代码思想一致:
如果采用CPU的计算骨骼变换,那么GPU的shader:
声明: 本文转自网络, 不做盈利目的,如有侵权,请与我们联系处理,谢谢。
uniform float4x4 _Matrices[24]; //设置的骨骼数量*大为24 struct appdata { float4 vertex:POSITION; float2 uv:TEXCOORD0; //存储的就是骨骼的变换矩阵,x/y为第一个骨骼的索引和权重,z/w为第二个的索引和权重 float4 tangent:TANGENT; }; v2f vert(appdata v) { v2f o; //蒙皮计算位置,注意看,其实就是矩阵变化加权重的表示 float4 pos = mul(_Matrices[v.tangent.x], v.vertex)* v.tangent.y + mul(_Matrices[v.tangent.z], v.vertex)* v.tangent.w //通用的mvp计算 o.vertex = mul(UNITY_MATRIX_MVP, pos); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } //怎么计算index和权重,此处一个蒙皮顶点受到2根骨骼的影响 Vector4[] tangents = new Vector4[mesh.vertexCount]; for(int i=0; i < mesh.vertexCount;++i) { BoneWeight boneWeight = mesh.boneWeights[i]; tangents[i].x = boneWeight.boneIndex0; tangents[i].y = boneWeight.weight0; tangents[i].z = boneWeight.boneIndex1; tangents[i].w = boneWeight.weight1; } newMesh.tangents = tangents;其优化的策略,就是用贴图的方式来存储这个变换矩阵,参看一下代码吧:
inline float4 indexToUV(int index) { int row = (int) (index /_MatricesTexSize.x); int col = (index - row * _MatricesTexsize.x; return float4(col/_MatricesTexSize.x, row/_MatricesTexSize.y, 0 , 0); } //算出当前的变换矩阵 inline float4x4 getMatrix(int frameStartIndex, float boneIndex) { int matStartIndex = frameStartIndex + boneIndex*3; float4 row0 = tex2Dlod(_MatricesTex, indexToUV(matStartIndx)); float4 row1 = tex2Dlod(_MatricesTex, indexToUV(matStartIndx + 1)); float4 row2 = tex2Dlod(_MatricesTex, indexToUV(matStartIndx + 2)); float4 row3 = float4(0,0,0,0); float4x4 mat = float4x4(row0, row1, row2, row3); return mat; } v2f vert(appdata v) { v2f o; float time = _Time.y; //算出当前时间对应的index int framIndex = (int)(((_Time.y + v.uv2.x)*_AnimFPS)%(_AnimLength * _AnimFPS)); int frameStartIndex = frameIndex * _MatricesTexFrameTexls; //去除对应的变换矩阵 float4 mat0 = getMatrix(frameStartIndex, v.tangent.x); float4 mat1 = getMatrix(frameStartIndex, v.tangent.z); float4 pos = mul(mat0, v.vertex) * v.tangent,y + mul(mat1, v.vertex) * v.tangent.w; o.vertex = mul(UNITY_MATRIX_MVP, pos); o.uv = TRANSFOR_TEX(v.uv, _MainTex); return o; }
- 上一篇:UNITY3D的立体显示方法(HTC VIVE) 2019/3/13
- 下一篇:UNITY3D运行动画时修改局部骨骼的角度 2019/2/14