【题外话】
最近要做一个3D动画演示的程序,由于比较熟悉C#语言,再加上XNA对模型的支持比较好,故选择了XNA平台。不过从网上找到很多XNA的入门文章,发现大都需要一些3D基础,而我之前并没有接触过游戏以及3D相关的开发,所以我来从另一个角度整理下入门XNA。本文尽量少涉及3D及数学方面的知识,因为同类文章介绍的挺多的。
【系列索引】
【文章索引】
【一、XNA项目的结构】
虽然微软在推出了XNA 4.0之后就再也没有升级过XNA的版本,但XNA还是在.NET平台上比较方便的3D框架。由于我使用的是VS2013,而XNA 4.0的安装程序只认VS2010,所以需要安装一个使用VS2010 Shell的程序(比如我使用的是SQL Server 2012 Management Studio,当然也可以安装VS2010 Express)才能通过XNA 4.0的安装。安装完后会自动在VS2010下添加相关扩展模板,但不会在更高版本添加,可以参考 http://ryan-lange.com/xna-game-studio-4-0-visual-studio-2012/ 将扩展模板添加到VS2012或2013中(VS2013需要将其中的版本号改为12.0)。
XNA 4.0相对之前的3.1做了很多修改,不仅代码上进行了很多调整,默认创建的项目也与之前的不同。创建XNA 4.0的Windows Game项目后,默认会创建两个项目,分别是WindowsGame以及WindowsGameContent,前者存放程序的逻辑代码,而后者则存放程序所需要的资源(模型、纹理等等),与其他项目不同的是,XNA项目增加了一个Content Reference内容引用,可以将逻辑与资源拆分成不同的项目,即由逻辑代码的项目引用资源项目,当然也可以合并在一起。
【二、XNA程序的结构】
在创建的WindowsGame项目中,与其他Windows程序一样都包含一个Program.cs,除此之外同时还有一个Game.cs。需要说明的是,与平常开发Windows应用以基于事件的方式不同,开发XNA(以及其他游戏框架)的应用是以基于轮询的方式。在Game.cs文件中,除了构造方法外还会生成以下几个方法,其执行顺序和微软给出的说明如下:
其中程序的主要部分就是Update()和Draw()两个方法,整个程序在运行时,几乎就是这两个方法在不断重复执行。需要说明的是,对于XNA,在默认情况下,执行一次Update()和Draw()是要控制在一定时间的(默认为1/60s,即60FPS),如果执行一次Update()和Draw()的时间小于这个时间将会进行等待,如果超过这个时间则会跳帧(不执行Draw()),当然也可以修改Game类中的TargetElapsedTime来改变这个时间,或者修改IsFixedTimeStep=false使得程序帧数能多大就多大。
【三、坐标系与摄像机】
对于二维的画面,我们可以直接使用屏幕的坐标系;而对于三维的画面,我们还需要将三维世界投影到二维的屏幕上。那么,我们就需要一个计算如何将三维世界投影到二维屏幕的工具,那么摄像机就是实现这个功能的。实际上,这里的摄像机与我们平时拿摄像机录像是一样的,在屏幕上显示的内容就是摄像机录下的内容。
首先需要说明的是,在XNA中使用的右手坐标系(与Direct3D中使用的左手坐标系Z轴相反),也就是说按正常方向去看的话,向右是X轴正方向,向上是Y轴正方向,而指向自己的(向外)是Z轴正方向,如下图。
在三维框架中,很多信息的存储和表示都是用四维矩阵(Matrix类)来的。所以要表示一个摄像机,通常由两个矩阵组成,分别是 视图矩阵(View Matrix) 和 投影矩阵(Projection Matrix),其中视图矩阵表示了摄像机的位置、摄像机的朝向以及摄像机的上方向;而投影矩阵则表示了摄像机的视角以及视觉范围。虽然听上去很复杂,但是XNA提供了直接由具体的参数创建矩阵的方法。
对于视图矩阵的创建,可以使用如下的方法:
Matrix.CreateLookAt(Vector3 cameraPosition, Vector3 cameraTarget, Vector3 cameraUpVector);
其中cameraPosition为摄像机在空间内的三维坐标;cameraTarget为摄像机所指向目标的三维坐标;cameraUpVector则表明了哪个方向是摄像机的向上方向(如果摄像机正着放的话,那么Y轴正方向为摄像机的向上方向)。
而对于投影矩阵的创建,则可以使用如下的方法:
Matrix.CreatePerspectiveFieldOfView(float fieldOfView, float aspectRatio, float nearPlaneDistance, float farPlaneDistance);
其中fieldOfView表示的是摄像机的视角弧度,范围为(0, Pi),通常为Pi/4(45°);aspectRatio为摄像机的长宽比,通常为屏幕的长宽比;nearPlaneDistance与farPlaneDistance则为当摄像机多近(远)时无法拍摄到物体。在下图中表示了fieldOfView与nearPlaneDistance和farPlaneDistance的关系,可以看到摄像机通过视角角度与距离可以产生一个近剪裁面和远剪裁面,而最终能投影的部分就是处在这两个平面之间的物体。
【四、模型的导入与显示】
对于三维模型,XNA平台支持两种格式,分别是.x与.fbx文件,其中后者在很多软件中都支持,比如Maya、MotionBuilder等等。对于模型的导入,只需要将模型文件拖到Content项目中即可。不过需要说明的是,为了保证效率等,XNA程序在运行时并不是调用的fbx等模型文件,而是通过Content Pipeline内容管道进行处理,编译成扩展名为.xnb的一种中间格式,在程序运行时程序实际调用的为这些中间格式,如下图。
Content Pipeline中主要有两个重要的部分,分别是Importer以及Content Processor。其中Importer负责将导入的资源文件解析为XNA可以识别的XNA Game Studio Content Document Object Model (DOM)。系统已经支持了很多的文件格式,比如三维模型支持.fbx和.x,纹理支持.bmp、.dds、.dib、.hdr、.jpg、.pfm、.png、.ppm、.tga等文件等,详情可以参考这里。如果需要的文件格式在XNA框架中不支持,可以自己写新的Importer来支持更多的格式。
而Processor则根据前者解析后的内容存储为Output Type,之后再编译成.xnb文件,在默认情况下使用系统自带的Processor已经足够了,不过当想存储XNA默认没有存储的内容时则需要自己扩展Processor。
虽然上述说了这么多,但加载资源则只需要一行代码即可解决。在上述的Game类中提供了一个ContentManager的实例,名为Content,我们可以使用其来加载我们的模型。ContentManager提供了一个名为Load的泛型方法,将资源类型以及资源的相对路径传入即可读取。比如我们将名为dude.fbx的文件拖到Content项目中,然后只需在上述提到的LoadContent方法中添加如下的一行代码(需要在Game类中定义一个名为model的Model类型):
protected override void LoadContent() { // TODO: use this.Content to load your game content here this.model = this.Content.Load("dude"); }
而如果要将模型绘制到屏幕上,只要调用Model对象的Draw方法即可。不过Draw方法需要提供 World世界矩阵 以及 View视图矩阵 和 Projection投影矩阵,对于后两个矩阵我们上文已经说明,而世界矩阵与视图矩阵类似,可以使用如下的方法创建:
Matrix.CreateWorld(Vector3 position, Vector3 forward, Vector3 up);
其中position与up均与之前的CreateLookAt类似,为模型在世界中所处的三维坐标和哪个方向是模型的向上方向;而forward则不同,为模型的朝向向量,其仅仅代表方向。当然我们也可以通过创建不同的平移、旋转等矩阵,然后相乘得到世界矩阵。
接下来我们可以在上述提到的Draw方法中添加如下的代码:
protected override void Draw(GameTime gameTime) { GraphicsDevice.Clear(Color.CornflowerBlue); // TODO: Add your drawing code here Matrix world = Matrix.CreateWorld(Vector3.Zero, Vector3.Forward, Vector3.Up); Matrix cameraView = Matrix.CreateLookAt(new Vector3(120, 120, 120), Vector3.Zero, Vector3.Up); Matrix cameraProjection = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4, this.GraphicsDevice.Viewport.AspectRatio, 10.0F, 10000.0F); this.model.Draw(world, cameraView, cameraProjection); base.Draw(gameTime); }
其中Vector3.Zero、Forward、Up为系统预设的几个常量,分别为(0, 0, 0)、(0, 0, -1)以及(0, 1, 0);而常用弧度值在MathHelper中也可以找到,比如Pi、PiOver2(90度弧度)、PiOver4(45度弧度)等等;GraphicsDevice.Viewport.AspectRatio为显示区域的宽高比。
这样我们就可以创建一个在(120, 120, 120)坐标上,朝向坐标原点的摄像机,并在坐标原点创建一个模型(由于dude模型的正面是朝Z轴负方向的,所以这里我们选用的Z轴负方向为模型的朝向)。
文中提到的dude.fbx从微软提供的sample中获得:http://xbox.create.msdn.com/en-US/education/catalog/sample/skinned_model
本文所有代码可以从如下地址下载:http://files.cnblogs.com/mayswind/XNA_Sample_1.zip
虽然现在使用XNA的人越来越少了,但是这个有点类似个人学习笔记的文章还是要正式开坑了。
【相关链接】
【题外话】
上一篇文章介绍了3D开发基础与XNA开发程序的整体结构,以及使用Model类的Draw方法将模型绘制到屏幕上。本文接着上一篇文章继续,介绍XNA中模型的结构、BasicEffect的使用以及用户输入和界面显示的方式等,本文尽量把遇到的概念都解析清楚,但又避开复杂的数学方面的知识,希望对没有接触过3D开发的同学有所帮助。
【系列索引】
【文章索引】
【一、Model模型的结构】
上一篇文章使用Model自带的Draw方法实现了直接将载入的Model绘制到指定的位置上去,但是有时候绘制出来的效果并不符合我们的预期,比如下图(下图的模型是通过Maya创建的一个屋子):
通过ILSpy查看Microsoft.Xna.Framework.Graphics.Model,可以看到其Draw方法的代码如下:
1 public void Draw(Matrix world, Matrix view, Matrix projection)
2 {
3 int count = this.meshes.Count;
4 int count2 = this.bones.Count;
5 Matrix[] array = Model.sharedDrawBoneMatrices;
6 if (array == null || array.Length < count2)
7 {
8 array = new Matrix[count2];
9 Model.sharedDrawBoneMatrices = array;
10 }
11 this.CopyAbsoluteBoneTransformsTo(array);
12 for (int i = 0; i < count; i++)
13 {
14 ModelMesh modelMesh = this.meshes[i];
15 int index = modelMesh.ParentBone.Index;
16 int count3 = modelMesh.Effects.Count;
17 for (int j = 0; j < count3; j++)
18 {
19 Effect effect = modelMesh.Effects[j];
20 if (effect == null)
21 {
22 throw new InvalidOperationException(FrameworkResources.ModelHasNoEffect);
23 }
24 IEffectMatrices effectMatrices = effect as IEffectMatrices;
25 if (effectMatrices == null)
26 {
27 throw new InvalidOperationException(FrameworkResources.ModelHasNoIEffectMatrices);
28 }
29 effectMatrices.World = array[index] * world;
30 effectMatrices.View = view;
31 effectMatrices.Projection = projection;
32 }
33 modelMesh.Draw();
34 }
35 }
其中可见,Draw方法通过遍历模型的Mesh,然后再遍历每个Mesh的Effect,并对每个Effect进行设置,最后使用Mesh的Draw方法将其绘制到屏幕上。
为了了解Model的渲染,我们首先需要了解Model的结构。实际上,在一个Model对象中,包含Bone集合(model.Bones)、Mesh集合(model.Meshes)以及根Bone(model.Root)三个属性,其结构和关系如下:
可以看到对于每个ModelMesh,包含一组ModelMeshPart与一个ParentBone。其中,
所以遍历一个Model中所有的ModelMesh,然后遍历其中所有的ModelMeshPart,并且根据ModelMesh的ParentBone来将每一个ModelMeshPart绘制到指定的位置上就可以绘制出完整的Model。
不过对于每个ModelMeshPart,其实际渲染的效果都存在Effect的属性中,对于默认来说,Effect均为BasicEffect。此外,对于ModelBone,其变换矩阵都是相对其自身的Parent来的,不过Model类也提供了一个方法,即CopyAbsoluteBoneTransformsTo(),即可将每个Bone相对于RootBone的变换矩阵复制到一个矩阵数组中,然后将其应用到Effect中即可。这种方式与上述提到的Model.Draw类似,不过自己写的话就可以自定义每个ModelMeshPart渲染的效果,当然也可以设置每个ModelMeshPart的渲染位置。
那么接下来就按照这个思路去实现,同时在设置每一个Effect时,使用Effect提供的使用默认光照的方法EnableDefaultLighting(),启用后效果如下:
这样的效果就达到了我们的预期,按上述的方法实现的代码如下:
1 Matrix world = Matrix.CreateWorld(Vector3.Zero, Vector3.Forward, Vector3.Up); 2 3 Matrix[] transforms = new Matrix[model.Bones.Count]; 4 this.model.CopyAbsoluteBoneTransformsTo(transforms); 5 6 foreach (ModelMesh mesh in model.Meshes) 7 { 8 Int32 boneIndex = mesh.ParentBone.Index; 9 10 foreach (ModelMeshPart part in mesh.MeshParts) 11 { 12 BasicEffect effect = part.Effect as BasicEffect; 13 14 effect.EnableDefaultLighting(); 15 effect.World = transforms[boneIndex] * world; 16 effect.View = cameraView; 17 effect.Projection = cameraProjection; 18 } 19 20 mesh.Draw(); 21 }
不过这与刚才看到的Model.Draw的代码并不相同。实际上,XNA为了简化操作,已经将ModelMeshPart的每个Effect放到了ModelMesh的Effects集合中,只需要遍历这个集合就可以,而无需再遍历ModelMeshPart,再获取Effect了。所以上述代码可以简化为如下的代码:
1 Matrix world = Matrix.CreateWorld(Vector3.Zero, Vector3.Forward, Vector3.Up); 2 3 Matrix[] transforms = new Matrix[model.Bones.Count]; 4 this.model.CopyAbsoluteBoneTransformsTo(transforms); 5 6 foreach (ModelMesh mesh in model.Meshes) 7 { 8 Int32 boneIndex = mesh.ParentBone.Index; 9 10 foreach (BasicEffect effect in mesh.Effects) 11 { 12 effect.EnableDefaultLighting(); 13 effect.World = transforms[boneIndex] * world; 14 effect.View = cameraView; 15 effect.Projection = cameraProjection; 16 } 17 18 mesh.Draw(); 19 }
【二、BasicEffect效果的设置】
首先用ILSpy查看下BasicEffect的EnableDefaultLighting()的代码:
public void EnableDefaultLighting() { this.LightingEnabled = true; this.AmbientLightColor = EffectHelpers.EnableDefaultLighting(this.light0, this.light1, this.light2); }
其中this.light0-2为BasicEffect的DirectionalLight0-2,即BasicEffect可以时候的三个光源。而EffectHelpers的EnableDefaultLighting是这样写的:
1 internal static Vector3 EnableDefaultLighting(DirectionalLight light0, DirectionalLight light1, DirectionalLight light2)
2 {
3 light0.Direction = new Vector3(-0.5265408f, -0.5735765f, -0.6275069f);
4 light0.DiffuseColor = new Vector3(1f, 0.9607844f, 0.8078432f);
5 light0.SpecularColor = new Vector3(1f, 0.9607844f, 0.8078432f);
6 light0.Enabled = true;
7 light1.Direction = new Vector3(0.7198464f, 0.3420201f, 0.6040227f);
8 light1.DiffuseColor = new Vector3(0.9647059f, 0.7607844f, 0.4078432f);
9 light1.SpecularColor = Vector3.Zero;
10 light1.Enabled = true;
11 light2.Direction = new Vector3(0.4545195f, -0.7660444f, 0.4545195f);
12 light2.DiffuseColor = new Vector3(0.3231373f, 0.3607844f, 0.3937255f);
13 light2.SpecularColor = new Vector3(0.3231373f, 0.3607844f, 0.3937255f);
14 light2.Enabled = true;
15 return new Vector3(0.05333332f, 0.09882354f, 0.1819608f);
16 }
可以看到在启用默认光照里实际上是给环境光AmbientLightColor以及三束定向光(包括光线的方向、漫反射颜色及镜面反射颜色)设置了预先定义好的颜色,并启用了这些光源,这三束定向光的颜色(Light1的漫反射光的颜色如下,但其镜面反射光的颜色为黑色)和方向大致如下。
下图第一个为启用了默认光照后的模型(上一篇文章中的dude),第二、三、四个为只启用默认光照的环境光及0、1、2三束定向光后的模型,第五个为没有启用默认光照的模型(如同上一篇产生的效果一样):
当然,在很多情况下(比如户外的日光等),我们仅需要一个光源,届时我们只要禁用(DirectionalLight*.Enabled = false)其他两个定向光即可,当然我们可能还需要修改光源的颜色等等。
除了使用EnableDefaultLighting,BasicEffect还提供了比较丰富的参数可以设置。首先来看下上述例子中Effect默认的属性:
其中与光线有关的:
其中需要注意的是,在XNA中,颜色的存储并不是使用的Color(ARGB或ABGR),而是使用的Vector3(或Vector4)。对于Vector3,其x、y、z三个分量存储的分别是R、G、B分别除以255的浮点值(Vector4的w分量存储的是Alpha通道除以255的浮点值),所以Vector3.Zero即为黑色,而Vector3.One为白色。当然XNA也提供了一个Color类,并且Color也提供了提供了直接转换为Vector3(或Vector4)的方法ToVector3()(或ToVector4())。
除此之外,BasicEffect还支持设置雾的效果:
也就是说,雾将会在距离相机(FogStart - FogEnd)的地方产生,这个距离需要根据物体所在的位置决定。设Distance为物体距离相机的距离,则Distance 例如当人的模型在(0, 0, 0),相机在(120, 120, 120)处,雾的颜色为Gray。下图第一个为没有加雾的效果,第二个为FogStart - FogEnd为200 - 300,第三个为1 - 300,第四个为1 - 100。 【三、XNA的用户输入】 在默认生成XNA程序中的Update方法里,有一个获取GamePad的状态,当用户1的GamePad按下了“Back”键后将会退出程序。微软对用户输入的支持都在Microsoft.Xna.Framework.Input中,除了GamePad之外,微软还支持获取Keyboard、Mouse这两种的状态。此外在Microsoft.Xna.Framework.Input.Touch中,还有TouchPanel可以获取触摸的状态。与GamePad相同,其他的这些状态也都是通过微软提供给类中的GetState()方法进行获取。 例如要获取键盘和鼠标的状态,我们可以通过如下方式: 对于判断键盘的按键,可以通过如下的方式获取是否按下了指定按键: 而对于鼠标的按键,则需要判断按键的ButtonState才可以,例如判断鼠标左键是否按下: 除此之外,如果要判断鼠标是否在程序区域内,可以通过如下的方式判断 虽然在大多数情况下,如果让用户操作鼠标的话会在程序内显示一个自定义的指针。但有时候写个小程序,为了简单希望直接使用系统的指针,我们可以在程序的任意位置(构造方法、Initialize甚至Update也可)写如下的代码,就可以显示鼠标指针了,反之则可以隐藏: 【四、XNA界面的显示方式】 默认情况下,运行XNA的程序会自动以800*480的分辨率显示,若要修改显示的分辨率,其实非常简单,仅需要在Game的构造方法中添加如下代码即可: 这样XNA的程序就能按照我们设定的分辨率显示了。除此之外,如果我们希望XNA的程序能全屏显示,我们还可以添加如下的代码: 当然我们还可以让用户来切换全屏与窗口化,但是这行代码写在Update()中是不起作用的,不过XNA提供另外一个方法,就是graphics.ToggleFullScreen()。例如我们需要按F键进行全屏与窗口化的切换,可以编写如下的代码:KeyboardState kbState = Keyboard.GetState();
MouseState mouseState = Mouse.GetState();
Boolean pressed = kbState.IsKeyDown(Keys.Enter);
Boolean pressed = (mouseState.LeftButton == ButtonState.Pressed);
if (this.GraphicsDevice.Viewport.Bounds.Contains(mouseState.X, mouseState.Y))
{
//TODO
}
this.IsMouseVisible = true;
graphics.PreferredBackBufferWidth = 1024;
graphics.PreferredBackBufferHeight = 768;
graphics.IsFullScreen = true;
KeyboardState kbState = Keyboard.GetState();
if (kbState.IsKeyDown(Keys.F))
{
graphics.ToggleFullScreen();
}