第十三章 渲染骨骼动画(Rendering Skeletal Animation)
所有的游戏都不可能只有静态物体,而是充满了动态元素,比如带动画的人物。通常这些动画都是通过动作捕捉技术记录下来,然后通过3D模型里的骨骼来实现的。当然,除了这些复杂的动画以外,还包括缩放,旋转,位移等简单操作。这一章,我们将讨论以下内容:
l 加载帧层次(loading frame hierarchy)
l 创建骨骼信息(generation any skeletal information)
l 渲染每一帧(rendering each frame)
l 控制动画时间(advancing the animation time)
l 使用索引动画提高性能(using indexed animation for better performance)
(注:最好先对骨骼动画和计算机动画有所了解,不然这章看起来会比较难懂)
创建帧层次
大部分mesh(包括不含动画信息的mesh)都包含某些层次信息;比如手臂连接到身体上,而手指又连接到手臂上。通过Direct3D扩展库,我们可加载并控制这些层次。
为了保存这些信息,需要先创建两个抽象类来控制这些层次:Frame类和MeshContainer类。每一个Frame可以保存0到多个兄弟(sibling)帧,当然,同时也包括可能存在的子帧(child frame)。每个Frame同时也能包含0到多个meshContainer。(注:帧层次是一种树状的数据结构)
当然在开始之前,创建一个新程序,并且为DirectX程序做好准备。之后,添加如下变量:
private AnimationRootFrame rootFrame;
private Vector3 objectCenter;
private float objectRedius;
private float elapsedTime;
AnimationRootFrame结构将保存层次树的根节点帧,同时还包括AnimationController对象。接下来的两个变量保存了所加载模型的中心点,以及围住模型的边界球体半径。设置摄像机位置时需要用到这几个参数,保证整个模型都是可见的。最后一个变量保存了程序流逝的时间,我们将依照这个时间来更新动画。
由于各动画mesh都是不同的,Direct3D没有强制使用一种特定方法来加载层次结构。所以,需要继承AllocateHierarchy来创建一个新类,在程序中添加代码:
public class AllocateHierarchyDerived : AllocateHierarchy
{
Form1 app = null;
public AllocateHierarchyDerived(Form1 parent)
{
app = parent;
}
}
现在看来,这段代码什么也没干。构造函数保存了一个主窗口的实例而已,这样稍后才能使用它来调用其它方法。但是现在,程序甚至不能通过编译。我们还必须实现两个抽象方法。这两个方法用来创建保存层次信息的frame和mesh container对象。由于这两个类也是抽象的,因此同样需要先创建派生类。我们还需要在派生类中添加一些额外信息来帮助实现渲染。
public class FrameDrived : Frame
{
private Matrix combined = Matrix.Identity;
public Matrix CominedTramsformationMatrix
{
get
{
return conbined;
}
set
{
combined = value;
}
}
}
除了保存普通的帧信息以外,每一帧还必须保存它自己以及所有父节点帧的混合变换矩阵。这有利于我们设置世界矩阵。接下来,创建MeshContainer的派生类。
public class MeshContainerDerived : MeshContainer
{
private Texture[] meshTexture = null;
private int numAttr = 0;
private int numInfl = 0;
private BoneCombination[] bones;
private FrameDerived[] frameMarices;
private Matirx[] offsetMatrices;
//public prperties
public Texture[] GetTexture() { reture meshTexture;}
public void SetTextures(Texture[] textures) { meshTextures = textures; }
public BoneCombination[] GetBones() { return bones; }
public void SetBones(BoneCombination[] b) { bones = b; }
public FrameDerived[] GetFrames() { return frameMatrices; }
public void SetFrames(FrameDerived[] frames) { frameMatrices = frames; }
public Matrix[] GetOffsetMatrices() { return offsetMatrices; }
public void SetOffsetMatrices(Matrix[] matrices) { offsetMatrices = matrices; }
public int NumberAttributes { get { return numAttr; } set { numAttr = value; } }
public int NumberInfluences { get { return numInfl; } set { numInfl = value; } }
}
每个mesh容器都包含了一些额外信息,包括骨骼连接表,属性的数量,influence的数量,还有编译矩阵。在渲染和动画mesh时,每一个变量都会用到。
实现了连个派生类,可以来添加刚才没有实现的两个抽象方法了,在AllocateHierarchyDerived中添加如下代码:
public override Frame CreateFrame(string name)
{
FrameDerived frame = new FrameDerived();
frame.Name = name;
frame.TransformationMatrix = Matrix.Identity;
frame.CominedTramsformationMatrix = Matrix.Identity;
return frame;
}
对要创建的每帧来说,这段代码会保存这一帧的名字,同时把变换和混合矩阵设置为单位矩阵。在后台,所有同层帧,子帧,以及mesh容器都在运行时填充,所以不必为他担心。对于创建mesh容器来说,还有很多需要完成的工作,添加如下代码:
public override MeshContainer CreateMeshContainer(string name, MeshData meshData, ExtendedMaterial[] materials,
EffectInstance[] effectInstances, GraphicsStream adjacency, SkinInformation skinInfo)
{
if(meshData.Mesh == null)
throw new ArgumentException();
if(meshData.Mesh.VertexFormat == VertexFormats.None)
throw new ArgumentException();
MeshContainerDerived mesh = new MeshContainerDerived();
mesh.Name = name;
int numFaces = meshData.Mesh.NumberFaces;
Device dev = meshData.Mesh.Device;
if((meshData.Mesh.VertexFormat & VertexFormats.Normal) == 0)
{
Mesh tempMesh = meshData.Mesh.Clone(meshData.Mesh.Options.Value,meshData.Mesh.VertexFormat | VertexFormats.Normal,dev);
meshData.Mesh = tempMesh;
meshData.Mesh.ComputeNormals();
}
mesh.SetMaterials(materials);
mesh.SetAdjacency(adjacency);
Texture[] meshTextures = new Texture[materials.Length];
for(int i=0;i<materials.Length;i++)
{
if(materials[i].TextureFilename != null)
{
meshTextures[i] = TextureLoader.FromFile(dev,@"../../" + materials[i].TextureFilename);
}
}
mesh.SetTextures(meshTextures);
mesh.MeshData = meshData;
if(skinInfo != null)
{
mesh.SkinInformation = skinInfo;
int numBones = skinInfo.NumberBones;
Matrix[] offsetMatrices = new Matrix[numBones];
for(int i = 0;i<numBones;i++)
offsetMatrices[i] = skinInfo.GetBoneOffsetMatrix(i);
mesh.SetOffsetMatrices(offsetMatrices);
app.GenerateSkinnedMesh(mesh);
}
return mesh;
}
代码看起来有一点点多。首先要做的就是验证mesh是否为程序所支持的类型。显然,如果mesh数据结构中没有包含mesh,那么会出现一些问题,所以抛出一个异常。另外,如果mesh没有包含正确的顶点格式,则抛出另外一个异常。
假设mesh通过验证,创建了新的mesh container。就设置mesh contain的名字、所包含的面数以及使用的device。这里虽然app成员包含device对象,但由于它是私有的,所以不能直接使用。
下面几行代码应该很熟悉了,处理关于法线的信息。
接下来,保存材质和邻接信息,之后会用到他们。因为材质可能包含了纹理,所以还需要创建一个纹理数组。之后,把正确的纹理放到纹理数组的成员中。
最后,检查mesh container中是否包含任何骨骼信息。如果有,就保存骨骼,同时创建偏移矩阵数组(每条骨骼对应一个矩阵),同样,把相应的偏移矩阵保存到整个数组中。最后调用现在还没有编写的GenerateSkinnedMesh方法,然后返回这个mesh container。
来看看GenerateSkinnedMesh是怎样的吧:
public void GenerateSkinnedMesh(MeshContainerDerived mesh)
{
if(mesh.SkinInformation == null)
throw new ArgumentException();
int numInfl = 0;
BoneCombination[] bones;
MeshData m = mesh.MeshData;
m.Mesh = mesh.SkinInformation.ConvertToBlendedMesh(m.Mesh,MeshFlags.Managed | MeshFlags.OptimizeVertexCache,
mesh.GetAdjacencyStream(),out numInfl,out bones);
mesh.NumberInfluences = numInfl;
mesh.SetBones(bones);
mesh.NumberAttributes = bones.Length;
mesh.MeshData = m;
}
如果没有骨骼信息就绝对不需要调用这个方法,所以万一那么做了,我们就抛出一个异常。把mesh数据保存到一个临时变量中,之后,使用ConvertToBlendedMesh方法创建一个包含了每个顶点混合权重以及骨骼连接表(bone combination table)的新mesh。ConvertToBlendedMesh是最根本也是最重要的实现mesh动画的方法。最后,保存mesh中influence的数量,骨骼连接表,以及属性的数量。
加载动画Mesh
我们已经建立了基本的mesh层次,现在要做的就是编写代码来使用他们。在这之前,还需要对显卡作一些检查:
这些代码大家应该很熟悉。需要注意,对这个例子来说,至少需要4个顶点混合矩阵(vertex blend Matrices)才能完全实现硬件渲染,所以我们在这里做了检查。创建了设备之后,就可以加载动画了。这里使用了一个还没有编写的方法,稍后我们就来实现它,不过在这之前,先来看看所订阅的OnDeviceReset方法:
private void OnDeviceReset(object sender, EventArgs e)
{
Device dev = (Device)sender;
Vector3 vEye = new Vector3(0,0, -1.8f *objectRadius);
Vector3 vUp = new Vector3(0,1,0);
dev.Transform.View = Matrix.LookAtLH(vEye,objectCenter,vUp);
float aspectRatio = (float)dev.PresentationParameters.BackBufferWidth / (float)dev.PresentationParameters.BackBufferHeight;
dev.Transform.Projection = Matrix.PerspectiveFovLH((float)Math.PI / 4,aspectRatio,objectRadius/ 64.0f ,objectRadius * 200.0f );
dev.Lights[0].Type = LightType.Directional;
dev.Lights[0].Direction = new Vector3( 0.0f , 0.0f , 1.0f );
dev.Lights[0].Diffuse = Color.White;
dev.Lights[0].Update();
dev.Lights[0].Enabled = true;
}
首先,设置了观察矩阵,把摄像机移动到足够远的地方,保证能看到整个模型,同时,我们的观察点指向模型的中心。同样,投影矩阵也创建了一个足够远的后裁剪平面,保证可以看到整个模型。另外,我们还添加了一个方向光来照亮模型。
接下来就是创建动画的代码:
private void CreateAnimation(string file ,PresentParameters presentParams)
{
AllocateHierarchyDerived alloc = new AllocateHierarchyDerived(this);
rootFrame = Mesh.LoadHierarchyFromFile(file,MeshFlags.Managed,device,alloc,null);
objectRadius = Frame.CalculateBoundingSphere(rootFrame.FrameHierarchy,out objectCenter);
SepupBoneMatrices((FrameDerived)rootFrame.FrameHierarchy);
DXUtil.Timer(DirectXTimer.Start);
}
这里的代码很简单,基本上就是调用其它方法而已。
首先,实例化AllocateHierarchyDerived对象。接下来,使用这个对象以及其它几个参数调用LoadHierarchyFromFile方法。注意,当调用这个方法时,根据mesh中帧和mesh container的数量,将会多次调用重载的CreateFrame和CreateMeshContainer方法。之后,它将返回AnimationRootFrame对象作为帧层次树的根节点,当然,还包括animation controller,用来控制mesh的动画。
之后,使用CalculateBoundingSphere根据根节点帧计算整个帧的边界球体,同时,返回这个球体的半径和mesh的中心。
最后,在完成动画前,设置骨骼矩阵。这个方法将遍历(walk)整个帧层次树,同时也是其它操作的基础。具体代码如下:
private void SetupBoneMatrices(FrameDerived frame)
{
if(frame.MeshContainer != null)
SetupBoneMatrices((MeshContainerDerived)frame.MeshContainer);
if(frame.FrameSibling != null)
SetupBoneMatrices((FrameDerived)frame.FrameSibling);
if(frame.FrameFirstChild != null)
SetupBoneMatrices((FrameDerived)frame.FrameFirstChild);
}
private void SetupBoneMatrices(MeshContainerDerived mesh)
{
if(mesh.SkinInformation != null)
{
int numBones = mesh.SkinInformation.NumberBones;
FrameDerived[] frameMatrices = new FrameDerived[numBones];
for(int i=0;i<numBones;i++)
{
FrameDerived frame = (FrameDerived)Frame.Find(rootFrame.FrameHierarchy,mesh.SkinInformation.GetBoneName(i));
if(frame == null)
throw new ArgumentException();
frameMatrices[i] = frame;
}
mesh.SetFrames(frameMatrices);
}
}
你看,遍历整个帧层次并不是很复杂。如果有兄弟帧或者子帧,重复调用当前的方法就可以了。我们只需要为第一个子帧调用方法就可以了,因为第一个子帧会把它之后的子帧作为兄弟帧来处理(the first child will have each subsequent child listed as its sibling)。这里,根据骨骼的名字来保存每帧。
实际上接受FrameDerived参数的重载并不是很有趣,它只是遍历整个层次树,并使用MeshContainerDerived作为参数,再调用下一个重载而已。这里,创建了一个FrameDerived数组,它的每个成员对应一个骨骼。每帧都通过遍历骨骼列表来寻找名字相符的骨骼。找到之后,就保存它。
接下来更新main()方法:
static void Main ()
{
using (Form1 frm = new Form1())
{
frm.Show();
if (!frm.InitializeGraphics())
{
MessageBox.Show("Your card can not perform skeletal animation on " +"this file in hardware. This application will run in " +
"ref mode instead.");
}
Application.Run(frm);
}
渲染Mesh动画
现在所要做的就是渲染动画角色了。渲染代码实际上是很简单的:
protected override void OnPaint(System.Windows.Forms.PaintEventArgs e)
{
ProcessNextFrame();
device.Clear(ClearFlags.Target | ClearFlags.ZBuffer, Color.CornflowerBlue, 1.0f , 0);
device.BeginScene();
DrawFrame((FrameDerived)rootFrame.FrameHierarchy);
device.EndScene();
device.Present();
this.Invalidate();
}
处理下一帧,clear设备,绘制根帧。看起来很简单,但实际处理了很多步骤。来看看如何处理下一帧:
private void ProcessNextFrame()
{
elapsedTime = DXUtil.Timer(DirectXTimer.GetElapsedTime);
Matrix worldMatrix = Matrix.Translation(objectCenter);
device.Transform.World = worldMatrix;
if(rootFrame.AnimationController != null)
rootFrame.AnimationController.AdvanceTimed(elapsedTime,null);
UpdateFrameMatrices((FrameDerived)rootFrame.FrameHierarchy,worldMatrix);
}
首先,保存逝去时间,接下来,创建根帧的世界矩阵。变换物体的位置,更新设备。假设这个mesh包含动画,使用保存的时间来改变(advance)动画时间。最后,更新混合的变换矩阵:
private void UpdateFrameMatrices(FrameDerived frame,Matrix parentMatrix)
{
frame.CominedTramsformationMatrix = frame.TransformationMatrix * parentMatrix;
if(frame.FrameSibling != null)
UpdateFrameMatrices((FrameDerived)frame.FrameSibling,parentMatrix);
if(frame.FrameFirstChild != null)
UpdateFrameMatrices((FrameDerived)frame.FrameFirstChild,frame.CominedTramsformationMatrix);
}
这里,当前帧的混合变换矩阵通过帧的变换矩阵与他的父变换矩阵相乘来计算。所有兄弟帧都使用当前帧的父矩阵,而子帧则使用当前帧的混合变换矩阵作为他自己的变换矩阵。这里实际上形成了一个矩阵链,子帧需要把他的变换矩阵和所有父节点帧的矩阵相乘。
处理完下一帧之后,就可以对它进行渲染了:
private void DrawFrame(FrameDerived frame)
{
MeshContainerDerived mesh = (MeshContainerDerived)frame.MeshContainer;
while(mesh != null)
{
DrawMeshContainer(mesh,frame);
mesh = (MeshContainerDerived)mesh.NextContainer;
}
if(frame.FrameSibling != null)
DrawFrame((FramejDerived)frame.FrameSibling);
if(frame.FrameFirstChild != null)
DrawFrame((FrameDerived)frame.FrameFirstChild);
}
这里同样遍历了整个树,同时,尝试着绘制frame对象所引用的所有mesh container。这个方法将完成相当多的工作。以下是DrawMeshContain方法:
private void DrawMeshContainer(MeshContainerDerived mesh, FrameDerived frame)
{
if(mesh.SkinInformation != null)
{
int attribIdPrev = -1;
for(int iattrib = 0; iattrib<mesh.NumberAttributes;iattrib++)
{
int numBlend = 0;
BoneCombination[] bones = mesh.GetBones();
for(int i=0; i<mesh.NumberInfluences; i++)
{
if(bones[iattrib].BoneId[i] != -1)
numBlend = i;
}
if(device.DeviceCaps.MaxVertexBlendMatrices >= numBlend +1)
{
Matrix[] offsetMatrices = mesh.GetOffsetMatrices();
FrameDerived[] frameMatrices = mesh.GetFrames();
for(int i=0;i<mesh.NumberInfluences;i++)
{
int matrixIndex = bones[iattrib].BoneId[i];
if(matrixIndex != -1)
{
Matrix tempMatrix = offsetMatrices[matrixIndex]*frameMatrices[matrixIndex].CombinedTransformationMatrix;
device.Transform.SetWorldMatrixByIndex(i,tempMatrix);
}
}
device.RenderState.VertexBlend = (VertexBlend)numBlend;
if((attribIdPrev != bones[iattrib].AttributeId) || (attribIdPrev == -1))
{
device.Material = mesh.GetMaterials()[bones[iattrib].AttributeId].Material3D;
device.SetTexture(0,mesh.GetTextures()[bones[iattrib].AttributeId]);
attribIdPrev = bones[iattrib].AttributeId;
}
mesh.MeshData.Mesh.DrawSubset(iattrib);
}
}
}
else
{
ExtendedMaterial[] mtrl = mesh.GetMaterials();
for(int iMaterial = 0; iMaterial<mtrl.Length;iMaterial++)
{
device.Material = mtrl[iMaterial].Material3D;
device.SetTexture(0,mesh.GetTextures()[iMaterial]);
mesh.MeshData.Mesh.DrawSubset(iMaterial);
}
}
}
这个方法看起来似乎有些可怕。但如果分开来看,其实也不是很复杂。首先检查skin information成员。如果mesh container没有骨骼信息,那么就使用以前的方法渲染它。如果有,渲染方法自然就不一样了。对mesh中的每种属性来说(比如材质、纹理等),需要进行一系列操作。首先,需要扫描骨骼连接表,同时选择mesh将使用的混合权重(blend weight)数量。示例中的文件最多使用了4个混合权重,因此,我们在创建设备时就对设备进行了检测。但是,以防更换了mesh,这里的代码还是检测了设备能力,以保证能混合包含更多矩阵的mesh。之后的代码设置了世界变换。对骨骼连接表中的每个骨骼id来说,把当前的索引世界变换矩阵(currently indexed world matrix tansform)设置为偏移矩阵和帧的混合变换矩阵的乘积。这样,Direct3D才能使用适当的世界矩阵来混合顶点。接下来,把顶点混合渲染状态设置为所希望的混合值。最后,设置纹理和材质,并且绘制图形。
使用不含骨骼的mesh动画:
不能说一个mesh没有骨骼信息就不包含动画。如果mesh的动画只包含一系列标准的矩阵操作(比如缩放,位移,或者旋转),那么就不需要骨骼。但是,东环系统仍然会为mesh更新矩阵,所以使用普通的渲染方法就能得到所需效果。
使用带索引的mesh动画骨骼(Using an Indexed Mesh To Animate the Bones)
很早以前我们就讨论过,使用索引缓冲来渲染顶点数据,可以减少所要绘制的三角形数量以及占用的内存,从而提高性能。对于复杂模型来说,就像我们使用的这个,使用带索引的mesh性能提升是很大的。另外,代码也要简单的多。
在使用带索引的mesh之前,先做一点小小的改动,为MeshContainerDerived类加一个新成员:
这个程序将保存骨骼矩阵的数量,当转换mesh时,我们将把他作为矩阵调色贴皮(matrix palette skinning)。因为我们不再使用标准的顶点混合来实现动画,所以更新初始化的方法,对设备能力进行一些额外检查。
if(hardware.MaxVertexBlendMatrixIndex >= 12)
下面来看看如何创建indexed mesh吧:
public void GenerateSkinnedMesh(MeshContainerDerived mesh)
{
if (mesh.SkinInformation == null)
throw new ArgumentException();
int numMaxFaceInfl;
MeshFlags flags = MeshFlags.OptimizeVertexCache;
MeshData m = mesh.MeshData;
using(IndexBuffer ib = m.Mesh.IndexBuffer)
{
numMaxFaceInfl = mesh.SkinInformation.GetMaxFaceInfluences(ib,m.Mesh.NumberFaces);
}
numMaxFaceInfl = (int)Math.Min(numMaxFaceInfl,12);
if(device.DeviceCaps.MaxVertexBlendMatrixIndex + 1 >= numMaxFaceInfl)
{
mesh.NumberPaletteEntries = (int)Math.Min((device.DeviceCaps.MaxVertexBlendMatrixIndex+1)/2,mesh.SkinInformation.NumberBones);
flags |= MeshFlags.Managed;
}
BoneCombination[] bones;
Int numInfl;
m.Mesh = mesh.SkinInformation.ConvertToIndexedBlendedMesh(m.Mesh,flags,mesh.GetAdjacency(),mesh.NumberPaletteEntries,out numInfl,out bones);
mesh.SetBones(bones);
mesh.NumberInfluences = numInfl;
mesh.NumberAttributes = bones.Length;
mesh.MeshData = m;
}
首先,获取面的influence最大值。接下来我们保证这个值至少为12(因为对每个三角形中的每个顶点来说,12是4顶点混合的幻数(12 would be the magic number number of 4 vertex blends for each vertex in a trangle))。假设设备支持这种操作(在创建设备时检测了这种能力),就计算将要用到的调色板数量(它是骨骼数或所支持的最大面influence值的一半)。现在,把mesh转换为indexed blended mesh,同时把数据保存到之前不含索引的meshData中。在来看看绘制mesh的方法,为了简短一点,这里只包含了当skin information不为null时的情况:
if(mesh.SkinInformation != null)
{
if(mesh.NumberInfluences == 1)
device.RenderState.VertexBlend = VertexBlend.ZeroWeights;
else
device.RenderState.VertexBlend = (VertexBlend)(mesh.NumberInfluences - 1);
if(mesh.NumberInfluences > 0)
device.RenderState.IndexedVertexBlendEnable = true;
BoneCombination[] bones = mesh.GetBones();
for(int iAttrib=0;iAttrib< mesh.NumberAttributes;iAttrib++)
{
for(int iPaletteEntry = 0; iPaletteEntry<mesh.NumberPaletteEntries;++iPaletteEntry)
{
int iMatrixIndex = bones[iAttrib].BoneId[iPaletteEntry];
if(iMatrixIndex != -1)
{
device.Transform.SetWorldMatrixByIndex(iPaletteEntry,mesh.GetOffsetMatrices()[iMatrixIndex]*
mesh.GetFrames()[iMatrixIndex].CombinedTransformationMatrix);
}
}
device.Material = mesh.GetMaterials()[bones[iAttrib].AttributeId].Material3D;
device.SetTexture(0,mesh.GetTextures()[bones[iAttrib].AttributeId]);
mesh.MeshData.Mesh.DrawSubset(iAttrib);
}
这个方法比以前的简单多了。首先把VertexBlend的值设置为influence-1。下一步,如果有influence(对于骨骼动画来说,这是必须的),就打开IndexedVertexBlendEnable选项。接下来的代码就和之前的差不多了。对每个调色板项,为每个索引的设置相应的世界矩阵。最后,设置材质,纹理,绘制图形。
使用indexed blended mesh,根据数据的不同,性能提升可以达到30%甚至更多。