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

6、骨骼绑定

  搞清楚了基本的骨骼动画的基本原理,那么就直接来进入“骨骼绑定”的编程学习,至于过于复杂的理论,就不多介绍了,我觉得直接上源码可能更容易让大家快速掌握骨骼动画,过多的理论反而让大家会觉得无从下手。

6.1、Shader中的骨骼绑定

  首先从数据的角度讲,骨骼绑定在Shader中就是指在顶点的数据结构中扩展属性,用以指明顶点受其影响的骨骼的索引号,以及受影响的权重。就像刚才的例子中的那样:

struct VSInput
{
    float4 position : POSITION0;        //顶点位置
    float4 normal   : NORMAL0;          //法线
    float2 texuv    : TEXCOORD0;        //纹理坐标
    uint4  bonesID  : BLENDINDICES0;    //骨骼索引
    float4 fWeights : BLENDWEIGHT0;     //骨骼权重
};

  其中最后两个4分量向量就是骨骼绑定的数据。其中bonesID的每一个分量就关联一个骨骼bone的id,在Shader中这其实就是后面要讲到的骨骼动画调色板(其实就是一组变换矩阵组成的数组)中的一个的数组元素索引,所以通常是UINT值。fWeights,就是对应ID的Bone使该顶点受到影响的程度权重值,是个0-1.0之间的值,一般情况下,所有fWeights的所有分量之和

1.0

\\leqslant 1.0

1.0

  这里可能大家会有个疑问,为啥一个顶点最多只能受到4个骨骼的影响呢?其实“4”这个值,更大意义上是一个经验值,因为一般的动画中,比如人体动作中,外表任何地方的活动(任何一副“皮囊”),也就最多用关联的4个关节的运动即可表达。而过多的关联骨骼,其实并不会带来更多的动画细节的改善,反倒是如果改进骨骼自身的变换矩阵参数反而会使动画效果提升更明显。所以,就没必要让一个顶点关联过多的骨头。这样做也大大的缩小了Shader中顶点自身的数据结构,并减轻了计算量。对于其它生物甚至非生命活动物其实其动作基本也不需要过多的骨头关联即可较逼真的模拟。当然也有一些动画中,为了更精细的表示,部分顶点会关联多于4根骨头。

  其次从形象的角度理解,可以看下图骨骼绑定的示意图:
在这里插入图片描述

  图中白色的部分即可视化的虚拟骨骼,而灰色部分即为人体网格外形。按照示意图,实质上骨骼绑定就是说外表网格上的顶点会受到哪些(BonesID)黄色圈出的关节点的影响,并且影响是多大(Weights)。直观的也可以看出,一般肯定是离关节近的顶点会受其较大的影响,而离得远的顶点肯定受其影响小,或者直接就不受影响了。比如脚部的关节,肯定不会影响到头部的外表顶点。同时仔细观察还会发现,其实人体骨骼系统中大的关节是不多的,而且作为一般的动作模拟,只做到大的关节模拟,基本也就足够了,但是对于复杂的追求逼真动画效果的模拟来说其实挑战就会在人体的手部关节模拟和表情动画模拟上。美术界就有所谓“画树难画柳,画人难画手”说法,既是指人类的手部动作实在是太丰富了要画的非常准确就很难,其实3D动画模拟起来也是异常复杂的。不过好消息就是这些复杂的模拟建模的考虑基本都是美工的事情,而我们作为程序员要做的就是知道怎么解析、理解和操作这些数据!

  最后,基于前述的结构体,在Shader中基本上一两行代码,就完成了骨骼绑定动画的计算:

// 根据骨头索引以及权重计算骨头变换的复合矩阵
float4x4 mxBonesTrans 
    = vin.fWeights[0] * mxBones[vin.bonesID[0]]
    + vin.fWeights[1] * mxBones[vin.bonesID[1]]
    + vin.fWeights[2] * mxBones[vin.bonesID[2]]
    + vin.fWeights[3] * mxBones[vin.bonesID[3]];
// 变换骨头对顶点的影响
vout.position = mul(vin.position, mxBonesTrans);
//最终变换到视-投影空间
vout.position = mul(vout.position, mxMVP);   

  这段代码,就是经典的Vertex Shader顶点动画计算过程。

6.2、aiMesh中的骨骼绑定信息

  前一篇文章中,已经介绍过了,在Assimp库中,导入的骨骼数据其实是反绑定的。即它不是在每个网格顶点数据中绑定对应的骨骼,而是在骨骼的数据中用数组标明受其影响的顶点索引数组。这其实是为了方便数据的压缩表示,或者说数据结构更符合“范式”要求。

  在Assimp中,骨骼绑定的数据主要在aiMesh对象中,并且以数组的形式存储如下:

struct aiMesh
{
//.......
    /** The number of bones this mesh contains.
    * Can be 0, in which case the mBones array is NULL.
    */
    unsigned int mNumBones;

    /** The bones of this mesh.
    * A bone consists of a name by which it can be found in the
    * frame hierarchy and a set of vertex weights.
    */
    C_STRUCT aiBone** mBones;
//.......
};

  而aiBone类中存储的主要就是骨骼影响哪些顶点的数组:

struct aiBone {
    //! The name of the bone.
    C_STRUCT aiString mName;

    //! The number of vertices affected by this bone.
    //! The maximum value for this member is #AI_MAX_BONE_WEIGHTS.
    unsigned int mNumWeights;

    //! The influence weights of this bone, by vertex index.
    C_STRUCT aiVertexWeight* mWeights;

    /** Matrix that transforms from bone space to mesh space in bind pose.
     *
     * This matrix describes the position of the mesh
     * in the local space of this bone when the skeleton was bound.
     * Thus it can be used directly to determine a desired vertex position,
     * given the world-space transform of the bone when animated,
     * and the position of the vertex in mesh space.
     *
     * It is sometimes called an inverse-bind matrix,
     * or inverse bind pose matrix.
     */
    C_STRUCT aiMatrix4x4 mOffsetMatrix;
    //.......
}

  上面的类定义中,一定要注意的是,之前一篇中已经介绍过,Assimp中最终骨骼的全部信息被分散在了三个地方,aiBone中存储的就是骨骼绑定信息。

  首先、aiBone::mName成员就是骨骼的名称,也是分散在三处的骨骼信息最终解算和汇聚成动画帧的关键线索,即这三处骨骼信息以相同名称相互关联。

  其次、aiBone::mNumWeights和aiBone::mWeights即该骨骼影响到的顶点列表极其对应的影响程度权重数组。而mWeights的类如下定义:

struct aiVertexWeight {
    //! Index of the vertex which is influenced by the bone.
    unsigned int mVertexId;

    //! The strength of the influence in the range (0...1).
    //! The influence from all bones at one vertex amounts to 1.
    float mWeight;
// ......
};

  这个类定义就很清晰了,就不做过多的说明了。

  最后、aiBone::mOffsetMatrix是一个4×4矩阵,这个矩阵很重要,它表示的就是该骨骼(或者说关节更准确)在模型坐标系中的“方位”,即包含了位置信息、也包含旋转和缩放的信息。当不做动画渲染时,直接用这个矩阵值就可以显示出模型静态时的样子。之前也说过这个矩阵通常被叫做“逆位姿绑定矩阵”,说白了就是从骨关节的局部空间将骨骼变换到模型空间的一个矩阵。这说明了,骨关节在建模时其坐标系就是相对于自身的一个局部坐标系,通常就是骨关节最灵活变动的那个点作为这个局部坐标系的原点,因为这样就可以不用考虑比如,人的小腿运动时,往往还会关联大腿运动以及胯骨的运动,这样如果都使用模型空间的某一个统一坐标系,那么变换的生成和表达都将过于复杂。而放在刚才说的关节点处,那么同样的小腿运动,就可以以大腿与小腿间的关节点作为局部坐标系的原点,这样小腿的运动就简单的抽象表达为一个围绕该点的一定角度的旋转运动!

6.3、骨骼绑定信息的解算

  在本章示例中,对上述数据做了必要的解算。首先是将mWeights数据填充到每个Vertex中去,代码如下所示:

UINT		VertexID = 0;
FLOAT		Weight = 0.0f;
UINT		nBoneIndex = 0;
CStringA	  strBoneName;
aiMatrix4x4     mxBoneOffset;
aiBone*		 pBone = nullptr;
// 加载骨骼数据
for ( UINT i = 0;  i < nMeshCnt;  i++ )
{
	paiSubMesh = stMeshData.m_paiModel->mMeshes[i];
	for (UINT j = 0; j < paiSubMesh->mNumBones; j++)
	{
		nBoneIndex = 0;
		pBone = paiSubMesh->mBones[j];
		strBoneName = pBone->mName.data;

		if ( nullptr == stMeshData.m_mapName2Bone.Lookup(strBoneName) )
		{
			// 新骨头索引
			nBoneIndex = nNumBones ++;
			stMeshData.m_arBoneDatas.SetCount(nNumBones);

			stMeshData.m_arBoneDatas[nBoneIndex].m_mxBoneOffset 
                		= XMMatrixTranspose(
                		MXEqual(stMeshData.m_arBoneDatas[nBoneIndex].m_mxBoneOffset
                        	, pBone->mOffsetMatrix));

			stMeshData.m_mapName2Bone.SetAt(strBoneName, nBoneIndex);
		}
		else
		{
			nBoneIndex = stMeshData.m_mapName2Bone[strBoneName];
		}

		for (UINT k = 0; k < pBone->mNumWeights; k++)
		{
			VertexID 
                		= stMeshData.m_arSubMeshInfo[i].m_nBaseVertex 
                			+ pBone->mWeights[k].mVertexId;
			Weight = pBone->mWeights[k].mWeight;
			stMeshData.m_arBoneIndices[VertexID].AddBoneData(nBoneIndex, Weight);
		}
	}
}

  上面代码中,稍微有点复杂,最终甚至嵌套了3重循环:

  1、第一重循环是遍历模型中所有的网格,之前就已经说过,一个模型文件中可能会有多个网格,所以在Assimp中将导入的模型干脆叫做aiScene,即“场景”。其实这里多个网格有两重意思,第一个意思确实是说该模型中包含了多个物体的网格,所以称之为“场景”;第二个意思则是说可能该物体不止一个网格构成,是一个有多张“画皮”组成的复杂物体。而模型文件中往往第二种情况出现的较多,此时不能再理解为是“场景”,而是一个复杂的物体而已。

  2、第二重循环就是针对每个aiMesh对象中的aiBone数组进行遍历。这里多了一个if-else判断,主要是为每个骨骼生成一个唯一索引,所以借助了一个Map对象,用骨骼名称与对应的索引建立映射。这个设计同时也保证了骨骼索引的唯一性,即如果模型数据中有同名的骨骼,那么最后它的索引是唯一的。这也暗示说,骨骼名称相同的都将被认为是同一根骨骼。然后代码中把每个骨骼的“逆位姿绑定矩阵”按照唯一索引存储到一个数组Array对象中,这个数组对象非常重要,后面的所有动画解算几乎都是围绕填充这个数组展开的,最后这个数组就被传入Vertex Shader的 Bone Const Buffer成为“骨骼动画调色板”。这里需要注意的就是我们对这个矩阵做了一个转置操作,因为默认情况下这个矩阵通常被存储为OpenGL的“右手坐标系”,而之前我们说过,我们将使用的是D3D祖传的“左手坐标系”,需要矩阵“右乘”行向量,所以做个转置即可。

  3、最后第三重循环就是遍历每个aiBone对象中的权重Weights数组,将对顶点影响大小的权重数据再还原存储到对应的顶点上去。这里使用了一个顶点结构体的辅助函数AddBoneData:

#define GRS_BONE_DATACNT 4
struct ST_GRS_VERTEX_BONE
{
public:
	UINT32  m_nBonesIDs[GRS_BONE_DATACNT];
	FLOAT   m_fWeights[GRS_BONE_DATACNT];
public:
	VOID AddBoneData(UINT nBoneID, FLOAT fWeight)
	{
		for (UINT32 i = 0; i < GRS_BONE_DATACNT; i++)
		{
			if ( m_fWeights[i] == 0.0 )
			{
				m_nBonesIDs[i] = nBoneID;
				m_fWeights[i] = fWeight;
				break;
			}
		}
	}
};

  利用这个方法将受影响的权重,反存回每个顶点中,并且这里每个顶点最多就存储4个权重值,即前面说的每个顶点最多受到4根骨头的影响。注意其中的if ( m_fWeights[i] == 0.0 )判断,其实这个判断大家不用担心,假如一个骨骼对一个顶点的影响是0,实际上这就是表示没有影响,那么其实不用存储这个骨骼的索引到顶点中的。

  这里还要提醒大家注意的是,即使一个模型中有骨骼绑定信息,但并不表示模型中一定有动画,这种情况可能只是为了能够进一步引用或产生动画而准备的骨骼绑定的原始信息。本章结束后,大家可能都会冲动的去某宝网站买各种小姐姐的模型回来加载运行试一下,这里就是提醒大家一定注意看清楚,很多模型是标明了带骨骼绑定,但并没有说具有完整动作动画,这类模型只是为了方便动画美工师进一步加上复杂动画用的,如果程序直接加载可能只有静态的模型显示。所以在冲动消费前,请一定看仔细说明。(正如你想的那样,我其实已经上过当了,损失了巨额的350分人民币!)

7、骨骼树(骨架)

  之前已经描述过,骨骼动画的根本原理就是模拟动物骨骼尤其是人体骨骼的动作,最终变换网格的顶点,形成动画的效果。这样骨骼数据本身,其实也是有结构的,这主要是几方面的原因,第一,人体或动物的骨骼天然具有关联关系,往往一个动作的完成不是简单的靠一两个骨骼就能完成的,而是需要一种具有“传导”关系的相互配合才能完成一个动作,比如跑动时都是靠“大腿带动小腿”这样的关系。第二、不论什么动物、人类、甚至机械等的动作,其实都是围绕“重心”展开的,比如人体天然的“重心”就是腰椎骨靠下部分的髋关节,而“重心”对于所有骨骼坐标系的模拟建模具有天然优势,完全可以将这类“重心”作为物体自身动作的原点,然后反算出其它各个“关节”部位的坐标系。第三、最终由于这些骨骼的天然层次关联关系,以及动作的“传导”展开,所以完全可以将所有的骨骼信息建模为“树形”的数据结构。即每个子关节的动作仅考虑自身坐标系的变动,而逐级向上关联父关节的动作即可形成完整的动作。
在这里插入图片描述
  在前一篇文章中,其实已经在命令行状态下输出了Assimp导入模型后解析出的骨骼树形结构了:
在这里插入图片描述
  在本章示例中,就不是简单的这样列印这颗树就完了,而是需要配合针对这棵树的递归算法,按照动画帧的变换数据来生成一帧帧的动画了。需要也就是说,上一篇文章中的递归算法“骨架”任然可以用于这一章真实动画渲染中。如果没搞明白,最好去复习一下先,前方高能,防止被虐。

  需要提醒大家的就是,一定要注意骨架中骨骼的名字,其中有些骨骼名字是“匿名”,这种节点就是没有绑定到顶点的骨骼,只是起到中间过渡变换的目的,而有名字的节点就可以通过名字来搜索骨骼绑定信息和动画信息了。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容