关于骨骼动画及微软示例Skinned Mesh的解析(二)

来源:互联网 发布:中铁网络通信改号软件 编辑:程序博客网 时间:2024/05/21 07:36

3.3 分析CAllocateHierarchy类
下面继续研究自定义数据容器CAllocateHierarchy,顾名思义,该类是在加载过程中自行分配层次数据空间。它有4个成员,都是重载D3D的接口虚函数。
它的成员CreateFrame()是用来创建D3DXFrame对象的,而CreateMeshContainer()是用来创建Mesh数据对象的。你可以在这两个函数中下断点,发现CreateFrame会运行多次,而CreateMeshContainer只运行一次,再次验证了上面的说法。

值得注意的是,示例对上面的D3DXFRAME,D3DXMESHCONTAINER两个结构做了扩展,分别代之以D3DXFRAME_DERIVED结构和D3DXMESHCONTAINER_DERIVED结构,以集中存储数据方便程序处理。

CreateFrame()处理比较简单,你只是new一个Frame对象空间,填入传进来的Name,其它内容由DX负责维护填充。

CreateMeshContainer()较为复杂。它的任务一是保存传入的网格数据数据,二是根据这些数据及蒙皮信息调用 GenerateSkinnedMesh()函数生成蒙皮网格。只有这个新的BlendMesh才能在Render()时支持顶点混合,完成蒙皮的显示。在D3DXMESHCONTAINER_DERIVED结构中,用pOrigMesh保存旧的Mesh普通网格信息。而Meshdata.Mesh则指向新产生的BlendMesh

在这个函数中,多次用到了AddRef(),对COM不熟悉的新手容易困惑。D3D是 COM组件,它在服务进程中运行,而不在当前的客户进程中。在DX组件运行过程中,要创建一系列接口对象,如CreateDevice()返回接口指针,这些接口及其占用内存什么时候释放,要通过“引用计数”的技术来解决。AddRef()给这个接口指针的计数加1,而Release()会将之减1。一旦减到0,表示没有客户使用了,相关的接口就释放了。 由此可知,每次调用Rlease()后,并不一定会释放内存,而是当引用计数归0时释放内存。
这样,对接口指针的使用,就像维护堆栈的平衡一样,要仔细,而且按照某种约定规则使用。

但平时D3D编程中,怎么不用AddRef()呢?这是由于一个接口指针,如ID3DDevice,或VertexBuf指针,都是 D3DXCreate出来的,在Create时候,在内部已经事先AddRef()了,你就不需要再做这工作了。只要你在不用时,调用 p指针 ->Relase()就释放了。一般编程,特别是小型示例程序,都是初始化时建立一次,关闭时释放,都遵守了这种约定,所以不存在这种问题。

但在CreateMeshContainer()函数中,以多种方式使用了指针,在局部指针变量中来回传递,所以问题复杂化了。在COM编程中约定,任何时候地接口指针赋值(复制),都要AddRef(),在指针变量结束生命期前,再Release(). 但许多程序员都不是严格这么做。因为在局部变量用完就废了,先AddRef()增加计数再Release()减少,和直接使用最后是等效的。几乎是多此一举。这与编程习惯有关系。一旦引用计数不对,如果没有统一的习惯,不好排查。在CreateMeshContainer()中,对接口指针的使用有三种方式,例举如下:

方式一:不使用AddRef()。和普通指针一样,临时变量是左值,接口指针是右值,直接赋值使用。如:
pMesh = pMeshData->pMesh;
这是由于pMesh是局部变量,它只是临时引用一下,没必要为它先AddRef(),后Release()。

方式二:隐式的使用AddRef()。 由于用到了一些内部有AddRef()动作的函数,就要按照COM约定,在子程序结束前Release()
pMesh->GetDevice(&pd3dDevice);//此处d3d设备引用计数已经加1
....
SAFE_RELEASE(pd3dDevice);//--此处将引用计数减1,并不是真的释放d3d设备
在本例中,pd3dDevice在GetDevice()中已经Addref()过了,所以,在退出CreateMeshContainer()前,必须pd3dDevice->Release()

方式三:显式的使用AddRef()。 如果一个指针值,不是由D3DXCreate出来的,而是通过赋值方式复制给一个全局变量或长期变量的。 所以,可以通过AddRef()的方式来延迟该对象的释放。因为,如果不AddRef(),极有可能在函数返回该对象就可能释放了。它就像一个加油站,使得传入对象的寿命延长至自己控制范围内。用了AddRef(),就要在相关的Destroy中添加Release()。

在本函数,有三处这样的语句:
pMeshContainer->MeshData.pMesh = pMesh;
pMeshContainer->MeshData.Type = D3DXMESHTYPE_MESH;
pMesh->AddRef();
....
pMeshContainer->pSkinInfo = pSkinInfo;
pSkinInfo->AddRef();

pMeshContainer->pOrigMesh = pMesh;
pMesh->AddRef();
....

将来在DestroyMeshContainer()中,要释放这些指针:
....
SAFE_RELEASE( pMeshContainer->MeshData.pMesh );
SAFE_RELEASE( pMeshContainer->pSkinInfo );
SAFE_RELEASE( pMeshContainer->pOrigMesh );

由于这些指针值的创建、更改等都是用户自己经营的,所以务必要加前后吻合,在CreateMeshContainer()中AddRef(),在DestroyMeshContainer()中Release().


再来看数据的保存部分。
在CreateMeshContainer()的传入参数中,有pMeshData,pMaterials,pEffectInstances,NumMaterials,pAdjacency,pSkinInfo
你需要把这些数据保存到自己的D3DXMESHCONTAINER对象中。并且其中的所有数组所需的空间都要在全局堆中new出来。所以在该代码中,有如下new:
pMeshContainer = new D3DXMESHCONTAINER_DERIVED;//自定义的扩展数据容器对象
memset(pMeshContainer, 0, sizeof(D3DXMESHCONTAINER_DERIVED));//初始化pMeshContainer,清0
...
pMeshContainer->pMaterials = new D3DXMATERIAL[pMeshContainer->NumMaterials];//准备保存材质
pMeshContainer->ppTextures = new LPDIRECT3DTEXTURE9[pMeshContainer->NumMaterials];//准备创建纹理对象。它声明在扩展部分。
pMeshContainer->pAdjacency = new DWORD[NumFaces*3];//准备保存邻接三角形数组,NumFaces = pMesh->GetNumFaces();

然后,对数据进行memcpy保存。pEffectInstances由于在绘制中不需要,并没进行保存。对于没有贴图的赋以默认材质属性。
值得注意的是,所有这些new,必须在DestroyMeshContainer()时进行delete.

接下来的处理中,如果发现Mesh的FVF中没有法向量,要用CloneMeshFVF()重建Mesh,计算顶点平均法向量。以备光照处理。

最后,我们看看蒙皮信息pSkinInfo的处理。这是重头戏。
如果发现pSkinInfo!=NULL,就准备着手从各个蒙皮骨骼信息创建SkinMesh.
首先,用扩展容器结构D3DXMESHCONTAINER_DERIVED中的各属性保存原Mesh指针值,pMeshContainer->pOrigMesh = pMesh, 因为接下来我们要创建SkinMesh替代原Mesh.然后,把 SkinInfo中的各骨骼的偏移矩阵保存到pMeshContainer->pBoneOffsetMatrices中
cBones = pSkinInfo->GetNumBones();
pMeshContainer->pBoneOffsetMatrices = new D3DXMATRIX[cBones];
.....
每个“骨骼偏移矩阵”pBoneOffsetMatrices,在将来DrawMeshContainer()中是必须要用的。因为原始Mesh中的顶点数据乘以“骨骼偏移矩阵”,再乘以“变换矩阵”,才能求得各骨骼顶点在世界坐标系中的坐标。 即:
骨骼上各点在世界坐标系中的新坐标=初始网格中的各点坐标*骨骼偏移矩阵*骨骼当前的变换矩阵
其中,“初始网格中的各点坐标*骨骼偏移矩阵” = 骨骼上各点初始时刻在该骨骼坐标系中的局部坐标

做了以上工作后,调用GenerateSkinnedMesh(pMeshContainer),创建SkinMesh. 接下来,我们看看GenerateSkinnedMesh()做了哪些工作。

3.4 怎样生成蒙皮网格SkinMesh? GenerateSkinnedMesh()分析

由于要重定义pMeshContainer->MeshData.pMesh,所以先SAFE_RELEASE( pMeshContainer->MeshData.pMesh ); 释放原pMesh

在这个函数中,是根据当前绘图方式设置进行加载数据的。因为顶点混合,有无索引的顶点混合,有含索引的顶点混合,所使用的函数和对应的SkinMesh数据内容也有所不同。
在示例中,自定义了枚举m_SkinningMethod,主要分为D3DNONINDEXED和D3DINDEXED,以有纯软件渲染等。运行示例后,你可以选择菜单中的Options选择不同的渲染方式。

我们着重分析一下带索引的蒙皮网格。在程序中,就是D3DINDEXED相关的部分。
if (m_SkinningMethod == D3DINDEXED){ ....}

注意! 示例默认工作在D3DNONINDEXED下,如果要跟踪D3DINDEXED部分的代码,必须选择菜单中的Options选择indexed!


最主要的,要通过DX的ConvertToIndexedBlendedMesh()函数,生成支持“索引顶点混合”的SkinMesh. 有关索引顶点混合的技术,你可以在DXSDK帮助文件中搜索“Indexed Vertex Blending”主题,对着英文和插图将就看,确有收获。

要想用硬件对顶点进行混合,那么参与混合者不能太多。也就是说同时影响一个顶点的骨骼数不能多。我们假定一个顶点最多同时受4个骨骼的影响(也就是同时最多有4个骨骼矩阵参与加权求和),那么同时影响一个三角形面的骨骼数最多就是3*4=12个。
我们用NumMaxFaceInfl表示影响一个三角面的最多骨骼矩阵数,那么,通过调用 pSkinInfo->GetMaxFaceInfluences()获取这个数值,一般也就3-4。如果这个数值太大,我们强制使用 NumMaxFaceInfl = min(NumMaxFaceInfl, 12);来最多取值12。

用NumMaxFaceInfl 这个数值干什么呢? 我们用来它分析当前的显卡倒底行不行。

if (m_d3dCaps.MaxVertexBlendMatrixIndex + 1 < NumMaxFaceInfl)//如果显卡达不到该要求
{
//很奇怪。2005年底买的GeForce 6600GT显卡,竟然m_d3dCaps.MaxVertexBlendMatrixIndex=0, 不支持索引顶点混合!是驱动问题还是怎么了?
//但它支持非索引混合。或者,也许要用HLSL支持混合。看起来,3D编程要多考虑。
..
pMeshContainer->UseSoftwareVP = true;//用软件渲染顶点。显然不实用。
}
else
{
pMeshContainer->NumPaletteEntries = min( ( m_d3dCaps.MaxVertexBlendMatrixIndex + 1 ) / 2,
pMeshContainer->pSkinInfo->GetNumBones() );//--什么意思?
pMeshContainer->UseSoftwareVP = false;//采用硬件顶点混合。
Flags |= D3DXMESH_MANAGED;
}

[评论]在上面有一行代码:
pMeshContainer->NumPaletteEntries = min( ( m_d3dCaps.MaxVertexBlendMatrixIndex + 1 ) / 2,pMeshContainer->pSkinInfo->GetNumBones() );
尽管作者加了大段注释,还是让人一头雾水。其实,我们做一个实验,反尔更能理解它的用途。
第一步,你在这句话后面下一个断点,看一下在你机器上这个数值。我的ATI 9550显卡机器上是19。比tiny.x中的骨骼数35少很多。
第二步,你将上面=右边瞎填一个大于4的数字,比如6。编译后照样运行。而且效果上几乎看不出任何差别。
为什么会这样呢? 我们在绘制代码部分,看看这个数值起什么作用。
在DrawMeshContainer()代码中,我们查找D3DINDEXED相关的部分。在mesh各子集的DrawSubset()之前,有如下代码:
for (iAttrib = 0; iAttrib < pMeshContainer->NumAttributeGroups; iAttrib++)
{
// first calculate all the world matrices
for (iPaletteEntry = 0; iPaletteEntry < pMeshContainer->NumPaletteEntries; ++iPaletteEntry)
{
iMatrixIndex = pBoneComb[iAttrib].BoneId[iPaletteEntry];
if (iMatrixIndex != UINT_MAX)
{
D3DXMatrixMultiply( &matTemp, &pMeshContainer->pBoneOffsetMatrices[iMatrixIndex], pMeshContainer->ppBoneMatrixPtrs[iMatrixIndex] );
m_pd3dDevice->SetTransform( D3DTS_WORLDMATRIX( iPaletteEntry ), &matTemp );
}
}
...
}
下面仔细评估一下这些代码.
先注意看其中奇怪的D3DTS_WORLDMATRIX()宏,我们以前还没这样用过。它是做什么用的呢?通过查DXSDK帮助,我们在 Geometry Blending主题中找到相关说明,并在"Indexed Vertex Blending"主题中给出了内部实现原理。原来,当你用m_pd3dDevice->SetRenderState(D3DRS_INDEXEDVERTEXBLENDENABLE, TRUE);开启了索引顶点混合后,在硬件上就启用了“palette of matrices”,即矩阵寄存器组,它最多支持同时256个索引。就像过去用256色调色板来表现彩色一样。D3DTS_WORLDMATRIX()宏就是有256-511这256个数表示矩阵索引号。

这些矩阵参与如下计算:

V最终顶点位置=V*M[索引值1]*权重1 + V*M[索引值2]*权重2 + ....+V*M[索引n]*(1-其它权重和)

这个公式的来源,相信大家在众多资料上见过,不赘述。 当然,我们也可以用程序完成这个蒙皮计算过程,但逐个读顶点却很麻烦。现在是由硬件代劳了。我们只设矩阵就行了。
我们用m_pd3dDevice->SetTransform( D3DTS_WORLDMATRIX( iPaletteEntry ), &matTemp );这种方式设定各索引对应的矩阵。

那么权重呢?我们怎么设?原来在上面所说的DX提供的ConvertToIndexedBlendedMesh()函数中,生成SkinMesh 时,各网格顶点格式FVF已经有变化了,增加了新格式,D3DFVF_XYZB2,D3DFVF_LASTBETA_UBYTE4,用以记录顶点对应的权重值以及矩阵索引。如下
struct VERTEX
{
float x,y,z;
float weight;
DWORD matrixIndices;
float normal[3];
};
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZB2 | D3DFVF_LASTBETA_UBYTE4 |D3DFVF_NORMAL);

D3DFVF_LASTBETA_UBYTE4对应于DWORD数值,用于矩阵索引时,每个字节表示一个索引,最多可以允许4个索引,同时有4个矩阵参于该点的混合。如果一次绘制中涉及了9块骨骼矩阵,你可以把这9个矩阵全部用SetTransform设置到矩阵寄存器中,但每个顶点在渲染时,最多使用其中的4个。由此可知,pMeshContainer->NumPaletteEntries这个数值,确定了一趟DrawSubset绘制所用到的矩阵个数,个数越多,在一趟绘制中就可以纳入的更多顶点。所以,当我们减少 pMeshContainer->NumPaletteEntries这个数值时,pMeshContainer->NumAttributeGroups数值就会增加。也就是说,一趟绘制中所允许涉及的骨骼数越少,那么子集的数量NumAttributeGroups就会增加,需要多绘几趟。
你可以在此下断点观察,当NumPaletteEntries=19 时,NumAttributeGroups=3 当NumPaletteEntries=6时,NumAttributeGroups=12 当 NumPaletteEntries=4时,NumAttributeGroups=31,几乎和无索引时的分组一样多了。

顶点中的权重weight存放了它当前骨骼的权重。(一个顶点对应的多个骨骼权重怎么存放?是不是在当前子集中有多个同样的顶点,权重不同,对应的矩阵索引不同,然后混合)


由上所述,ConvertToIndexedBlendedMesh()是一个很重要函数,由DX自动将Mesh顶点分组成多个子集,以便DrawSubset. 你必须把它的返回参数都记录下来,在绘制时使用。

四. 怎样绘制显示动画?

DrawFrame()用来绘制整个X框架。它遍历各个框架,找到Mesh不为空的进行绘制。(其实整个.x中通常只有一个不为空,见上文所述)
DrawMeshContainer()是绘制函数。

4.1 怎样开启顶点混合?
注意应用有关的Vertex Blending技术。如在索引方式的绘制中,
m_pd3dDevice->SetRenderState(D3DRS_VERTEXBLEND, pMeshContainer->NumInfl - 1);
其实是设定了D3DVBF_2WEIGHTS或D3DVBF_3WEIGHTS
注意要m_pd3dDevice->SetRenderState(D3DRS_INDEXEDVERTEXBLENDENABLE, TRUE);

4.2 矩阵的刷新:
首先,在FrameMove()调用 m_pAnimController->SetTime()设置当前时间(或在DX9.0c中用AdvanceTime()设置时间差),从而刷新各个pFrame->TransformationMatrix,即骨骼转换矩阵
其次,调用UpdateFrameMatrices()做乘法累积,计算出各骨骼坐标系到根世界转换矩阵。
最后,在绘制前,将该转换矩阵左乘偏移矩阵,得到最终的转换矩阵。
D3DXMatrixMultiply( &matTemp, &pMeshContainer->pBoneOffsetMatrices[iMatrixIndex], pMeshContainer->ppBoneMatrixPtrs[iMatrixIndex] );

由此可见,你如果注释掉了m_pAnimController->SetTime,画面肯定停了。

4.3 绘制输出 是在DrawMeshContainer()中,调用SkinMesh的DrawSubset进行绘制。一些细节内容如D3DTS_WORLDMATRIX(),在上面已经有说明,不再罗嗦。

4.4 关于示例中多种绘制方式分析
在示例中,用到了多种渲染方式,包括传统的非索引顶点混合,还有新兴的HLSL方式。而且我发现,ATI RADEON 9550 显卡 MaxVertexBlendMatrixIndex=37,而价格更高的 Gefoce 6600GT MaxVertexBlendMatrixIndex竟然为0,不支持index vertex blending!
所以,还是有必要分析一下该示例中各种vertex blending方式的处理,以便掌握多种绘制方式适应不同显卡。
经测试,示例中所涉及的多种方式,由慢到快,依次是以下几种:
SOFTWARE,
D3DNONINDEXED,
D3DINDEXED,
D3DINDEXEDVS,
D3DINDEXEDHLSLVS,

从最慢的SW到最快的HLSL,大约相差20%,有时会大到40%。 差别不是特别悬殊的原因,主要是顶点混合并不是瓶颈。

关于顶点处理方式,是在创建D3D设备时指定的。共有三种方式:
D3DCREATE_SOFTWARE_VERTEXPROCESSING 软件顶点运算 (简记 sw vp)
D3DCREATE_HARDWARE_VERTEXPROCESSING 硬件顶点运算。必须有这项才支持有HAL (简记 hw vp)
D3DCREATE_MIXED_VERTEXPROCESSING 混合顶点运算,即硬件+软件 (简记 mixed vp)

一旦用D3DCREATE_HARDWARE_VERTEXPROCESSING方式创建设备,就只能在硬件方式下进行顶点处理。如果调用 m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)来切换到软件顶点处理,HRESULT会返回失败。
所以,如果你对客户的显卡没有足够的信息,就用D3DCREATE_MIXED_VERTEXPROCESSING方式创建设备。它默认工作方式是HAL。一旦发现进行某种绘制时硬件能力不够,就可以调用调用 m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)切换到软件模式。在示例中就是这么做的,启动示例后,运行在mixed模式下。

在Gefoce6600GT显卡中,由于D3DINDEXED方式不支持,采用了软件混合方式,在这种方式下速度甚至比SOFTWARE慢。HLSL还好,还是最快。

要确定设备的硬件顶点处理能力,可以参考D3DCAPS9结构的VertexProcessingCaps成员。可以获取下列属性
MaxActiveLights,MaxUserClipPlanes,MaxVertexBlendMatrices,MaxStreams,MaxVertexIndex

(1)D3DNONINDEXED方式:

首先看GenerateSkinnedMesh()中怎样创建蒙皮网格的。
这种方式下,用ConvertToBlendedMesh()建立蒙皮网格,而不是ConvertToIndexBlendedMesh()

为了绘制蒙皮,在这个函数中对Mesh各子集的顶点再次进行的分组。分组的标准是各顶点(或三角面)所涉及的骨骼矩阵个数不超过 pMeshContainer->NumInfl个。(这个数字是由在ConvertToBlendedMesh()时,由参数 pMaxFaceInfl返回的)。一个Mesh子集可能被拆开成多个分组。 最后,分组的属性保存在pBoneCombinationBuf中,如子集 ID,该子集的各骨骼ID,起始三角面,三角面个数等供绘制时使用,分组的个数保存在 pMeshContainer->NumAttributeGroups中。

接下来检查每个分组所涉及的骨骼数,是不是超过硬件允许的最大混合矩阵数---MaxVertexBlendMatrices。如果超过了就把所有分组截为两大部分,前一部分用硬件混合,后一部分采用软件混合。而且,一旦发现有需要软件混合,要采用 CloneMeshFVF(D3DXMESH_SOFTWAREPROCESSING|...)的方式重新生成网格。

再来看绘制部分DrawMeshContainer()

用pBoneComb指向骨骼分组属性,扫描各分组。找出其中骨骼数满足硬件性能的用进行绘制。
然后开启软件顶点渲染m_pd3dDevice->SetSoftwareVertexProcessing(TRUE),对那些骨骼数超出硬件性能的进行绘制。
SetSoftwareVertexProcessing()需要当前d3d设备以D3DCREATE_MIXED_VERTEXPROCESSING方式创建。

(2)D3DINDEXED,这种方式上面分析过了,从略。用pMeshContainer->UseSoftwareVP表示是否采用软件绘制。
值得注意的是在这种方式下,一旦硬件性能不足,会彻底使用软件顶点渲染,而不是像上面一样拆为两部分。

(3)D3DINDEXEDVS,D3DINDEXEDHLSLVS
这种情况下使用了着色器和高级着色语言。超出本文主旨,讨论从略。

(4)SOFTWARE--软件方式? 让人有些迷惑,与上面的m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)有何区别?

从代码看,这种方式下反而比较简单。GenerateSkinnedMesh()中,
先直接从原始Mesh克隆一个Mesh,然后读取它的材质属性数组。开辟一个空间m_pBoneMatrices,用以存放各块骨骼的转换矩阵。

在绘制时,从pMeshContainer中的变换矩阵乘以偏移矩阵,放在pBoneMatrices中。把这个矩阵数组,以原Mesh的顶点作为源顶点,以新克隆的MeshData.pMesh做为目标顶点,调用pSkinInfo->UpdateSkinnedMesh(),用软件方式计算各骨骼顶点的新位置(相当于软件计算方式蒙皮)。

然后调用MeshData.pMesh->DrawSubset()绘制。

可见,在SOFTWARE方式下,最终顶点的渲染还是HAL方式的,只不过蒙皮计算是由软件完成的。它和上面的m_pd3dDevice->SetSoftwareVertexProcessing(TRUE)直接设置软件顶点渲染还是有区别的。

原创粉丝点击