SlimDX-游戏开发-全-

SlimDX 游戏开发(全)

原文:zh.annas-archive.org/md5/240b9d1a7be92353bff31ef89d2341e1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是使用 SlimDX 快速上手游戏编程基础的指南。SlimDX 是 DirectX 的管理包装器,允许我们在 C#(发音为 C Sharp)等托管语言中使用 DirectX 的全部功能。这是一个巨大的优势,因为与 C++等底层语言相比,托管语言如 C#更易于工作,这提高了生产力,因为许多事情可以更快地完成。

阅读本书时,您将经历从创建基本框架,到在这个框架中构建您未来的所有游戏开发项目,再到处理用户输入、2D 图形、声音和 3D 图形的旅程。到本书结束时,您将发现游戏开发这些主要领域的所有基础知识,足以让您开始制作第一个基本游戏。

本书涵盖的内容

第一章, 入门,通过创建游戏窗口和本书其余部分的工作基本框架,使用 SlimDX 开始,并涵盖了 SlimDX 的一些基础知识。

第二章,响应用户输入,解释了如何使用 DirectInput 和 XInput 处理用户输入,使我们的游戏发生动作。

第三章,渲染 2D 图形,介绍了使用 Direct2D 绘制 2D 图形,以创建快速 2D 技术演示或 2D 游戏。

第四章,添加声音,介绍了如何使用 DirectSound 和 XAudio2 将声音和音乐添加到我们的游戏中。

第五章,渲染简单 3D 图形,深入 3D 图形的世界。我们将学习 3D 图形的基础知识以及如何使用 Direct3D 将它们显示在屏幕上。

第六章,下一步该做什么,通过讨论下一步如何继续学习游戏开发的艺术,以及一些其他重要主题的简要讨论,来结束全书。

本书所需的内容

要与本书一起工作,您需要两款软件。第一款是 Visual Studio 2012,第二款是 SlimDX 的 2012 版本。第一章的前两节,入门,将指导您安装这两款软件以及从哪里下载它们。

本书提供的图形和声音资源包含在可下载的代码中,因此您不需要任何软件来创建它们。

本书面向的对象

这本书是为任何热爱电子游戏并渴望从头开始创建自己游戏的人而写的。它适合那些认为游戏真正是一种艺术形式,将众多其他艺术(如图形、声音、音乐、故事、关卡设计和角色设计)融合成一个最终产品的人。它也适合那些喜欢编程的人,因为编程是创建电子游戏的一个重要方面。

拥有一些基本的编程经验将是有益的,但这不是必需的。这本书试图解释得很好,即使你对这个主题没有经验,你也会理解正在发生的事情以及为什么会发生。我们将在这本书中使用 C#,这是一种相对容易学习的语言。

所以基本上,如果你对视频游戏有热情,并且认为在计算机中创建自己的虚拟 2D 或 3D 世界听起来非常酷,那么这本书就是为你准备的。它将帮助你学习游戏开发中主要编程领域的基础知识,这样你就可以开始制作你的第一个基本游戏。

术语

在这本书中,你会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“我们可以通过使用using关键字来包含其他 SlimDX 组件。”

代码块将如下所示:

public virtual void FormClosed(object o, FormClosedEventArgs e)
{
  if (!m_IsDisposed)
    Dispose();
}

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

public UserInput()
{
  InitDirectInput();
 InitXInput();

  m_KeyboardStateCurrent = new KeyboardState();
  m_KeyboardStateLast = new KeyboardState();

  m_MouseStateCurrent = new MouseState();
  m_MouseStateLast = new MouseState();

  m_Joy1StateCurrent = new JoystickState();
  m_Joy1StateLast = new JoystickState();

 m_Controller1StateCurrent = new Gamepad();
 m_Controller1StateLast = new Gamepad();
}

新术语重要词汇将以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的单词,在文本中会这样显示:“点击安装开发者 SDK按钮。”

注意

警告或重要注意事项将以如下所示的框中显示。

小贴士

小技巧和窍门看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们来说很重要,以便我们开发出真正能让你受益的标题。

要发送给我们一般性的反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。如果你在某个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 书籍的骄傲拥有者了,我们有一些事情可以帮助你从你的购买中获得最大收益。

下载示例代码

您可以从您在www.packtpub.com的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

错误更正

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。任何现有勘误都可以通过从 www.packtpub.com/support 选择您的标题来查看。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。

问题

如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章。入门

所以也许您已经玩过 XNA Game Studio,并且已经知道它可以帮助您相对快速地开始一个小游戏。随着您对其越来越熟悉,您开始发现它的局限性。您更希望拥有 DirectX 的全部功能,在 C# 中使用,因为 C# 比使用 C++ 更容易工作。有没有一种替代 XNA 的方法可以提供这种功能?答案是肯定的。SlimDX 将在 C# 中为您提供这种功能。它还为您提供了使用不同版本的 DirectX 的能力。这意味着您可以使您的游戏支持多个 DirectX 版本,以便它们可以在更多计算机上运行,扩大您的潜在用户群。

在本章中,我们将涵盖以下主题:

  • 设置 Visual Studio 2013 Express 和 SlimDX

  • 创建我们的初始框架

  • GameWindow 类和 GameLoop 方法

  • 测试我们的游戏窗口

设置 Visual Studio 2013 Express

我们将使用 Visual Studio 2013 Express,因为它可以从微软免费获得。

这里是设置 Visual Studio 2013 Express 的步骤:

  1. 要下载 Visual Studio 2013 Express,您可以访问微软网站:www.visualstudio.com/downloads/download-visual-studio-vs

    该网站将显示所有可用的 Visual Studio Express 版本。

  2. 您将必须选择 Visual Studio Express 2013 for Windows Desktop,因为我们专注于 PC 开发。点击它以展开页面上的部分,然后点击 立即安装 链接以开始下载安装程序(wdexpress_full.exe)。

  3. 现在您已经下载了 Visual Studio 2013 Express 的安装程序,是时候安装它了。只需双击安装文件即可开始安装过程。

  4. 当您启动 Visual Studio 2013 Express 时,您将得到一个窗口,要求您使用您的微软账户登录。这是您将用于登录您的 Hotmail 账户或您的 Xbox 360(如果您有的话)的账户。

  5. 登录后,Visual Studio 2013 Express 将注册到您的微软账户。这是一个比以前更顺畅的过程。

设置 SlimDX

接下来,我们需要下载并安装 SlimDX。您可以从其官方网站获取 SlimDX:www.slimdx.org.

按照以下步骤设置 SlimDX:

  1. 一旦您进入 SlimDX 网站,点击页面顶部的 下载 选项卡。这将带您进入下载页面。

  2. 在撰写本文时,2012 年 1 月发布的 SlimDX 是最新版本。为了创建基于 SlimDX 的软件,我们需要获取 SlimDX 开发者 SDK

  3. 因此,转到页面标题为开发者 SDK的部分。这里有一段简短的段落和一个写着安装开发者 SDK的橙色按钮。如短段落所示,此 SDK 包含所有 SlimDX 二进制文件(编译后的 SlimDX 软件)、文档和示例。SDK 还支持 32 位和 64 位系统。单击安装开发者 SDK按钮。

  4. 这将打开一个包含顶部链接到名为SlimDX SDK (January 2012).msi的文件的第二个网页。单击此链接开始下载 SlimDX 开发者 SDK。

  5. 下载完成后,只需双击安装文件即可开始安装过程。安装程序完成后,点击完成按钮关闭它。我们现在可以开始编写我们的第一段代码了!

创建框架

为了让事情变得简单,我们将创建一个框架项目,该项目将包含我们在众多演示项目中将使用的代码。它将是一个类库,将包含我们的引擎代码。这是一种不特定于某个游戏的代码,而是设计成可以在多个游戏开发项目中重复使用的。如果这听起来很复杂,请不要担心。实际上,这非常容易做到。我们将使用游戏代码这个术语来指代特定于某个游戏的代码。

小贴士

您应该始终尽可能将您的引擎代码与您的游戏代码分开。这增加了引擎代码的可重用性,使您能够更容易地在多个游戏开发项目中使用它。这还可以在未来的项目中节省您大量时间,因为您不必每次都从头开始。

现在我们准备创建一个 Visual Studio 项目,用于存储我们的框架代码和资源:

  1. 如果您还没有打开,请打开 Visual Studio Express。

  2. 开始页面的左侧单击新建项目...链接,或从文件菜单中选择新建 项目...;无论哪种方式,您都会进入新建项目窗口。

  3. 我们需要选择我们希望创建的项目类型。在这种情况下,我们首先需要在左侧列中的Visual C#类别上单击。屏幕将改变以显示窗口中心部分的 C#项目类型。

  4. 现在选择窗口中间显示的类库。类库正是如此,一个类的库。尽管如此,它本身不能执行。

  5. 在窗口底部的名称文本框中输入项目的名称。我们将把这个项目命名为SlimFramework

  6. 如果您还没有准备好,您需要在您的计算机上某个位置创建一个文件夹来存储您的作品。然后,在以下截图所示的窗口底部的位置文本框中指定该文件夹:创建框架

    新建项目窗口

  7. 您可以单击浏览按钮打开文件夹浏览器窗口,您可以使用它来指定您想要保存到的文件夹。

  8. 保持为解决方案创建目录复选框选中,这样 Visual Studio 将创建一个子文件夹来放置此项目。你的新建项目窗口应该看起来像之前的截图。

  9. 点击确定按钮以创建SlimFramework项目。Visual Studio 将创建项目并显示Class1.cs文件。目前它只是一个空类。

要制作一个游戏,我们首先需要一个窗口来显示我们的游戏。因此,我们将通过创建一个简单的游戏窗口类来开始我们的框架。

按照以下简单步骤操作:

  1. 解决方案资源管理器窗格中选择Class1.cs文件。解决方案资源管理器窗格位于 Visual Studio 窗口的右上角,允许我们查看项目中的文件。

  2. 如果它不在那里,你可以通过打开视图菜单并选择解决方案资源管理器来打开它。

  3. 右键点击Class1.cs文件,将其重命名为GameWindow.cs

  4. 你可能会收到一个消息框询问你是否想要也将代码元素Class1的所有引用重命名。如果你点击,它将替换代码中所有Class1的出现,以新的名称,这样你的代码就不会因为重命名了类而损坏。通常你需要点击来完成这个操作。

  5. 在我们开始创建这个类之前,我们需要向项目中添加一些引用。要完成这个操作,在解决方案资源管理器窗格中右键点击引用标题,并选择添加引用...

  6. 引用管理器窗口将出现。当前选中的是框架类别,这是可以的,因为我们需要的两个引用都在这个类别中。

  7. 滚动列表,直到找到System.Windows.Forms。会出现一个复选框用于高亮显示的扩展名。勾选此复选框,因为我们想添加对这个扩展名的引用。这在上面的截图中有展示:创建框架

    添加 System.Windows.Forms 引用

  8. 现在,向上滚动以找到System.Drawing,并在其复选框中勾选。

  9. 现在我们需要在左侧列中选择扩展类别。这将导致窗口中央的列表显示扩展。

  10. 滚动直到你找到列表中的SlimDX。你会发现有两个版本。你必须选择版本 4,如以下截图所示:创建框架

    添加 SlimDX 引用

  11. 点击确定按钮,Visual Studio 将为我们添加所有指定的项目引用。

如果你现在展开解决方案资源管理器窗格中的引用标题,你将看到SlimDX现在出现在我们项目使用的引用列表中,同样还有System.Windows.FormsSystem.Drawing。现在我们已经添加了引用,我们将在项目中使用 SlimDX。

GameWindow 类

GameWindow 类将提供基本的游戏窗口功能。它将提供我们希望在游戏窗口中拥有的所有基本属性,并且它将被用作基类。在这本书中,我们不会创建很多游戏窗口子类,但这里的想法是,你可以为不同类型的游戏窗口创建不同的子类。例如,你可以有一个用于 DirectX 10 的游戏窗口类,以及一个用于 DirectX 11 的游戏窗口类。

我们需要为 GameWindow 类实现的主要事情是一个构造函数来初始化它,游戏循环UpdateScene()RenderScene() 方法,以及当窗口关闭时的清理代码。游戏循环是一个本质上游戏引擎核心的方法。只要游戏在运行,它就会被反复调用。它每帧调用一次,以运行使我们的游戏世界中的一切发生代码。它调用 UpdateScene() 方法,该方法更新我们游戏世界中的对象。例如,此方法将调用物理系统来模拟我们游戏世界中移动对象的物理。

一旦 UpdateScene() 完成更新我们游戏世界中所有对象的州,游戏循环就会调用 RenderScene() 方法来绘制当前帧。所以最终,游戏循环是逐帧模拟和绘制游戏世界的。每次调用时,它都会模拟另一个帧。

电子游戏由帧组成,就像电影一样,只不过在电子游戏中,每个帧都是由计算机实时生成的。理想情况下,我们希望游戏至少以 30 FPS(每秒帧数)运行,以便视频流畅。如果帧率太低,游戏的视频会变得不连贯,或者更糟糕的是,游戏可能无法玩。让我们开始实现我们的 GameWindow 类。首先,我们需要在 GameWindow.cs 文件的开头添加一些 using 语句,以便我们可以使用由 SlimDX 和 .NET 框架定义的一些类:

using System;
using System.Windows.Forms;
using System.Diagnostics;
using SlimDX;
using SlimDX.Windows;

提示

你可以从你购买的所有 Packt 书籍的账户中下载所有示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给你。

由于空间限制,本书中的一些地方没有显示所有代码。在这种情况下,我会做笔记,所以你需要下载这本书的代码来查看某些演示的完整代码。

接下来,我们将在 GameWindow 类的开始处创建一个成员变量部分。在其中,我们将定义所有用于存储有关游戏窗口信息的成员变量。以下是 GameWindow 类中完成的成员变量部分:

private bool m_Isisposed = false;private bool m_IsInitialized = false;
private bool m_IsFullScreen = false;private bool m_IsPaused = false;
private RenderForm m_Form;private Color4 m_ClearColor;
private long m_CurrFrameTime;private long m_LastFrameTime;
private int m_FrameCount;private int m_FPS;

大多数内容都是相当直观的,但我还是想逐一过一遍,以防万一。

提示

本书提供的可下载代码已全部注释。我在这里移除了注释以节省空间并使页面看起来更整洁。

这里是我们成员变量及其用途的列表:

成员变量 目的
m_IsDisposed 简单地表示我们的游戏窗口是否已被销毁。
m_IsInitialized 表示我们的游戏窗口是否已经初始化。
m_IsFullScreen 表示游戏窗口是否以全屏模式运行。
m_IsPaused 当然,这表示游戏窗口是否已暂停。
m_Form 这保存了代表游戏窗口本身的 SlimDX RenderForm对象。
m_ClearColor 这简单地指定了在清除屏幕时使用的颜色。这基本上是我们绘制场景时开始时的背景颜色。
m_CurrFrameTime 保存我们开始处理当前帧时的刻度时间。
m_LastFrameTime 保存我们开始处理上一帧时的刻度时间。
m_FrameCount 仅在GameLoop()方法中的调试代码中使用。
m_FPS 保存游戏循环当前运行的 FPS(每秒帧数)。

现在我们已经创建了成员变量,我们需要创建一些属性来提供对那些需要从类外部访问的成员变量的访问。属性只是代表成员变量值的一对获取和设置方法。这些方法通常被称为访问器方法访问器。我们刚刚创建的大多数变量都将有访问器,但这里没有足够的空间展示它们,所以我们只看第一个。

提示

查看可下载代码以查看其余的访问器方法。

这是ClearColor属性的代码:

public Color4 ClearColor
{
  get
  {
    return m_ClearColor;
  }
  protected set
  {
    m_ClearColor = value;
  }
}

如您所见,ClearColor属性定义了getset方法。set方法被声明为protected,这样这个属性就只能由这个类或其子类设置。

构造函数

现在我们需要为我们的GameWindow类设置一个构造函数。以下是代码:

public GameWindow(string title, int width, int height, bool fullscreen)
{
  // Store parameters in member variables.
  m_IsFullScreen = fullscreen;
  m_ClearColor = new Color4(1.0f, 0.0f, 0.0f, 0.0f);
  // Create the game window that will display the game.
  m_Form = new RenderForm(title);
  m_Form.ClientSize = new System.Drawing.Size(width, height);
  // Hook up event handlers so we can receive events from the form
  m_Form.FormClosed += FormClosed;
}

代码的前两行只是将窗口的基本属性设置为构造函数中传入的值。下一行设置了默认颜色,即黑色。这意味着每次我们绘制新帧时,我们都从一个空白的黑色屏幕开始。颜色是一个ARGBAlpha,红色,绿色,蓝色)值。如您所见,这一行将所有颜色通道的值设置为0.0f(除了 alpha),这给我们了黑色。alpha 的值为1.0f意味着颜色是不透明的,而值为0.0f将使其完全透明。值为0.5f将使颜色 50%透明。

接下来的两行创建了RenderForm对象,设置了其标题文本,并将其初始化为构造函数中传入的大小。RenderForm是 SlimDX 类,代表我们可以绘制其上的窗口。最后,最后一行简单地将GameWindow类订阅到RenderForm对象的Closed事件。这会导致当窗口关闭时,GameWindow类被通知。

现在我们已经有了构造函数,我们需要为刚刚订阅了GameWindow类的那个事件定义一个事件处理器。正如你可能猜到的,这个事件是在游戏窗口关闭时触发的(无论是用户关闭还是程序关闭)。以下是代码,如你所见,相当简短:

public virtual void FormClosed(object o, FormClosedEventArgs e)
{
  if (!m_IsDisposed)
    Dispose();
}

if语句检查游戏窗口是否已经被释放。如果没有,它将调用Dispose()方法来释放它。

IDisposable接口

目前,我们GameWindow类的声明表明它没有从任何其他类继承,也没有实现任何接口。我们将要改变这一点,因为我们将要实现IDisposable接口。这个接口非常小,实现它将很快。首先,我们需要编辑我们类的声明,使其表明它将实现这个接口。为此,只需将GameWindow类的开头从public class GameWindow更改为public class GameWindow : IDisposable

这告诉编译器这个类实现了IDisposable接口。现在我们必须遵守这个接口。它有一个我们需要实现的方法。这个方法将在游戏窗口关闭时执行清理操作。目前这个函数里没有太多内容,但这里它是:

protected virtual void Dispose(bool disposing)
{
  if (!this.m_IsDisposed)
  {
    if (disposing)
    {
      // Unregister eventsm_Form.FormClosed -= this.FormClosed;
      // get rid of managed resources here
    }
  // get rid of unmanaged resources here
  }
  m_IsDisposed = true;
}

Dispose(bool)方法被声明为受保护的,因为我们不希望它从GameWindow类外部被调用。相反,我们将创建第二个Dispose()方法,它是public的,没有参数。这样我们就可以通过调用这个公共方法来告诉对象我们已经完成了它,它将负责清理。Dispose(bool)方法首先检查游戏窗口是否已经被释放。如果没有,它将检查disposing参数是否设置为true。这个参数简单地指示游戏窗口是正在自行释放还是被垃圾回收器释放。

垃圾回收器是 C#等托管语言的一部分。当你的程序不再使用一个对象时,垃圾回收器将回收该对象占用的内存,以便它可以用于其他目的。这只会发生在程序不再有任何对该对象的引用时。然而,垃圾回收器并不保证立即回收该内存。

小贴士

你应该避免创建大量仅用于短时间内的对象。这可能会让垃圾回收器更加忙碌,你将付出以降低 FPS 或延迟波动的代价。

如果disposing参数设置为true,则if语句内的代码将注销事件并调用它所使用的任何托管对象的Dispose()方法。目前,它只注销了我们之前订阅的Closed事件。然后,if语句之后的代码清理它可能正在使用的任何非托管对象。由于我们只使用托管代码,所以我们将没有非托管对象。托管意味着大部分情况下内存管理是由我们自动处理的。

最后,在这个方法的末尾,将m_IsDisposed成员变量设置为true。这表示窗口已经被销毁,因此RenderScene()方法将知道它不应该再尝试渲染任何内容,因为这样做可能会导致程序崩溃。我们稍后会讨论RenderScene()方法,但首先我们需要完成IDisposable的处理。

现在我们必须实现公共的Dispose()方法,正如你可以在下面的代码片段中看到的那样,这是一个非常简短的方法:

public void Dispose()
{
  Dispose(true);

  // Since this Dispose() method already cleaned up the resources 
    used
  // by this object, there's no need for the Garbage Collector to
    call
  // this class's Finalizer, so we tell it not to
  GC.SuppressFinalize(this);
}

这个方法调用Dispose(bool)方法来销毁游戏窗口。传递的值是true,因为此方法是GameWindow类的一部分,因此在这种情况下GameWindow类正在销毁自己。然后我们调用GC.SuppressFinalize(this)来告诉垃圾回收器此对象已经被销毁。你可能已经注意到我们没有在这个类中实现Finalizer。这是因为Finalize()方法用于清理对象中的非托管资源。它在垃圾回收器销毁对象之前自动调用。这允许它在被销毁之前清理其非托管资源。因此,我们不需要实现此方法,因为我们只使用托管代码。

游戏循环方法

现在我们需要创建我们的游戏循环。如前所述,游戏循环是代码的主要块,它会无限重复,直到我们关闭游戏。它调用处理和绘制每个帧的代码,因此它是游戏的核心。我们将创建一个新的GameLoop()方法,它将成为我们的游戏循环。以下是代码:

public virtual void GameLoop()
{
  m_LastFrameTime = m_CurrFrameTime;
  m_CurrFrameTime = Stopwatch.GetTimestamp();

  UpdateScene((double) (m_CurrFrameTime - m_LastFrameTime) / Stopwatch.Frequency);

  RenderScene();

  // This code tracks our frame rate.
  m_nFPS = (int)(Stopwatch.Frequency / ( (float) (m_CurrFrameTime – m_LastFrameTime)));
}

在这个函数中,我们首先将m_CurrFrameTime的值复制到m_LastFrameTime。每次调用这个函数时,我们都在处理一个新的帧。这意味着m_CurrFrameTime中的值现在是上一个帧的时间。因此,我们将它复制到那个变量中。接下来,我们从高性能计时器获取当前时间,并通过StopWatch类将其存储在m_CurrFrameTime中。这个类使用高性能硬件计时器,如果可用。现在大多数 PC 都有这些计时器,所以这应该不会成问题。接下来,我们继续调用UpdateScene()RenderFrame()方法。UpdateScene()方法目前只是一个空方法,如下面的代码片段所示:

public virtual void UpdateScene(double frameTime)
{
}

最后,我们根据上一帧的持续时间计算我们的 FPS。我们只需将StopWatch.Frequency除以上一帧的持续时间。

UpdateScene()方法的参数是自上次调用UpdateScene()以来经过的时间量。我们通过从当前帧的时间减去上一帧的时间来计算这个值。然后我们除以StopWatch.Frequency将结果转换为秒。这是必要的,因为StopWatch.GetTimeStamp()函数返回当前时间的时间戳。本质上,它是在 Windows 上次启动以来系统计时器上经过的时间戳的总数。StopWatch.Frequency属性告诉我们系统计时器在一秒钟内进行多少次时间戳。这很重要,因为一台计算机的计时器可能比另一台计算机的计时器快或慢。现在RenderScene()方法也大多是空的,但它确实在其中有一个简单的if语句。以下是它的代码:

public virtual void RenderScene()
{
  if ((!this.IsInitialized) || this.IsDisposed)
  {
    return;
  }
}

RenderScene()方法中的if语句检查游戏窗口是否已准备好进行渲染。如果游戏窗口尚未初始化,或者如果游戏窗口已被销毁,那么我们只需从这个函数中返回。这很重要,因为它可以防止游戏窗口首次启动和关闭时可能发生的崩溃。

注意,IsInitializedIsDisposed是我们之前讨论的成员变量属性中的两个。

现在我们几乎有了功能齐全的GameWindow类。但我们需要添加一个StartGameLoop()方法。这个方法被调用以启动游戏循环。它只包含以下几行代码:

public void StartGameLoop()
{
  // If initialization is already finished, then simply return.
  if (m_IsInitialized)
    return;

  m_IsInitialized = true;

  // Start the message pump.
  MessagePump.Run(m_Form, GameLoop);
}

首先,这个函数检查游戏窗口是否已经初始化。如果是这样,那么我们只需从这个函数中返回。否则,我们将m_IsInitialized成员变量设置为true以表示它已经被初始化。这个函数本质上是我们游戏循环的初始化函数。最后,它调用MessagePump.Run,传入我们的RenderForm对象(即游戏窗口本身)和我们的GameLoop()函数。这将导致GameLoop()函数被反复调用,直到我们关闭游戏窗口。(RenderFormMessagePump是 SlimDX 类。)

那么为什么我们需要MessagePump呢?在 Windows 中,应用程序接收消息,这些消息只是通知某个事件已经发生。例如,按键会产生按键消息。这些消息被发送到当前活动的任何窗口。然后该程序可以处理并响应这些消息。

在游戏中,我们希望有一个持续运行的循环来模拟和绘制每一帧,紧接着上一帧之后立即进行。尽管如此,我们仍然需要处理来自 Windows 的消息,否则我们的游戏窗口将无法正确工作。例如,如果我们只是让程序陷入循环,从不检查 Windows 消息,那么当你尝试关闭窗口时,由于程序永远不会处理关闭消息,所以将不会发生任何操作。因此,我们使用这个MessagePump类来在我们运行游戏循环的同时为我们处理 Windows 消息。

测试我们的游戏窗口

现在是我们测试游戏窗口的时候了!我们将向我们的解决方案中添加第二个项目。在SlimFramework解决方案仍然打开的情况下,打开文件菜单,并选择新建项目...。为新项目命名,例如,Ch01。确保将选定的项目类型更改为Windows 窗体应用程序。同样,确保窗口底部的解决方案下拉列表设置为添加到解决方案,否则 Visual Studio 将创建一个新的解决方案而不是将此项目添加到我们的现有解决方案中。如果此选项不存在,则它将把新项目添加到这个解决方案中。

单击确定,新项目将被创建并添加到我们的解决方案中。在解决方案资源管理器窗格中右键单击它,并选择设置为启动项目。通过这样做,我们已经告诉 Visual Studio,这是我们想要在告诉它运行我们的代码时启动的项目。

SlimFramework项目不能作为启动项目,因为它只是一个类库,因此不能独立执行。这是因为类库项目类型没有Main方法,就像控制台应用程序Windows 窗体应用程序那样。Main方法是在程序启动时首先被调用的方法。因此,它是程序的起点。你还应该从这个新项目中删除Form1.cs文件,因为我们不需要它。

现在我们需要将我们的项目添加引用。在解决方案资源管理器窗格中,右键单击Ch01项目的引用标题。然后单击添加引用...。在引用管理器窗口中,选择项目类别。现在勾选SlimFramework项目旁边的复选框。单击确定,Visual Studio 将把SlimFramework项目的引用添加到Ch01项目中。我们现在可以在Ch01中使用SlimFramework项目中定义的类。

接下来,我们需要在我们的新Ch01项目中的Main方法中添加几行代码。以下是代码:

static void Main(string[] args)
{
  Application.EnableVisualStyles();
  Application.SetCompatibleTextRenderingDefault(false);

  SlimFramework.GameWindow gameWindow = new SlimFramework.GameWindow("Our First Game Window", 640, 480, false);

  gameWindow.StartGameLoop();
}

这段代码相当简单。前两行是 Visual Studio 创建的。我移除了 Visual Studio 添加的第三行——它调用Application.Run()——因为我们不需要它。下一行简单地创建了一个新的GameWindow对象,并传入参数来指定窗口的标题、窗口的宽度和高度,最后是否要以全屏模式运行。在这种情况下,我们将窗口标题设置为Our First Game Window,窗口大小设置为640x480。最后,我们将最后一个参数的值设置为false,因为我们不想以全屏模式运行,因为我们还没有实现它。此方法中的最后一行代码调用GameWindow类的StartGameLoop()方法来启动游戏循环。Main()方法的参数只是一个String数组,它包含在应用程序启动时传入的任何命令行参数。如果你想要处理传入的命令行参数,你只需在某个地方添加代码即可。这就是你如何向你的游戏添加一些命令行开关的方法。

现在我们已经准备好编译和运行我们的代码了。为此,点击 Visual Studio 工具栏上的启动按钮。这个按钮旁边有一个绿色的三角形图标。以下截图显示了运行程序时我们的游戏窗口的样子。你可能还记得,我们将黑色设置为默认背景颜色,但这个窗口的背景并不是非常黑。这并不是一个错误,只是因为我们还没有绘制代码。

注意

当你尝试运行程序时,如果出现错误信息表明无法直接启动具有类库输出类型的项目,这意味着你忘记按照前面提到的将Ch01设置为启动项目了。

以下截图显示了我们的游戏窗口的实际运行情况:

测试我们的游戏窗口

游戏窗口的实际运行情况

现在看起来并不那么令人印象深刻,但不久我们就可以开始在屏幕上渲染一些图形了,这将使事情变得更加有趣。

摘要

在本章中,我们设置了 Visual Studio 和 SlimDX,使其准备就绪。我们创建了一个项目来工作,并构建了我们小型框架的起点。目前它只包含我们的GameWindow类,它为我们创建了一个游戏窗口,以便我们在上面绘制游戏。我们为GameWindow类提供了一个构造函数,并介绍了GameLoop函数。我们为它提供了UpdateScene()RenderScene()方法,用于在每一帧更新和绘制我们的游戏世界。我们还介绍了接口,特别是IDisposable接口,并为游戏窗口提供了一个ToggleFullscreen()方法。最后,我们测试了我们的游戏窗口,并第一次看到了它的实际运行情况。在下一章中,我们将深入探讨用户输入以及如何响应用户的动作。

第二章. 响应玩家输入

游戏的本质是交互式的。它们可以响应用户输入,而电影每次播放都是完全相同的。因此,你需要一种方法来检测和响应用户通过鼠标、键盘或游戏手柄的输入。我们如何在游戏中实现这一点?我们可以使用两种选项来完成这项任务:DirectInputXInput

在本章中,我们将涵盖以下主题:

  • DirectInput 与 XInput 的比较

  • 鼠标和键盘输入

  • 使用 DirectInput 控制摇杆

  • 使用 XInput 控制摇杆

DirectInput 与 XInput 的比较

那么,我们应该选择这两个选项中的哪一个呢?答案是可能两者都要用。你可能会问,为什么两者都要用?原因是我们需要使用 DirectInput 来支持不支持 XInput 新特性的旧输入设备。输入设备是指用户用来向游戏提供输入的任何设备,例如鼠标、键盘、游戏手柄和方向盘。

我们本可以使用 DirectInput,但这种方法有一些局限性。DirectInput 可以处理 XInput 设备,但游戏手柄上的左右扳机按钮将被视为单个按钮。一个扳机被视为正方向,另一个被视为负方向。因此,这两个扳机被一起视为一个单轴。DirectInput 也不支持 XInput 振动效果,并且无法查询耳机设备。另一方面,我们也可以只使用 XInput,但这样,拥有较老 DirectInput 设备的玩家将无法使用它们来玩游戏。

为了支持这些新输入设备的特性,我们需要使用 XInput 和 DirectInput。这允许拥有 XInput 设备的玩家利用它们的新特性,同时允许使用较老 DirectInput 设备的用户仍然能够使用它们来玩游戏。玩家无疑会为此感到高兴。那么,DirectInput 和 XInput 之间真正的区别是什么?XInput 专门针对 Xbox 360 控制器和专门的 Xbox 360 控制器,如吉他。XInput 最多支持四个控制器,每个控制器最多有四个轴,10 个按钮,两个扳机和一个八方向数字垫。

XInput 仅真正支持下一代控制器,并且不支持键盘或鼠标类型设备。另一方面,DirectInput 适用于所有控制器,并支持最多八个轴和 128 个按钮的控制器。

因此,关于是否使用 DirectInput、XInput 或两者都使用的真正答案,实际上取决于你正在开发的游戏。只需注意,正如之前讨论的那样,如果我们要在 PC 游戏中支持 Xbox 360 及其类似控制器的某些功能,微软实际上强迫我们使用 XInput。

鼠标和键盘输入

尽管玩家可以使用游戏手柄和其他类型的控制器玩游戏,但在 PC 游戏开发中,鼠标和键盘输入仍然非常重要。例如,有些游戏有太多的命令,无法将它们全部映射到游戏手柄上。当我们将游戏中的动作分配给键盘、鼠标或游戏手柄上的特定按钮时,我们说我们已经将该动作映射到该特定按钮上。这也被称为绑定,因为我们实际上是将某个键或按钮绑定到特定的游戏动作上。

让我们先实现我们的鼠标和键盘输入。启动 Visual Studio 并打开上一章中我们工作的解决方案。我们将添加一个新类来处理我们的用户输入。在解决方案资源管理器窗格中右键单击SlimFramework项目,并添加一个名为UserInput.cs的新类。我们将使这个类实现IDisposable接口,就像我们在第一章中的GameWindow.cs类所做的那样。因此,我们需要将类声明从public class UserInput更改为public class UserInput : IDisposable

我们还需要在类文件顶部添加两个using语句。一个用于 DirectInput,一个用于 XInput:

using SlimDX.DirectInput;
using SlimDX.XInput;

现在,我们准备为我们的新用户输入类设置成员变量。我们将创建一个成员变量部分,就像我们在第一章中做的那样,入门。以下是代码:

bool m_IsDisposed = false;
DirectInput m_DirectInput;

Keyboard m_Keyboard;
KeyboardState m_KeyboardStateCurrent;
KeyboardState m_KeyboardStateLast;

Mouse m_Mouse;
MouseState m_MouseStateCurrent;
MouseState m_MouseStateLast;

在这里,我们有几个成员变量。第一个是m_IsDisposed,它具有与我们创建在GameWindow类中的m_IsDisposed成员变量相同的目的。第二个变量m_DirectInput将存储我们的 DirectInput 对象。

接下来,我们有一组三个变量。第一个,m_Keyboard,存储键盘对象。接下来的两个变量跟踪键盘的当前和上一个状态。因此,m_KeyboardStateCurrent存储当前帧的键盘状态,而m_KeyboardStateLast存储上一个帧的键盘状态。为什么我们需要两者?例如,如果你想检测用户是否按下了某个键,而不仅仅是简单地按下它。

接下来,我们有一组三个非常相似的变量用于我们的鼠标对象和当前和上一个鼠标状态(m_Mousem_MouseStateCurrentm_MouseStateLast)。

构造函数

现在,我们需要创建我们的构造函数来初始化我们的用户输入对象。以下是初始化的代码:

public UserInput()
{
  InitDirectInput();

  m_KeyboardStateCurrent = new KeyboardState();
  m_KeyboardStateLast = new KeyboardState();

  m_MouseStateCurrent = new MouseState();
  m_MouseStateLast = new MouseState();

}

第一行调用 InitDirectInput() 方法为我们初始化 DirectInput。我们将在下一秒创建这个方法,但首先我们需要完成对 UserInput() 方法的查看。接下来的两行使用空的 KeyboardState 对象初始化我们的键盘状态变量。这是必要的,以防止程序在第一帧(当它们未初始化且因此为 null 时)尝试访问这些变量时发生崩溃。这种异常发生在程序尝试访问一个 null 变量时。毕竟,在使用对象之前,您必须先初始化它!

最后两行执行完全相同的事情,但这次是为了我们的鼠标状态变量。

初始化 DirectInput

现在构造函数已经完成,我们需要创建我们的 InitDirectInput() 方法。这是一个相当简短的方法,以下是代码:

private void InitDirectInput()
{
  m_DirectInput = new DirectInput();

  // Create our keyboard and mouse devices.
  m_Keyboard = new Keyboard(m_DirectInput);
  m_Mouse = new Mouse(m_DirectInput);

}

目前,这个方法只有三行代码。第一行创建并初始化我们的 DirectInput 对象,并将其存储在我们的 m_DirectInput 成员变量中。第二行创建并初始化我们的键盘对象,并将其存储在我们的 m_Keyboard 成员变量中。第三行做同样的事情,但针对我们的鼠标对象,将其存储在我们的 m_Mouse 成员变量中。

这个方法之所以如此简短,归功于 SlimDX 的帮助。如果您用 C++ 编写相同的代码而不使用 SlimDX,它将会更长,也可能更晦涩。这是 SlimDX 成为一个优秀框架的原因之一。它在幕后为我们处理一些事情,同时仍然允许我们利用 DirectX 的全部功能。

Update() 方法

现在,我们将向我们的用户输入类添加一个 Update() 方法。这个方法将在每一帧被调用一次,以获取最新的用户输入数据。我们将从 GameWindow 类的 UpdateScene() 方法中调用这个方法。以下是代码:

public void Update()
{
  // Reacquire the devices in case another application has
  // taken control of them.
  m_Keyboard.Acquire();
  m_Mouse.Acquire();

  // Update our keyboard state variables.
  m_KeyboardStateLast = m_KeyboardStateCurrent;
  m_KeyboardStateCurrent = m_Keyboard.GetCurrentState();

  // Update our mouse state variables.
  m_MouseStateLast = m_MouseStateCurrent;
  m_MouseStateCurrent = m_Mouse.GetCurrentState();
}

前两行代码重新获取键盘和鼠标设备,以防自上一帧以来另一个应用程序已经控制了它们。我们必须获取鼠标和键盘设备,以便我们的程序可以访问它们。只要设备被获取,DirectInput 就会将其数据提供给我们的程序。然而,获取设备并不是永久的,这就是为什么我们在 UpdateScene() 方法的开头做这件事。这确保了在我们尝试在下一行代码中使用键盘和鼠标设备之前,我们已经可以访问这些设备。

那么,为什么需要这种获取机制呢?首先,DirectInput 需要一种方式来告诉我们的应用程序,如果系统中断了从设备的数据流,DirectInput 需要一种方式来告诉我们的应用程序,如果系统中断了从设备的数据流。例如,如果用户使用 Alt + Tab 切换到另一个应用程序窗口并使用该应用程序中的相同输入设备,就会发生这种情况。

第二个需要这个获取机制的原因是因为我们的程序可以改变设备的属性。DirectInput 要求我们在改变属性之前释放设备。这是通过调用它的 Unacquire() 方法来完成的。然后,在你完成属性更改后,你会重新获取它。这确保了在改变属性时设备没有被使用,因为这可能会引起严重问题。请注意,这里有一个例外,那就是你可以在获取设备时改变力反馈设备的增益。

回到我们的代码。接下来的两行更新了我们的键盘状态变量。首先,将上一帧的当前键盘状态从 m_KeyboardStateCurrent 成员变量复制到 m_KeyboardStateLast 成员变量中。然后,我们获取当前的键盘状态并将其存储在我们的 m_KeyboardStateCurrent 成员变量中。

最后两行与之前做的是同一件事,但这次是针对我们的鼠标状态成员变量。

IDisposable 接口

如您从本章前面的内容中回忆起来,我们更改了 UserInput 类的声明,使其实现 IDisposable 接口。我们在 第一章 中介绍了这个接口,入门

如您所记得,我们必须实现两个方法。public void Dispose() 方法与我们之前在 GameWindow 类中创建的方法相同。所以,这里不会展示它。另一方面,protected void Dispose(bool) 方法是不同的。以下是它的代码:

protected virtual void Dispose(bool disposing)
{
  if (!this.m_IsDisposed)
  {
    if (disposing)
    {
      // Unregister events

      // get rid of managed resources here
      if (m_DirectInput != null)
      m_DirectInput.Dispose();

      if (m_Keyboard != null)
      m_Keyboard.Dispose();

      if (m_Mouse != null)
      m_Mouse.Dispose();
    }

    // get rid of unmanaged resources here

  }

  m_bDisposed = true;
}

如您所见,这个方法的内部结构与我们创建在 GameWindow 类中的结构相同。它里面也有相同的 if 语句。不同的是,这次我们没有要取消绑定的事件,并且在方法的托管资源部分添加了销毁我们的 DirectInput、键盘和鼠标对象的代码。

那么,为什么这些对象都在它们自己的小 if 语句内部被销毁呢?这样做的原因是为了防止如果这些对象中的任何一个由于某种原因而为空时可能发生的潜在崩溃。因此,我们检查对象是否为空。如果不是,那么我们就销毁它。对一个空对象调用 dispose 将会导致空引用异常。

现在,我们只需要向我们的用户输入类添加几个属性。它们都非常简单,只是提供了对我们成员变量的访问。以下是其中两个属性。查看本章的下载代码以查看所有属性。

public bool IsDisposed
{
  get
  {
    return m_bDisposed;
  }
}

public Keyboard Keyboard
{
  get
  {
    return m_Keyboard;
  }
}

现在这个类已经完成,我们只需要修改我们的 GameWindow 类,使其现在可以使用它。

更新 GameWindow

现在我们需要做的第一件事是在 GameWindow.cs 文件的顶部添加一个 using 语句:

using SlimDX.DirectInput;

这将允许我们使用 Key 枚举来指定我们想要检查的键。接下来,我们需要向我们的 GameWindow 类添加一个新的成员变量。这个变量将被命名为 m_UserInput,它将包含我们刚刚创建的新 UserInput 对象。这个成员变量的声明如下代码所示:

private UserInput m_UserInput;

接下来,我们需要修改我们的构造函数以创建和初始化我们的用户输入对象。为了完成这个任务,我们只需在构造函数的末尾添加以下代码行,就在关闭的 } 之上:

m_UserInput = new UserInput();

在我们的 UserInput 类中添加一些成员方法来简化我们对用户输入的处理是个好主意。所以,让我们创建一个新的方法,命名为 IsKeyPressed(),其代码如下:

public bool IsKeyPressed(Key key)
{
  return m_KeyboardStateCurrent.IsPressed(key);
}

此方法检查指定的键是否被按下,如果按下则返回 true,如果没有按下则返回 false。从该方法中的代码可以看出,KeyboardState 对象具有 IsPressed() 方法,我们使用它来检查指定的键是否被按下。它还包含一个 IsReleased() 方法,用于测试键是否没有被按下。除此之外,它还有 PressedKeysReleasedKeys 属性,分别返回当前按下的键和当前未按下的键的列表。最后,它还有一个 AllKeys 属性,可以提供键盘上所有键的状态。

注意

本章提供的可下载代码包含一些额外的键盘处理方法,如这个方法。它们是 IsKeyReleased()IsKeyHeldDown()

在我们能够看到我们的键盘输入代码起作用之前,我们还需要做最后一步。我们需要在我们的 UpdateScene() 方法中添加一些代码来检查一些按键。这是 UpdateScene() 方法中的新代码:

public virtual void UpdateScene(double frameTime)
{
  // Get the latest user input.
  m_UserInput.Update();

  if (m_UserInput.IsKeyPressed(Key.Return) &&
    (m_UserInput.IsKeyPressed(Key.LeftAlt) ||
    m_UserInput.IsKeyPressed(Key.RightAlt)))
  {
    // Toggle fullscreen mode.
    ToggleFullscreen();
  }
  else if (m_UserInput.IsKeyPressed(Key.Escape))
  {
    // Close the program.
    m_Form.Close();
  }
}

这段代码向我们的窗口添加了一些基本的键盘命令。第一个 if 语句检查用户是否同时按下 Return 键和左 Alt 键或右 Alt 键。如果是这种情况,那么 if 语句将调用 ToggleFullscreen() 方法。

else if 子句检查用户是否正在按下 Esc 键。如果是这样,那么我们将关闭游戏窗口,程序终止。

在我们可以测试运行程序之前,我们需要在 GameWindow 类的 protected void Dispose(bool) 方法中添加一行代码。我们需要在函数的资源管理部分添加以下代码行:

m_UserInput.Dispose();

完成这些后,我们现在可以测试运行程序了。游戏窗口看起来与图 The game window in action 中的方式相同,该图位于 第一章,入门。然而,你现在可以通过按下 Esc 键来关闭它。

如果您同时按下Enter + Alt,目前不会有任何反应。正如前一章所述,我们无法切换全屏模式,因为我们还没有使用 DirectX 的图形 API。应用程序编程接口API)简单地指代 API 提供的所有公共方法和类型。例如,SlimDX 是一个 API,DirectX 也是如此。

API 的较小部分有时也可以被视为独立的 API。例如,DirectX 的 DirectInput 本身就是 API。DirectX 更像是一个集合,包含针对不同目的的多个不同 API,SlimDX 也是如此。

如您所见,使用 SlimDX 实现键盘输入相当简单。虽然我们还没有真正使用过,但鼠标输入也同样简单。响应鼠标输入几乎与键盘输入相同。只需检查MouseState对象的XY属性,以找出鼠标光标的当前位置。Z属性允许您检测鼠标滚轮(如果有的话)的移动。如果您的鼠标没有滚轮,则此属性将简单地返回0。请注意,Z属性的值是一个增量,换句话说,它是自上次更新以来滚轮移动的量。最后,您可以使用IsPressed()IsReleased()方法来检测指定的鼠标按钮是否被按下或释放。

注意,本章提供的可下载代码还包括添加到我们的UserInput类中的鼠标处理方法。这些方法包括IsMouseButtonPressed()IsMouseButtonReleased()IsMouseButtonHeldDown()MouseHasMoved()MousePosition()等。IsMouseButtonHeld()方法可以用来实现点击并拖拽的行为,而HasMouseMoved()方法在鼠标自上一帧以来有移动时返回true,否则返回false

使用 DirectInput 的摇杆

现在,让我们换挡,看看如何使用摇杆。在这本书中,我们将使用摇杆一词来指代任何游戏控制器。首先,我们将探讨如何使用 DirectInput 与摇杆配合。

枚举设备

您可能见过一些游戏,如果您在 PC 上连接了多个游戏控制器,它们允许您选择想要使用的控制器。在本节中,我们将探讨如何获取可用设备的列表。使用 SlimDX,这实际上相当简单。

DirectInput对象(记住我们将其存储在m_DirectInput成员变量中)有一个名为GetDevices()的方法。要获取可用控制器的列表,我们可以像这样调用该方法:

m_DirectInput.GetDevices(DeviceClass.GameController, DeviceEnumerationFlags.AttachedOnly);

为了尝试这个功能,让我们向我们的UserInput.cs类中添加一个新的方法。此方法将简单地输出有关可用设备的一些调试信息。以下是代码:

public void GetJoysticks()
{
  IList<DeviceInstance> deviceList = m_DirectInput.GetDevices(DeviceClass.GameController, DeviceEnumerationFlags.AttachedOnly);
  if (deviceList.Count < 1)
  {
    System.Diagnostics.Debug.WriteLine("NO GAME CONTROLLERS WERE FOUND!");
  }
  else
  {
    foreach (DeviceInstance device in deviceList)
    {
      System.Diagnostics.Debug.WriteLine("PRODUCT NAME: " + device.ProductName);
    }
  }
}

首先,我们创建一个名为deviceList的变量,获取游戏控制器的列表,并将其存储在这个新变量中。对于GetDevices()方法的第一个参数,我们传入值DeviceClass.GameController来告诉它我们只对游戏控制器感兴趣。对于第二个参数,我们给它传入值DeviceEnumerationFlags.AttachedOnly,因为我们只想获取实际安装并连接到 PC 的设备。

接下来,我们有一个if语句,用于检查游戏控制器的列表是否为空。如果是这样,它将打印一条调试消息来通知你没有游戏控制器连接到你的计算机。在if语句的else子句中,我们有一个foreach循环,它遍历我们刚刚检索并存储在deviceList变量中的游戏控制器列表。在foreach循环内部,我们只有一行代码。这一行代码只是将一行调试输出写入 Visual Studio 的输出面板,针对列表中的每个游戏控制器。输出面板通常位于 Visual Studio 窗口的底部。如果你启用了自动隐藏,你可能需要点击窗口左下角的输出选项卡来显示它。你还可以通过转到视图菜单并选择输出来访问它。

默认情况下,Visual Studio 在运行你的程序时会自动显示输出面板,以便你可以看到你的程序的调试输出,如下面的截图所示。如果它没有显示输出面板,请参阅前面的段落了解如何访问它。

接下来,转到InitDirectInput()方法,并在函数末尾添加以下代码行:

GetJoysticks();

这在构造函数的末尾调用我们的新GetJoysticks()方法。如果你现在运行此代码,你将在 Visual Studio 的输出面板中看到游戏控制器的列表。以下截图显示了在我的系统上看起来是什么样子,其中我有一个游戏控制器连接到计算机:

设备枚举

显示我们的调试输出的输出面板

注意

你从这个代码输出的结果很可能与我不同。所以,你可能会看到与我不同的控制器列表,因此你的输出可能会与前面截图中的输出不同。

从摇杆获取输入

这一切都很不错,但我们仍然无法从摇杆获取输入。所以,让我们现在看看这个问题。首先,我们需要为我们的摇杆添加三个成员变量,就像我们为鼠标和键盘所做的那样。以下是我们需要添加到UserInput.cs类中的三个新成员变量:

Joystick m_Joystick1;
JoystickState m_Joy1StateCurrent;
JoystickState m_Joy1StateLast;

如前所述,我们有一个变量来保存我们的设备对象(在这种情况下,是一个Joystick对象),以及另外两个变量来保存当前帧和前一帧的摇杆状态。

现在,我们需要在构造函数的底部添加两行代码来初始化摇杆状态变量。正如本章前面所讨论的,这可以防止潜在的崩溃。在构造函数的末尾添加这两行代码:

m_Joy1StateCurrent = new JoystickState();
m_Joy1StateLast = new JoystickState();

现在,让我们修改我们的GetJoysticks()方法。我们将简单地让它使用返回的控制器列表中的第一个摇杆。以下是GetJoysticks()方法的新代码:

public void GetJoysticks()
{
  IList<DeviceInstance> deviceList = m_DirectInput.GetDevices(DeviceClass.GameController, DeviceEnumerationFlags.AttachedOnly);

  for (int i = 0; i < deviceList.Count; i++)
  {
    if (i == 0)
    {
      m_Joystick1 = new Joystick(m_DirectInput, deviceList[0].InstanceGuid);
      // Set the range to use for all of the axis on our game controller.
      m_Joystick1.Properties.SetRange(-1000, 1000);

    }
  }
}

如你所见,我们在if语句内部也添加了第二行代码。这设置了游戏控制器上每个轴的最小和最大可能值。在这种情况下,我们将其设置为-1,0001,000。这意味着当摇杆完全向左时,其水平位置是-1,000。当它完全向右时,其水平位置是1,000。垂直轴也是如此。当摇杆居中时,其位置将是(0,0)。了解可能值的范围对于使我们的控制正确工作非常重要。你可以从Joystick.Properties.LowerRangeJoystick.Properties.UpperRange属性中获取范围。请注意,这些属性在某些情况下可能会抛出异常,这取决于你的游戏控制器驱动程序和 DirectX 版本。

现在,我们需要在我们的Update()方法中添加几行代码来获取最新的摇杆数据。为此,我们首先需要在方法的开头添加一行代码来获取摇杆。在没有获取摇杆之前,你不能使用设备(参见本章的鼠标和键盘输入部分,了解获取信息以及为什么我们需要这样做)。因此,我们将添加以下代码行来为我们获取摇杆:

m_Joystick1.Acquire();

我们基本上是在通知系统我们现在希望使用摇杆并获取对它的访问权限。现在我们已经获取了对摇杆的访问权限,我们需要在Update()方法的末尾添加这两行代码:

m_Joy1StateLast = m_Joy1StateCurrent;
m_Joy1StateCurrent = m_Joystick1.GetCurrentState();

正如我们对鼠标和键盘所做的那样,我们对Joystick对象也这样做。我们将m_Joy1StateCurrent成员变量的值复制到m_Joy1StateLast变量中,因为现在的状态数据已经是一帧之前的了。然后我们获取当前的摇杆状态并将其存储在m_Joy1StateCurrent成员变量中。

我们的用户输入类现在支持使用一个摇杆。你可以通过添加与第一个摇杆相同的变量和代码来支持更多摇杆。现在,让我们在Update()方法的末尾添加一些测试代码,以查看其效果:

if (m_Joy1StateCurrent.IsPressed(0))
  System.Diagnostics.Debug.WriteLine("DIRECTINPUT: BUTTON 0 IS PRESSED!");
if (m_Joy1StateCurrent.IsPressed(1))
  System.Diagnostics.Debug.WriteLine("DIRECTINPUT: BUTTON 1 IS PRESSED!");
if (m_Joy1StateCurrent.IsPressed(2))
  System.Diagnostics.Debug.WriteLine("DIRECTINPUT: BUTTON 2 IS PRESSED!");
if (m_Joy1StateCurrent.IsPressed(3))
  System.Diagnostics.Debug.WriteLine("DIRECTINPUT: BUTTON 3 IS PRESSED!");

小贴士

如果你没有游戏控制器,那么你将看不到前面代码的任何输出。程序仍然会工作,但由于没有游戏控制器来获取它,所以不会有调试输出。

这段测试代码是一组简单的if语句。第一个if语句检查按钮0是否被按下。如果是,它将写入一行调试输出以显示它已经检测到按钮按下。第二个if语句检查按钮1是否被按下,如果是,则写入一条调试消息。最后两个if语句对按钮23做同样的处理。

那么,为什么我们在这里使用数字呢?原因是因为每个摇杆按钮都有一个索引,我们用它来引用它。例如,在我的游戏手柄上,按钮0A按钮。

现在,我们需要在我们的Dispose(bool)方法中添加两行代码。它们将放在方法的管理资源部分。下面是它们:

if (m_Joystick1 != null)
  m_Joystick1.Dispose();

这只是简单地检查Joystick对象是否为 null。如果不是,那么我们就销毁它。

运行程序并按下你的游戏控制器上的按钮。如果你按下按钮0123,你将在 Visual Studio 的输出面板上看到一些新的调试输出行。当我们编写的这些按钮之一被按下时,其消息会多次出现。这是由于游戏循环运行的速率。现在它运行得超级快,因为它甚至不需要渲染任何图形或模拟任何东西!本章节的可下载代码添加了更多的if语句来覆盖比这里更多的按钮。它还有一些注释掉的行,用于显示左右摇杆的当前位置以及用于表示扳机的轴的位置(这些是可以稍微按下、完全按下或不按下的按钮,通常位于游戏手柄式控制器的手肩上)。

注意

你可以用检测用户按下拇指按钮的方式检测用户按下摇杆按钮,你只需要找出哪个索引代表每个拇指按钮。这通常不是一个问题,因为大多数游戏允许用户将游戏动作绑定到他们想要的任何按钮或轴。换句话说,你通常不应该在游戏中硬编码控制,因为它们可能对某些玩家不正确或不理想。

我们实际上只是刚刚触及了使用 DirectInput 摇杆的表面。花些时间探索我们存储在m_Joystick1成员变量中的Joystick对象的各种属性。你会发现还有很多我们没有在这里使用的其他属性。例如,XY属性通常会告诉你左摇杆正在做什么。RotationXRotationY属性通常会告诉你右模拟摇杆的位置。摇杆有两个轴,正如你所见。如果摇杆完全没有移动,它就是居中的,因此它的位置读数将在两个轴的范围中心。如果你将摇杆推到最右边,它将在其水平轴上达到最大值。如果你将它完全向上推,它将在其垂直轴上达到最小值。

注意

如果你将摇杆推到最上方和最左侧,你可能会预期摇杆的位置是(0,0),但实际上并非如此。这是因为大多数摇杆都有一个圆形的运动范围,因此摇杆永远不会位于由其一对轴定义的运动范围的绝对左上角。

在大多数情况下,Z属性会给出代表游戏手柄式设备上的扳机按钮的轴的值。如果两个扳机都没有被按下,则该值位于范围中间。如果左扳机完全按下,Z属性将具有该轴的最大值,当然,如果右扳机完全按下,那么Z将具有该轴范围的最低值。这个范围可能不同,你也可以通过修改Joystick对象的Properties属性来修改类似的内容(记住,在改变其属性之前,你必须释放设备)。这个范围可能因控制器而异。

那么关于方向垫(通常简称为 D-Pad)呢?你如何处理它取决于控制器如何报告它。有些可能将 D-Pad 报告为普通按钮,在这种情况下,它将以与普通按钮相同的方式处理。其他控制器将 D-Pad 报告为 POV(视角)控制器。在这种情况下,你可以使用JoystickState对象的GetPointOfViewControllers()方法来访问它。它返回一个int数组。数组的第一个索引代表你的游戏控制器上的第一个 POV 控制器。数组第一个元素的值将根据你按的方向而变化。对于我来说,当我按下向上时,第一个元素的值是0,当我按下向右时是9,000,当我按下向下时是18,000,当我按下向左时是27,000在 D-Pad 上。

注意

这其中很多都可能因你所使用的游戏控制器类型以及 DirectInput 如何识别它而有所不同。因此,你可能需要在不同属性的JoystickState对象(记住我们将其存储在m_Joy1StateCurrent成员变量中)上尝试不同的设置,以找到你需要的内容。

随意尝试我们在Update()方法中刚刚添加的调试代码。实验是学习新事物的好方法。有时候,这比阅读大量枯燥的文档要快得多!在这里,我们不会全面介绍 DirectInput,因为那可能需要一本书的篇幅来涵盖。

本章提供的可下载代码为我们的UserInput类添加了一些实用的摇杆处理方法。包括DI_LeftStickPosition()DI_RightStickPositionDI_TriggersAxis()等。当然,DI是 DirectInput 的缩写。TriggersAxis()方法获取表示扳机的轴的当前值(前面已讨论)。摇杆方法获取摇杆的当前位置。对于我的摇杆,每个轴的范围是065535,每个摇杆当然有两个轴(水平和垂直)。当摇杆没有被按下时,它的位置将在水平和垂直轴的中心。

注意

这些方法可能不会与某些设备完全正常工作,因为不同的游戏控制器设置方式不同。但对于大多数游戏手柄式控制器,它应该可以正常工作。

提示

记住,你绝对不应该在游戏中硬编码控制。如果控制不正常或在他们特定的游戏控制器上不起作用,玩家会非常烦恼,并且发现他们无法更改它们,因为你在游戏中硬编码了控制。

使用 XInput 摇杆

再次强调,我们首先需要为我们的 XInput 设备添加一些成员变量。这次它们看起来有些不同,但它们就在这里:

Controller m_Controller1;
Gamepad m_Controller1StateCurrent;
Gamepad m_Controller1StateLast;

在 XInput 中,我们使用Controller类来表示控制器。Gamepad结构存储控制器的状态。和之前一样,我们有一个变量来保存我们的设备,还有两个额外的变量来保存其当前和之前的状态。

现在,我们将添加一个非常简短的新方法,名为InitXInput()。以下是它的代码:

private void InitXInput()
{
  m_Controller1 = new Controller(UserIndex.One);
}

这段代码为我们设置了一个 XInput 控制器以供使用。我们将其构造函数的值传递为UserIndex.One,以指示这个控制器将由玩家 1 使用。

我们需要修改我们用户输入类的构造函数,现在来调用这个新方法。我们还需要添加一些代码来初始化我们的 XInput 摇杆状态变量。如前所述,这是防止程序崩溃所必需的。以下是现在带有新代码高亮的构造函数看起来像什么:

public UserInput()
{
  InitDirectInput();
 InitXInput();

  m_KeyboardStateCurrent = new KeyboardState();
  m_KeyboardStateLast = new KeyboardState();

  m_MouseStateCurrent = new MouseState();
  m_MouseStateLast = new MouseState();

  m_Joy1StateCurrent = new JoystickState();
  m_Joy1StateLast = new JoystickState();

 m_Controller1StateCurrent = new Gamepad();
 m_Controller1StateLast = new Gamepad();
}

现在,我们必须将以下代码添加到我们用户输入类的Update()方法末尾:

m_Controller1StateLast = m_Controller1StateCurrent;
m_Controller1StateCurrent = m_Controller1.GetState().Gamepad;"
if (XI_IsButtonPressed(GamepadButtonFlags.A))
  System.Diagnostics.Debug.WriteLine("XINPUT: THE A BUTTON IS PRESSED!!");
if (XI_IsButtonPressed(GamepadButtonFlags.B))
  System.Diagnostics.Debug.WriteLine("XINPUT: THE B BUTTON IS PRESSED!!");
if (XI_IsButtonPressed(GamepadButtonFlags.X))
  System.Diagnostics.Debug.WriteLine("XINPUT: THE X BUTTON IS PRESSED!!");
if (XI_IsButtonPressed(GamepadButtonFlags.Y))
  System.Diagnostics.Debug.WriteLine("XINPUT: THE Y BUTTON IS PRESSED!!");

这段代码与我们之前的 DirectInput 摇杆测试代码非常相似。它将前一帧的状态数据复制到m_Controller1StateLast成员变量中,然后获取当前控制器状态并将其存储在m_Controller1StateCurrent变量中。

if语句就像我们用来测试 DirectInput 摇杆代码的那些一样。第一个检查A按钮是否被按下。如果是,它将在 Visual Studio 的输出面板中打印一条调试信息。第二个if语句对B按钮做同样的事情,最后两个if语句对XY按钮做相同处理。

你可能已经注意到,我们不需要在Update()方法的开头像处理 DirectInput 下的鼠标、键盘和摇杆那样获取XInput 控制器。相反,我们只是在我们的InitXInput()方法中设置了 XInput 控制器。你也可能注意到,我们不需要在我们的Dispose(bool)方法中添加代码来释放 XInput 控制器对象。它甚至没有Dispose()方法。

我们现在准备好测试我们的新代码了。你需要一个 XInput 兼容的控制器来测试它。如果你没有,这段代码仍然可以运行,但它不会做任何事情,因为没有 XInput 控制器可以从中获取输入。

如果你有一个支持 XInput 的控制器,你可能会看到这段代码的双重输出,因为 DirectInput 和 XInput 测试代码将同时向 Visual Studio 的输出窗格输出调试信息(如果它们都从同一个控制器读取输入),如下面的截图所示:

使用 XInput 摇杆

DirectInput 和 XInput 同时从同一设备读取输入

我们在这里只是触及了表面。XInput 还有更多我们尚未探讨的内容。例如,你可以通过访问左摇杆的LeftThumbXLeftThumbY属性以及右摇杆的RightThumbXRightThumbY属性来获取左右摇杆的状态。请注意,XInput 中摇杆轴值的范围始终是-32,76832,767

你也可能注意到,我们没有向用户输入类添加属性来提供对我们的摇杆对象的访问。它们将与我们在本章中添加的属性一样简单,所以它们已经被从章节中省略以节省空间。然而,它们确实包含在本章的可下载代码中。还包括了一组用于 XInput 设备的摇杆处理方法,包括XI_LeftStickPosition()XI_RightStickPosition()XI_LeftTrigger()XI_RightTrigger()等。XI当然是指 XInput。请注意,对于左右扳机,它们的值在0255之间,具体取决于你按下扳机的程度。此外,在 XInput 中,D-Pad 被视为常规按钮,因此你将在GamepadButtonFlags枚举中找到所有方向的按钮标志。这也适用于摇杆按钮。

探索 XInput Controller对象的各个属性,以了解更多你可以做什么。记住,我们将我们的Controller对象存储在m_Controller1成员变量中。尝试这段代码,看看你能发现什么。

注意,本章的可下载代码还包括一些额外的测试代码,用于在UserInput类的Update()方法中测试键盘和鼠标输入。这段代码与本章中显示的 DirectInput 和 XInput 的摇杆测试代码非常相似。

摘要

在本章中,我们快速学习了如何响应用户输入。首先,我们探讨了 DirectInput 和 XInput 之间的区别。然后,我们了解了如何检测和响应用户的鼠标和键盘输入。接下来,我们转向使用 DirectInput 与游戏手柄配合,首先我们学习了如何获取连接到计算机的可用游戏控制器的列表。为了简化,我们添加了代码来从列表中获取第一个游戏控制器并从中获取一些输入。我们编写了测试代码,当按下0123按钮时,会输出一些调试文本。最后,我们探讨了 XInput 控制器。我们从 XInput 控制器获取输入所实现的代码与 DirectInput 代码非常相似,但略有不同。最后,我们添加了一些代码,每当你在 XInput 控制器上按下ABXY按钮时,都会将一些调试文本写入 Visual Studio 的输出面板。在下一章中,我们将学习如何在屏幕上绘制 2D 图形并创建一个基于 2D 瓦片的游戏世界。

第三章。渲染 2D 图形

在视频游戏中,最大的一个方面就是图形。这也是为什么我们称之为视频游戏!那么我们如何在屏幕上创建图像呢?就像我们在上一章中处理用户输入一样,我们这里有几个选择。它们是Direct2DDirect3D。在本章中,我们将专注于 Direct2D,并将 Direct3D 留到以后的章节中。

在本章中,我们将涵盖以下主题:

  • 创建 Direct2D 游戏窗口类

  • 在屏幕上绘制矩形

  • 创建一个基于 2D 瓦片的游戏世界和实体

创建 Direct2D 游戏窗口类

我们终于准备好在屏幕上放置一些图形了!对我们来说,第一步是创建一个新的游戏窗口类,它将使用 Direct2D。这个新的游戏窗口类将派生自我们的原始游戏窗口类,同时添加 Direct2D 功能。

注意

您需要下载本章的代码,因为为了节省空间,一些代码被省略了。

打开 Visual Studio,我们将开始我们的Ch03项目。向Ch03项目添加一个名为GameWindow2D的新类。我们需要将其声明更改为:

public class GameWindow2D : GameWindow, IDispoable

如您所见,它继承自GameWindow类,这意味着它具有GameWindow类的所有公共和受保护成员,就像我们在这个类中再次实现了它们一样。它还实现了IDisposable接口,就像GameWindow类一样。另外,如果您还没有这样做,请别忘了将 SlimDX 添加到这个项目中。

我们还需要在这个类文件顶部添加一些using语句。它们都是GameWindow类中相同的using语句,再加上一个。新的一个是SlimDX.Direct2D。它们如下所示:

using System.Windows.Forms;
using System.Diagnostics;
using System.Drawing;
using System;
using SlimDX;
using SlimDX.Direct2D;
using SlimDX.Windows;

接下来,我们需要创建一些成员变量:

WindowRenderTarget m_RenderTarget;
Factory m_Factory;
PathGeometry m_Geometry;
SolidColorBrush m_BrushRed;
SolidColorBrush m_BrushGreen;
SolidColorBrush m_BrushBlue;

第一个变量是一个WindowRenderTarget对象。术语渲染目标用于指代我们将要绘制的表面。在这种情况下,它就是我们的游戏窗口。然而,情况并不总是如此。游戏也可以渲染到其他地方。例如,将渲染到纹理对象用于创建各种效果。一个例子就是一个简单的安全摄像头效果。比如说,我们在一个房间里有一个安全摄像头,在另一个房间里有一个监视器。我们想让监视器显示安全摄像头所看到的内容。为此,我们可以将摄像头的视图渲染到纹理中,然后可以使用这个纹理来纹理化监视器的屏幕。当然,这必须在每一帧重新执行,以便监视器屏幕显示摄像头当前看到的内容。这个想法在 2D 中也很实用。

回到我们的成员变量,第二个是一个Factory对象,我们将用它来设置我们的 Direct2D 相关内容。它用于创建 Direct2D 资源,例如RenderTargets。第三个变量是一个PathGeometry对象,它将保存我们将要绘制的第一个图形的几何形状,这将是一个矩形。最后三个变量都是SolidColorBrush对象。我们使用这些对象来指定我们想要用其绘制的颜色。它们还有更多功能,但现阶段我们只需要这些。

构造函数

现在,让我们将注意力转向我们的 Direct2D 游戏窗口类的构造函数。它将做两件事。首先,它将调用基类构造函数(记住基类是原始的GameWindow类),然后初始化我们的 Direct2D 相关内容。以下是我们构造函数的初始代码:

public GameWindow2D(string title, int width, int height,bool fullscreen)
    : base(title, width, height, fullscreen)
{
    m_Factory = new Factory();

    WindowRenderTargetProperties properties = new WindowRenderTargetProperties();
    properties.Handle = FormObject.Handle;
    properties.PixelSize = new Size(width, height);

    m_RenderTarget = new WindowRenderTarget(m_Factory, properties);
}

在前面的代码中,以冒号开始的行是在为我们调用基类构造函数。这确保了从基类继承的所有内容都被初始化。在构造函数的主体中,第一行创建了一个新的Factory对象,并将其存储在我们的m_Factory成员变量中。接下来,我们创建了一个WindowRenderTargetProperties对象,并将我们的RenderForm对象的句柄存储在其中。请注意,FormObject是我们定义在第一章,入门GameWindow基类中的一个属性,但我们没有在书中详细讨论这个属性。你可以在本书的可下载代码中看到它。记住,RenderForm对象是一个 SlimDX 对象,它代表了一个我们可以绘制的窗口。下一行将游戏窗口的大小保存到PixelSize属性中。WindowRenderTargetProperties对象基本上是我们创建WindowRenderTarget对象时指定其初始配置的方式。构造函数中的最后一行创建我们的WindowRenderTarget对象,并将其存储在我们的m_RenderTarget成员变量中。我们传递的两个参数是我们刚刚创建的Factory对象和WindowRenderTargetProperties对象。WindowRenderTarget对象是一个指向窗口客户端区域的渲染目标。我们使用WindowRenderTarget对象在窗口中绘制。

创建我们的矩形

现在我们已经设置了渲染目标,我们准备开始绘制内容,但首先我们需要创建一些可以绘制的东西!因此,我们将在构造函数的底部添加一些额外的代码。首先,我们需要初始化我们的三个SolidColorBrush对象。在构造函数的底部添加以下三行代码:

m_BrushRed = new SolidColorBrush(m_RenderTarget, new Color4(1.0f, 1.0f, 0.0f, 0.0f));
m_BrushGreen = new SolidColorBrush(m_RenderTarget, new Color4(1.0f, 0.0f, 1.0f, 0.0f));
m_BrushBlue = new SolidColorBrush(m_RenderTarget, new Color4(1.0f, 0.0f, 0.0f, 1.0f));

这段代码相当简单。对于每个画笔,我们传入两个参数。第一个参数是我们将在其上使用此画笔的渲染目标。第二个参数是画笔的颜色,它是一个 ARGBAlpha 红绿蓝)值。我们给颜色的第一个参数是 1.0f。末尾的 f 字符表示这个数字是 float 数据类型。我们将 alpha 设置为 1.0,因为我们希望画笔是完全不透明的。值为 0.0 将使其完全透明,值为 0.5 将是 50% 透明。接下来,我们有红色、绿色和蓝色参数。这些都是在 0.01.0 范围内的 float 值。正如您所看到的,对于红色画笔,我们将红色通道设置为 1.0f,绿色和蓝色通道都设置为 0.0f。这意味着我们颜色中有最大红色,但没有绿色或蓝色。

我们已经设置了 SolidColorBrush 对象,现在我们有三个画笔可以用来绘制,但我们仍然缺少绘制的内容!所以,让我们通过添加一些代码来创建我们的矩形。将以下代码添加到构造函数的末尾:

m_Geometry = new PathGeometry(m_RenderTarget.Factory);

using (GeometrySink sink = m_Geometry.Open())
{
    int top = (int) (0.25f * FormObject.Height);
    int left = (int) (0.25f * FormObject.Width);
    int right = (int) (0.75f * FormObject.Width);
    int bottom = (int) (0.75f * FormObject.Height);

    PointF p0 = new Point(left, top);
    PointF p1 = new Point(right, top);
    PointF p2 = new Point(right, bottom);
    PointF p3 = new Point(left, bottom);

    sink.BeginFigure(p0, FigureBegin.Filled);
    sink.AddLine(p1);
    sink.AddLine(p2);
    sink.AddLine(p3);
    sink.EndFigure(FigureEnd.Closed);
    sink.Close();
}

这段代码稍微长一些,但仍然相当简单。第一行创建了一个新的 PathGeometry 对象,并将其存储在我们的 m_Geometry 成员变量中。下一行开始 using 块,并创建了一个新的 GeometrySink 对象,我们将使用它来构建矩形的几何形状。using 块将在程序执行到达 using 块的末尾时自动为我们释放 GeometrySink 对象。

注意

using 块仅与实现 IDisposable 接口的对象一起工作。

接下来的四行计算矩形的每条边的位置。例如,第一行计算矩形顶边的垂直位置。在这种情况下,我们使矩形的顶边从屏幕顶部向下延伸的 25%。然后,我们对矩形的其他三边做同样的处理。第二组四行代码创建了四个 Point 对象,并使用我们刚刚计算出的值初始化它们。这四个 Point 对象代表矩形的角点。一个点也常被称为 顶点。当我们有多个顶点时,我们称它们为 顶点(发音为 vert-is-ces)。

最后一段代码有六行。它们使用我们刚刚创建的GeometrySinkPoint对象来设置矩形在PathGeometry对象内部的几何形状。第一行使用BeginFigure()方法开始创建一个新的几何图形。接下来的三行各自通过添加另一个点或顶点来向图形添加一个线段。当所有四个顶点都添加完毕后,我们调用EndFigure()方法来指定我们已完成顶点的添加。最后一行调用Close()方法来指定我们已完成几何图形的添加,因为我们可能想要添加多个。在这种情况下,我们只添加了一个几何图形,即我们的矩形。

绘制我们的矩形

由于我们的矩形从不改变,所以我们不需要在我们的UpdateScene()方法中添加任何代码。无论如何,我们都会覆盖基类的UpdateScene()方法,以防我们以后需要在这里添加一些代码,如下所示:

public override void UpdateScene(double frameTime)
{
    base.UpdateScene(frameTime);
}

如您所见,我们在这个基类UpdateScene()方法的override修饰符中只有一行代码。它只是调用基类版本的此方法。这很重要,因为基类的UpdateScene()方法包含我们每帧获取最新用户输入数据的代码,您可能还记得上一章的内容。

现在,我们终于准备好编写代码,将我们的矩形绘制到屏幕上了!我们将覆盖RenderScene()方法,以便我们可以添加我们的自定义代码:

public override void RenderScene()

{
    if ((!this.IsInitialized) || this.IsDisposed)
    {
        return;
    }

    m_RenderTarget.BeginDraw();
    m_RenderTarget.Clear(ClearColor);
    m_RenderTarget.FillGeometry(m_Geometry, m_BrushBlue);
    m_RenderTarget.DrawGeometry(m_Geometry, m_BrushRed, 1.0f);
    m_RenderTarget.EndDraw();
}

首先,我们有一个if语句,碰巧与我们在基类的RenderScene()方法中放置的那个相同。这是因为我们没有调用基类的RenderScene()方法,因为其中唯一的代码就是这个if语句。不调用这个方法的基类版本将给我们带来轻微的性能提升,因为我们没有函数调用的开销。我们也可以用UpdateScene()方法做同样的事情。在这种情况下我们没有这样做,因为基类版本的该方法中有很多代码。在你的项目中,你可能想要将那段代码复制粘贴到你的UpdateScene()方法覆盖版本中。

下一条代码调用渲染目标的 BeginDraw() 方法,告诉它我们已准备好开始绘制。然后,在下一条代码中,我们通过填充由我们的 GameWindow 基类定义的 ClearColor 属性中存储的颜色来清除屏幕。最后三条代码绘制我们的几何形状两次。首先,我们使用渲染目标的 FillGeometry() 方法绘制它。这将使用指定的画笔(在这种情况下,纯蓝色)填充我们的矩形。然后,我们再次绘制矩形,但这次使用 DrawGeometry() 方法。这次只绘制形状的线条,不填充它,因此在矩形上绘制一个边框。DrawGeometry() 方法上的额外参数是可选的,并指定了我们正在绘制的线条的宽度。我们将其设置为 1.0f,这意味着线条将是一像素宽。最后一行调用 EndDraw() 方法,告诉渲染目标我们已经完成绘制。

清理

如同往常,当程序关闭后,我们需要自己清理。因此,我们需要添加基类 Dispose(bool) 方法的 override,就像我们在上一章所做的那样。我们已经做过几次了,所以这应该相当熟悉,这里不再展示。查看本章的可下载代码以查看此代码。

清理

我们带有红色边框的蓝色矩形

如你所猜,你可以用绘制几何图形做更多的事情。例如,你可以绘制曲线线段,也可以使用渐变画笔绘制形状。你还可以使用渲染目标的 DrawText() 方法在屏幕上绘制文本。但由于这些页面空间有限,我们将探讨如何在屏幕上绘制位图图像。这些图像构成了大多数二维游戏图形的一部分。

位图渲染

我们不会简单地演示在屏幕上绘制单个位图,而是将创建一个小的二维基于瓦片的游戏世界。在二维图形中,瓦片是指代表二维世界中一个空间方格的小位图图像。瓦片集瓦片图是一个包含多个瓦片的单个位图文件。单个二维图形瓦片也被称为精灵。要开始,请向 SlimFramework 解决方案中添加一个名为 TileWorld 的新项目。到目前为止,我们直接使用了我们制作的游戏窗口类。这次,我们将看看如何在现实世界的游戏项目中这样做。

TileWorld 项目添加一个新类文件,并将其命名为 TileGameWindow.cs。正如你可能猜到的,我们将使这个新类继承自 SlimFramework 项目中的 GameWindow 类。但首先,我们需要添加对 SlimFramework 项目的引用。我们已经讨论过这一点,所以请继续添加引用。别忘了也要添加对 SlimDX 的引用。如果你还没有,你还需要添加对 System.Drawing 的引用。另外,别忘了将 TileWorld 设置为启动项目。

接下来,我们需要将using语句添加到TileGameWindow.cs文件的顶部。我们需要添加以下using语句:

using System.Windows.Forms;
using System.Collections,Generic;
using System.Diagnostics;
using System.Drawing;
using System;

using SlimDX;
using SlimDX.Direct2D;
using SlimDX.DirectInput;
using SlimDX.Windows;

接下来,我们需要创建几个结构体和成员变量。首先,让我们在这个类的顶部定义以下常量

const float PLAYER_MOVE_SPEED = 0.05f;

这个常量定义了玩家的移动速度。常量只是一个初始化后其值不能改变的变量,因此其值始终相同。现在,我们需要一个地方来存储关于我们的玩家角色的信息。我们将创建一个名为Player的结构体。只需在以下代码中将其添加到我们刚刚创建的常量下方:

public struct Player
{
    public float PositionX;
    public float PositionY;
    public int AnimFrame;
    public double LastFrameChange;
}

这个结构体的前两个成员变量存储玩家在 2D 世界中的当前位置。AnimFrame变量跟踪玩家角色当前所在的动画帧,最后一个变量跟踪玩家角色在当前动画帧上的时间。这是为了确保动画的运行速度大致相同,无论您的 PC 速度有多快。

现在,我们需要在这个结构体下方添加第二个结构体。我们将把这个结构体命名为Tile。它存储单个瓦片的信息。正如你可能猜到的,我们将创建一个包含我们游戏世界中每种瓦片类型的结构体列表。以下是一个Tile结构体的示例:

public struct Tile
{
    public bool IsSolid;
    public int SheetPosX;
    public int SheetPosY;
}

第一个变量表示这个瓦片是否是实心的。如果一个瓦片是实心的,这意味着玩家不能在其上或穿过它行走。例如,砖墙瓦片将设置为true,因为我们不希望玩家穿过砖墙!这个结构体的最后两个成员变量持有瓦片图像在瓦片图中的坐标。

接下来,让我们将注意力转向为TileGameWindow类创建成员变量。您可以将这些变量添加到我们刚刚创建的结构体下方,如下所示:

WindowRenderTarget m_RenderTarget;
Factory m_Factory;

Player m_Player;
SlimDX.Direct2D.Bitmap m_PlayerSprites;
SlimDX.Direct2D.Bitmap m_TileSheet;

List<Tile> m_TileList;
int[ , ] m_Map;
SolidColorBrush m_DebugBrush;

前两个成员变量应该与我们本章开头编写的矩形程序中熟悉。m_Player变量持有Player对象。这是我们之前创建的第一个结构体。接下来的两个变量将持有我们将用于此程序的位图图像。一个持有组成玩家角色动画的精灵,另一个将持有我们将用于绘制游戏世界的瓦片图。下一个变量是一个名为m_TileList的列表。我们将为每种瓦片类型添加一个条目。m_Map变量,正如你可能猜到的,将包含我们的游戏世界地图。最后,我们有一个名为m_DebugBrushSolidColorBrush成员变量。

初始化

现在,是时候创建构造函数并开始初始化一切了。首先,我们需要设置渲染目标。这与我们在创建矩形的程序中所做的方法非常相似,但略有不同。以下是一段代码:

m_Factory = new Factory();

RenderTargetProperties rtProperties = new RenderTargetProperties();
rtProperties.PixelFormat = new PixelFormat(SlimDX.DXGI.Format.B8G8R8A8_UNorm, AlphaMode.Premultiplied);

WindowRenderTargetProperties properties = new WindowRenderTargetProperties();
properties.Handle = FormObject.Handle;
properties.PixelSize = new Size(width, height);

m_RenderTarget = new WindowRenderTarget(m_Factory, rtProperties, properties);

m_DebugBrush = new SolidColorBrush(m_RenderTarget, new Color4(1.0f, 1.0f, 1.0f, 0.0f));

正如我们在创建矩形的程序中所做的那样,我们首先创建工厂对象。然后,事情略有不同。这次我们需要创建两个属性对象而不是一个。新的一个是 RenderTargetProperties 对象。我们使用它来设置渲染目标的像素格式。正如你所见,我们正在使用一个 32 位格式,每个通道(蓝色、绿色、红色和 alpha)有 8 位。是的,这与我们之前讨论过的 ARGB 格式相反。不过没关系,因为我们的 LoadBitmap() 方法会为我们将 ARGB 格式翻转成 BGRA。下一行代码创建了一个 WindowRenderTargetProperties 对象,就像我们在本章早些时候的 Rectangle 程序中所做的那样。我们使用这个对象来指定我们想要绘制的窗口句柄以及窗口的大小。最后,我们创建渲染目标对象,并将我们的调试画笔初始化为不透明的黄色画笔。

那么,我们现在已经完成了初始化工作,对吧?嗯,不;还不是。我们还有一些东西需要初始化。但首先,我们需要创建我们的 LoadBitmap() 方法,这样我们就可以加载我们的图形了!以下就是代码:

public SlimDX.Direct2D.Bitmap LoadBitmap(string filename)
{
    // This will hold the Direct2D Bitmap that we will return at the end of this function.SlimDX.Direct2D.Bitmap d2dBitmap = null;

    // Load the bitmap using the System.Drawing.Bitmap class.
      System.Drawing.Bitmap originalImage = new System.Drawing.Bitmap(filename);
    // Create a rectangle holding the size of the bitmap image.
    Rectangle bounds = new Rectangle(0, 0, originalImage.Width, originalImage.Height);

    // Lock the memory holding this bitmap so that only we are allowed to mess with it.
    System.Drawing.Imaging.BitmapData imageData = originalImage.LockBits(bounds, System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format32bppPArgb);

    // Create a DataStream attached to the bitmap.
    SlimDX.DataStream dataStream = new DataStream(imageData.Scan0,  imageData.Stride * imageData.Height, true, false);

    // Set the pixel format and properties.
    PixelFormat pFormat = new PixelFormat(SlimDX.DXGI.Format.B8G8R8A8_UNorm, AlphaMode.Premultiplied);
    BitmapProperties bmpProperties = new BitmapProperties();
    bmpProperties.PixelFormat = pFormat;

    // Copy the image data into a new SlimDX.Direct2D.Bitmap object.
    d2dBitmap = new SlimDX.Direct2D.Bitmap(m_RenderTarget, new Size(bounds.Width, bounds.Height), dataStream, imageData.Stride, bmpProperties);

    // Unlock the memory that is holding the original bitmap object.
    originalImage.UnlockBits(imageData);

    // Get rid of the original bitmap object since we no longer need it.
    originalImage.Dispose();
    // Return the Direct2D bitmap.
    return d2dBitmap;
}

这个方法有点令人困惑,所以我保留了代码列表中的注释。你可能已经注意到,在调用 LockBits() 方法的行中,有一个像素格式参数,但它与我们本章稍早前看到的格式不同;它是 System.Drawing.Imaging.PixelFormat.Format32bppPArgb。这是我们正在使用的相同格式,但那里的 P 是什么意思呢?P 是指 预先计算的 alpha。这基本上意味着在渲染之前,红色、绿色和蓝色通道会根据 alpha 值自动调整。所以,如果你将红色通道设置为最大值,而 alpha 通道为 50%,红色通道的强度将减半。

还有 直接 alpha,它比预先计算的 alpha 效率低。红色、绿色和蓝色通道的值保持不变。它们的强度根据渲染时的 alpha 通道值进行调整。预先计算的 alpha 比较快,因为它在渲染发生之前只调整一次颜色通道,而直接 alpha 每次我们渲染新帧时都必须调整颜色通道。最后,还有一个 忽略 alpha 模式。在这个模式下,alpha 通道被完全忽略,因此你不能使用透明位图。

在这个情况下,我们正在使用预先计算的 alpha 模式,并且这很重要。如果你不这样做,玩家角色在机器人图像的所有透明区域都会有白色,这看起来相当滑稽。我们使用 LockBits() 方法锁定包含位图的内存,因为如果在另一个线程上的其他代码在访问该内存的同时我们在对其进行操作,这可能会导致崩溃和其他奇怪的行为。

现在,让我们回到构造函数,初始化玩家角色,它将是一个相当愚蠢的机器人。在构造函数的底部添加以下代码:

m_PlayerSprites = LoadBitmap(Application.StartupPath + "\\Robot.png");

m_Player = new Player();
m_Player.PositionX = 4;
m_Player.PositionY = 8;

代码的第一行使用我们的LoadBitmap()方法来加载机器人精灵图集并将其存储在m_PlayerSprites成员变量中。第二行创建玩家对象以保存有关玩家角色的信息。最后两行设置玩家的起始位置。请注意,坐标(0,0)代表屏幕的左上角。机器人精灵图集只是我们机器人的一系列动画帧,我们将快速连续显示这些帧来动画化机器人。

现在玩家对象已经初始化,我们需要初始化游戏世界!以下代码是第一部分:

m_TileSheet = LoadBitmap(Application.StartupPath + "\\TileSheet.png");

m_TileList = new List<Tile>();

// First row of sprites in the sprite sheet.
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 0, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 1, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 2, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 3, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 4, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 5, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = false, SheetPosX = 6, SheetPosY = 0 });
m_TileList.Add(new Tile() { IsSolid = true, SheetPosX = 7, SheetPosY = 0 });

第一行再次调用我们的LoadBitmap()方法来加载瓦片图集并将其存储在m_TileSheet成员变量中。第二行创建我们的瓦片列表对象。这将存储有关每种瓦片类型的信息。底部八行代码在瓦片列表中为瓦片图集的第一行的所有瓦片创建条目。当然,瓦片图集中有不止一行瓦片,但在这里我不会展示其他行的代码,因为它非常相似,并且会占用几页纸。

为了完成初始化游戏世界,我们还有一件事要做。这包括初始化地图。地图简单地是一个二维数组。数组中的每个元素代表游戏世界中的一个瓦片位置。因此,数组的数据类型是int;它是int类型,因为每个元素存储一个瓦片列表中的数字索引。所以基本上,数组中的每个元素都包含一个数字,告诉我们游戏世界中这个位置上的瓦片类型。由于填充这个数组的代码太宽,无法适应页面,我将在这里展示一个简短的初始化示例:

m_Map = new int[,] { {14, 14, 14 },
                     {14, 0, 14 },
                     {14, 14, 14 } };

如您所见,我们正在创建一个新的二维int数组。在这个示例代码中,我们有一个 3 x 3 的世界。我们使用瓦片类型14(一块砖墙瓦片)来围绕这个小世界的边缘建造墙壁。在中心,我们有瓦片类型0,在我们的游戏演示中,这是一个草地瓦片。每个值行都有一对括号{},后面跟着一个逗号。这基本上就是设置 2D 瓦片地图的方法。当然,你可以使它更加复杂。例如,你可以在游戏中实现动画瓦片类型。这些动画将与我们将要动画化的机器人角色非常相似。查看本章的可下载代码,以查看完整的数组初始化代码,它比之前的示例要大得多。

渲染游戏世界

为了清晰起见,我们将创建几个不同的渲染方法,每个方法都将从我们的 RenderScene() 方法中调用。由于我们需要首先绘制的是游戏世界本身,让我们首先创建这个方法。我们将把这个方法命名为 RenderWorld

public void RenderWorld()
{
    Tile s;
    // Loop through the y axis.
    for (int y = 0; y < m_Map.GetLength(0); y++)
    {
        // Loop through the x axis.
        for (int x = 0; x < m_Map.GetLength(1); x++)
        {
            // Get the tile at the current coordinates.
            s = m_TileList[ m_Map[y, x] ];

            // Render the tile.
            m_RenderTarget.DrawBitmap(m_TileSheet,
                new Rectangle(x * 32, y * 32, 32, 32),
                1.0f,
                InterpolationMode.Linear,
                new Rectangle(s.SheetPosX * 32,
                              s.SheetPosY * 32,
                               32, 32));
        }
    }
}

这段代码相当直接。第一行创建了一个 Tile 对象变量。接下来,我们有两个嵌套的 for 循环,它们遍历游戏世界中每个瓦片的位置。在内层 for 循环中,我们获取地图上这个位置的瓦片类型,并在瓦片列表中查找它。我们将结果存储在变量 s 中,这样我们就可以方便地之后使用它。最后一行渲染瓦片。这里的第一个参数是包含瓦片的位图。第二个参数是一个矩形,指定我们在屏幕上想要绘制瓦片的位置。第三个参数是不透明度。我们将其设置为 1.0f,这样瓦片就完全不透明。第三个参数是插值模式。最后一个参数是另一个矩形,它指定我们在屏幕上想要绘制瓦片图的一部分。为此,我们指定包含我们想要绘制的瓦片的瓦片图的一部分。对于两个矩形参数的 x 和 y 坐标,你可能已经注意到我们正在乘以 32。这是因为每个瓦片的大小是 32 x 32 像素。因此,我们必须乘以 32 来正确获取瓦片在瓦片图中的位置。我们的瓦片大小为 32 x 32 像素的事实也是为什么我们在这里创建的两个矩形都指定了它们的 widthheight 参数的值为 32

渲染玩家角色

现在我们已经有了绘制世界的代码,我们需要绘制玩家角色!为此,我们将创建一个名为 RenderPlayer() 的方法。与 RenderWorld() 方法相比,这个方法相当简短。以下是对应的代码:

public void RenderPlayer()
{
    // Render the player character.
    m_RenderTarget.DrawBitmap(m_PlayerSprites,
          new Rectangle((int) (m_Player.PositionX * 32),
                        (int) (m_Player.PositionY * 32),
                        32, 32),
                       1.0f,
                       InterpolationMode.Linear,new Rectangle(m_Player.AnimFrame * 32,
                                      0, 32, 32));
}

这个方法只包含一行。它与我们在 RenderWorld() 方法中用来绘制每个瓦片的代码非常相似。但这次我们使用的是玩家精灵图,而不是瓦片图。你可能也会注意到,我们是根据玩家对象的 AnimFrame 变量来确定要绘制哪个精灵的,我们使用这个变量来跟踪机器人当前所在的动画帧。

渲染调试信息

这并不是严格必要的,但了解如何做总是一件好事。我们将创建一个新的方法,称为 RenderDebug()。它将在游戏世界中每个实心瓦片上绘制一个黄色边框。以下是对应的代码:

public void RenderDebug()
{
    Tile s;

     // Loop through the y axis.
     for (int y = 0; y < m_Map.GetLength(0); y++)
     {
         // Loop through the x axis.
         for (int x = 0; x < m_Map.GetLength(1); x++)
         {
             // Get the tile at the current coordinates.
             s = m_TileList[m_Map[y, x]];

             // Check if the tile is solid. If so, draw a yellow border on it.
             if (s.IsSolid)
                 m_RenderTarget.DrawRectangle(m_DebugBrush,
                     new Rectangle(x * 32, y * 32, 32, 32));
        }
    }
}

如你所见,这个方法看起来与 RenderWorld() 方法非常相似;它像那个方法一样遍历游戏世界中的每个位置。唯一的重大区别是我们在这里使用的是 DrawRectangle() 方法,而不是 DrawBitmap() 方法。使用我们的黄色调试画笔,它会在游戏世界中任何实心瓦片上绘制一个黄色边框。

完成渲染代码

现在我们需要在RenderScene()方法中添加代码来调用我们刚才创建的方法。以下是RenderScene()代码:

public override void RenderScene()
{
    if ((!this.IsInitialized) || this.IsDisposed)
    {
        return;
    }

    m_RenderTarget.BeginDraw();
    m_RenderTarget.Clear(ClearColor);

    RenderWorld();

#if DEBUG
    RenderDebug();
#endif

    RenderPlayer();

    // Tell the render target that we are done drawing.
    m_RenderTarget.EndDraw();
}

有了这些,我们的渲染代码现在已经完成。顶部的if语句防止程序在启动或关闭时崩溃。接下来的两行通过调用BeginDraw()方法通知渲染目标我们准备开始绘制,然后通过调用Clear()方法清除屏幕。下一行调用我们的RenderWorld()方法来绘制游戏世界。但是,调用RenderDebug()方法之前是#if DEBUG,之后是#endif。这些被称为预处理器指令。这个指令检查一个名为DEBUG的符号是否已定义,如果是,则这个if指令内部的代码将被编译进程序。预处理器指令由预处理器处理,它在编译代码之前运行。预处理器完成工作后,编译器将运行。除了#if之外,还有许多其他的预处理器指令,但它们超出了本文的范围。当你以Debug配置编译代码时,DEBUG符号会自动为我们定义,这意味着我们的RenderDebug()调用将被编译进游戏。在 Visual Studio 中,你可以通过点击位于开始按钮右侧的下拉列表框来更改编译配置,点击该按钮以编译和运行你的程序。Visual Studio 提供DebugRelease配置。你也可以通过按F5键来运行程序。

下一行调用我们的RenderPlayer()方法,使用机器人精灵图集中的适当动画帧来绘制玩家角色。最后,我们调用EndDraw()方法来告知渲染目标我们已经完成了这一帧的渲染。

处理用户输入

现在,我们需要在我们的UpdateScene()方法中添加一些代码来处理玩家输入:

base.UpdateScene(frameTime);

// Figure out which grid square each corner of the player sprite is currently in.
PointF TL = new PointF(m_Player.PositionX + 0.25f, m_Player.PositionY + 0.25f); // Top left corner
PointF BL = new PointF(m_Player.PositionX + 0.25f, m_Player.PositionY + 0.75f); // Bottom left corner
PointF TR = new PointF(m_Player.PositionX + 0.75f, m_Player.PositionY + 0.25f); // Top right corner
PointF BR = new PointF(m_Player.PositionX + 0.75f, m_Player.PositionY + 0.75f); // Bottom right corner

第一行调用基类的UpdateScene()方法,以便它能够执行其功能。接下来的四行可能看起来有些奇怪。为什么我们需要找出玩家精灵每个角落所在的网格方块?这与我们的玩家移动方式有关。具体来说,这是由我们的碰撞检测代码使用的。

你可能也会注意到,前四行代码将四个角落向内倾斜了 25%。你可以将这些四个角落视为我们的碰撞检测边界框。以这种方式缩小边界框使得玩家更容易进入只有一格宽的狭窄空间。注意,TL代表左上角,TR代表右上角,BL代表左下角,BR代表右下角。以下是我们碰撞检测代码的第一部分:

// Check if the user is pressing left.
if (m_UserInput.KeyboardState_Current.IsPressed(Key.A) ||
   (m_UserInput.KeyboardState_Current.IsPressed(Key.LeftArrow)))
{
    if ((!m_TileList[m_Map[(int) TL.Y, (int) (TL.X - PLAYER_MOVE_SPEED)]].IsSolid) && (!m_TileList[m_Map[(int) BL.Y, (int) (BL.X – PLAYER_MOVE_SPEED)]].IsSolid)){
         m_Player.PositionX -= PLAYER_MOVE_SPEED;
     }
}

这段代码从一个复合的if语句开始,检查用户是否按下了A键或左箭头键。是的,你可以使用WASD键,或者如果你希望使用键盘移动角色,可以使用箭头键来控制我们的游戏角色。接下来,我们还有一个if语句。这个if语句检查将玩家向左移动是否会导致碰撞。如果没有,我们就将玩家向左移动。正如你所见,我们使用了我们在本章早期创建的PLAYER_MOVE_SPEED常量来控制机器人移动的距离。显然,我们需要再添加三个这样的if语句来处理右、上和下方向。由于代码非常相似,这里我就不再描述了。

注意

本章的可下载代码也支持使用摇杆/游戏手柄来控制机器人。它向TileGameWindow类添加了一个名为m_UseDirectInput的成员变量。将此变量设置为true以使用 DirectInput 进行摇杆/游戏手柄控制,或将此变量设置为false以使程序使用 XInput 进行摇杆/游戏手柄控制。我们需要m_UseDirectInput成员变量,因为如果我们同时使用 DirectInput 和 XInput 来控制同一个游戏控制器设备,这将导致玩家每帧移动两次。

玩家角色的动画

用户输入和碰撞检测代码完成后,现在在UpdateScene()中只剩下一件事要做。我们需要添加一些代码来动画化玩家角色:

m_Player.LastFrameChange += frameTime;
if (m_Player.LastFrameChange > 0.1)
{
    m_Player.LastFrameChange = 0;
    m_Player.AnimFrame++;
    if (m_Player.AnimFrame > 7)
       m_Player.AnimFrame = 0;
}

这段代码相当简单。第一行将frameTime添加到玩家对象的LastFrameChange变量中。记住,frameTimeUpdateScene()方法的参数,它包含了自上一帧以来经过的时间量。接下来,我们有一个if语句,检查玩家对象的LastFrameChange变量是否有大于0.1的值。如果是这样,这意味着自上次我们更改动画帧以来已经过去了 1/10 秒或更长时间,因此我们将再次更改它。在if语句内部,我们将LastFrameChange变量重置为0,这样我们就会知道何时再次更改动画帧。下一行增加玩家对象的AnimFrame变量的值。最后,我们还有一个if语句,检查AnimFrame变量的新值是否太大。如果是,我们将它重置为0,动画从头开始。

运行游戏

我们几乎准备好运行游戏了,但别忘了你需要添加Dispose(bool)方法。在这个程序中,只有四个对象需要释放。它们是m_RenderTargetm_Factorym_TileSheetm_DebugBrush。它们应该在Dispose(bool)方法的托管部分被释放。你可以在本章的可下载代码中看到这一点。

在清理代码就绪后,我们就可以运行游戏了。正如你所见,你控制着一个相当滑稽的机器人。请注意,玩家精灵在Robot.png文件中,而瓦片图集保存在TileSheet.png文件中。当然,这两个文件都包含在本章可下载的代码中。随后的截图显示了关闭调试覆盖层时的游戏窗口外观。

你可能已经注意到我们没有实现全屏模式。这是因为 Direct2D 不幸地不支持全屏模式。然而,在 Direct2D 应用程序中实现全屏模式是可能的。要做到这一点,你需要创建一个 Direct3D 渲染目标并将其与 Direct2D 共享。这样你就可以用 Direct2D 在上面绘制,并且能够使用全屏模式。

运行游戏

运行中的 2D 游戏

下面的截图显示了开启调试覆盖层时的游戏。

运行游戏

开启调试覆盖层时的 2D 游戏运行情况

实体

我们创建的这个 2D 游戏演示中只有一个实体(玩家角色)。在游戏开发中,实体这个术语指的是可以与游戏世界或其他游戏世界中的对象交互的对象。实体通常会被实现为一个类。因此,我们会创建一个类来表示我们的玩家对象。在这个演示中,我们的玩家对象非常简单,里面没有方法,所以我们只是将它做成一个结构体。在一个真正的游戏引擎中,你可能会有一个Entity基类,所有其他的实体类都会从这个基类继承。这个基类会定义如Update()Draw()等方法,以便每个实体都有这些方法。然后每个实体类会重写它们以提供自己的自定义更新和绘制代码。

一个单独的水平或游戏世界可以有数百个实体,那么我们如何管理它们呢?一种方法是为当前加载的水平或世界中的实体集合创建一个EntityManager类。EntityManager类将有一个Update()方法和一个Draw()方法。Update()方法当然会被我们的游戏窗口类的UpdateScene()方法每帧调用一次。同样,Draw()方法也会被RenderScene()方法每帧调用一次。实体管理器的Update()方法会遍历所有实体并调用每个实体的Update()方法,以便实体可以更新自己。当然,实体管理器的Draw()方法也会做同样的事情,但它会调用每个实体的Draw()方法,以便实体可以绘制自己。

在一些游戏中,实体可以通过某种消息系统相互通信。一个很好的例子是《半条命 2》中使用的输入和输出系统。例如,在门旁边墙上有一个按钮。我们将在按钮上设置一个输出,当按钮被按下时触发。我们将将其连接到门的输入,使门打开。所以,基本上,当按钮的输出触发时,它会激活门上的指定输入。简而言之,按钮向门发送一条消息,告诉它打开。一个对象的输出可以潜在地向其目标输入发送参数。这里的重大好处是,许多对象之间的交互可以像这样处理,并且不需要专门编码,而只需在游戏关卡编辑器中简单设置即可。

基于组件的实体

实现我们的实体还有另一种方法。它实现了一个用于表示任何可能实体的Entity类。不同之处在于,这个Entity类包含了一组Components组件是一个类,它代表游戏世界中一个对象可以拥有的特定动作或特性。例如,你可能有一个装甲组件,允许实体拥有装甲值,或者一个健康组件,允许实体拥有健康和承受伤害的能力。这个健康组件可能有一个属性来设置实体的最大健康值,另一个属性用于获取实体的当前健康值。

这是一种非常强大的方法,因为你可以通过将健康组件(以及承受伤害的能力)添加到任何实体中,来赋予任何实体健康(和承受伤害的能力)。所以,正如你所看到的,每个实体都由基本的Entity类表示,并从添加到其中的组件中获得所有特性和属性。这就是这种方法如此强大的原因。你只需编写一次健康代码,然后就可以在任意数量的实体上重用它,而无需为每个实体重新编写。然而,基于组件的实体编程比常规实体要复杂一些。例如,我们需要在Entity类上添加一个方法,让你可以传入一个组件类型来指定你想要访问哪个组件。然后它会找到指定类型的组件,并将其返回给你使用。你通常会设计你的实体系统,使其不允许一个实体拥有任何给定类型的多个组件,因为这通常也没有太多意义。例如,给一个实体添加两个健康组件就没有太多意义。

摘要

在本章中,我们首先创建了一个简单的演示应用程序,它在屏幕上绘制了一个矩形。然后,我们变得更加雄心勃勃,构建了一个基于 2D 瓦片的游戏世界。在这个过程中,我们介绍了如何在屏幕上渲染位图、基本的碰撞检测以及回顾了一些基本的用户输入处理。我们还探讨了如何创建一个实用的调试覆盖层。当然,这个调试覆盖层相当简单,但它们可以显示各种有用的信息。当涉及到解决 bug 时,它们是非常强大的工具。在下一章中,我们将探讨播放音乐和音效,以增加我们在这章中构建的 2D 游戏世界的活力!

第四章:添加声音

如您无疑已经注意到的,我们在上一章中制作的那个小游戏演示在没有声音的情况下显得有些死气沉沉。这说明了声音和音乐对于创造游戏完整体验的重要性。音乐为场景设定了基调,而音效则为游戏世界增添了更多深度。

在本章中,我们将涵盖以下主题:

  • DirectSound 与 XAudio2 的比较

  • 声音的基本原理

  • DirectSound

  • XAudio2

DirectSound 与 XAudio2 的比较

与用户输入和图形渲染一样,在声音方面我们也有一些选择;它们是 DirectSoundXAudio2。所以,让我们来看看这两个。

首先,是 DirectSound。当 DirectSound 开发时,游戏音频仍然相当简单。当游戏世界中发生特定事件时,游戏会播放一个单一的 .wav 音频文件,DirectSound 允许如果你的 PC 中有声音卡,通过将声音处理从 CPU(中央处理单元)卸载到声音卡来提高性能。这与图形卡处理图形的方式非常相似,允许 CPU 做其他事情。

随着时间的推移,PC 和游戏机的处理能力都大幅提升,DirectSound 所使用的简单声音模型对于游戏开发者开始创建的日益复杂的音效系统来说已经不够用了。

在开发 Xbox 360 游戏机的过程中,微软意识到 DirectSound 简直无法满足需求。因此,他们创建了 XAudio 来满足视频游戏行业中作曲家和音效设计师日益增长的需求。同时,Windows Vista(代号 Longhorn)也在开发中。负责该项目的团队创建了一个新的音频 API,称为 Longhorn 可扩展音频处理器

Xbox 360 和 Windows Vista 推出不久后,微软将注意力重新转向创建一个跨平台音频 API 以取代 DirectSound 的想法。他们从 Xbox 360 游戏开发者那里收到了对 XAudio API 的积极反馈,但与此同时,Longhorn 可扩展音频处理器 API 在 XAudio 上有一些优势,例如提供更流畅、更高效的音频引擎以及 XAudio 所不具备的一些附加功能。因此,微软决定取两者之长,XAudio2 就是结果。

因此,这显然意味着我们肯定想使用 XAudio2,对吧?答案是肯定的,但这并不是全部答案。但这又引出了另一个潜在的问题:为什么 DirectSound 仍然包含在 DirectX 中,尽管它已经被取代了?答案是,当然是为了向后兼容。如果从 DirectX 中移除 DirectSound,将破坏所有使用 DirectSound API 编写的应用程序。他们保留它,以便这些应用程序仍然可以工作。

正如我所说的,XAudio2 并不是我们为游戏音效需求应该使用的 API 的完整解决方案。你可能希望支持 DirectSound,以适应可能拥有较老系统的用户。然而,正如之前提到的,XAudio2 是一个多平台 API。你可以使用它来处理 Xbox 360、Windows Phone 和 Windows PC(Windows XP 及以上版本)应用程序的音效需求。

为了完整性,我们将在本章中查看 DirectSound 和 XAudio2,但首先让我们看看声音究竟是什么以及它的某些属性。

声音的基本原理

在我们开始进行声音编程之前,让我们先了解声音的基本原理。在我们开始声音编程之前,我们需要对声音有一个基本了解。

声音由波组成。声波本质上就是通过空气传播的压力波。当它撞击你的耳膜时,大脑就会接收到一个信号,告诉它有关撞击它的声波。大脑将这个信息转换成我们认为是声音的东西。

我们赋予声音的最常见属性之一是音量的概念。那么,音量究竟是什么呢?简单来说,它就是声波的振幅。如果你想象一下声波的图示,波的高度就是它的振幅。换句话说,振幅就是波的大小。查看以下图表以了解这一点:

声音的基本原理

声波的振幅或音量

声音的另一个非常重要的属性是频率的概念。因此,在波的图示中,波长就是波有多宽,波的长度决定了它们的频率。频率指的是波撞击的频率,而不是单个波的长度。声波的频率越高,声音的音调就越高。同样,低频声音的音调较低。音调这个术语当然指的是声音的高低。以下图表展示了频率的概念:

声音的基本原理

声波的波长或频率

频率以赫兹Hz)为单位测量。赫兹这个术语通常意味着每秒的周期数。所以,如果一个声音的频率是 100 Hz,这意味着每秒有 100 个声波撞击我们的耳膜。赫兹这个单位可以应用于任何发生规律的事件,比如时钟的滴答声、心脏的跳动或计算机处理器的速度。现代计算机处理器的速度通常以千兆赫兹GHz)表示。如果你考虑到 1 千赫兹(KHz)是 1,000 Hz,1 兆赫兹是 1,000 千赫兹(或 1 百万赫兹),1 千兆赫兹是 1,000 兆赫兹(或 10 亿赫兹),那么这相当快。人类的听觉范围平均在 20 到 20,000 Hz 之间。

好吧,既然你的脑袋可能已经“赫兹”了,那就足够了。明白了吗?无论如何,我们现在知道声音的频率是什么了。那么,让我们继续前进,看看立体声的概念。

立体声音

当立体声播放声音时,这意味着声音有两个通道:一个通道由左扬声器播放,另一个通道由右扬声器播放。这给听众带来了一定的 3D 效果。换句话说,声音由两个独立的声音轨道组成,每个扬声器一个。

这引出了相位的概念。你可以将其视为指声音中的通道彼此同步的程度。如果通道完全同步,则称它们为同相。否则,它们被称为异相。所以如果你将其中一个通道延迟一秒钟的几分之一,你的声音就处于异相,因为左右扬声器中的声音没有正确同步。

声音还有一个有趣的特性,我们将简要探讨。它与相位的概念直接相关。以下图表展示了这一点:

立体声声音

在前面的图中,我们有两套声波:一个是连续的,另一个是点划线的。正如你所见,点划线的是连续波的倒置。所以结果是,我们有两个音频轨道,其中一个与另一个具有相反的相位。那么当我们播放这个声音时会发生什么?什么也没有!没错,绝对什么也没有。为什么?因为相反相位的声波会相互抵消。换句话说,如果两个相同幅度和频率的声波相遇,但其中一个被倒置,那么它们会相互抵消。前面图表的后半部分展示了这一点。由于它们相互抵消,所以没有声波留下,因此我们得到了静音。

DirectSound

我们将向上一章创建的 2D 世界中添加一些简单的声音。所以,打开 Visual Studio,让我们开始吧!

注意

在我们开始之前,应该注意的是,我们在这章使用的音乐轨道(lost_village_128.wav)由 wrathgames.com/blog (WrathGames Studio) 提供。所以,非常感谢他们。

当然,这个声音文件包含在本章可下载的代码中。

打开 TileGameWindow.cs 文件。首先,我们需要在文件顶部添加以下 using 语句,以便我们可以使用 DirectSound:

using SlimDX.DirectSound;
using SlimDX.Multimedia;

现在,我们需要向这个类添加一些新的成员变量。我们将首先添加一个名为 m_UseDirectSound 的布尔变量。这个变量可以设置为 truefalse。当它设置为 true 时,程序将使用 DirectSound;如果这个变量设置为 false,程序将使用 XAudio2。以下是这个变量的声明:

bool m_UseDirectSound = true; 

接下来,我们需要创建三个额外的成员变量来存储我们的 DirectSound 对象。具体如下:

DirectSound m_DirectSound;  
PrimarySoundBuffer m_DSoundPrimaryBuffer; 
SecondarySoundBuffer m_DSoundBuffer;

第一个变量将存储我们的 DirectSound 对象。第二个是主要声音缓冲区,第三个变量是一个次要声音缓冲区,它将存储我们想要播放的声音。

现在,我们将向TileGameWindow类添加一个名为InitDirectSound()的新方法。以下是它的代码:

public void InitDirectSound()
{
    // Create our DirectSound object.
    m_DirectSound = new DirectSound();

    // Set the cooperative level.
    m_DirectSound.SetCooperativeLevel(m_Form.Handle, SlimDX.DirectSound.CooperativeLevel.Priority);

    // Create the primary sound buffer.
    SoundBufferDescription desc = new SoundBufferDescription();
    desc.Flags = SlimDX.DirectSound.BufferFlags.PrimaryBuffer;
    m_DSoundPrimaryBuffer = new PrimarySoundBuffer(m_DirectSound, desc);

    // Create our secondary sound buffer.
    using (WaveStream wavFile = new WaveStream(Application.StartupPath + "\\" + "lost_village_128.wav"))
    {
        SoundBufferDescription DSoundBufferDesc;
        DSoundBufferDesc = new SoundBufferDescription();
        DSoundBufferDesc.SizeInBytes = (int) wavFile.Length;
        DSoundBufferDesc.Flags = SlimDX.DirectSound.BufferFlags.ControlVolume;
        DSoundBufferDesc.Format = wavFile.Format;

        m_DSoundBuffer = new SecondarySoundBuffer(m_DirectSound, DSoundBufferDesc);

        // Now load the sound.
        byte[] wavData = new byte[DSoundBufferDesc.SizeInBytes];
        wavFile.Read(wavData, 0, (int)wavFile.Length);
        m_DSoundBuffer.Write(wavData, 0, LockFlags.None);

        // Play our music and have it loop continuously.
        m_DSoundBuffer.Play(0, SlimDX.DirectSound.PlayFlags.Looping);
    }

}

此方法的第一行创建了我们的DirectSound对象。

提示

在一个真正的程序中,您将会有比我们在这本书中的演示中更好的错误处理。为了节省空间,这部分内容已被省略。

在这种情况下,我们需要处理创建DirectSound对象失败的情况。例如,当用户在其系统中未安装 DirectSound 兼容的声音卡时,这可能会发生。如果发生这种情况,SlimDX 将抛出一个DirectSound异常(或错误)。我们可以通过在初始化代码周围放置一个try块来捕获异常,如下所示:

try
{
    // Create our DirectSound object.
    m_DirectSound = new DirectSound();
}
catch (DirectSoundException dsException)
{
    return;
}

catch块内部,我们输入我们的代码来处理错误条件。在这种情况下,我们只是让构造函数返回而不完成初始化。这可以防止程序崩溃,但它仍然不会工作,对吧?(它不会有声音。)所以,基本上,如果DirectSound对象的初始化失败,catch块中的代码将会运行。

在现实世界的应用程序中,错误处理非常重要,所以不要忘记它!

接下来,我们设置协作级别。协作级别决定了系统允许此程序使用设备的程度。这是因为 Windows 是一个多任务环境,因此可能有多个应用程序同时使用声音设备。协作级别是系统确保我们不会有两个程序试图在完全相同的时间使用设备的方式,因为这可能会引起问题。如您所见,我们在这里将协作级别设置为CooperativeLevel.Priority。如果您应用程序是一个游戏,通常您会希望将其设置为这个值。

下三行代码创建我们的PrimarySoundBuffer对象,并给它赋予BufferFlags.PrimaryBuffer标志。

下一段代码设置了我们的SecondarySoundBuffer对象。它从一个using块开始,该块创建了一个使用我们的声音文件的WaveStream对象。在using块内部,我们创建了一个SoundBufferDescription对象,我们将使用它来指定创建SecondarySoundBuffer对象时的属性。我们将SizeInBytes属性设置为我们的波形文件的大小。然后,我们将Flags属性设置为具有BufferFlags.ControlVolume标志。设置此标志允许我们控制声音的音量。当然,还有其他缓冲区标志。一些例子包括ControlPan,它允许您控制声音的左右平衡,ControlFrequency,它允许您控制声音的频率,以及ControlEffects,它允许您对声音应用效果。

接下来,我们从WaveStream对象获取波形格式,并将其复制到SoundBufferDescription对象的Format属性中。然后,我们使用DirectSound对象和刚刚填写好的SoundBufferDescription对象创建SecondarySoundBuffer对象。

下一段代码加载我们的声音文件。这里的第一行创建了一个与波形文件大小相同的字节数组。下一行将波形文件中的所有数据读取到字节数组中。第三行将数据从字节数组复制到我们的SecondarySoundBuffer对象中。这里的第二个参数是我们想要开始写入数据的缓冲区偏移量。由于我们想要从缓冲区的开始处开始,我们指定0。如果我们指定10,波形数据将从缓冲区开始处的10个字节处写入。

最后的行通过调用其Play()方法来告诉SecondarySoundBuffer对象开始播放我们的声音。第一个参数是此声音的优先级。第二个参数指定了影响声音播放方式的标志。在这种情况下,我们使用PlayFlags.Looping来使我们的音乐连续循环。

注意

你需要确保你的声音文件正确设置了循环。如果一个声音文件不是为循环设计的,那么当它循环回起点时可能听起来不太好。一个为循环设计的声音文件在结尾处有一个平滑的过渡,这样结尾可以很好地过渡到开始,从而使声音可以无缝重复。

SecondarySoundBuffer对象还有其他方法。例如,Pause()方法将暂停声音。当你再次开始播放时,它将从上次停止的地方继续播放。另一方面,Stop()方法停止播放并将声音回滚到其开始处。所以当你再次开始播放声音时,它将从开始处重新开始。

在这一点上,我们需要回到构造函数中添加一行代码来调用我们刚刚创建的InitDirectSound()方法。为此,我们只需在构造函数的末尾添加以下if语句:

if (m_UseDirectSound)
    InitDirectSound();

尽管如此,我们还没有完全完成。我们仍然需要在完成使用DirectSound对象后将其释放。因此,将以下代码添加到我们的Dispose(bool)方法的托管部分:

if (m_DSoundBuffer != null)
    m_DSoundBuffer.Dispose();

if (m_DSoundPrimaryBuffer != null)
    m_DSoundPrimaryBuffer.Dispose();

if (m_DirectSound != null)
    m_DirectSound.Dispose();

到这里,我们的 DirectSound 代码就完成了。如果你现在运行程序,你应该会注意到在我们的 2D 基于瓦片的世界上正在播放音乐。你也可能注意到,如果窗口失去焦点,音乐将会暂停。如果你再次点击窗口以将其聚焦,音乐将从上次停止的地方重新开始播放。

我们还应该讨论声音缓冲区的Status属性,你可以通过它来获取声音缓冲区的当前状态。例如,你可以使用它来检查声音是否正在播放或循环,以及其他事项。

音量控制

在本节代码中,我们提到了几个缓冲区标志,并在我们的 SecondarySoundBuffer 对象上设置了 BufferFlags.ControlVolume 标志,这告诉 DirectSound 我们想要改变声音音量的能力。我们实际上并没有修改这个设置,但要改变音量,您只需更改声音缓冲区的 Volume 属性的值。在 DirectSound 中,音量以分贝(dB)的百分之一来指定。此属性的合法范围是 010,000。请注意,10,000 的最大值代表声音的原始音量。如您所见,这意味着 DirectSound 不支持放大,正如他们的文档中所述。

频率控制

这与控制音量类似。您可以通过设置 BufferFlags.ControlFrequency 标志并更改声音缓冲区的 Frequency 属性的值来改变声音的频率。此属性的值的有效范围是 100100,000。如果您想使用音频轨道的原始频率,请将此属性设置为 0。如果您想将声音的播放速度加倍,例如,您需要将其频率加倍。

平衡控制

如果在缓冲区上设置了 BufferFlags.ControlPan 标志,您可以通过编辑声音缓冲区的 Pan 属性来改变声音的左右平衡。如果您将其向右移动,声音将比左扬声器更多地从右扬声器输出。此属性的值的有效范围是 -10,00010,000。在 -10,000 时,声音将只从左扬声器输出,在 10,000 时将只从右扬声器输出。0 的值指定中心,换句话说,声音将从两个扬声器中均匀输出。

当然,除了这些之外,还有更多标志和其他可以应用于您声音的效果,但我们没有足够的空间在这里介绍它们。这些标志都在 BufferFlags 枚举中定义。

注意

在您可以使用这些各种控制来调整声音之前,需要设置之前提到的适当缓冲区标志。

XAudio2

XAudio2 当然比 DirectSound 更新且功能更强大。我们将向我们在本章第一部分工作的同一文件(TileGameWindow.cs)中添加一些代码。

如同往常,我们首先需要在文件顶部添加一些 using 语句,以便我们可以使用 XAudio2。

using SlimDX.XAudio2;

接下来,我们将创建一些成员变量来保存我们的 XAudio2 对象。这次有四个。

XAudio2 m_XAudio2;
MasteringVoice m_MasteringVoice;
AudioBuffer m_AudioBuffer;
SourceVoice m_SourceVoice;

第一个,m_XAudio2,将保存我们的 XAudio2 对象。第二个将保存我们的主音。在 XAudio2 中,MasteringVoice 类用于表示声音输出设备。第三个变量是我们将存储声音的缓冲区。最后,我们有一个 SourceVoice 对象。这是用于将音频数据提交给 MasteringVoice 对象进行处理的。

下一步我们应该做的是编辑我们在本章早期构造函数底部添加的if语句。以下代码需要更改:

if (m_UseDirectSound)
    InitDirectSound();

必须进行以下更改:

if (m_UseDirectSound)
    InitDirectSound();
else
    InitXAudio2();

这个更改使得当m_UseDirectSound成员变量设置为false时,程序将使用 XAudio2 而不是 DirectSound。所以,去到类文件的顶部,找到那个变量,并将其值更改为false

处理完这些后,我们需要创建InitXAudio2()方法,以便如果m_UseDirectSound变量设置为false,该方法将被调用。以下是这个方法的代码:

public void InitXAudio2()
{
    // Create the XAudio2 object.
    m_XAudio2 = new XAudio2();

    // Check that we have a valid sound device to use.
    if (m_XAudio2.DeviceCount == 0)
        return;

    // Create our mastering voice object. This object represents the sound output device.
    m_MasteringVoice = new MasteringVoice(m_XAudio2);

    // Open the .wav file that contains our sound.
    using (WaveStream wavFile = new WaveStream(Application.StartupPath + "\\" + "lost_village_128.wav"))
    {
        // Create the audio buffer and store the audio data from the file in it.
        m_AudioBuffer = new AudioBuffer();
        m_AudioBuffer.AudioData = wavFile;
        m_AudioBuffer.AudioBytes = (int) wavFile.Length;

        // Setup our audio buffer for looping.
        m_AudioBuffer.LoopCount = XAudio2.LoopInfinite;

        // Create the source voice object. This is used to submit our audio data to the
        // mastering voice object so we can play it.
        m_SourceVoice = new SourceVoice(m_XAudio2, wavFile.Format);
        m_SourceVoice.SubmitSourceBuffer(m_AudioBuffer);
        m_SourceVoice.Start();
    }
}

这个方法可能有点令人困惑,所以我保留了之前代码列表中的注释。正如你所见,我们首先创建我们的 XAudio2 对象。然后,我们有一个if语句,检查是否有任何有效的声音设备可以使用。如果没有,那么XAudio2对象的DeviceCount属性将返回0。在这种情况下,如果没有声音设备可用,我们只需返回,并不要尝试继续初始化 XAudio2,因为这可能会引起崩溃。这回到了本章DirectSound部分中的提示;在实际游戏中,错误处理非常重要,所以不要忘记它或推迟它!接下来,我们创建MasteringVoice对象。我知道你可能可能在想“到底什么是主声音?”好吧,它是一个表示音频输出设备的类;然而,你不能直接将音频缓冲区提交给主声音对象。我们将使用我们的SourceVoice对象在稍后完成。

现在,我们有一个与我们在InitDirectSound()方法中使用的using块相似的块。它打开我们的声音文件,以便我们可以获取音频数据并将其放入缓冲区。

注意

记住,当程序执行到达using块的末尾时,using块将自动释放我们的WaveStream,并且只有实现了IDisposable接口的类型才能这样工作。

using块内部的代码的前四行设置了我们的音频缓冲区。第一行创建了AudioBuffer对象。第二行将缓冲区的AudioData属性设置为我们的WaveStream对象,以便从.wav文件获取音频数据并将其放入音频缓冲区。第三行将缓冲区的AudioBytes属性设置为.wav文件的长度,这样缓冲区就知道我们已将其中的多少数据放入其中。

下一行代码告诉 XAudio2 我们想要循环播放这个声音。这是通过将 LoopCount 属性设置为 XAudio2.LoopInfinite 来实现的。LoopCount 属性设置我们想要循环播放声音的次数。你可以通过 AudioBufferBeginLoopEndLoop 属性设置循环的开始位置和结束位置。如果你的音频文件已经包含循环数据,则这不是必需的。在这种情况下,这些属性已经根据文件中的数据为我们设置好了。请注意,AudioBuffer 类还有一个 PlayBeginPlayLength 属性,允许你设置你希望播放的声音部分。这些属性默认设置为声音文件的开始和结束。

注意

我们刚才使用的 BufferFlags 枚举与我们之前在处理 DirectSound 时不相同。DirectSound 的缓冲区标志枚举是 SlimDX.DirectSound.BufferFlags,而 XAudio2 的缓冲区标志枚举是 SlimDX.XAudio2.BufferFlags

代码的最后三行设置了我们的 SourceVoice 对象。这个对象用于将我们的音频数据提交给 MasteringVoice 对象,以便我们可以播放它。因此,这三行中的第一行创建了 SourceVoice 对象。第二行提交了来自我们的 AudioBuffer 的音频数据,以便我们可以播放它,最后一行播放了我们的声音。

在我们测试 XAudio2 代码之前,还需要注意一个小细节。我们需要在我们的 Dispose(bool) 方法的托管部分添加一些新的代码。我们需要处理四个对象,所以代码看起来如下所示:

// XAudio2 Stuff
if (m_SourceVoice != null)
    m_SourceVoice.Dispose();

if (m_AudioBuffer != null)
    m_AudioBuffer.Dispose();

if (m_MasteringVoice != null)
    m_MasteringVoice.Dispose();

if (m_XAudio2 != null)
    m_XAudio2.Dispose();

我们现在可以测试我们的新代码了。如果你运行程序,音乐将持续循环播放,直到你关闭程序。你可能还会注意到,与 DirectSound 示例不同,如果窗口失去焦点,XAudio2 仍然会继续播放声音。

就像在 DirectSound 中一样,我们当然可以在 XAudio2 中改变声音的音量,或者将其平移。那么我们如何实现这一点呢?

音量控制

我们可以通过改变 SourceVoice 对象的 Volume 属性的值来改变我们声音的整体音量。这个属性的值是一个范围在 -2²⁴ 到 2²⁴ 的浮点幅值乘数。当值为 1.0f 时,没有衰减也没有增益。值为 0 时结果是静音。负值可以用来反转音频的相位。

频率控制

在 XAudio2 中调整声音频率与在 DirectSound 中有所不同。在 XAudio2 中,这表示为频率比率。你可以通过改变SourceVoice对象FrequencyRatio属性的值来更改它。1:1 的比率意味着没有音调变化。这是此属性的默认值。值的有效范围是1/1,0241,024/1。在 1:1,024 的比率下,声音的音调会降低 10 个八度。另一方面,1,024:1 的比率会将声音的音调提高 10 个八度。此属性是float类型,因此你必须计算比率并将其传递。所以如果你想传递 1:1 的比率,这个属性将被设置为1,因为一除以一是等于一的。

为了使这个更具体,默认比率具有我们刚才所说的1的值。2的值将使声音频率加倍,并将音调提高一个八度。0.5的比率将频率减半,使音轨播放时间加倍,并将音调降低一个八度。所以例如,如果你想使声音以三倍正常速度播放,你将把FrequencyRatio属性设置为3

注意

每次将声音频率减半,其音调就会降低一个八度,同样地,每次将频率加倍,音调就会提高一个八度。

扬声器控制

在 XAudio2 中,声像比在 DirectSound 中要复杂一些。我们必须采取一系列步骤来在 XAudio2 中进行声像处理。第一步是创建一个输出矩阵。矩阵只是一个数字的二维表。它仅包含每个通道的计算声像值。我们创建数组的步骤如下:

float[] outputMatrix = new float[8];

我们使用八个元素创建了这个数组,以便它可以支持高达 7.1 的扬声器配置。为了简化,我们这里的示例代码将仅用于立体声输出。接下来,我们需要计算或设置左右扬声器的值。因此,在这种情况下,我们将创建两个额外的变量来存储这些值。

float left = 0.5f;
float right = 0.5f;

我们将这两个值都设置为0.5,这意味着声音将均匀地从两个扬声器播放。如果我们把左边的值设置为1.0,右边的值设置为0.0,声音将只从左扬声器播放。当然,如果我们把左边的值设置为0.0,右边的值设置为1.0,那么声音将只从右扬声器播放。所以正如你所看到的,这些值本质上就是声音左右通道的音量级别。

现在,我们需要将这些值设置到我们之前创建的outputMatrix数组中的正确索引。我们首先需要获取通道掩码,这样我们才知道我们正在处理哪种扬声器配置。具体如何操作取决于你是为 Windows 7 及更早版本还是 Windows 8 开发。对于 Windows 7 及更早版本,我们通过使用XAudio2对象的GetDeviceDetails()方法来获取通道掩码:

m_XAudio2.GetDeviceDetails(0).OutputFormat.ChannelMask

注意,GetDeviceDetails()方法的参数只是要查询的设备的索引,0是默认设备。在 XAudio2 的 Windows 8 版本中,我们从MasteringVoice对象的ChannelMask属性获取通道掩码:

m_MasteringVoice.ChannelMask

如您所见,与 DirectX SDK 版本(Windows 7 及更早版本)相比,XAudio2 的 Windows 8 版本中的此代码要短一些。

那么,这个通道掩码值是什么意思呢?嗯,它只是一个带有各种标志的标志变量,用于指定 PC 的扬声器配置。每个不同的扬声器类型都有一个标志。这些标志由Speakers枚举(位于SlimDX.Multimedia命名空间中)定义,如下所示:

  • BackCenter

  • BackLeft

  • BackRight

  • FrontCenter

  • FrontLeft

  • FrontLeftOfCenter

  • FrontRight

  • FrontRightOfCenter

  • SideLeft

  • SideRight

  • LowFrequency

  • TopBackCenter

  • TopBackLeft

  • TopBackRight

  • TopCenter

  • TopFrontCenter

  • TopFrontLeft

  • TopFrontRight

因此,基本上,这些标志用于指示正在使用哪些类型的扬声器。你可能不需要使用这些标志中的大多数,但我将它们包含在之前的列表中供参考。然而,Speakers枚举实际上还有一些额外的标志,使我们的编程生活变得简单一些。这些标志与之前的列表不同,因为它们不指定单个扬声器;相反,这些标志中的每一个都是之前列出的标志的组合。它们如下所示:

  • All

  • FourPointOne

  • FivePointOne

  • FivePointOneSurround

  • None

  • Mono

  • Quadraphonic

  • SevenPointOne

  • SevenPointOneSurround

  • Stereo

  • Surround

  • TwoPointOne

我们将在示例代码中使用这些简单的标志。现在我们了解了这些标志,我们可以使用它们来编写测试扬声器配置的代码。我们不会使用第一个列表中的任何标志,而是将检查某些扬声器配置,使用之前列出的第二组标志中的某些标志。例如,一个检查是否有两个输出通道的if语句可能如下所示:

if (channelMask.HasFlag(Speakers.Stereo) ||
     channelMask.HasFlag(Speakers.TwoPointOne) ||
     channelMask.HasFlag(Speakers.Surround))
    {
        outputMatrix[0] = left;
        outputMatrix[1] = 0.0f;
        outputMatrix[2] = 0.0f;
        outputMatrix[3] = right;
     }

如果这段代码正在检查使用两个扬声器的配置,为什么在这里设置四个值?原因是因为我们有两个输入通道。因此,对于左扬声器,我们为两个通道设置音量级别。数组中的索引0被设置为我们的left变量的值。数组的索引1代表右通道的音量级别。由于我们不希望右通道的声音从左扬声器播放,我们将此设置为0.0f。同样,元素23正在设置右扬声器的音量级别。我们将元素2设置为0.0f,因为我们不希望左通道的声音从右扬声器播放。最后,元素3被设置为我们的right变量。所以,如您所见,对于每个将输出声音的扬声器,我们必须为声音的所有通道设置音量级别。

您可以在本章的可下载代码中找到此示例代码。不过,这段声像代码已被注释掉。只需取消注释并尝试使用它。

注意,更好的声音配置,如 5.1 或 7.1,将使用此数组的更多元素,因为它们有更多的通道需要设置音量级别。

小贴士

XAudio2 对象具有许多在特定情况下需要的实用方法和常量。这一点也适用于本书中我们迄今为止讨论的许多其他主要 DirectX 对象,例如本章早些时候提到的DirectSound对象,或者前一章中讨论的Direct2D对象。

如果您想使用.ogg.mp3文件而不是本章中使用的.wav文件,您将需要编写一个方法来加载每种文件类型,因为您必须在播放之前对其进行解码。当然,您也可以将文件转换为.wav格式,然后直接使用它们。

摘要

在本章中,我们进入了声音编程的世界。首先,我们探讨了声音是什么以及它的一些基本属性。然后,我们比较了 DirectSound 和 XAudio2 之间的区别。我们使用 DirectSound 将音乐添加到我们的 2D 瓦片世界演示中。接下来,我们添加了一些代码来完成相同的功能,但使用 XAudio2,并创建了一个成员变量来控制程序是否使用 DirectSound 或 XAudio2 来播放声音。我们还探讨了如何在 DirectSound 和 XAudio2 中控制声音的音量、频率和声像。在下一章中,我们将探讨 Direct3D 以及如何渲染简单的 3D 图形。

第五章. 渲染简单的 3D 图形

现在我们已经涵盖了用户输入、2D 图形和声音,让我们来看看 3D 图形。如今的大多数游戏都是 3D 的,可能是因为 3D 图形比 2D 图形更酷!这并不是说 2D 图形不好或过时。仍然有许多使用 2D 图形制作的游戏,但在 3D 中创建游戏实际上为游戏增加了另一个维度,使世界更具深度,使其更具探索性。

在本章中,我们将涵盖以下主题:

  • Direct3D 图形渲染管线

  • 着色器

  • 渲染三角形

  • 带纹理的立方体渲染

Direct3D 图形渲染管线

Direct3D 图形渲染管线将我们的几何数据转换为屏幕上的图形。它旨在为实时应用程序(如视频游戏)生成图形。该管线由多个阶段组成,我们的数据通过这些阶段生成当前帧的最终图像。其中一些阶段是可编程的,这为我们提供了更多的功能。让我们看看主要的管线阶段,它们是:

  • 输入汇编器

  • 顶点着色器

  • 光栅化器

  • 像素着色器

  • 输出合并

输入汇编器

输入汇编器的主要目的是将我们的原始顶点数据(点、线或三角形)组装成原语,例如PrimitiveType.TriangleList,这些原语可以被图形渲染管线使用。这种原语类型告诉 Direct3D 我们的顶点数据是按照列表中的每个三个顶点构成一个三角形的顺序排列的。Direct3D 支持的所有原语类型都在PrimitiveType枚举中定义。

顶点着色器

顶点着色器阶段对从输入汇编器阶段输出的顶点数据进行逐顶点操作。这些操作包括变换、逐顶点光照、变形和蒙皮。

光栅化器

光栅化器阶段将之前阶段处理过的原语转换为像素,以生成渲染图像。它还执行顶点裁剪,这意味着如果三角形的一部分在屏幕外,它将裁剪掉这部分三角形,换句话说,因为它不需要被绘制。

像素着色器

图形管线中的像素着色器阶段执行着色技术,例如逐像素光照或后期处理内容。光栅化器为它正在处理的当前原语中的每个像素调用像素着色器阶段。

输出合并

输出合并器通过结合众多因素生成最终的像素颜色,包括图形管线的当前状态、像素着色器阶段生成的数据以及渲染目标的内容,以及稍后在本章中将要讨论的深度/模板缓冲区。

这只是一个对 Direct3D 图形管道的简要概述。除了这些阶段之外,管道中还有其他阶段,但我们不会使用它们。要深入了解图形管道,请查看微软的文档:msdn.microsoft.com/en-us/library/windows/desktop/ff476882(v=vs.85).aspx

着色器

如上图所示,着色器是 3D 图形编程的一个相当重要的部分。那么,着色器究竟是什么呢?着色器是我们为 Direct3D 图形管道的可编程阶段编写的小程序。我们之前查看的两个阶段是可编程的:顶点着色器阶段和像素着色器阶段。然而,图形管道中并不仅限于这些可编程阶段。

着色器有一个被称为着色器签名的东西。着色器的签名只是该着色器的输入和/或输出参数的列表。

在本章中,我们将创建两个演示。每个演示都将有自己的着色器文件,命名为Effects.fx。这是一个包含我们着色器代码的文本文件。着色器是用HLSL高级着色器语言)编写的。本章的可下载代码包括两个演示项目的Effects.fx文件(它们不相同)。

要了解更多关于着色器和 HLSL 的信息,请查看微软网站上的 HLSL 编程指南:msdn.microsoft.com/en-us/library/windows/desktop/bb509635(v=vs.85).aspx

渲染三角形

在本节中,我们将设置一个基本的 Direct3D 应用程序,并在屏幕上渲染一个简单的三角形。我们首先将创建一个名为Triangle的新项目。在这个项目中,我们首先将创建一个名为TriangleGameWindow.cs的新类。我们需要让它继承我们的GameWindow基类并实现IDisposable,当然。

注意

您需要获取本书的可下载代码来完成本章,特别是我们将在本章中创建的第二个演示。

现在我们有了一个新的游戏窗口类,下一步要做的就是将 Direct3D 设置好并准备好。我们首先需要在文件顶部添加一些using语句。这些语句允许我们在代码中使用 Direct3D。以下是我们需要的新语句:

using SlimDX.D3DCompiler;
using SlimDX.Direct3D11;
using SlimDX.DirectInput;
using SlimDX.DXGI
using SlimDX;

接下来,我们需要创建我们的成员变量。它们如下:

SlimDX.Direct3D11.Device m_Device;
SlimDX.Direct3D11.DeviceContext m_DeviceContext;
RenderTargetView m_RenderTargetView;
SwapChain m_SwapChain;
Viewport m_Viewport;

InputLayout m_InputLayout;

VertexShader m_VertexShader;
ShaderSignature m_VShaderSignature;
PixelShader m_PixelShader;

SlimDX.Direct3D11.Buffer m_VertexBuffer;

m_Device变量将保存我们的 Direct3D 设备对象。m_DeviceContext变量只是一个方便的变量,它为我们保存设备上下文。这缩短了一些代码行,因为我们不需要通过m_Device来访问它。

m_RenderTargetView变量将保存我们的RenderTargetView对象,这与我们在第三章中使用的RenderTarget对象类似,渲染 2D 图形。这基本上是我们的 Direct3D 渲染目标。

m_SwapChain变量将保存我们的交换链。交换链只是一系列缓冲区。记住,缓冲区只是内存中用于存储数据的一个区域。最简单的交换链将有两个缓冲区,它们保存着程序绘制的图形。每次我们绘制一个新帧时,缓冲区就会交换,因此包含我们新帧的缓冲区就会在屏幕上可见。我们绘制到的缓冲区被称为后缓冲区,因为它在幕后,所以当我们在绘制下一个帧时,玩家看不到它。当前显示在屏幕上的缓冲区被称为前缓冲区。当缓冲区交换时,后缓冲区成为新的前缓冲区,反之亦然。

起初,这可能会感觉像是一种内存浪费。为什么不直接绘制到当前显示在屏幕上的缓冲区,这样我们就可以有一个缓冲区而不是两个?这样做的原因是,这样可能会引起一些闪烁。这是因为玩家可能会看到东西在屏幕上被绘制出来。所以,我们而是在离屏缓冲区中绘制,直到帧完全绘制完成。然后,我们交换缓冲区,使新帧一次性出现在屏幕上。这使得动画更加平滑。这种渲染技术被称为双缓冲。你还可以在后台缓冲区和前台缓冲区之间添加更多的缓冲区。如果你在它们之间添加一个额外的缓冲区,你将进行三缓冲,这实际上比双缓冲提供了速度提升。

回到我们的成员变量;下一个是m_Viewport。视口简单地指定了我们想要绘制在渲染目标上的区域。它还指定了最小和最大深度值。这些通常设置为0.0f1.0f

下一个成员变量是m_InputLayoutInputLayout对象告诉 Direct3D 我们正在使用的顶点格式和着色器模型。记住,着色器基本上只是一个由显卡运行的小程序。

m_VertexShader变量将保存我们的顶点着色器。m_VShaderSignature变量保存顶点着色器的签名。着色器签名只是一个参数列表,这些参数被输入到或从着色器输出。最后,m_PixelShader将保存我们的像素着色器。我们将在本章稍后讨论着色器。

如果你没有完全理解所有这些成员变量,那没关系。一旦我们开始使用它们,它们应该会变得稍微清晰一些。我们现在需要初始化 Direct3D,所以让我们开始吧。

初始化 Direct3D

我们现在准备初始化 Direct3D。为此任务,我们将创建一个新的方法,命名为InitD3D()。以下是这个方法的代码:

public void InitD3D()
{
    // Setup the configuration for the SwapChain.
    var swapChainDesc = new SwapChainDescription()
    {
        BufferCount = 2, // 2 back buffers (Triple Buffering)
        Usage = Usage.RenderTargetOutput,
        OutputHandle = FormObject.Handle,

        IsWindowed = true,
        ModeDescription = new ModeDescription(FormObject.Width,
          FormObject.Height,new Rational(60, 1),Format.R8G8B8A8_UNorm),
        SampleDescription = new SampleDescription(1, 0),
        Flags = SwapChainFlags.AllowModeSwitch,
        SwapEffect = SwapEffect.Discard
    };

    // Create the SwapChain

    SlimDX.Direct3D11.Device.CreateWithSwapChain(
      DriverType.Hardware,
      DeviceCreationFlags.Debug,
      new FeatureLevel[] { FeatureLevel.Level_11_0 },
      swapChainDesc,
      out m_Device,

out m_SwapChain);

    // create a view of our render target, which is the backbuffer of the swap chain we just created
    using (var resource =SlimDX.Direct3D11.Resource.FromSwapChain<Texture2D>(m_SwapChain, 0))
    {
        m_RenderTargetView = new RenderTargetView(m_Device,resource);
    }

    // Get the device context and store it in our m_DeviceContext member variable.
    m_DeviceContext = m_Device.ImmediateContext;

    // Setting a viewport is required if you want to actually see anything
    m_Viewport = new Viewport(0.0f,0.0f,m_Form.Width,m_Form.Height,0.0f,1.0f);

    m_DeviceContext.Rasterizer.SetViewports(m_Viewport);
    m_DeviceContext.OutputMerger.SetTargets(m_RenderTargetView);

    // Prevent DXGI handling of Alt+Enter since it does not work properly with Winforms
    using (var factory = m_SwapChain.GetParent<Factory>())
    {
        factory.SetWindowAssociation(m_Form.Handle,
             WindowAssociationFlags.IgnoreAltEnter);
    }
}

正如你所见,在这个方法中我们首先做的事情是创建 SwapChainDescription 来配置我们即将创建的交换链。我们在这里使用初始化器语法,这是一种方便的方式,让我们在创建结构体时设置其属性的值。你可以通过在创建新对象的行之后打开花括号块,并在花括号块内设置其属性的值来使用结构体、数组和列表的初始化器语法。

BufferCount 属性指定了我们希望在交换链中拥有的缓冲区数量,除了前缓冲区。你不应该使用超过四个缓冲区,因为过多的缓冲区会导致性能下降。在窗口模式下,桌面被用作前缓冲区,但在全屏模式下,交换链中需要一个专用的前缓冲区。Usage 属性指定了我们打算如何使用我们的交换链表面。术语 surface 指的是我们将要绘制的缓冲区,因此交换链的表面只是其中的缓冲区。对于 OutputHandle 属性,我们将它设置为游戏窗口的句柄,以告诉它我们将要在哪个窗口中显示我们的图形。IsWindowed 属性确定我们是否希望以窗口模式启动程序。请记住,我们还没有实现全屏模式。

接下来,我们有 ModeDescription 属性,它指定了我们想要使用的视频模式。ModeDescription 对象的前两个参数是高度和宽度。换句话说,它们是我们想要使用的屏幕分辨率。第三个参数是一个分数,表示刷新率,这里我们将其设置为 60/1,这意味着每秒刷新 60 次。最后,我们有格式参数。这告诉它像素格式,正如你所见,我们将其设置为 Format.R8G8B8A8_UNorm。这意味着我们为每个四个颜色通道(红色、绿色、蓝色和 alpha)分配了 8 位。格式名称中的 UNorm 部分表示颜色通道的值是无符号、归一化的整数(归一化意味着它们在 0.0-1.0 范围内)。一个 无符号整数 类似于一个正常的 integer 变量,但它不支持负数。这使得无符号整数可以存储比相同大小的正常整数变量更大的值。无符号整数的对立面是 有符号整数 变量,它必须使用其可能值的一半来表示负数。

我们在SwapChainDescription对象上设置的下一个属性是SampleDescription属性。为此,我们创建一个新的采样描述对象,并给它两个参数:第一个参数是每像素的多采样次数,第二个参数是质量级别。质量级别的有效范围是0到 Direct3D 设备对象的CheckMultisampleQualityLevel()方法返回的级别减一(记住设备存储在我们的m_Device成员变量中)。当然,质量级别越高,执行成本越高。在这里,我们将计数设置为1,质量设置为0。这是没有抗锯齿的默认采样器状态。请注意,我们还可以使用一些标准值来设置我们的多采样级别,这些值定义在Direct3D11.StandardMultisamplQualityLevel枚举中。

注意

如果你的 PC 无法使用 Direct3D 11,你可以将本章中的代码更改为使用 Direct3D 10。代码应该几乎相同,因为 Direct3D 11 实际上是 Direct3D 10 的扩展,我们没有使用 Direct3D 11 带来的任何高级功能或新特性。只需确保将所有 Direct3D 11 命名空间的对象更改为 Direct3D 10 命名空间中的对应对象。

你可能会注意到,也存在 Direct3D 11.1。我们不使用它,因为它只是 Windows 8 版本的 Direct3D 11,并且你只能在 Windows 8 上使用它。

那么,什么是多采样抗锯齿呢?抗锯齿实际上是多采样的一种效果,它通过平滑物体的锯齿边缘,使得它们在屏幕上不再显得锯齿状。这种锯齿状是由屏幕上的像素不是无限小的事实造成的,因此你有时会在屏幕上物体的边缘看到类似阶梯的图案。多采样涉及查看相邻像素的颜色并将它们以某种方式混合在一起,以在屏幕上软化边缘并消除由走样引起的其他图形伪影,例如摩尔纹。你可以在维基百科上查找摩尔纹:en.wikipedia.org/wiki/Moir%C3%A9_pattern

我们在SwapChainDescription对象上设置的下一个属性是Flags属性。此属性允许我们设置影响我们的交换链行为的各种标志。在这种情况下,我们将它设置为SwapChainFlags.AllowModeSwitch标志,这允许我们通过调用其ResizeTarget()方法来切换交换链的屏幕模式。

我们在SwapChainDescription对象上设置的最后一个属性是SwapEffect属性。这设置了处理屏幕上已显示的后缓冲区内容的方式的选项。我们将此设置为SwapEffect.Discard。就这样,我们完成了SwapChainDescription对象的设置。

现在,我们已经准备好创建我们的交换链和 Direct3D 设备。这两者都通过下一行代码完成,该代码调用 SlimDX.Direct3D11.Device 类的静态成员方法;该方法就是 CreateWithSwapChain() 方法。正如你所见,有六个参数。第一个参数指定了驱动类型。在这种情况下,我们将其设置为 DriverType.Hardware,因为我们想要如果可用的话硬件加速。第二个参数是设备创建标志。在这种情况下,我们使用了 DeviceCreationFlags.Debug,这会创建一个支持调试层的 Direct3D 设备。当然,在你发布游戏之前,你会将其更改为其他设置,因为调试代码会减慢游戏速度并损害性能。

第三个参数指定了功能级别。在这种情况下,我们只使用了 FeatureLevel.Level_11_0,这意味着我们想要 Direct3D 11 的功能。由于这个参数是一个数组,当然,如果你的程序需要支持更多级别,你可以提供多个功能级别。

第四个参数是我们的 SwapChainDescription 对象,最后两个参数以 out 关键字开头。这意味着它们实际上是函数的输出,换句话说,我们传递的变量被函数修改以返回数据。在这种情况下,第五个参数是我们的 m_Device 成员变量,第六个参数是我们的 m_SwapChain 变量。所以这个函数创建了 Direct3D 设备并将其存储在我们传递到第五个参数的变量中。它还创建了交换链,并将其存储在我们传递到第六个参数的变量中。

接下来,我们需要创建我们的渲染目标。正如你所见,下一行代码是一个 using 块,它使用 Resource 类的静态方法 FromSwapChain() 创建了一个 SlimDX.Direct3D11.Resource 对象。这一行中的 <Texture2D> 部分表示这个函数是 泛型 的。一个泛型方法是在调用它时允许你指定数据类型的方法。换句话说,它是一个能够作用于多种数据类型的函数,而普通函数则不能。在这种情况下,我们指定了 Direct3D11.Texture2D 的数据类型。一个 Texture2D 对象代表一个图像。FromSwapChain() 方法接受两个参数。第一个参数是我们想要从中创建资源的交换链,第二个参数是那个交换链中一个缓冲区的索引。

然后,在这个 using 语句内部,我们有一行代码创建我们的 RenderTargetView 对象。这个对象本质上就是我们的渲染目标。正如你所见,当我们创建 RenderTargetView 对象时,我们传递了两个参数。第一个参数是我们的 Direct3D 设备,第二个参数是我们刚刚创建的资源。

下一行代码将 Direct3D 设备上下文存储到我们的 m_DeviceContext 成员变量中。请记住,这个变量只是为了方便。使用它允许我们缩短一些代码行。设备上下文是通过 Direct3D 设备对象的 ImmediateContext 属性访问的。

接下来,我们创建我们的视口。ViewPort 对象指定了我们想要绘制到交换链缓冲区的区域,以及最小和最大的深度值。当我们创建 Viewport 对象时,我们传递的前两个参数是我们想要绘制的矩形区域的 x 和 y 位置。第三个和第四个参数是该区域的宽度和高度。正如你所见,我们指定了想要使用整个窗口进行绘制。最后两个参数是最小和最大的深度值,通常分别设置为 0.0f1.0f。这些指定了绘制深度范围的大小。将它们设置为 0.0f1.0f 告诉它绘制场景的整个深度范围。你可以将它们都设置为 0.0f 来绘制所有对象到前景,或者将它们都设置为 1.0f 来绘制所有对象到背景。

如你所见,接下来是两行代码。第一行将我们刚刚创建的 Viewport 设置到 光栅化器 上。请记住,光栅化器是 Direct3D 图形处理管道中的一个阶段。它裁剪原语,为像素着色器(管道中的另一个阶段)做准备,并确定如何调用这些像素着色器。

这里第二行代码将我们的 RenderTargetView 设置到输出合并器(请记住,输出合并器是图形管道的一个阶段)。

我们终于到达了 InitD3D() 方法中的最后一部分代码。这是一个 using 块,这次获取一个 Factory 对象。然后它调用这个对象的 SetWindowAssociation() 方法。这个方法接受两个参数:第一个是我们游戏窗口的句柄,第二个是我们想要使用的窗口关联标志。在这种情况下,我们使用的是 WindowAssociationFlags.IgnoreAltEnter 标志。我们为什么要这样做呢?答案是防止 DXGI 处理 Alt + Enter,因为它与 WinForms 不兼容。请记住,DXGI 是我们在开始这个项目时通过 using 语句包含的命名空间之一。DXGI 是 DirectX Graphics Infrastructure 的缩写。我们稍后会自己处理 Alt + Enter 键盘快捷键。它将切换我们程序的全屏模式。

完成这些后,我们需要创建另外两个初始化函数:一个用于初始化我们的着色器,另一个用于初始化我们的几何形状(即我们要绘制的三角形)。

初始化着色器

我们将创建一个名为 InitShaders() 的新方法来初始化我们的着色器。在这个演示中,我们将设置一个顶点着色器和像素着色器。以下是这个方法的代码:

public void InitShaders()
{
    // Load and compile the vertex shader
    string vsCompileError = "Vertex Shader Compile Error!!!";
    using (var bytecode =ShaderBytecode.CompileFromFile("Effects.fx","Vertex_Shader","vs_4_0",ShaderFlags.Debug,SlimDX.D3DCompiler.EffectFlags.None,null,null,out vsCompileError))

    {
        m_VShaderSignature = ShaderSignature.GetInputSignature(bytecode);
        m_VertexShader = new VertexShader(m_Device, bytecode);
    }

    // Load and compile the pixel shader
    string psCompileError = "Pixel Shader Compile Error!!!";
    using (var bytecode =ShaderBytecode.CompileFromFile("Effects.fx","Pixel_Shader","ps_4_0",ShaderFlags.Debug,SlimDX.D3DCompiler.EffectFlags.None,null,null,out psCompileError))
    {
        m_PixelShader = new PixelShader(m_Device, bytecode);
    }
}

你可以看到这个函数包含了两块相当相似的代码块。第一个初始化我们的顶点着色器。可下载的代码中包含一个名为 Effects.fx 的文件,它只是一个包含我们基本着色器代码的文本文件。

第一行创建了一个名为 vsCompileError 的字符串变量。这个变量将接收下一行代码引发的任何错误。正如你所见,它是一个 using 块,调用 ShaderBytecode.CompileFromFile() 方法来编译我们的顶点着色器。返回的字节码是我们顶点着色器的编译形式。CreateFromFile() 方法接受一些参数,并且有几个重载版本。重载方法是与相同功能具有不同参数列表的另一个版本。

CompileFromFile() 方法的第一个参数是要编译的着色器代码的文件。第二个参数是包含此着色器代码的着色器文件中的方法名称。第三个参数是着色器模型。在这种情况下,我们使用了 "vs_4_0",这告诉它我们想要使用着色器模型 4。第四个参数是我们使用的着色器标志。在这里我们使用了 ShaderFlags.Debug。同样,当你完成游戏时,你可能想要移除这个标志,因为调试代码会降低性能。接下来的两个参数是定义在着色器编译期间的着色器宏列表和处理 include 文件的接口。这两个参数设置为 null,因为它们超出了本章的范围。最后一个参数是我们上面 using 块中创建的 psCompileError 变量。如果有任何错误,它们将被放入这个变量中。

在这个 using 块内部,我们有两行代码。第一行获取这个着色器的签名。记住,着色器的签名只是该着色器输入和/或输出参数的列表。第二行代码创建一个 VertexShader 对象来保存我们的顶点着色器,并将其存储在我们的 m_VertexShader 变量中。

我们 InitShaders() 方法中的第二个代码块与第一个非常相似。它执行与顶点着色器相同的功能,但针对的是像素着色器。它编译我们的像素着色器并将其存储在我们的 m_PixelShader 成员变量中。你可能已经注意到,它使用了与该方法顶部顶点着色器代码相同的着色器文件。你可以在单个文件中定义多个着色器,我们在这里这样做是为了简化。

注意

记住,顶点着色器是 Direct3D 图形管线中的阶段之一,像素着色器也是如此。

此方法中的最后两行代码告诉图形处理管线使用我们的顶点着色器和像素着色器。那么为什么我们还需要着色器呢?原因是 Direct3D 图形管线中的某些阶段是可编程的。着色器是我们为这些阶段编写的程序,因此着色器本质上是一个小程序,它告诉 Direct3D 在管线中的该阶段要做什么。除了顶点和像素着色器之外,还有更多类型的着色器,但它们超出了本书的范围。着色器是一个强大的工具,它允许我们自定义图形处理管线,因此我们可以做一些我们可能无法做到的事情。您可以有多个给定类型的着色器,并且可以随意在它们之间切换(我们将在本章制作的第二个演示中实际这样做),因此您不会卡在您设置的第一个着色器上。到目前为止,我们已经准备好初始化我们的场景。

初始化场景

在 3D 图形中,术语场景的使用方式与电影中的使用方式大致相同。然而,在这种情况下,术语场景指的是我们正在渲染的世界或 3D 场景。为了初始化我们的场景,我们将创建一个名为InitScene()的初始化方法。此方法的代码如下:

public void InitScene()
{
    // Create the vertices of our triangle.
    Vector3[] vertexData =
    {
        new Vector3(-0.5f,  0.5f,  0.5f),
        new Vector3( 0.5f,  0.5f,  0.5f),
        new Vector3( 0.0f, -0.5f,  0.5f),
    };

    // Create a DataStream object that we will use to put the vertices into the vertex buffer.
    using (DataStream DataStream =new DataStream(Vector3.SizeInBytes * 3, true, true))
    {
        DataStream.Position = 0;
        DataStream.Write(vertexData[0]);
        DataStream.Write(vertexData[1]);
        DataStream.Write(vertexData[2]);
        DataStream.Position = 0;

        // Create a description for the vertex buffer.
        BufferDescription bd = new BufferDescription();
        bd.Usage = ResourceUsage.Default;
        bd.SizeInBytes = Vector3.SizeInBytes * 3;
        bd.BindFlags = BindFlags.VertexBuffer;
        bd.CpuAccessFlags = CpuAccessFlags.None;
        bd.OptionFlags = ResourceOptionFlags.None;

        // Create the vertex buffer.
        m_VertexBuffer = new SlimDX.Direct3D11.Buffer(m_Device,DataStream,bd);
    }

    // Define the vertex format.
    // This tells Direct3D what information we are storing for each vertex, and how it is stored.InputElement[] InputElements = new InputElement[]
    {
        new InputElement("POSITION", 0, Format.R32G32B32_Float,InputElement.AppendAligned, 0,SlimDX.Direct3D11.InputClassification.PerVertexData, 0),};

    // Create the InputLayout using the vertex format we just created.
    m_InputLayout = new InputLayout(m_Device,m_VShaderSignature,InputElements);

    // Setup the InputAssembler stage of the Direct3D 11 graphics pipeline.
    m_DeviceContext.InputAssembler.InputLayout = m_InputLayout;
    m_DeviceContext.InputAssembler.SetVertexBuffers(0,new VertexBufferBinding(m_VertexBuffer,Vector3.SizeInBytes,0));

    // Set the Primitive Topology.
    m_DeviceContext.InputAssembler.PrimitiveTopology =PrimitiveTopology.TriangleList;
}

在此方法中,我们首先创建一个Vector3对象的数组。这些是我们组成倒置三角形的顶点。因此,每个Vector3对象包含它所代表的顶点的 x、y 和 z 坐标。

下一段代码是一个using块,用于创建一个DataStream对象。我们在创建此对象时传递了三个参数。第一个参数是我们顶点数据的总字节数。最后两个参数是canReadcanWrite。它们指定是否允许读取和写入缓冲区。

下一行将数据流的定位设置为起始位置。接下来的三行将我们的顶点逐个写入数据流。最后一行将数据流的定位再次设置回开始位置。

现在我们已经准备好了几何数据,我们需要创建一个VertexBuffer对象来存放它。下一块代码创建了一个用于此目的的BufferDescription对象。我们将ResourceUsage属性设置为ResourceUsage.Default。接下来,我们将SizeInBytes属性设置为我们的顶点数据的大小,这样顶点缓冲区就足够大,可以容纳所有数据。然后,我们将BindFlags属性设置为BindFlags.VertexBuffer,因为此缓冲区将用作顶点缓冲区。在接下来的两行中,我们将CpuAccessFlagsOptionFlags属性都设置为None,因为它们超出了本次讨论的范围。

下一行代码创建了一个VertexBuffer对象。我们在创建它时传递了三个参数。第一个参数是我们的 Direct3D 设备。第二个参数是我们写入顶点数据到其中的DataStream对象,最后一个参数是我们刚刚创建的BufferDescription对象。

在这一点上,using 块结束了。当程序执行到达这一点时,我们的 DataStream 对象被销毁,因为我们不再需要它。

接下来的一小段代码创建了一个 InputElement 对象的数组。这告诉 Direct3D 我们在每个顶点中存储了哪些数据,以及它们的格式。正如你所见,我们这里只添加了一个输入元素。它是顶点在 3D 空间中的位置。

在创建这个 InputElement 对象时,我们传递了一些参数。第一个参数是一个字符串,表示这个元素的类型。我们将其设置为 "POSITION",因为这个输入元素持有我们在 3D 空间中顶点的位置。第二个参数是一个索引,当有多个具有相同名称的元素时使用。所以如果我们有两个名为 "POSITION" 的元素,我们会将索引参数设置为 1 对第二个元素。第三个参数是此输入元素使用的数据格式。在这种情况下,我们需要存储三个数字,因为 3D 空间中的一个坐标由三个整数组成。因此,我们使用了格式 Format.R32G32B32_Float。这个格式包含三个浮点值,每个值的大小为 32 位。下一个参数是到下一个输入元素的偏移量。

为了方便,我们将其设置为 InputElement.AppendAligned,这意味着这个输入元素将直接在之前的元素之后开始。下一个参数是要使用的输入槽位。这个属性的合法值是 015。然后,我们有槽位类参数,我们将其设置为 InputClassification.PerVertexData。这是因为这个元素是按顶点基础使用的,因为我们需要为每个顶点存储位置。最后一个参数是步进速率。在我们的代码中,我们将其设置为 0,因为我们没有使用这个功能,而且它超出了本章的范围。

这样,我们几乎就完成了。下一行代码创建了一个 InputLayout 对象,它将保存我们刚刚设置的信息。我们创建它时传递了三个参数。第一个参数是我们的 Direct3D 设备对象。第二个参数是我们顶点着色器的签名,最后一个参数是我们刚刚创建的输入元素数组。

下一行代码告诉输入装配器使用我们新的 InputLayout 对象。记得在本章前面提到,输入装配器是 Direct3D 图形管道中的一个阶段。

接下来,我们在 InputAssembler 上调用 SetVertexBuffers() 方法。这告诉它我们想要使用哪个顶点缓冲区。如果你有多个对象要绘制,你可以在 RenderScene() 方法中多次重置顶点缓冲区。此方法有三个参数。第一个参数是我们想要使用的槽位。根据我们使用的功能级别,可用的最大槽位数量可能会有所不同。第二个参数是一个 VertexBufferBinding 对象。我们创建它时给它提供了三个参数。第一个参数是我们刚刚创建的顶点缓冲区。第二个参数是我们顶点缓冲区的总大小,最后一个参数是缓冲区中第一个顶点的偏移量。我们将其设置为 0,因为我们的第一个顶点位于缓冲区的开头。

最后,我们还有一行代码来设置原语拓扑。这个设置基本上告诉图形管线如何解释我们的顶点数据。在这种情况下,我们将此设置为 PrimitiveTopology.TriangleList。这告诉 Direct3D,我们的顶点数据是一个三角形的列表,换句话说,列表中的每个三个顶点形成一个三角形。对于这个设置,你可以使用许多其他选项,它们都在 PrimitiveTopology 枚举中定义。

输入汇编器还提供了一个 SetIndexBuffer() 方法用于设置索引缓冲区。索引缓冲区简单地保存了一个指向顶点缓冲区中偏移量的列表,以便更有效地渲染。例如,假设我们想要渲染一个正方形。它有四个顶点,但如果我们仅使用顶点缓冲区来渲染它,则需要创建六个顶点(每个三角形三个,而一个正方形由两个三角形组成)。如果我们使用索引缓冲区,则只需四个顶点就能完成。我们的索引缓冲区将包含两个值。

第一个值将是 0,因为第一个三角形从第一个顶点开始。第二个值将是第二个三角形在顶点缓冲区中第一个顶点的索引。这允许三角形共享顶点,因为在三维空间中,两个三角形共享相同点的顶点是常见的。显然,如果我们为包含该顶点的每个三角形重新定义相同的顶点,则会浪费内存。索引缓冲区允许我们解决这个问题。然而,为了简单起见,我们将在本演示中不使用索引缓冲区。

渲染场景

要绘制我们的场景,我们只需在 RenderScene() 方法中添加三行代码,使其看起来如下:

public override void RenderScene()
{
    if ((!this.IsInitialized) || this.IsDisposed)
    {
         return;
    }

    // Clear the screen before we draw the next frame.
    m_DeviceContext.ClearRenderTargetView(m_RenderTargetView,
                                          ClearColor);

    // Draw the triangle that we created in our vertex buffer.
    m_DeviceContext.Draw(3, 0);

    // Present the frame we just rendered to the user.
    m_SwapChain.Present(0, PresentFlags.None);
}

如您所见,此代码相当简单。在方法顶部,我们有之前在演示中使用的相同 if 语句。请记住,这个 if 语句防止在程序尚未初始化或已被销毁时执行此方法,从而防止程序启动或关闭时可能发生的崩溃。

下一行代码使用存储在由GameWindow基类定义的ClearColor属性中的颜色清除屏幕。然后,我们调用 Direct3D 设备上下文的Draw()方法来绘制我们的几何形状。此方法接受两个参数。第一个参数是我们想要绘制的顶点总数。第二个参数是在顶点缓冲区中开始的位置的索引。我们想要绘制所有顶点,所以我们将这个设置为0以从第一个顶点开始。

最后,我们在交换链上调用Present()方法。它接受两个参数。第一个参数是同步间隔,第二个参数是呈现标志。这两个参数都不在本章的讨论范围内,所以我们使用0作为第一个参数,并使用PresentFlags.None作为第二个参数。

在测试代码之前,我们还会做一件小事。我们将编辑TriangleGameWindow类的ToggleFullscreen()方法,使其看起来像下面的代码片段。记住,这个函数是GameWindow基类中定义的方法的覆盖:

public override void ToggleFullscreen()
{
    base.ToggleFullscreen();

    m_SwapChain.IsFullScreen = this.IsFullScreen;
}

第一行切换由GameWindow基类定义的IsFullScreen属性的值。第二行将交换链的全屏状态设置为IsFullScreen属性中的新值。通过这段代码,我们可以在程序运行时切换全屏模式。如果您按下Alt + Enter,程序将切换其全屏模式。记住,我们在第一章的“入门”部分创建GameWindow基类时添加了检测Alt + Enter键位的代码。

注意,当您这样做时,渲染的分辨率不会改变。当我们调整窗口大小时,我们绘制的图像会简单地拉伸以适应新窗口的大小。您可以通过添加事件处理程序并订阅表单的Resize事件来调整交换链和视口的尺寸(记住,我们的RenderForm对象存储在我们的由GameWindow基类定义的FormObject属性中)。在这个事件处理程序中,您将使用其Dispose()方法处理RenderTargetView对象,并使用新的窗口大小重新创建它。然后,您还需要重置视口。

在运行程序之前,请记住编辑Dispose(bool)方法,并确保它处理掉我们所有的对象。查看本章的可下载代码,以查看此方法的新代码。完成这些后,我们就可以运行程序了。以下是在运行中的程序截图,显示了我们的倒置三角形:

渲染场景

游戏窗口中渲染的倒置三角形

您可能想知道为什么三角形是蓝色的。我们从未为它设置颜色,那么这是怎么发生的呢?好吧,如果您查看我们的 Effects.fx 文件中的着色器代码,您将看到像素着色器被硬编码为绘制每个像素为蓝色。像素着色器只有一行代码,返回 RGBA 格式的蓝色。像素着色器返回的颜色是图形管道正在处理的当前像素的颜色。Effects.fx 文件包含在本章的可下载代码中。

渲染一个立方体

在本节中,我们将渲染一个立方体,因为它实际上是三维的,与之前演示中的三角形不同。我们将修改之前的演示项目以创建立方体演示。在本章的可下载代码中,您将找到立方体演示的代码,它位于一个单独的项目中,这样您就可以查看两个演示的代码。默认情况下,Triangle 项目被设置为启动项目。当您想要运行 Cube 演示时,请记住您必须将 Cube 项目设置为启动项目才能运行它。

要开始,我们将添加一个名为 GraphicsMode 的枚举。我们将使用它来指定我们将如何渲染我们的立方体。这个枚举看起来如下所示:

enum GraphicsMode
{
    SolidBlue = 0,
    PerVertexColoring,
    Textured
}

第一个选项将使程序渲染立方体的所有像素为蓝色。第二个选项使用每个顶点指定的颜色渲染立方体,并将它们混合到立方体的每个(或侧面)上。第三个选项将渲染带有纹理的立方体,这个纹理恰好来自我们第三章的 2D 演示中的红砖瓦片,第三章,渲染 2D 图形。接下来,我们需要创建一个新的结构来表示顶点,因为我们现在需要为每个顶点存储更多信息。我们将它命名为 Vertex。它看起来如下所示:

struct Vertex
{
    public Vector4 Position;
    public Color4 Color;
    public Vector2 TexCoord;
}

第一个变量存储顶点在 3D 空间中的位置。第二个变量存储该顶点的颜色,第三个变量存储该顶点的纹理坐标。纹理坐标简单地定义了纹理如何应用于多边形。例如,要纹理化一个正方形,您会给左上角的顶点 (0,0) 分配纹理坐标。右上角的顶点将是 (1,0),左下角的顶点将是 (0,1),而右下角的顶点将具有 (1,1) 的纹理坐标。在纹理坐标中,(0,0) 是纹理图像的左上角,(1,1) 代表纹理图像的右下角。因此,我们刚才看到的纹理坐标将使纹理填充整个正方形的面。它们基本上是将纹理的左上角附着到正方形的左上角,纹理的右下角附着到正方形的右下角,依此类推。

现在,我们需要添加几组新的成员变量。第一个是为我们的常量缓冲区。常量缓冲区只是一个我们用来向显卡传达某些信息的缓冲区,例如投影和视图矩阵。我们有四个变量用于我们的常量缓冲区:

SlimDX.Direct3D11.Buffer m_CbChangesOnResize;
SlimDX.Direct3D11.Buffer m_CbChangesPerFrame;
SlimDX.Direct3D11.Buffer m_CbChangesPerObject;

// We use this to send data into the constant buffers.
DataStream m_DataStream;

前三个变量将存储我们的三个常量缓冲区。但为什么是三个呢?原因是这样比只用一个更有效率。m_CbChangesOnResize 缓冲区将存储仅需要在窗口大小调整时改变的投影矩阵。在这个演示中,这个矩阵永远不会改变,因为我们只是让它保持以相同的分辨率渲染,并将其拉伸以适应窗口。通过将其放在单独的缓冲区中,我们只有在窗口大小改变时才需要更改它,这样可以节省时间。m_CbChangesPerFrame 缓冲区将存储我们的视图矩阵,该矩阵可以在你按下任意一个移动键时每帧改变。最后,m_CbChangesPerObject 缓冲区将存储特定于对象的信息。这个缓冲区将在你绘制场景中的下一个对象之前更新,通过填充该对象的信息来实现。

接下来,我们需要添加几个矩阵变量:

Matrix m_ViewMatrix;  // This is our view matrix.
Matrix m_ProjectionMatrix;  // The projection matrix.

Matrix m_CubeWorldMatrix;
Matrix m_CubeRotationMatrix;

前两个变量将存储我们的视图和投影矩阵。我们将在稍后更详细地查看这些矩阵。其他两个变量存储了立方体对象的两个矩阵。世界矩阵用于将模型的坐标转换为世界空间,这是我们的 3D 世界的坐标系。

模型是对象的 3D 几何表示。换句话说,它存储了它所代表对象的全部几何信息。模型通常有自己的坐标系,称为模型空间,这就是为什么我们需要将其转换的原因。

最后,你看到的用于控制立方体的旋转矩阵控制着立方体的俯仰、偏航和翻滚。它被称为变换矩阵,因为它以某种方式变换了我们使用的对象,例如移动它、缩放它或旋转它。投影和视图矩阵当然也是变换矩阵。变换矩阵是 3D 图形中的一个非常核心的概念。

现在,我们还需要添加几个深度模板和采样器成员变量:

// Depth stencil vars
Texture2D m_DepthStencilTexture = null;
DepthStencilView m_DepthStencilView = null;

// Sampler vars.
ShaderResourceView m_CubeTexture;
SamplerState m_CubeTexSamplerState;

第一个变量存储深度模板的纹理。深度模板基本上是一个纹理。它中的每个像素都存储一个深度值,代表在渲染当前帧时已经绘制在该像素上的最近对象。这就是 Direct3D 知道一个对象是否在另一个对象之前或之后的原因。当像素着色器准备绘制一个像素时,它会检查深度模板纹理中该像素的深度值。如果该像素的深度值比它试图绘制的像素的深度值更近,那么该像素将被丢弃,因为它属于一个比我们已经在该像素上绘制的更近的对象。

第二个变量存储我们的DepthStencilView对象,当 Direct3D 在像素上进行深度测试时,它会访问深度模板纹理。接下来的两个变量与采样有关。第一个将存储我们将放在立方体上的纹理。第二个变量存储我们将与纹理一起使用的采样器状态。

采样是从我们的纹理中读取图像数据的行为,以便我们可以在像素着色器中使用它来渲染像素。基本上,像素着色器根据构成它所绘制面的顶点的纹理坐标从纹理中获取像素颜色。

最后,我们还有一组更小的成员变量要查看:

Vector3 m_CameraPosition = new Vector3(0, 2, -5);

float m_CubeRotation = 0.005f;

float m_MoveSpeed = 0.01f;

GraphicsMode m_GraphicsMode = GraphicsMode.PerVertexColoring;

这里的第一个变量当然跟踪我们摄像机的位置。第二个变量跟踪我们立方体的当前旋转量(在 y 轴上)。m_MoveSpeed变量指定当你按下移动键时,摄像机移动的速度。最后一个变量指定我们想要如何渲染我们的立方体。

我想制作一个我们可以真正实验的演示,所以我添加了这个功能。那么它是如何工作的?如果你查看这个演示的可下载代码中的InitShaders()方法中的代码,你可以看到我们已经更改了加载像素着色器的代码。现在,它上面有if语句,检查m_GraphicsMode成员变量的值。所以基本上,根据你设置的图形模式,它将加载并使用适当的像素着色器。如果你查看这个演示的可下载代码中的Effects.fx文件,你可以看到里面有三个像素着色器,每个对应我们的三种图形模式。

初始化深度模板

无论如何,现在我们已经涵盖了新的成员变量和InitShaders()方法的更改,我们需要添加几个全新的方法。第一个是InitDepthStencil()方法,它将为我们初始化深度模板:

public void InitDepthStencil()
{
    // Create the depth stencil texture description
    Texture2DDescription DepthStencilTextureDesc =new Texture2DDescription();DepthStencilTextureDesc.Width = m_Form.ClientSize.Width;
    DepthStencilTextureDesc.Height = m_Form.ClientSize.Height;
    DepthStencilTextureDesc.MipLevels = 1;
    DepthStencilTextureDesc.ArraySize = 1;
    DepthStencilTextureDesc.Format = Format.D24_UNorm_S8_UInt;
    DepthStencilTextureDesc.SampleDescription =new SampleDescription(1, 0);
    DepthStencilTextureDesc.Usage = ResourceUsage.Default;
    DepthStencilTextureDesc.BindFlags = BindFlags.DepthStencil;
    DepthStencilTextureDesc.CpuAccessFlags = CpuAccessFlags.None;
    DepthStencilTextureDesc.OptionFlags =ResourceOptionFlags.None;

    // Create the Depth Stencil View description
    DepthStencilViewDescription DepthStencilViewDesc =new DepthStencilViewDescription();
    DepthStencilViewDesc.Format = DepthStencilTextureDesc.Format;
    DepthStencilViewDesc.Dimension =DepthStencilViewDimension.Texture2D;
    DepthStencilViewDesc.MipSlice = 0;

    // Create the depth stencil texture.
    m_DepthStencilTexture = new Texture2D(m_Device,DepthStencilTextureDesc);

    // Create the DepthStencilView object.
    m_DepthStencilView = new DepthStencilView(m_Device,m_DepthStencilTexture,DepthStencilViewDesc);

    // Make the DepthStencilView active.
    m_DeviceContext.OutputMerger.SetTargets(m_DepthStencilView,
        m_RenderTargetView);
}

如您所见,我们首先创建一个Texture2DDescription来配置深度模板纹理。当然,widthheight属性是将它的大小设置为与我们的渲染区域相同。MipLevelsArraySize参数超出了本文的范围,所以我们将忽略它们。Format属性当然是我们的纹理格式。D24_UNorm_S8_UInt格式意味着深度有 24 位,模板组件有 8 位,但这涉及到深度模板实际工作细节,这超出了本文的范围。SampleDescription属性设置了这个纹理的多采样参数。Usage属性指定了在渲染过程中如何使用这个资源。我们将BindFlags属性设置为BindFlags.DepthStencil来告诉 Direct3D 这将用于深度模板化。最后,我们将CpuAccessFlagsOptionsFlags设置为None,就像我们之前做的那样。

接下来,我们创建一个DepthStencilViewDescription来配置深度模板视图对象。Format属性指定了格式,我们只需传入我们刚刚设置的深度模板纹理描述中的Format属性的值。我们将Dimension属性设置为DepthStencilViewDimension.Texture2D,因为我们正在使用Texture2D对象作为深度模板纹理。而MipSlice属性超出了本文的范围,所以我们将其设置为0

下一条代码创建深度模板纹理对象。之后,下一行创建DepthStencilView对象。最后一行指示输出合并器使用我们新的深度模板和渲染目标。

注意

在这个演示中我们只有一个对象,所以我们实际上看不到深度模板的作用。如果我们有两个立方体,其中一个部分被另一个遮挡,那么我们就会看到深度模板的作用,使得前面的立方体实际上按照我们的意愿被绘制在前面。

完成这些后,我们现在需要初始化我们的常量缓冲区,以便我们可以将各种信息传达给显卡,例如我们的投影和视图矩阵。

初始化常量缓冲区

接下来,我们将创建InitConstantBuffers()方法来初始化我们的常量缓冲区:

public void InitConstantBuffers()
{
    // Create a buffer description.
    BufferDescription bd = new BufferDescription();
    bd.Usage = ResourceUsage.Default;
    bd.BindFlags = BindFlags.ConstantBuffer;
    bd.CpuAccessFlags = CpuAccessFlags.None;
    bd.SizeInBytes = 64;

    // Create the changes on resize buffer.
    m_CbChangesOnResize = new SlimDX.Direct3D11.Buffer(m_Device,bd);// Create the changes per frame buffer.
    m_CbChangesPerFrame = new SlimDX.Direct3D11.Buffer(m_Device,bd);
    // Create the changes per object buffer.
    m_CbChangesPerObject = new SlimDX.Direct3D11.Buffer(m_Device,bd);

    // Send the Projection matrix into the changes on resize constant buffer.
    m_DataStream = new DataStream(64, true, true);
    m_DataStream.Position = 0;
    m_DataStream.Write(Matrix.Transpose(m_ProjectionMatrix));
    m_DataStream.Position = 0;
    m_Device.ImmediateContext.UpdateSubresource(new DataBox(0, 0, m_DataStream),m_CbChangesOnResize,0);

    // Send the View matrix into the changes per frame buffer.
    m_DataStream.Position = 0;
    m_DataStream.Write(Matrix.Transpose(m_ViewMatrix));
    m_DataStream.Position = 0;
    m_Device.ImmediateContext.UpdateSubresource(new DataBox(0, 0, m_DataStream),m_CbChangesPerFrame,0);

    // Tell the VertexShader to use our constant buffers.
    m_DeviceContext.VertexShader.SetConstantBuffer(m_CbChangesOnResize, 0);
    m_DeviceContext.VertexShader.SetConstantBuffer(m_CbChangesPerFrame, 1);
    m_DeviceContext.VertexShader.SetConstantBuffer(m_CbChangesPerObject, 2);
}

在这个方法中,我们首先创建一个BufferDescription。在这种情况下,我们所有的三个常量缓冲区都将具有相同的大小(64 字节),这意味着我们可以只使用这个BufferDescription来创建所有三个缓冲区。我们将它的ResourceUsage属性设置为default,将BindFlags属性设置为BindFlags.ConstantBuffer,因为我们想将这些缓冲区用作常量缓冲区。我们再次将CpuAccessFlags属性设置为None,并将SizeInBytes属性设置为64,因为这是我们需要的常量缓冲区的大小。原因是,在这个演示中,这些缓冲区中的每一个将简单地存储一个 4 x 4 的矩阵,这需要 64 字节的内存。

接下来的三行代码创建了我们的三个常量缓冲区中的每一个。然后,下一块代码创建了一个DataStream对象,并将其存储在我们的m_DataStream成员变量中,以便我们可以重用它。然后我们将数据流的定位设置为0,以便我们从它的开始处写入。接下来,我们将投影矩阵的转置写入数据流,并将其位置重置回0。最后一行稍微复杂一些,但它只是将数据流中的数据发送到m_CbChangesOnResize常量缓冲区,使其对图形管道可用。这一行实际工作细节超出了本章的范围。

注意

你可能已经注意到这次我们没有在using块中创建我们的DataStream对象。原因是我们在整个程序的生命周期中继续使用它,所以我们不能在这里销毁它,否则演示会崩溃!

下面的代码执行同样的操作,但针对视图矩阵,将其发送到m_CbChangesPerFrame常量缓冲区。最后,这个方法中的最后三行告诉顶点着色器使用我们新的三个常量缓冲区。正如你所见,我们将每个常量缓冲区放入它自己的槽中;因此,每一行的第二个参数递增。这个参数指定了将常量缓冲区设置到哪个槽中。

我们现在准备初始化场景并创建我们的立方体!

初始化场景

这个方法的很多代码与之前相同,所以我们不会在这里展示所有代码。在这个方法的顶部,我们添加了一些新的代码来初始化投影和视图矩阵:

// Create our projection matrix.
m_ProjectionMatrix = Matrix.PerspectiveFovLH(1.570796f, // 90 degrees in radians(float) m_Form.Width / (float) m_Form.Height,0.5f,1000.0f);

// Create our view matrix.
    m_ViewMatrix = Matrix.LookAtLH(m_CameraPosition,new Vector3(0, 0, 0),new Vector3(0, 1, 0));

第一行创建了投影矩阵。投影矩阵类似于为相机选择一种镜头类型。我们传递给Matrix.PerspectiveFovLH()方法的四个参数设置了垂直视野、宽高比以及近裁剪面远裁剪面的距离。《Fov》部分在方法名称中当然是视野的缩写。《LH》部分表示如果你在一个左手坐标系中工作,这是你应该使用的方法。在这个演示中,我们使用左手坐标系,因为视频游戏通常使用左手坐标系。当然,还有另一个以RH结尾的方法,用于右手坐标系。要深入了解这两种类型的坐标系,请查看微软 MSDN 网站上的这篇文章 msdn.microsoft.com/en-us/library/windows/desktop/bb324490(v=vs.85).aspx

裁剪是从渲染列表中移除不需要在当前帧中绘制的物体——通常是因为它们无论如何都是不可见的。这提供了性能上的好处,并且是必要的,因为试图渲染你 3D 世界中的所有内容并不实际,除非它碰巧是一个非常小的世界。这样做可能会导致非常低的帧率。

近裁剪面是指物体必须与相机保持的最小距离,才能被渲染。如果一个物体比这个距离更靠近相机,它将不会被渲染。这可以防止玩家离物体太近时物体被部分渲染。同样,远裁剪面是指物体可以离相机多远仍然可以被绘制。超过这个距离的物体将不会被绘制。请注意,Direct3D 会为我们处理基本的裁剪。

下一段代码使用Matrix.LookAtLH()方法创建视图矩阵。我们传递的三个参数都是Vector3对象。第一个是相机(换句话说,玩家的视点)在 3D 空间中的位置。第二个参数是相机要看的坐标。最后一个参数是一个向量,指定了在 3D 世界中向上是哪个方向。在这里,我们使用正 y 轴作为垂直轴,这是你大多数时候会使用的。

在此代码下方,我们有新的顶点数据,但它太大,无法在这里显示,所以请查看可下载的代码以查看它。它指定了每个顶点的位置、颜色和纹理坐标,这与我们之前的演示有很大的不同,之前的演示只为每个顶点提供了位置。这意味着这次输入元素数组也会非常不同。同样,请查看可下载的代码以查看这一点。

最后,我们需要将以下代码添加到这个方法的底部:

// Load the cube texture.
m_CubeTexture = ShaderResourceView.FromFile(m_Device,Application.StartupPath + "\\Brick.png");
// Create a SamplerDescription
SamplerDescription sd = new SamplerDescription();
sd.Filter = Filter.MinMagMipLinear;
sd.AddressU = TextureAddressMode.Wrap;
sd.AddressV = TextureAddressMode.Wrap;
sd.AddressW = TextureAddressMode.Wrap;
sd.ComparisonFunction = Comparison.Never;
sd.MinimumLod = 0;
sd.MaximumLod = float.MaxValue;

// Create our SamplerState
m_CubeTexSamplerState = SamplerState.FromDescription(m_Device,sd);

如您所见,第一行加载了我们的立方体纹理。如前所述,这只是我们 2D 演示中的红色砖块瓦片,来自第三章,渲染 2D 图形。接下来,我们创建一个采样器描述,我们将使用它来创建用于我们的立方体纹理的采样器状态。SamplerDescription的大多数属性超出了本文的范围。最后,最后一行为我们立方体纹理创建了一个SamplerState。现在,我们的立方体纹理已经设置好,我们就可以在UpdateScene()方法中更新场景了。

更新场景

接下来,我们需要修改我们的UpdateScene()方法。首先,我们需要在方法顶部的if语句之后添加以下代码:

// Keep the cube rotating by increasing its rotation amount
m_CubeRotation += 0.00025f;
if (m_CubeRotation > 6.28f) // 2 times PI
    m_CubeRotation = 0.0f;

在这里,我们增加m_CubeRotation变量的值,以略微增加这个帧中立方体的旋转。请注意,这个旋转值是以弧度为单位的,而不是以度为单位。当这个变量太大时,if语句将其重置为0。在许多帧中,这会导致立方体旋转。

在下面,我们将添加以下if语句:

// If the player pressed forward.
if (UserInput.IsKeyPressed(Key.UpArrow) ||UserInput.IsKeyPressed(Key.W))
{
    m_CameraPosition.Z = m_CameraPosition.Z + m_MoveSpeed;
}

这个if语句检查玩家是否按下了上箭头键或W键。如果是这样,我们将增加相机在 z 轴上的位置。然后,在这个下面,我们会添加另一个if语句,如果你按下下箭头键或D键,它将执行相同的向后移动。如果你按下了这些键之一,它将减少相机在 z 轴上的位置。请查看本章的可下载代码以查看此代码。

注意

记住UserInput是我们GameWindow基类定义的变量,它提供了对其UserInput对象的访问,我们在第二章,响应玩家输入中创建了它。

您可能会注意到,如果您向前移动直到超过立方体,相机会表现得有点奇怪。这仅仅是因为相机实际上被锁定在立方体上。如果您尝试添加左右或上下移动的控制,您会注意到当您向这些方向移动时,相机会因为相同的原因表现得有点奇怪。相机会自动旋转,使其始终朝向立方体,无论您将其移动到何处。下一块代码重新创建视图矩阵,然后将这些数据发送到m_CbChangesPerFrame常量缓冲区。我们需要在每次相机移动时更新视图矩阵。以下就是那段代码:

// Update the view matrix.
m_ViewMatrix = Matrix.LookAtLH(m_CameraPosition,new Vector3(0, 0, 0),new Vector3(0, 1, 0));

// Send the updated view matrix into its constant buffer.
m_DataStream.Position = 0;
m_DataStream.Write(Matrix.Transpose(m_ViewMatrix));
m_DataStream.Position = 0;

m_Device.ImmediateContext.UpdateSubresource(new DataBox(0, 0, m_DataStream),m_CbChangesPerFrame,0);

最后,我们将立方体的旋转矩阵更新为新值,这个值位于我们在此方法顶部更新的m_CubeRotation变量中:

// Update the cube's rotation matrix.
Vector3 rotationAxis = new Vector3(0.0f, 1.0f, 0.0f);
m_CubeRotationMatrix =Matrix.RotationAxis(rotationAxis, m_CubeRotation);

// Update the cube's world matrix with the new translation and rotation matrices.
m_CubeWorldMatrix = m_CubeRotationMatrix;

Matrix.RotationAxis()方法的第一个参数是Vector3,指定了立方体要围绕其旋转的轴。第二个参数是我们以弧度表示的旋转量,它位于我们的m_CubeRotation成员变量中。最后,我们使用我们刚刚创建的新旋转矩阵更新立方体的世界矩阵。

渲染场景

我们还有最后一件事要更改,然后我们就可以运行程序了。我们需要更改RenderScene()方法中的代码来绘制立方体。在清除屏幕的行之后,我们添加以下行:

m_DeviceContext.ClearDepthStencilView(m_DepthStencilView,DepthStencilClearFlags.Depth,1.0f,0);

这行清除深度模板纹理,因此在开始渲染这一帧之前它是空的。然后在此之下,我们需要添加以下代码块:

m_DeviceContext.PixelShader.SetShaderResource(m_CubeTexture, 0);
m_DeviceContext.PixelShader.SetSampler(m_CubeTexSamplerState, 0);

// Send the cube's world matrix to the changes per object constant buffer.
m_DataStream.Position = 0;
m_DataStream.Write(Matrix.Transpose(m_CubeWorldMatrix));
m_DataStream.Position = 0;

m_Device.ImmediateContext.UpdateSubresource(new DataBox(0, 0, m_DataStream),m_CbChangesPerObject,0);

前两行将我们的立方体纹理设置为像素着色器上的资源,以便它可以使用它。第二行设置用于它的采样状态。然后下一块代码使用我们的DataStream将立方体的更新信息发送到m_CbChangesPerObject常量缓冲区。

现在我们需要更改调用DeviceContext上的Draw()方法的行:

m_DeviceContext.Draw(36, 0);

如您所见,我们现在改为绘制 36 个顶点。这是因为我们的立方体有 36 个顶点。但一个立方体只有八个角,对吧?嗯,每个角顶点都会被它所共享的每一面重复。您可以通过使用我们这里使用的TriangleList而不是TriangleStrip原语拓扑,以及使用本章前面讨论过的索引缓冲区来避免这种情况。

像往常一样,不要忘记编辑Dispose(bool)方法,并确保它释放了所有可释放的对象。以下是一些程序在所有三种图形模式下的运行示例:

渲染场景

在所有三种图形模式下的立方体演示

上一图的第一部分显示了当将m_GraphicsMode设置为GraphicsMode.SolidBlue时的程序。第二张图显示了GraphicsMode.PerVertexColoring,最后一张图显示了GraphicsMode.Textured

摘要

在本章中,我们深入探索了 3D 图形的世界。这是一个非常复杂的话题,但在我们的第一个演示中,我们在屏幕上绘制了一个蓝色三角形,那时我们学习了设置 Direct3D 应用程序的基础。然后,我们开始制作我们的立方体演示,其中我们介绍了深度模板和常量缓冲区等概念。我们为这个演示提供了三种图形模式,您可以通过更改CubeGameWindow类中的m_GraphicsMode成员变量的值来运行它。在下一章中,我们将简要地探讨几个其他主题,并讨论在游戏编程艺术的学习中下一步该走向何方。

第六章 接下来去哪里

我们现在终于到达了这段旅程的终点,但关于游戏编程的学习还有很多。在这个过程中,我们学习了如何开始构建一个游戏开发框架,以便在未来继续构建,使用 DirectInput 和 XInput 处理用户输入,使用 Direct2D 渲染 2D 图形和基于瓦片的宇宙,使用 DirectSound 和 XAudio2 为我们的游戏世界添加声音和音乐,最后,如何使用 Direct3D 进行基本的 3D 图形渲染。因此,在本章中,我们将探讨如何继续扩展你的游戏开发知识,以及一些我们在本书中简要介绍的一些重要主题,以及一些我们没有涉及的主题。

在本章中,我们将涵盖以下主题:

  • 裁剪

  • 碰撞检测

  • 人工智能

  • 物理学

  • 多线程编程

  • 游戏设计

  • 进一步阅读

剔除和裁剪

在我们构建Cube演示时,我们在上一章中简要讨论了这一概念。在 2D 术语中,剔除意味着移除或跳过屏幕边界之外的对象,而不是浪费时间绘制那些无论如何都不可见的东西。在 3D 术语中,意义大致相同,但由于需要处理第三个维度,所以显然要复杂一些。

裁剪是指移除游戏对象中那些在屏幕上无法完全看到的三角形部分。屏幕外的三角形部分会被移除。Direct3D 在我们的图形管道中为我们处理裁剪,但剔除则留给了我们。剔除是指移除或跳过场景中那些不需要绘制、当前不可见的整个对象。尽管 Direct3D 可以为我们处理背面剔除。这是移除面向相机的三角形背面。由于我们看不到它,因此不需要绘制它。

剔除和裁剪在大多数现代视频游戏中都被使用。这是一个非常重要的概念,因为它可以给你带来显著的性能提升。你的游戏世界中对象越多,计算机循环遍历并绘制每一个对象所需的时间就越长。因此,裁剪可以节省很多时间。

虽然这不是限制每帧绘制内容的唯一方法。许多游戏中使用了其他方法来确保对象不会被不必要地绘制。例如,如果玩家和对象之间有一堵墙或其他障碍物,我们不需要绘制那个对象,因为玩家无论如何都看不到它(除非,当然,有一个窗户或类似的东西)。这有点棘手。关键是,就像大多数事情一样,完成这项工作有不止一种方法。

碰撞检测

碰撞检测正如其名;检测游戏世界中对象的碰撞。在二维空间中,由于一切通常都具有简单的方形边界框,所以相对容易。边界框是围绕对象的一个想象中的形状,我们用它来检测碰撞。你只需测试两个对象的边界框是否相交,如果是的话,你就知道它们已经发生了碰撞。当然,它们对玩家来说是不可见的,因为我们从未在屏幕上绘制我们的边界框。然而,为调试目的添加代码以让你看到边界框可能很有用,并验证它们是否按预期工作。另一个好主意是在你的游戏中添加一个开发者控制台,就像在许多第一人称射击游戏中看到的那样,例如半条命 2

在三维空间中,事情变得更加复杂。你可以使用各种类型的边界框。常用的有立方体或球形边界框。立方体不一定需要是完美的立方体。它们可以是任何大小,或者宽度比高度长,例如。球形边界框通常是一个围绕对象的完美球体,但如果对象的高度比宽度大,它也可能是细长的。球形边界框的行为也略有不同,因为你不会有两个边界框的角仅仅相交的情况,因为球体没有像立方体那样的角。当然,你也可以在二维中使用不同类型的边界框。

人工智能

人工智能AI)指的是我们编码到游戏世界中生物的智能。这本质上是指控制生物行为的代码。这段代码的智能程度决定了我们的生物的智能程度。当然,这个概念也与路径查找紧密相关。路径查找是寻找一条路径,使生物能够到达其目标位置的过程。一种路径查找算法是A*算法(见en.wikipedia.org/wiki/A*_search_algorithm)。

当然,我们不能根据世界的几何形状来处理这个问题。这会太复杂,也太耗时。一种方法是用航点。航点本质上是在世界中的战略位置散布的标记。例如,几条路径的交汇处将是一个很好的航点位置。路径查找代码简单地找到生物当前位置最近的航点,然后从那里找到从航点到航点的路径,使其到达期望的目的地。这显然比尝试根据世界的实际几何形状计算路径要简单得多。

物理

物理与碰撞检测紧密相关,因为碰撞检测是游戏物理的核心。如果两个物体发生碰撞,我们需要检测这一点,以便让它们表现得更加真实。如果它们在碰撞时都突然停下来,或者像幽灵一样继续前进,看起来会很愚蠢。

物理学是一个非常复杂的学科,并且高度依赖于数学。它本质上只是使用数学来计算我们游戏世界中物体的运动。当然,如果你想达到不同的效果,你可以稍微调整一下你的物理设置。例如,如果你的游戏有一个卡通风格的世界而不是一个真实世界,你的物理可能就会有所不同。

我们在上一节中提到的边界框通常位于游戏的物理系统中,因为它们在那里被使用。游戏中的物理系统通常负责模拟游戏世界中物体的物理。通常也会给物体一个睡眠状态或空闲状态,通常被称为。当物体处于这种状态时,物理系统会忽略该物体,不会对其进行任何物理模拟。这允许你在需要之前关闭特定物体的物理,这样可以带来巨大的性能提升,因为物理,充满了数学,可能运行起来相对较慢。当你有更多处于这种状态的物体时(因为它们本来就没有移动或做任何事情),性能提升会更大,因此,你实际上是在有效地节省物理系统进行大量不必要的计算。毕竟,对一个不移动或不做任何事情的对象运行物理代码是浪费时间的。

多线程编程

通常,一个程序只有一个执行线程。线程基本上是正在由具有多核处理器的系统中的一个核心运行的代码。大多数现代处理器都有这个特性,尽管应该注意的是,即使在单核 CPU 上也可以有多个线程。我想包括这个主题,因为多线程在处理密集型应用,如视频游戏,中非常有用。

基本上,多线程允许你在应用程序中同时运行多个执行点。例如,你可以有一个线程执行一些后台工作,比如在一个类似《我的世界》的大型世界中加载资源(图形和声音)。随着玩家的移动,游戏需要加载世界的下一部分,并卸载那些现在离玩家太远的部分。因此,你可以有一个线程在玩家在世界中移动时处理加载新的世界块,而主线程则继续运行游戏代码。

当然,你也不必局限于仅仅两个线程。应用程序通常创建一组线程并将任务分配给它们。如果有许多任务需要完成,这会很有用。你只需创建一个线程管理器类,并传入你将调用的函数来完成需要完成的任务。线程管理器会查看其线程(统称为线程池),并找到空闲的那个。如果它找到一个空闲的工作线程,它将通过运行你传入的函数来启动它。工作线程只是线程池中的一个线程,它之所以得名是因为它为我们做各种工作。

同时运行多个代码片段显然可以带来显著的性能优势。然而,多线程编程也是一个相当棘手的话题。你必须确保不要有两个线程同时访问相同的数据,因为这可能会引起大问题。

游戏设计

游戏设计可能看起来相当简单,但其中包含的远比大多数人最初想象的要多。游戏设计包括决定图形的风格、世界将是什么样子、氛围、设计游戏的主要游戏玩法机制、玩家的整体目标以及基本的故事想法。当然,通常会有图形和声音艺术家来完成游戏图形和声音的实际工作。但图形和声音的风格首先需要在游戏设计中指定,否则艺术家不知道要创建什么样的图形和声音。

游戏可能最关键的一点是它很有趣。如果不好玩,玩家就不会玩。然而,这并不那么简单,因为不同的玩家喜欢不同类型的游戏。所以,对某个玩家来说有趣的东西可能对另一个玩家来说就不一定有趣。所以不要试图取悦所有人,因为这通常是不可能的。如果你这样做,你可能会发现自己追逐一个无法达到的目标。

在游戏设计的世界里,可能会有人争论最重要的元素是什么。有些人会说图形,而有些人会说游戏玩法。然而,在现实中,它们都非常重要。图形可以让游戏看起来很好,但图形不一定要逼真。它们可以是卡通风格,或者有其他看起来不逼真的风格。这并不意味着游戏不好。一个很好的例子是任天堂的《塞尔达传说:时之笛 HD》,它使用了一种名为卡通渲染的图形风格,使其世界看起来像卡通。

你从游戏中获得的最终体验并不仅仅取决于图形、游戏玩法或音效。它是所有结合在一起创造最终游戏体验的元素的综合体。当然,控制非常重要,因为它们是玩家与游戏之间的接口。如果接口奇怪或者工作不顺畅,玩家可能不会玩很多游戏。音效和音乐为游戏世界设定了氛围,而图形则提供了视觉元素,并有助于营造氛围。

游戏难度

游戏设计中可能最棘手的一个方面就是游戏难度。你不想让它太简单,否则一些玩家会感到无聊并放弃。同样,如果你让它太难,一些玩家可能会愤怒地退出。那么,我们如何处理这个问题呢?一种方法是为你的游戏设置多个难度级别,让玩家可以选择他们想要的难度。最常见的难度级别是“简单”、“普通”、“困难”,有时还有“非常困难”。另一种解决方案被称为自适应难度。这意味着游戏会观察玩家的表现,并根据需要调整难度。如果玩家表现非常好,它会稍微提高难度,如果玩家遇到困难,它会适当地降低难度。

难度的另一个方面被称为游戏进度。进度指的是随着你在游戏中前进,难度是如何增加的。它应该开始时缓慢增加,然后随着你进一步深入游戏,增加的速度会更快。因此,难度不应该从一个等级突然上升到下一个等级的 100%,因为这会在玩家中引起挫败感。

这引出了挑战的概念。一些游戏为硬核玩家提供了挑战级别以供享受。这是一个好主意,但它们不应该太难以至于需要花费很长时间才能击败。这只是我的个人观点,但一个设计良好的挑战应该是一个玩家在持续尝试中能够看到自己进步的地方。如果这是一个你需要尝试 1500 万次才能最终成功的等级,当然它很困难,但这不是一个设计良好的挑战,因为大多数玩家可能在他们成功之前就会放弃。为什么?因为他们可能会认为这是浪费时间。

那么如果你为击败它设置了酷炫的奖励会怎样呢?嗯,一些玩家会努力去获得它,但另一方面,一些玩家可能尝试了无数次后仍然无法获得它,他们会因为无法解锁而感到恼火。所以,挑战应该是困难的但又是合理的。正如我所说的,玩家应该看到他们的表现随着他们不断尝试而提高。看到他们在表现上的进步给了玩家一种希望的感觉。随着他们每次尝试都做得更好,他们感觉他们的目标越来越近。玩家感觉只要他们再稍微努力一点就能做到。另一方面,一个你需要重试 1500 万次才能幸运地完成关卡的水平,会产生相反的效果。如果玩家在尝试这个关卡时看不到任何明显的进步,他们会觉得自己没有变得更好,因此,继续尝试是浪费时间。玩家会失去希望。有一些非常困难的挑战是可以的,但它们应该是可选的,并且通常不应该有击败它们后解锁的内容。再次强调,这只是我的观点。

操作玩家的情绪

这引出了另一个有时被忽视的观点。如果你仔细想想,电子游戏在其核心功能之一就是操纵玩家的情绪。如果玩家变得愤怒,他们可能会退出,也许永远不再玩这个游戏。但除此之外,操纵玩家的情绪要深入得多。例如,游戏让玩家与某些角色产生情感上的联系,并让他们讨厌其他人。操纵玩家的情绪可以嵌入到基本的游戏玩法机制中。如果玩家定期获得奖励,这会让他们感觉良好,并给他们更多继续前进的理由。另一方面,一个深刻而强大的故事也能达到同样的效果。

所以,正如你所见,在游戏设计的世界中,有许多工具和问题需要克服。但如果你热爱电子游戏,并渴望创造它们,当你看到你的成品游戏在运行中,并且希望从你的粉丝那里得到很多积极的反馈时,所有的努力最终都是值得的。

最后,对于新游戏程序员来说,最好的建议之一是从小开始。不要过于雄心勃勃,因为如果你承担了超出你能力范围的工作,你很可能会结束一个永远无法完成的项目。那些绝对不是我们想要做的项目!

如果你真的成为了一名独立开发者,那么恭喜你!在这种情况下,你应该努力与你的粉丝进行良好的沟通。他们会非常感激,他们也是你可能会将它们整合到你的游戏中的伟大想法的来源。

注意

如前所述,不要忘记在你的代码中包含错误处理。例如,如果用户没有安装兼容的声卡或显卡,这一点尤为重要。因此,当你创建你的 DirectSound / XAudio2 或 Direct3D / DirectInput 设备时,确保它创建后不是 null,因为这显然不会工作得很好。你的程序可能会崩溃。

进一步阅读

在本节中,我们将探讨一些优秀的书籍和网站,如果你对游戏编程真正感兴趣的话。

网站

SlimDX 网站—slimdx.org/—有一个文档部分,你可以看到所有的 SlimDX 类及其成员。不幸的是,在撰写本文时,它是不完整的。

作为 SlimDX 网站的替代,你可以访问 SharpDX 网站,sharpdx.org/。这个网站也有一个文档部分,目前它比 SlimDX 的文档更完整。

小贴士

大多数 SharpDX 类与它们的 SlimDX 对应类相同,但这两者之间有一些差异。SharpDX 只是 SlimDX 的一个替代品。所以,大多数时候,你可以查看它们的文档来帮助解决 SlimDX 的问题,但有时,由于两者之间的差异,它可能不会与 SlimDX 的文档完全准确。不过,它仍然是一个很好的资源。

GameDev.net 网站—www.Gamedev.net—是游戏开发者的一大资源。如果你真的遇到了难题,需要帮助解决 SlimDX 中的某个问题,他们的论坛是提问的好地方。网站上也有很多文章。

这个网站不仅涵盖了使用 SlimDX 和 C#进行游戏开发的内容,还有自网站重构以来的旧文章存档,可以在archive.gamedev.net/archive/reference/找到。

Stack Overflow 网站—stackoverflow.com/—是一个提问的地方,人们可以对其评论并帮助你解决问题。实际上,我使用这个网站帮助我解决了一个我在创建本书第五章时遇到的特别棘手的问题!

这两个网站,braynzarsoft.net/index.php?p=DX11Lessonswww.rastertek.com/tutdx11.html,上有许多优秀的 Direct3D 11 教程。只是要注意,这些教程使用的是 C++;因此,你将不得不找出如何在 SlimDX 中编写相同的代码。SlimDX 中的大多数对象与它们的原生 C++版本有相同的名称;所以,在大多数情况下,这个过程相对容易执行。

Riemers XNA 教程网站— www.riemers.net/—为新手游戏程序员提供了一些非常棒的 XNA 教程,对于刚开始接触游戏编程并想了解更多的人来说,这是一个极好的资源。

ShaderX 书籍网站— tog.acm.org/resources/shaderx/—有一系列名为ShaderX的书籍,其中几本可以免费下载!

NVIDIA 开发者区域网站上的GPU Gems书籍页面— http.developer.nvidia.com/GPUGems/gpugems_pref02.html—提供了书籍的摘录,以给你一个关于书中内容的想法。

书籍

如果你想要了解更多关于底层概念,例如在游戏开发中图形是如何在最低级别实际渲染的,可以查看安德烈·拉莫特(André LaMothe)的这两本书:

  • 《Windows 游戏编程大师技巧,安德烈·拉莫特(André LaMothe),Sams Publishing》

  • 《3D 游戏编程大师技巧:高级图形和光栅化,安德烈·拉莫特(André LaMothe),Sams Publishing》

安德烈·拉莫特(André LaMothe)的这两本书都非常优秀。第一本专注于 2D 开发,DirectInput 的玩家输入,基本 AI,碰撞检测等。第二本书提供了关于 3D 视频游戏数学的非常好的入门,并专注于 3D 图形领域以及更多内容。

接下来,我想提到一本我在今年(2013 年)初接触到的书,结果证明它非常优秀:《游戏编程完全指南:第四版,迈克·麦克沙弗里(Mike McShaffry)和大卫“Rez”格雷厄姆(David "Rez" Graham),Cengage Learning》

这本书非常优秀,因为它涵盖了众多 DirectX 书籍中遗漏的内容。例如,许多书籍教你如何在屏幕上绘制图形,但它们并没有真正教你如何构建一个真正的游戏。这本书涵盖了创建每个视频游戏中发现的各个系统的许多方面。因此,这本书对于任何想要学习所有这些内容的严肃游戏程序员来说,确实是极其宝贵的。

书籍《3D 图形和游戏开发数学入门》,弗莱彻·邓恩(Fletcher Dunn),伊恩·帕伯里(Ian Parberry),A K Peters/CRC Press详细介绍了 3D 游戏的数学,并解释了概念背后的理论。

摘要

在本章中,我们讨论了一些在这本书中略过的杂项主题。其中许多主题本身就可以写成一本书。我们探讨了裁剪及其重要性,然后转向碰撞检测和物理。接着,我们探讨了多线程编程及其在视频游戏中的优势。之后,我们审视了一些游戏设计的问题和陷阱。最后,我们查看了一些进一步阅读的材料。

恭喜你完成了这本书!愿你在未来创造许多令人惊叹的游戏,并感谢你抽出时间阅读这本书。祝你在游戏设计之路上继续冒险好运!

posted @ 2025-10-24 10:01  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报