深入Managed DirectX9(八)
使用Managed DirectX编写游戏
选择游戏
虽然很多关于3D游戏编程的高级主题还没有讨论,但我们已经有足够的背景知识来写一个简单游戏了。这一章,我们将使用至今学过的知识,再加上一点点新的东西来创建游戏。
真正开始写游戏之前,最好先拟一份计划。我们需要确定写什么类型的游戏,它将有哪些最基本的特性,等等。考虑到目前的技术限制,自然不能写太复杂的游戏。这将是一个简单的游戏。在MS-DOS环境下,曾经有一个叫做“Donkey”的游戏,玩家控制着车不能撞到路上的donkey。听起来足够简单吧,我们将创建一个三维版本,并且用普通的障碍物来代替donkey。这个游戏叫做“躲避者(Dodger)”。
开始编码之前,需要花一点时间来策划和设计游戏。我们需要怎样的游戏,玩的时候来控制。Well,显然,要有一个Car类来控制交通工具。接下来,使用另一个类来控制障碍物将会很不错。除此之外,主要的游戏引擎类必须完成所有的渲染操作并把所有对象组织起来。
如果尝试商业游戏,那么大部分时间将会花在游戏创意上。游戏创意将会写成详细的文档,包括了游戏主题和特性的各种细节。本书的着重于讨论游戏的实际开发工作,而不是游戏发行和创意,所以我们将略过这一步。
通常开之发写还必须写完整的技术文档(technical specification)(简称为spec)。它包以适当的细节列出了所以类,以及需要实现的各种方法、属性。通常还包括表示对象之间关系的UML图。这份文档的目的是让你在编码前坐下来认真考虑程序的设计。由于本书聚焦于代码的编写,我们同样略过这一步。需要说明的是,强烈建议你在写任何代码前花点时间撰写技术文档。
编写游戏
现在可以打开VS创建项目了。创建一个名为Dodger的windows应用程序。使用DodgerGame代替代码中所有出现Form1的地方。添加对DirectX程序集的引用。创建私有的device成员,如下修改构造函数:
public DodgerGame()
{
this.Size = new Size(800,600);
this.Text = “Dodger Game”;
this.SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.Opaque,true);
}
这将会把窗口设置为800×600(注:实际代码中我将会创建一个全屏的游戏,另外如果现在运行程序,会发现我们创建了一个透明的窗口),设置窗口标题和样式(style),这样渲染代码才会正常工作。接下来修改程序的入口点:
static void Main() {详见源码}
这个应该很熟悉了吧,基本上就是之前每一章用来启动程序的代码。创建窗体、初始化图形引擎,运行窗体。在initializeGraphics内做如下改动:
private void InitializeGraphics() {详见源码};
创建了presentation parameters结构之后,确保有它有深度缓冲。这里有什么新内容呢?首先,保存了默认的适配器的序数号,接下来保存了creation flags,并把它的默认值设为software vertex processing。但是,现代图形卡都在硬件层实现了vertex processing。何必把宝贵的CPU资源用在显卡可以完成的任务上呢?答案是不需要这样做,但你不知道是否真的支持这种特性,于是有了接下来的代码。在真正创建device之前,需要先保存显卡的功能(capabilities,简称Caps),这样可以用来决定使用那一种flags创建device。因为你只是创建一个硬件设备,所以只储存这几个Caps就可以了。关于检查适配器所有Caps的内容回忆一下第二章吧。
还记得使用顶点缓冲时需要在重置设备之后重建缓冲吗?我们为device订阅了created事件。当device重置之后,设定device的所有默认状态,添加如下代码:
private void OnDeviceReset(object sender,EventArgs e) {详见源码};
(注意:类似于这里的代码,你可能会使用一个层(layer)来检查支持的灯光。这种情况下,先检查是否支持一盏灯,如果可以,则创建它。然后再用类似的方法检测是否支持第二盏灯。这样即使最差的情况你也能获得一盏灯光)
这里和前面学过的代码也很类似,通过projection fransform和view transform来设置摄像机。对于这个游戏来说,我们的摄像机不需要移动,所以只需要在重置设备之后设置一次就可以了(与设备相关的状态都会在重值之后丢失)。
环境光不是最好的选择,我们已经知道他不能产生真实的光影效果,所以方向光将是不错的选择。但并不能确定设备是否支持这种光源。创建了设备之后,就不需要再使用先前的Caps结构了,device会为你保留着这些信息。如果device支持方向光,而且支持一盏以上的灯光,你应该使用它;否则,使用默认的环境光。它虽然不真实,但总比黑色的场景要好吧。最后,重载OnPaint方法,:
protected override void OnPaint(PaintEventArgs e){详见源码};
这里没有什么新内容,当然你可以把背景改为任何你喜欢的颜色。现在已经为加载模型做好了准备。创建变量来储存.X文件中的赛道模型吧。
private Mesh roadMesh = null;
private Material[] roadMaterials = null;
private Texture[] roadTextures = null;
接下来修改一下前一章里的load mesh方法。最大的改变是将把它改为静态方法,因为不止一个类会调用它,同样把所有的材质和纹理作为参数来传递,而不是作为类成员来访问。添加如下代码:
public static Mesh LoadMesh(Device device,string file,ref Material[] meshMaterials,ref Texture[] meshTextures){详见源码};
这个方法前面已经深入讨论过了。使用这个方法来加载赛道模型,还需要在重置设备的事件里添加它,在OnDeviceReset最后加上如下代码:
roadMesh = LoadMesh(device,@"..\..\road.x",ref roadMaterials,ref roadTextures);
确定你已经把赛道模型和纹理文件复制到了源文件的目录下。这段代码将会加载模型以及纹理,并储存纹理、裁制以及模型。每一帧道路mesh都需要渲染很多次,因该创建一个方法来完成渲染工作。添加如下代码:
private void DrawRoad(float x, float y ,float z) {详见源码};
你应该还记得这个方法吧,它和我们之前使用的方法如此类似。把mesh变换为正确的位置然后渲染每一个子集。我们需要每次渲染两段赛道mesh:一段是赛车现在行驶的赛道,一段是即将行驶到的赛道。实际上我们的赛车并没与移动,而是赛道在移动。这样做的原因有两个:如果每一帧都移动赛车,那么还必须同时移动摄像机来跟上它。这些而外的计算实际上是不必要的。还有一个更重要的原因:如果赛车向前移动,而且玩家很厉害,那么赛车的位置可能会超出浮点值的范围,甚至导致溢出。因为我们的游戏世界并没有边界(游戏不会有终点),所以让赛车停留在原地,移动赛道。
自然,需要一些变量来控制赛道。添加如下代码:
public const float RoadLocationLeft = 2.5f;
public const float RoadLocationRight = -2.5f;
private const float RoadSize = 100.0f;
private const float MaxRoadSpeed = 250.0f;
private const float RoadSpeedIncrement = 0.5f;
private float RoadDepth0 = 0.0f;
private float RoadDepth1 = -100.0f;
private float RoadSpeed = 30.0f;
作为mesh的赛道模型是已知的,长宽各为100个单位。RoadSize常量就是赛道的长度,两个location常量标记了赛道两边的中点。最后两个常量用来控制游戏操作。最大速度让游戏每秒移动250个单位,每次加速多移动0.5个单位。
最后,设置两段赛道的深度。把地一段赛道设置为0,第二段紧跟着上一段赛道。添加绘制赛道的代码,使用这几个变量来绘制赛道。在BeginScene方法之后添加如下代码:
DrawRoad(0.0f,0.0f,RoadDepth0);
DrawRoad(0.0f,0.0f,RoadDepth1);
现在运行程序,可以看到已经正确的绘制了赛道,但是这条沥青的赛道看起来极度可怕。这种结果是由Direct3渲染计算像素的方式引起的。当一个texel要覆盖屏幕中的多个像素时,这些像素需要通过一个放大过滤器来补偿(magnify filter to compensate)。当几个texel需要被绘制为一个像素时,他们会通过一个缩小过滤器。两种情况下的默认过滤器是一个名为Point的过滤器,它将会使用texel最接近的颜色作为像素的颜色,因此导致了这种情况。
有很多种方法来过滤纹理,但是,device不一定支持。我们只需要一个可以在texel之间插值计算,让赛道纹理看起来比较平滑的过滤器就可以了。在OnDeviceReset方法里添加如下代码:
详见private void OnDeviceReset(object sender,EventArgs e)中的代码
如你所见,先检查设备在放大(magnification)和缩小(minification)上是否支持各向异性(anisotropic)的过滤器。如果可以,就使用它。不行的话,再检测是否支持线性(linear)过滤器。如果两者都不可用,那么只能什么都不作,保留这种粗糙的效果。假设你的图形卡能支持其中一种过滤器,那么现在可以看到效果要好多了。
赛道以及处在了屏幕的中间,但还没有移动。还需要一个方法来更新游戏状态,完成移动赛道,进行碰撞检测。应该再OnPaint方法一开始就调用这个方法(再clear方法前):
OnFrameUpdate();
以下则是这个方法的代码:
private void OnFrameUpdate(){详见源码}
整个游戏编写完之后会有很庞大的代码,但现在,我们所需的只是让路动起来而已。先忽略elapsedTime,这段代码所作的只是移动路面而已。最后还需要添加一个变量:
private float elapsedTime = 0.0f;
~~~~~~~~~~~~~~未完待续~~~~~~~~~~~~~
例子代码,参与讨论。