转自  http://dev.gameres.com/Program/Visual/DirectX/ManagedDirectX9Game_01.htm

       http://dev.gameres.com/Program/Visual/DirectX/ManagedDirectX9Game_02.htm

       http://dev.gameres.com/Program/Visual/DirectX/ManagedDirectX9Game_03.htm

       http://dev.gameres.com/Program/Visual/DirectX/ManagedDirectX9Game_04.htm

3D游戏编程,显然,这是一个很复杂的主题。首先,让我们来学习一些编写任何游戏都会用到的基础知识。把你的创意转变为真实的游戏是一种很有趣的经历。把你的想法雕琢为一个人们可以获得乐趣的游戏,是每个开发者的目标。

  所有的一切都来源于创意,游戏也是如此。游戏创意可以来自于生活中所有地方。也许你看到了另一个很好玩的游戏,但总觉得稍做修改它将会更好玩。也许你昨晚的梦就能创造一个完美的游戏。无论你的灵感来自于哪里,都必须先有了灵感再编写游戏。

  当然,对于我们即将要编写的游戏来说,你将使用我的创意。选择这几个游戏是有很多原因的,但主要原因还是和难度有关。无论如何,我们将会尽可能的覆盖所有你可能遇到的游戏类型。

  第一种类型是益智游戏(puzzle game)。经典的Tetris(俄罗斯方块)就是这类游戏的代表。几乎所有学习游戏编程的人都克隆过这个游戏,因此,跳过它,编写一些特别一点的东西。游戏中将有一个可以在板子上跳动的角色。板子由一系列的立方体构成,类似一个棋盘。每个立方体都有一种特定的颜色,当角色跳到这些立方体时,他将会变为另一种指定的颜色。当所有方块都和这一关制定的颜色相同时,这一关就算赢了。


设计细节
  当游戏创意浮现出来之后,就应该花一些时间把它写为游戏方案。并确保对所有可能遇到的问题都有解决方法。对于这个游戏来说,我们列出了一下条目作为方案的细节。

*名字叫做Blockers的益智游戏
*单人游戏
*全3D环境
*基于完成每一关的时间来计算得分
*每一关都由一系列相连的立方体构成,就像建立在棋盘上一样
*每个立方体都是单独的实心颜色
*当每一个立方体的颜色都和本关预定的结束颜色相同时,管卡结束
*每个立方体都有一个颜色列表,这个列表最多包含6种颜色,最少2种
*玩家通过调到另一个立方体来移动,同时,所跳到的立方体变为列表中的下一个颜色
*游戏开始时,颜色列表中只有2种颜色
*高级关卡通过增加颜色列表中的颜色来增减难度
*如果玩家通关了,难度将回到一开始的状态
*如果玩家不能在最大的制定时间完成本关,则游戏结束。

  这是否是一份毫无遗漏的文档了呢?也许不是吧。但他回答了大部分我们将如何编写游戏的问题。明白在开发之前,你不可能把所有性都设计好了是很重要的,随着开发和思考的深入,游戏将会需要越来越多的特性,但是,无论如何都不能贸然在没有考虑到底想完成些什么特性时就开始编码,这会导致更大的混乱。

  有了方案,就可以开始设计游戏中的对象模型了。你将有一个神奇的游戏引擎,由他来维护玩家信息、当前关卡、以及渲染的设备。渲染的设备用来完成所有美妙的绘图工作。玩家总是需要一些直观的表示,渲染设备会处理好这个工作。

  大多数的游戏都需要当前关卡的信息。当前的时间是最重要的,因为最终的得分和是否结束游戏都是由他来决定,因此,当前的管卡必须可以访问时间。

  实际的关卡将被储为文件,保存在程序的media目录中。当前的关卡必须是所存在关卡中的一个,所以关卡必须能访问这些文件。实际上,关卡只要追踪组成每一关的立方体列表就可以了。每一关最少有两个立方体,当然高级别的管卡中将添加大量立方体。

  虽然编写游戏不会很容易,但你看,要达到目标,所创建的对象并不多。另外,为了让游戏有具有可玩性,只要让高级关卡不会难到使玩家有挫败感就可以了。没有比让玩家苦恼的游戏更糟的东西。如果游戏不好玩,那么就不会有人玩它,就不会成为成功的游戏。


为什么需要3D游戏
  你需要认识到这个游戏(实际上是所有游戏)根本不“需要”作为全三维的场景来渲染。考虑到所有显示器都是平面的,再好看的3D场景都要映射为2D的图片。完全可以把游戏中所有可能的场景设置为一组2D的精灵(sprite),当然,这样需要更多的美工操作。

  假设你安装了DirectX SDK的任何版本,打开DirectX Sample Broeser,确保只选择了左边的C#选项,点击左上的Direct3D标题,找到Empty Project项目,并安装它。之后,在VS中打开项目。


  这样我们就创建了一个“空白”项目。(实际上它还绘制了一些UI控件,但先忽略他们)。但是,这里还没有任何3D 的物体,这一部分的内容是告诉你为什么我们需要编写3D游戏,因此,来绘制一些东西吧。

添加几行代码来显示一个缓慢旋转的茶壶。添加如下变量

private Mesh teapotMesh = null;
private Material teapotMaterial;

接下来创建茶壶和材质,在OnCreateDevice方法的最后添加如下代码:

teapotMesh = Mesh.Teapot(e.Device);
teapotMaterial = new Material();
teapotMaterial.DiffuseColor = new Colorvalue(1.0f,1.0f,1.0f,1.0f);

为了让茶壶更好看,接下来添加灯光,在OnResetDevice方法中添加代码:

e.Device.Lights[0].DiffuseColor = new Colorvalue(1.0f,1.0f,1.0f,1.0f);
e.Device.Lights[0].Direction = new Vector3(0,-1,0);
e.Device.Lights[0].Type = LightType.Directional;
e.Device.Lights[0].Enabled = true;

准备工作到这里就结束了。找到OnFrameRender方法,在BeginScene方法之后添加代码:

e.Device.Transform.View = camera.ViewMatrix;
e.Device.Transform.Projection = camera.ProjectionMatrix;
e.Device.Transform.World = Matrix.RotationX((float)appTime);
e.Device.Material = teapotMaterial;
teapotMesh.DrawSubset(0);

  运行程序,可以看到一个渲染的茶壶。茶壶在3D世界里有一段悠久的历史。在那个建模软件还不发达的时代,获得一个真实的3D模型相当困难,而茶壶就是最早供人们免费使用的模型之一。另外,茶壶有许多不错的特性适合于用来做测试:它有圆滑的曲面,可以显示光影渐变的效果,并且容易识别。


  需要注意的是,虽然茶壶是这个程序里唯一的模型,但我们并没有使用任何额外资源来省城模型,而是使用了Mesh类的一个静态方法。我们使用了最少的资源得到了一个不错的茶壶。

  对于3D版本来说,我们可以从任何角度观察茶壶。但是假设我们需要在2D世界里实现同样的效果呢?我们需要为茶壶旋转的每一个角度保留一张位图(就是360张图),如果还想让茶壶沿任意轴旋转,那么就需要4664600张不同的位图。那么对于虚幻竞技场这样的游戏来说,就算他什么都不干,只绘制2D精灵,也至少需要一张DVD来保存数据,同时还要有一大群艺术家花上几年时间来创建这些东西。

  如果你的艺术家可以创建高质量的3D模型,显然,你就可以使用相对很少的资源更加自由的创建场景 。可以用许多不同方法来渲染一个简单的模型,比如不同的灯光、缩放比例、位置、角度等等,而这一切使用2D来实现都是不切实际的。

  3D程序的这种自由性需要强大的处理能力来实现,为了满足这种处理能力,甚至形成了一整个产业。nVidia和ATI就是这个产业中的领导者。现代的图形卡有了飞速的创新,甚至在处理能力上超越了CPU。

  现在的图形卡(支持DirectX9的图形卡意味着他至少支持shader model2.0)每秒能渲染数百万三角形。不要担心,我们稍候会讨论关于着色器(shader)的内容。如果你想知道为什么需要那么多三角形,那么你需要知道三角形是构成3D模型的基本多边型。以下就是线框模式之下的茶壶模型。


  你看,整个茶壶都是由三角形构成。为什么使用三角形呢?因为它是唯一能够保证共面的闭合多边形,这样,在渲染时更容易计算。任何3D图形都可以用三角形模拟出来。

  至此,我们的游戏需要是3D的吗?当然不需要,你可以完全使用精灵来编写游戏,但这还有什么乐趣呢?大部分游戏编成的书籍都只涉及2D部分,但3D世界才是真正让游戏精彩的地方。我们即将开始三维之旅。


编写文档

  接下来,我们将来到编写游戏最重要的一个环节,编写文档。在一行代码都没有编写的情况下,我实在无法强迫你花一点时间来考虑可能遇到的问题。事实上,我遇到的所有年青游戏开发者在开发第一个游戏时都是直接开始编码。慢慢你才会明白快速的开始编码会带来很多麻烦。对于这个游戏,需要解决什么问题?当然,需要一个游戏引擎作为游戏的大脑。一个玩家对象,一个用于渲染的设备,以及一个维护关卡信息的方法。最常见的现实开发文档就是UML。如下图所示:


  如果你熟悉UML,那么各熟悉之间的关系应该是很明显的。如果不熟悉,就需要花点时间来看看这篇文档在说什么。首先,他把问题分成了组成游戏的几个逻辑组件。这里,四个对象分别是游戏引擎、玩家、关卡、方块列表,以及单独的方块。每个对象的属性和方法都列在了对象盒里,这样就可以快速的对每个对象有一个总揽。

  把图放到一边,我们到底需要做些什么呢?显然,需要有一个控制中心来控制所有操作。在这里,就是游戏引擎。注意看UML图,游戏引擎包含了用于渲染的device(从InitializeGraphics方法可以看出来)。游戏引擎需要知道以下东西:

*玩家对象
*当前关卡
*游戏是否结束
*如果是,那么玩家是否赢了当前关卡
*如果是,那么玩家是由通关了

  游戏引擎还需要保存其他信息,比如渲染的设备,以及一些其他对象,但这些都是私有方法,因此,不现示在UML中。接下来的对象是玩家,这是一个相当简单的类,我们只需要知道它的位置,同时,让他可以渲染它自己就可以了。在我们的引擎里,玩家更像一个抽象的概念而不是实际对象。这个对象主要是为了控制任何现实玩家。

  游戏引擎所需的其它信息都来自于levels对象。实际上这也是一个很简单的对象,他也只包含了几个其他对象,比较重要的就是blocks集合。每一个方块包含了在不同层次如何控制它自己的信息,包括可能的颜色列表,以及是否需要翻颜色。

  好了,有了这最最简单的文档,可以开始编码了。

 

理解框架

  首先创建工程,添加对DirectX程序集的引用。接下来,把sample framework添加到工程中。我们把这些文件放到一个单独的文件夹中,在解决方案管理器中点击右键---添加---新建文件夹,并把它命名为framework。右键点击新创建的文件夹,选择添加现有项,导航到SDK的\Samples\Managed\Common目录下,把每一个文件添加到项目中。

  好了,现在回到我们刚才创建的Form1.cs文件中来,可以看到大部分自动生成的代码都是用来创建Windows Form应用程序的。因此删除所有代码,并添加如下代码:

using System;
using System.Configuration;
using Microsoft.DirectX;
using Microsoft.DirectX.Direct3D;
using Microsoft.Samples.DirectX.UtilityToolkit;

public class GameEngine : IDeviceCreation
{
    /// <summary>
    /// Entry point to the program. Initializes everything and goes into a
    /// message processing loop. Idle time is used to render the scene.
    /// </summary>
    static int Main()
    {
        using(Framework sampleFramework = new Framework())
        {
            return sampleFramework.ExitCode;
        }
    }
}

  这里有三个需要注意的地方 。首先,我们只留下了一个修改过的main方法。由于其他代码都是窗体设计器为Windows Form程序生成的,所以完全可以删除它们。其次,代码现在还不能通过编译,应为GameEngine类还有两个接口没有实现。第三,这段代码实际上什么也没做。

  首先我们来实现IDeviceCreation接口,你将通过他来控制枚举和创建device。在这里,枚举的含义包括检查目标机器上有几块显卡、几个显示器、可以支持多少种显示模式、控制刷新率等等。

public bool IsDeviceAcceptable(Caps caps, Format adapterFormat,Format backBufferFormat, bool windowed)
{
    if (!Manager.CheckDeviceFormat(caps.AdapterOrdinal, caps.DeviceType,adapterFormat, Usage.QueryPostPixelShaderBlending, ResourceType.Textures, backBufferFormat))
        return false;
    if (caps.MaxActiveLights == 0)
        return false;
    return true;
}

public void ModifyDeviceSettings(DeviceSettings settings, Caps caps)
{
    if ( (caps.DeviceCaps.SupportsPureDevice) && ((settings.BehaviorFlags & CreateFlags.HardwareVertexProcessing) != 0 ) )
        settings.BehaviorFlags |= CreateFlags.PureDevice;
}

  第一个方法将在device初始化时调用,用来检查device最低能支持什么特性(capability)。当sample framework在系统上枚举设备时,他会对所找到的每一种可能组合调用这个方法。注意看这个方法是如何决定返回值的。他的第一个参数包含了大量关于制定设备的信息,可以帮你决定它是否是你希望创建的device类型。接下来的参数一个是关于后备缓冲的,另一个则是关于设备格式。最后一个参数则是检查是否支持窗口模式。虽然大多数游戏都是运行在全屏模式,但在窗口模式之下调试程序会方便一些。这个方法默认情况下将返回true,但他还做了两个特别的检查。首先,检查它是否支持alpha混合(创建游戏的用户界面将用到他)。其次,检查是否支持动态灯光----没有灯光物体看起来会很单调而且不真实,所以至少使用一个灯光。

  再来看第二个方法:在创建device之前调用它,来修改创建设备的参数。Setting参数包含了框架为device所制定的设置,你可以自由的修改这些设置。需要特别注意的是sample framework不会验证这些设置的有效性,因此,你需要自己来验证。

  在继续之前,还有一件事情要做。由于sample framework包含一些unsafe的代码块,因此,必须允许工程中包含不安全代码:


现在,可以使用框架来枚举设备了。首先,为GameEngine类添加一个构造函数,把从main方法中创建的sample framework实例作为参数:

private Framework sampleFramework = null;
public GameEngine(Framework f)
{
    // Store framework
    sampleFramework = f;
}

  在调用了sample framework之后,他所做的第一件事就是枚举系统设备。在工程中,打开之前添加的dxmutenum.cs文件。这个文件包含了枚举设备的所有代码。由于知道如何以及为什么枚举设备是很重要的,我们来仔细看一下这些代码。

  首先注意到Enumeration是不能被实例化的,塔顶每一个方法和成员也都是静态的。因为就目前来说,你的硬件图形设备在运行时不太可以改变,所以枚举的过程在程序一开始运行一次就可以了。

  大多数的枚举过程都是在创建device之前,通过sample framework调用Enumerate方法开始的。这个方法所接受的唯一参数,就是我们至今为止在GameEngine类中实现的接口。在枚举设备状态组合的时候,需要调用IsDeviceAcceptable方法来判断这个设备状态是否应该添加到正确的设备列表中。那么到底是如何来枚举设备的呢?实际上大多数功能都是通过Manager类来完成的。如果你熟悉普通的DirectX API,那么这个类实际上就是COM接口Idirect3D9的映射。(注:枚举设备的过程这里不再讲解,请看我以前翻译过的文章)

把所有符合框架要求的显示模式都保存到一个列表中,最后,通过实现Icomparer接口对他们进行排序。

public class DisplayModeSorter : IComparer
{
    public int Compare(object x, object y)
    {
        DisplayMode d1 = (DisplayMode)x;
        DisplayMode d2 = (DisplayMode)y;
        if (d1.Width > d2.Width)
            return +1;
        if (d1.Width < d2.Width)
            return -1;
        if (d1.Height > d2.Height)
            return +1;
        if (d1.Height < d2.Height)
            return -1;
        if (d1.Format > d2.Format)
            return +1;
        if (d1.Format < d2.Format)
            return -1;
        if (d1.RefreshRate > d2.RefreshRate)
            return +1;
        if (d1.RefreshRate < d2.RefreshRate)
            return -1;
        return 0;
    }
}

这里的算法很简单,大家自己看吧。保存了可用的显示模式之后,调用EnumerateDevices方法。

private static void EnumerateDevices(EnumAdapterInformation adapterInfo, ArrayList adapterFormatList)
{
    // Ignore any exceptions while looking for these device types
    DirectXException.IgnoreExceptions();
    // Enumerate each Direct3D device type
    for(uint i = 0; i < deviceTypeArray.Length; i++)
    {
        // Create a new device information object
        EnumDeviceInformation deviceInfo = new EnumDeviceInformation();

        // Store the type
        deviceInfo.DeviceType = deviceTypeArray[i];

        // Try to get the capabilities
        deviceInfo.Caps = Manager.GetDeviceCaps((int)adapterInfo.AdapterOrdinal, deviceInfo.DeviceType);

        // Get information about each device combination on this device
        EnumerateDeviceCombos( adapterInfo, deviceInfo, adapterFormatList);

        // Do we have any device combinations?
        if (deviceInfo.deviceSettingsList.Count > 0)
        {
            // Yes, add it
            adapterInfo.deviceInfoList.Add(deviceInfo);
        }
    }
    // Turn exception handling back on
    DirectXException.EnableExceptions();
}

  观察一下这段代码,你应该注意到并且记住两件事。猜猜是什么?如果答案是DirectXException类方法调用,那么恭喜,答对了。 DirectXException.IgnoreExceptions();方法关闭了Managed DirectX程序集中所有异常抛出。你可能会问这样做有什么好处,答案是性能的提升。捕捉和抛出异常是系统花费很大的操作,而这段代码有可能导致多个异常的抛出。你只希望快速的完成枚举,因此,暂时忽略所有异常,在这段代码结束的时候,再打开异常机制。虽然这里的代码很简单,但你可能会问问什么会导致异常。

  很高兴你问到了这个问题,那么我们就来仔细讨论一下吧。最常见的问题就是你的设备不支持DirectX 9。有可能你没有更新驱动程序,或者当前的驱动程序没有正确安装。也有可能是你的显卡太老了无法使用DirectX 9。通常PCI接口的显卡都不能很好的支持DirectX 9了。

  这段代码尝试获得关于显卡能力的信息,并为适配器枚举合适的组合。可用的设备类型有以下几种:Hardware, Reference, software。

  假设枚举时找到了合适的设备设置组合,就把他保存到一个列表中。Enumeration类为之后创建device保存了一些列表,观察EnumerateDeviceCombos方法。注意,如果IsDeviceAcceptable方法返回false,那么就忽略这种设备组合类型。

 

理解sample framework之事件处理

Framework类是sample framework中最重要的类,完成了创建窗体,初始化设备,创建命令行,事件处理(render loop)以及调节各种参数的任务。Framework类包含在dxmut.cs文件中。其中,比较特别的就是事件处理模型(或render loop)。

  为了获得高性能的渲染以及事件处理机制,framework类在初始化的方法中使用Device.IsUsingEventHandlers = false;关闭了事件处理模型。我们先来看看为什么默认的事件处理机制会导致性能的损失。默认情况下,Managed DirectX中的类在每创建一个资源时,都会为device订阅一些必然的事件。在最简单的情况下,每个资源(比如纹理或顶点缓冲)将会订阅Disposing事件,以及其他一些诸如DeviceLost和DeviceReset的事件。这个步骤在整个对象的生存期都会发生。但是为什么我们在程序中不需要这种行为呢? 

主要原因就是需要对这种行为付出一定代价,有些情况下,代价还会很大。我们举个例子来说明这一点。看看下面这段简单的伪代码:

SomeResource res = new SomeResource(device);
device.Render(res);

  这段代码看起来几乎是“无害”的。只是创建了一个资源,并且渲染它。当这个对象不再使用时,垃圾回收器应该会智能的清除它。但是,这个想法是完全错误的。当创建新资源时,至少需要对device订阅一个事件,允许device正确的清除它。这种订阅实际上是一把“双刃剑”。

  首先,订阅事件时需要分配(allocation)EventHander对象完成实际的订阅工作。虽然这种分配的代价很小,但是,我们稍候就会看到,就算是很小的分配也会迅速膨胀。第二,订阅事件之后,资源和设备之间就有了一个硬连接(hard link)。因此,在垃圾回收器的眼里,这个对象在device的生存期里仍然处于使用状态,并且没有取消事件的订约。设想一下,这代码如果在渲染每帧的时候都运行一次;再设想一下,你的程序每分钟需要进行上千次渲染,并且程序已经运行了两分钟。结果,你创建了120000个在device生存期间不会被回收的对象,以及120000个事件句柄。所创建的这些对象不但会迅速消耗内存,而且会导致额外的垃圾回收,严重影响程序性能。如果你的资源都位于显存中,那么很快就会耗尽显存。

  这里,我们还没有考虑当最后释放设备时可能发生的情况。在前面的例子中,当释放device时,首先触发Disposing事件,此时,有120000个监听者订约了这个事件。你是否已经考虑到,调用这个巨大的事件句柄列表会将花费很多时间?实际上这将会花去几分钟时间,并且让用户认为程序已经处于死锁状态。

  因此,最好只在最简单的Direct3D程序中使用Managed Direct3D内建的事件处理机制。在任何需要考虑内存容量和性能的应用中(比如游戏),都必须避免这些处理过程。

  接下来,我们来看看framework中是如何实现事件处理模型的。实际上,SDK中的事件处理模型也是几经修改,现在使用的方法最早由Tom Mille 在他的bolg上贴出了具体的实现:

public void MainLoop()
{
    // Hook the application's idle event
    System.Windows.Forms.Application.Idle += new EventHandler(OnApplicationIdle);
    System.Windows.Forms.Application.Run(myForm);
}

private void OnApplicationIdle(object sender, EventArgs e)
{
    while (AppStillIdle)
    {
        // Render a frame during idle time (no messages are waiting)
        UpdateEnvironment();
        Render3DEnvironment();
    }
}

private bool AppStillIdle
{
    get
    {
        NativeMethods.Message msg;
        return !NativeMethods.PeekMessage(out msg, IntPtr.Zero, 0, 0, 0);
    }
}

And the declarations for those two native methods members:

[StructLayout(LayoutKind.Sequential)]
public struct Message
{
    public IntPtr hWnd;
    public WindowMessage msg;
    public IntPtr wParam;
    public IntPtr lParam;
    public uint time;
    public System.Drawing.Point p;
}

[System.Security.SuppressUnmanagedCodeSecurity] // We won't use this maliciously
[DllImport("User32.dll", CharSet=CharSet.Auto)]
public static extern bool PeekMessage(out Message msg, IntPtr hWnd, uint messageFilterMin, uint messageFilterMax, uint flags);

  这里,通过平台调用,使用了一些Win32 API。首先,在main方法中订阅了Application.Idle事件。在程序处理完了所有消息(如果不熟悉消息,那么可以把消息理解为系统定义的一个32位的值,他唯一的定义了一个事件,向Windows发出一个通知,告诉应用程序某个事情发生了。例如,单击鼠标、改变窗口尺寸、按下键盘上的一个键都会使Windows发送一个消息给应用程序。)之后,将会触发Application.Idle事件。我们的目标是让程序尽可能快,尽可能多的处理消息,同时不打断wendows消息的输入。

  在OnApplicationIdle事件处理程序中,使用的了简单的Win32 API PeekMessage来检查程序是否有任何未处理的消息。这里使用while循环的原因是保证在处理完所有消息,同时消息队列还为空时,只触发一次Application.Idle事件。所以,我没一直循环,直到有新的消息,然后,跳出循环。普通的.Net WinForm窗体消息句柄将会选择未处理的消息。

  接下来,我们将使用框架,来显示一些物体了(源码请参考SDK中的empty project)。由于框架已经为我们完成了以上工作。我们只需要选择订阅那些事件就可以了。Sample framework通过这些事件通知应用程序关于改变设备、用户输入以及各种窗口消息。这些事件是可选的,但是,如果你没有设置,那么框架就不会为你处理相应的事件。在main方法中,创建了GameEngine对象之后,添加代码:

sampleFramework.Disposing += new EventHandler(blockerEngine.OnDestroyDevice);
sampleFramework.DeviceLost += new EventHandler(blockerEngine.OnLoseDevice);
sampleFramework.DeviceCreated += new DeviceEventHandler(blockerEngine.CreateDevice);
sampleFramework.DeviceReset += new DeviceEventHandler(blockerEngine.OnResetDevice);
sampleFramework.SetWndProcCallback(new WndProcCallback(blockerEngine.OnMsgProc));
sampleFramework.SetCallbackInterface(blockerEngine);
(注意,虽然在SDK October 2005的文档中还可以查到framework对象的SetKeyboardCallback方法,但实际上这个方法已经被删除了,老版本的SDK示例中使用了整个方法。)

  这一段代码作了很多工作,首先,为四个事件订阅了事件处理程序,分别是创建设备,失去设备,重置设备,销毁设备。我们将在后面实现这些事件处理程序。SetWndProcCallback方法订阅了处理windows消息的方法。随后,使用当前game engine实例作为参数,调用SetCallbackInterface方法。之后,编写事件处理程序:

private void OnCreateDevice(object sender, DeviceEventArgs e)
{
    SurfaceDescription desc = e.BackBufferDescription;
}

private void OnResetDevice(object sender, DeviceEventArgs e)
{
    SurfaceDescription desc = e.BackBufferDescription;
}

private void OnLostDevice(object sender, EventArgs e)
{
}

private void OnDestroyDevice(object sender, EventArgs e)
{
}

public IntPtr OnMsgProc(IntPtr hWnd, NativeMethods.WindowMessage msg, IntPtr wParam, IntPtr lParam, ref bool noFurtherProcessing)
{
}

  由于之前的SetCallbackInterface需要接收一个IframeworkCallback的变量作为参数,但是我们的game engine类并没有实现这个类,所以添加以下代码:

public class EmptyProject : IFrameworkCallback, IdeviceCreation

实现这个接口所定义的方法:

public void OnFrameMove(Device device, double appTime, float elapsedTime)
{
}
public void OnFrameRender(Device device, double appTime, float elapsedTime)
{
}

哦,框架性的东西总算是弄的差不多了。在SetCallbackInterface之后加上以下代码:

try
{

    sampleFramework.SetCursorSettings(true, true);
    sampleFramework.Initialize( false, false, true );
    sampleFramework.CreateWindow("haha");
    sampleFramework.Window.KeyDown += new System.Windows.Forms.KeyEventHandler(blockerEngine.onKeyEvent);
    sampleFramework.CreateDevice( 0, true, Framework.DefaultSizeWidth, Framework.DefaultSizeHeight, blockerEngine);
    sampleFramework.MainLoop();

}
#if(DEBUG)
catch (Exception e)
{
    sampleFramework.DisplayErrorMessage(e);
#else
    catch
    {
        // In release mode fail silently
#endif
        // Ignore any exceptions here, they would have been handled by other areas
        return (sampleFramework.ExitCode == 0) ? 1 : sampleFramework.ExitCode; // Return an error code here
    }

    return sampleFramework.ExitCode;
}

}

  现在运行程序看看,虽然只是一个蓝色的窗口,但是我们背后所搭建的框架已经可以实际应用到一个游戏之中了。为了让程序开起来有一点点交互,我们还订阅了键盘事件,通过空格键可以改变程序的颜色。


使用Managed DirectX创建三维地形

使用Height Map作为输入
  首先,什么是高度图(Height Map)呢?所谓高度图实际上就是一个2维数组。创建地形为什么需要高度图呢?我们这样考虑,地形实际上就是一系列高度不同的网格而已,这样数组中每个元素的索引值刚好可以用来定位不用的网格(x,y),而所储存的值就是网格的高度(z)。正是由于这个简单的映射关系,最常见的地形生成方法都使用高度图作为输入数据。同时,为了减小数组的尺寸,通常使用Byte类型来保存高度值,因此,地形中最低点将用0表示,而最高点使用255表示(当然,这样做可能会出现一些问题,比如,地形中大部分区域的高度差别都不大,但是有少数地方高度差特别大时,不过大多数情况下这个系统都能运行的很好)。使用2D Byte数组的另一个好处就是我们高度图刚好可以用一张灰度位图(grayscale bitmap) 来表示。对于位图中的每个像素来说,同样使用0~~255之间的值来表示一个灰度。这样,我们又能把不同的灰度映射为高度,并且用像素索引表示不同网格。

  那么如何来创建高度图呢?有两种方法:直接使用程序创建2D数组或者使用其他绘图软件创建灰度位图。先来看看两种方法的优缺点。直接创建数组,通过特定算法填充每个元素的值(只为每个元素赋随即值是不可行,这样会导致你的地面看起来极度不真实,不连续的高度值可能创建出很扭曲的地形。),你不需要任何额外的工具就能创建地形。但是,通过这种方法创建的地形基本是随机的,虽然可以通过调节算法的参数控制大概的地形形状,却不能精确控制每个点应该凹下还是凸起。而使用灰度图,你不必掌握复杂的地形生成算法,可以把3维软件建好的地形模型渲染为灰度图,也可以使用通过卫星采样的图片作为灰度图。我们的示例程序将使用后一种方法,不过首先,我们还是来看看完全使用程序生成地形的算法。


使用Midpoint Displacement方法生成高度图
  这里我们介绍一种比较常用,也比较简单的地形生成算法,称为Midpoint Displacement中点偏移算法。使用这个方法,我们先创建一张平坦的高度图,然后再来升高或降低不同的网格创建随机地形。为了避免生成的值是完全没有规则的,我们先把整个平面分为4个正方形区域,接下来重复对这四个正方形进行同样的分割,同时,调整每个正方形顶点的高度。随着细份层次的增加,相应减少顶点高度调整的幅度。 


  使用[0,255]之间的浮点值来进行调整,以保证最后能用8位的灰度值来表示所有高度。每一步,都在一个确定范围内产生一个随机值来作为顶点偏移值。对于第一步来说,随机值将在[-128,128]之间(为了方便说明,我们把这个随机值范围记为[-delta,delta]产生,并且赋给上图左边的A,B,C,D四个顶点。接下来,用虚线把它分为4个小区域,这将创建5个新的顶点。计算每个新顶点所在边两个顶点高度的平均值作为这个点的基准值(比如 把点A和B的高度平均值作为点1的基准值),其中,点5的基准值是由四个顶点A,B,C,D的平均值来决定的。再计算[-delta,delta]之间的一个随机值,对基准值进行偏移,作为这个点的最终值。5个点的值都计算完毕之后,我就调到下一阶段,使用同样的方法,计算个顶点值,如上图右边所示。

  为了引导地形的产生,再把delta和一个缩放因子相乘。我们把这个因子称为roughness,它是一个1~0之间的值,这样,每个阶段都会减小delta的值。

delta = delta * roughness

roughness的值越大,地形起伏就越明显,而越小,相应的地形也就越平坦。


使用Perlin Noise生成高度图
  任何没有讨论噪声函数的程序地形算法都是不完整的。最重要的噪声函数就是Perlin Noise。他几乎是现代图形软件包生成各种火焰,云彩,奇形怪状的岩石,以及树木和大理石表面等许多应用的基础。这里不对Perlin Noise的理论做详细介绍,我们主要看看如何使用它为我们的地形添加噪声。

  Perlin噪声可以适用于任何维度的空间,但这里我们只讨论二维的情况。本质上,2D Perlin噪音就是对每个网格顶点法线的一种插值,来仔细看看这个技术吧。


  首先,使用网格把整个图片划分为几个不同部分。如上图所示,我们使用了一个4X4的网格来划分整个图片。这里,网格的多少控制着噪声的复杂性。网格越多,噪声越密集(tiger),类似于电视没有信号时显示出的雪花点;而网格越少,噪声的波形就越明显,类似于云朵的效果。

  对于每个网格顶点我们都分配一个随机法线(normal vector)。这些法线实际上就是一些指向不同方向的单位矢量而已。这里,常见的方法是创建一张有256个指向不同方向(形成一个圆周)的向量查找表。然后为每个网格随机分配一个向量,如上图所示。

  对于图片中的每个像素来说,我们先找到包含它的网格单元。然后,再创建4个从网格顶点指向所要计算的像素的方向矢量,如下图所示。现在,每个网格顶点有2个向量:一个随机的单位向量以及一个指向像素的方向向量。计算每对向量的点积,把它作为每个网格顶点的梯度高度值(scalar height value)。接下来,混合这4个值决定所计算像素的高度。这里,不同的混合方法可以产生不同效果,最常见的就方法就是通过目标像素与每个顶点位置的权重来计算。


我们将执行3次混合操作。首先需要计算混合权重。使用如下公式:

W = 6t^5 – 15t^4 + 10t^3 (^符号表示幂运算)

其中w表示权重,t根据需要替换为x或y值。这个方法与最早Perlin提出的公式(w = 3t^2 – 2t^3)有些区别。它虽然计算起来比较慢,但效果要好得多。
首先,计算x方向上的权重,使用公式:

V = Ca(w) + Cb(1-w)

  混合网格上边的两个顶点。其中Ca和Cb分别为上面两个顶点的梯度高度值,w是上一个公式计算出的权重值。然后,使用同样的方法混合下面两个顶点。最后,使用前两部混合的结果,以及y方向上的权重再进行一次混合。最后为这个像素计算出的高度值位于[0,1]之间,我们再把它缩放为相应的灰度值。

  举个例子,假如网格上边两个顶点的坐标分别为Ca[2,0]和Cb[8,0],梯度高度值分别为h0和h1,所求像素位置为[4,2],那么两个顶点指向这个像素的矢量就是:

Vector2 d0(4 -2,2-0)
Vector2 d1(4-8,2-0);

X轴方向的权重就为:

Sx = 6*d0.x^5 – 15d0.x^4 + 10d0.x^3

相应的插值就为:

avgX0 = h0*Sx + h1(1 –Sx)

如果下面两个顶点的插值为avgX1,则最后的插值就是:

Result = avgX0 * Sy + avg2(1- Sy)

通常情况下,为了获得真实的地形,会选取不同网格粒度,分别对图像进行多次Perlin噪音处理,最后把这些处理过的图加到一起,获得最终结果。


生成地形
  现在来看看如何把高度图转变为为多边形网格。一开始就说过,把高度图中像素的x,y值转换为顶点的x,y值,把像素的颜色值转换为顶点高度。我们可以把这些值缩放为所需要的尺寸。

  每2X2个像素就对应着2X2个顶点,同时可以组成2个三角形。可以把把顶点数据储存为一个简单的(x,y,z)列表,三角形数据储存为三个索引值一组的顶点列表。这两个列表之后就转变为顶点缓冲和索引缓冲。

public class Terrain
{
    private Device device;
    private VertexBuffer vb;
    private IndexBuffer ib;
    private int numVertices, numIndices, numTriangles;
    //保存从高度图中提取的数据
    float[,] heights;
    //地形大小
    private float terrainSize;

    public unsafe Terrain(Device d,float Min, float Max,float terrainSize)
    {
        device = d;
        //加载高度图
        Bitmap heightMap = new Bitmap(@"..\..\heightmap.bmp");
        //根据位图大小创建数组
        heights = new float[heightMap.Width,heightMap.Height];
        //锁定数据
        BitmapData data = heightMap.LockBits(new Rectangle(0,0,heightMap.Width,heightMap.Height, ImageLockMode.ReadOnly,PixelFormat.Format24bppRgb);
        //获得位图中第一个像素的地址
        byte* p = (byte*) data.Scan0;
        //遍历位图,获得最高和最低点的灰度值
        byte lowest = 255;
        byte hightest = 0;
        for(int i=0;i<heightMap.Width;i++)
        {
            for(int j=0;j<heightMap.Height;j++)
            {
                if ( *p < lowest)
                    lowest = *p;
                if( *p > hightest)
                    hightest = *p;
                //由于每个像素是24位,而指针是8位,所以+3指向下一个像素
                p += 3;
            }
        }
        //填充数组,max表示地形最高点的位置,min标志最低点。
        p = (byte*) data.Scan0;
        for(int i=0;i< heightMap.Width;i++)
        {
            for(int j=0; j< heightMap.Height; j++)
            {
                heights[i,j] = (float)(*p - lowest) / (float)(hightest - lowest) * (Max - Min) + Min;
                p += 3;
            }
        }
        heightMap.UnlockBits(data);
        //计算顶点,索引,三角形数量
        numVertices = heightMap.Width * heightMap.Height;
        numIndices = 6 * (heightMap.Width - 1) * (heightMap.Height - 1);
        numTriangles = 2 * (heightMap.Width - 1) * (heightMap.Height - 1);
        //创建顶点数组
        Vector3[] verts = new Vector3[numVertices];
        int[] index = new int[numIndices];
        int x = 0;
        int n = 0;
        float dx = terrainSize / (float) heightMap.Height;
        float dy = terrainSize / (float) heightMap.Width;
        //填充顶点数组
        for ( int i = 0; i < heightMap.Height; i ++)
        {
            for ( int j = 0; j < heightMap.Width; j ++)
            { 
                verts[i*heightMap.Width+j] = new Vector3((float)j*dx -terrainSize/2f,heights[j,i],(float)i*dy -terrainSize/2f); 
            }
        }
        //填充索引数组
        for ( int i = 0; i < heightMap.Width-1; i ++)
        {
            for ( int j = 0; j < heightMap.Height-1; j ++)
            {
                x = i * heightMap.Width + j;
                index[n++] = x; 
                index[n++] = x+1;
                index[n++] = x+heightMap.Width+1;
                index[n++] = x;
                index[n++] = x+heightMap.Width;
                index[n++] = x+heightMap.Width+1;
            }
        }
        //设置顶点以及索引缓冲
        vb = new VertexBuffer(typeof(Vector3),numVertices,device,Usage.None,VertexFormats.Position,Pool.Default);
        vb.SetData(verts,0,0);
        ib = new IndexBuffer(typeof(int),numIndices,device,Usage.None,Pool.Default);
        ib.SetData(index,0,0);
    }

    public void DrawTerrain()
    {
        device.VertexFormat = VertexFormats.Position;
        device.SetStreamSource(0,vb,0);
        device.Indices = ib;
        device.Transform.World = Matrix.Translation(0,0,0);
        device.DrawIndexedPrimitives(PrimitiveType.TriangleList,0,0,numVertices,0,numTriangles);
    }
}

  好了,看看我们的工作成果吧,还不错把。源码中我们使用了一张位图作为高度图。

  当然,这只是初级的地形技术而已,我们没有为地形贴纹理,顶点没有法线信息,以至于不能使用灯光照亮他,另外,也没有进行任何LOD处理。下一次,我们将仔细讨论这些问题。

posted on 2011-11-02 14:48  Blanche  阅读(1332)  评论(1编辑  收藏  举报