译者的话

继一年前翻译了《Professional XNA Game Programming》（以下简称《ProXNA》）之后，近段时间终于把《XNA 3.0 Game Programming Recipes》（以下简称《XNARecipes》）也翻译完成了，这的确是一本好书，作者深入浅出，把问题解释得非常清楚，以前中《ProXNA》看不懂的地方几乎都解决了，《ProXNA》的确对得起书名中的“Professional”，对一些作者认为基础的东西一笔带过，如果学习XNA看的第一本书就是它的话，很多东西是一知半解的，至少我是如此。所以推荐应该两本书一起看。

读完了《XNARecipes》中1.1 安装XNA Game Studio 3.0，1.2 开始第一个XNA 3.0项目，3.1 显示2D图像：使用SpriteBatch类加载和绘制图像，3.2 旋转，缩放和镜像一张图像，3.3 使用层绘制透明图像，3.4 使用SpriteBatch类时的性能考虑，7.3 通过Xact播放简单的.wav文件后，就可以开始学习制作《ProXNA》中的第一个游戏Pong了。这个游戏使用GameState枚举进行场景管理，创建了一个SpriteToRender 类将要绘制的图像进行封装，体现了面向对象编程的思想。为了能自动适应分辨率的改变，此游戏的图像位置都是基于1024*768的，而使用一个技巧让游戏可以随着分辨率的改变自动调整图像的位置，当然XNA帮助文件中有一个例子用的是SpriteBatch.Draw的一个重载方法的第四个参数，我觉得更简单，可以参考一下。

《ProXNA》 第二个游戏Breakout中创建了几个辅助类，像Pong游戏一样将所有的代码都放在一个文件中通常是不可取的。辅助类中最重要的就是SpriteHelper，它的作用就是管理一个SpriteToRender的集合，真正体现了面向对象编程的思想。

第三个游戏《Tetris》中提及了GameComponent，你应该看一下1.7 使用GameComponents了解相关知识。此游戏还编写了绘制文本的TextureFont类，而在XNA1.0Refresh版本中已经提供了SpriteBatch.DrawFont方法绘制文字，使用起来更简单，但是好像还是用图像表示文字更漂亮，当然如果是中文系统就另当别论了。

《ProXNA》 的第五章涉及3D编程，应该先看一下4.1 使用BasicEffect类载入模型，4.2 对不同对象设置不同的世界矩阵，组合世界矩阵，4.6 缩放模型，5.2 在三角形上添加纹理，谈及相机时看一下2.1 创建一个相机：Position，Target和View Frustum，2.2 指定相机的目标，2.3 创建一个第一人称射击游戏（FPS）的相机：Quake风格的相机。

《ProXNA》 的第六章应参考《XNARecipes》第五章、第六章的前面几个教程。

《ProXNA》 的第七章应参考《XNARecipes》中的5.13 凹凸映射：固定法线，5.14 通过切线空间的凹凸映射添加逐像素细节。

《ProXNA》 的第八章Post-Screen Shaders应参考3.8 将场景绘制到纹理，2.12 创建一个Post-Processing Framework，2.13 创建一个BlurGlow Post-Processing Effect。

《ProXNA》 的第九章处理声音可参考7.3 通过Xact播放简单的.wav文件。

《ProXNA》 的第十章场景管理使用了堆栈，可参考3.6 创建2D菜单界面，spaceCamera可参考2.4 创建一个Freelancer风格的相机：使用四元数的3D旋转。

《ProXNA》 的第十一章中的爆炸效果是使用动画纹理实现的，你可以使用粒子系统产生同样的效果，可参见3.11 Billboarding：在3D世界绘制2D图像使它们总是面向相机，3.12 创建一个3D爆炸效果/简单的粒子系统。这个游戏使用的地形并不需要自己定义顶点和索引，而是直接在3DSMAX做好的，但仍需用到高度图。

《ProXNA》 的第十二章的赛车游戏的场景仍是在3DSMAX中做好，表面贴了一张很大的纹理并添加了一些细节。赛道使用了Collada格式，但是构思和5.16 在3D空间中使用Catmull-Rom插值生成额外的顶点，5.17 为赛道创建顶点是类似的。车辆与赛道的碰撞检测比较简单，并没有《XNARecipes》第四章中的几个高级。游戏中的阴影映射比较复杂，我还不太懂，与6.11 在Deferred Shading引擎中添加阴影能力还是有区别的，推荐去看《XNARecipes》作者的网站中3D Series 3: HLSL中的相关文章。

最后祝玩得愉快，本电子书的word版本可以在上海市第八中学物理组网站下载。

PS：一转眼儿子已经要3岁了，不过现在已经天天缠着要玩电脑上的《WaWaYaYa》儿童学习软件了，这么小就开始上瘾了！

下一篇


1.0 第一章概述

本章第一部分指导安装过程和帮助你将代码运行在PC和Xbox360上，第二部分包含XNA框架内部运行的一些高级话题。

本章的教程覆盖以下内容：

  • 安装XNA Game Studio 3.0，开始第一个XNA 3.0 项目(教程1-1和1-2)
  • 在PC、Zune和Xbox 360上运行代码(教程1-3、1-4和1-5)
  • 学习XNA框架的时间(教程1-6)
  • 使用GameComponent 类和GameServices(教程1-7和1-8)
  • 使用XNA的storage功能让用户可以存储和加载游戏(教程1-9)
上一篇


1.1 安装XNA Game Studio 3.0

问题

你想开始编写自己的游戏。

解决方案

在开始编写自己的游戏前，你首先应该按照开发环境。XNA Game Studio 3.0让你可以使用一个单一的环境开发整个游戏项目。而且它是免费的。

首先需要Visual Studio 2008让你可以开发C#程序，因为XNA使用的是C#语言。

安装好Visual Studio 2008才能安装XNA Game Studio 3.0。

工作原理

安装Visual Studio 2008

XNA Game Studio 3.0需要Visual Studio 2008安装在PC上。如果没有，你可以免费下载Visual C# 2008 Express Edition。

可至http://www.microsoft.com/express/download/找到Visual C# 2008 Express Edition（确保选择C#版本，用绿色显示）。选择语言，点击Download按钮。这会下载一个小文件，需要之后运行。

在运行setup时，使用默认选项点击Next直到程序开始下载和安装。

注意：你也可以在我的网站(http://www.riemers.net)找到这个安装包的更新链接。

安装XNA Game Studio 3.0

至http://www.microsoft.com/downloads/details.aspx?FamilyID=80782277-d584-42d2-8024-893fcd9d3e82&displaylang=en，点击DOWNLOADS XNA STUDIO 3.0链接 ，在打开的页面中，点击DownLoad按钮(就在“Quick Details”上方)开始下载XNA Game Studio 3.0。

下载和运行了文件后，安装包会检查你是否已经安装了Visual C# Express Edition 2008。如果按照前面的指示，你不会碰到任何错误信息。

在安装过程中会弹出防火墙设置页面。请选择第一个选项，并允许两个子选项。如果不是这样选择，当连接Xbox360或测试多人游戏时会碰到麻烦。当你将游戏连接到其他PC或Xbox360时失败，往往是第三方防火墙的错误设置。

最后，点击Install按钮安装XNA Game Studio 3.0。


1.2 开始第一个XNA 3.0项目

问题

你想编写一个新的XNA 3.0游戏。此外，默认的初始代码已经包含了一些方法，你想知道这些方法是做什么用的。

解决方案

打开一个新项目和大多数Windows程序是相同的。在XNA Game Studio 3.0中，打开File菜单，选择New→Project。

工作原理

打开XNA Game Studio 3.0 通过点击Start按钮并选择Programs打开XNA Game Studio 3.0，找到Microsoft XNA Game Studio 3.0，点击，选择Microsoft Visual Studio 2008 (或Microsoft Visual C# 2008 Express Edition，如果你安装的是免费版本)。

打开一个新XNA 3.0项目 在XNA Game Studio 3.0中，打开File菜单并选择New→Project。在左边的列表中，Visual C#下面的XNA Game Studio 3.0默认是高亮的，如图1-1所示。在右边，选择Windows Game (3.0)，给你的项目起一个好听的名称，点击OK按键。

图1-1 打开一个新XNA 3.0项目(Visual Studio 2008版本)

预定义方法

当开始一个新项目时，你发现代码文件中已经包含了一些代码。注释(显示为绿色)占据了50%的代码帮助你开始编程。 等会你就会发现这些方法非常有用，因为它们节省了你处理基本内容的时间。例如，当你运行这个程序时，会获得一个空的窗口，这意味着你无需将时间浪费在编写窗口或处理窗口信息队列上。

下面讨论预定义的方法。

Game1构造函数

Game1方法只在运行项目的一开始调用一次。这意味着当这个方法（构造函数）被调用时内部组件还没有被初始化。添加在这里的唯一代码应该是GameComponent类的实例化(见教程1-6)，这里你无法访问任何资源(诸如GraphicsDevice类)，因为它们还没有被初始化。

Initialize方法

Initialize方法也只被调用一次，在所有内部初始化完成之后。这个方法可以放置初始化变量，诸如游戏中的物体的初始位置和初始速度等，你可以访问游戏对象的所有资源了。

Update方法

当运行程序时，XNA会以每秒60次的频率调用Update方法(或者说每隔0.0167秒)。更多的知识可参见教程1-6。 所以Update方法中可以放置更新游戏逻辑的代码，可以是更新物体的位置，碰撞检测，在一个位置开始爆炸，增加得分等。 还有，处理用户输入和更新相机/模型的矩阵也应该在这个方法中进行。

Draw方法

在这个方法中，你应该放置绘制场景的代码。它绘制所有的2D图像，3D物体，爆炸和显示当前得分等。 默认情况下，Draw方法的调用频率与屏幕刷新率相同，这取决于屏幕或Zune设备，更多内容可参见教程1-6。

LoadContent方法

在游戏中你总要从磁盘加载素材(诸如图像，模型和声音等) 。要加速这个过程并拥有灵活性，XNA通过内容管道管理这个素材。 应该在LoadContent方法中加载素材，这个方法在项目开始只被调用一次。 如何将一张2D图像加载到XNA项目中的详细例子可参见教程2-1，其他类型的素材加载方法是相同的。

UnloadContent方法

如果游戏中使用的对象需要清除，理想的放置位置是在UnloadContent方法中，这个方法在游戏退出前被调用一次。

添加一个.fx HLSL文件

如果你想更深入一步在项目中添加一个HLSL文件，只需在 Solution Explorer找到Content，右击并选择Add→New Item，选择“Effect file,”并起一个名称。 你会获得一些默认代码，可以用这本书中的其他代码扩展或替换这些代码。之后需要像其他素材一样导入这个文件：通过创建一个对应的变量并将这个文件连接到这个变量。 将这个变量添加到Game类的顶部：

Effect myEffect;

然后在LoadContent 方法中将这个变量连接到代码文件上：

protected override void LoadContent()
{
    myEffect = Content.Load<Effect>("effectFile");
}

注意：你必须将素材的名称改成你的HLSL文件的名称，本例中是effectFile。


1.3 将你的XNA3.0游戏部署到Xbox 360

问题

在PC上创建和测试了代码后，你想将游戏上传并运行在Xbox 360上。

解决方案

XNA最好的功能之一是可以让代码运行在PC和Xbox 360上而无需任何改动。但在将游戏上传到Xbox 360之前需要进行一些准备工作。

首先，需要一个Xbox Live账号，这可以通过http://creators.xna.com或在Xbox 360上免费创建。然后需要一个Creators Club许可，对大多数学生是免费的，也可以通过Xbox Live Marketplace购买。这个许可四个月花费49美元或一年99美元。

然后需要下载并安装XNA Game Studio Connect， 一个前端段程序用来监听来自于PC的连接。

最后，在PC和Xbox360之间还需要一个LAN连接，Xbox 360需要连接到Internet。PC和Xbox 360还需进行配对，否则如果在网络上有多个Xbox360时会遇到麻烦。

如果满足了以上四个条件，你就可以上载并在Xbox360上运行程序了。

工作原理

设置Xbox Live账号

登录Silver Xbox Live账号是免费的，如果你想让自己的代码运行在Xbox360上账号也是必须的。如果你已经有了一台Xbox360，你可能已经有了一个Live账号了。如果没有，那么打开Xbox 360，插入一张游戏光盘，根据屏幕上的指示操作。

获取Creators Club License

如果你是一名学生，有可能从Microsoft DreamSpark program获取一个免费的授权。你可以从http://downloads.channel8.msdn.com访问到Microsoft DreamSpark。用你的学生证登录获取一个号码，这可以通过进入XBox360的Marketplace 选项卡并选择“Redeem code.”做到。

否则，你可以简单地将Xbox360连接到Xbox Live Marketplace导航到Games→All Game Downloads。在列表中，找到XNA Creators Club并选择它。

然后选择Memberships，你可以购买四个月或一年的授权，或者，你也可以输入Creators Club voucher卡上的号码。

在Xbox 360上安装XNA Game Studio Connect

这个程序让Xbox 360可以监听来自于PC的连接，你可以通过Xbox Live Marketplace，在Game Store→More→Genres→Other中免费下载，安装后就可以打开这个程序了。

连接Xbox 360和PC

在PC可以将数据传输到Xbox 360前，这两个设备需要通过LAN或Internet进行连接，如果Xbox 360和PC都连接到一个路由器/交换器/hub，就能工作正常。

近来，越来越多的家庭网络使用无线连接，这会产生一个问题，因为Xbox 360默认没有安装无线适配器。一个解决方法是拥有一台同时具有无线和有线(Ethernet)网络的PC，现在的大多数笔记本电脑都符合这个条件。在家中用PC连接无线网络，用网线连接Xbox 360和PC。最后，在PC上点击Start按钮，并导航到Settings→Network Connections。同时选择无线和有线适配器，右击选择Bridge Connections（桥接）。如图1-2所示。等几分钟，两个机器就会连接到Internet和互相连接！

图1-2 桥接PC上的两个网络适配器

配对PC和Xbox 360

如果网络中有多台Xbox 360，你需要指定在哪台Xbox 360上上传你的代码。在Xbox 360上找到Game选项卡选择Games Library→My Games→XNA Game Studio Connect，可以开始XNA Game Studio连接。如果这是你第一次打开连接，会要求填写Xbox360的序列号，一组5*5字符串。

在PC上点击Start按钮，导航到Programs→Microsoft XNA Game Studio 3.0→XNA Game Studio Device Center，点击Add Device键，给你的Xbox 360主机起一个名称。然后，要求输入Xbox360的序列号。如果Xbox 360和PC都通过网络连接，配对就会成功，主机就会显示在设备列表中。如果PC连接了多个Xbox30主机，那么绿色的符号表示当前活动的Xbox 360。

从已有的XNA 3.0项目生成Xbox 360项目

在XNA Game Studio 3.0中，很容易将PC游戏转换为Xbox 360项目。只需打开项目，找到Solution Explorer，右击项目名称选择Create Copy of Project for Xbox 360，如图1-3所示。

图1-3 生成Xbox 360项目

这会导致创建第二个项目，原始项目的所有文件都会被项目引用，而不是复制，因此在一个项目中作出的改变也会反映到另一个项目中。

某些情况中，你可能还需要添加一些向导忘记复制的引用，但无论如何，这个向导会节省你许多时间。

现在，你可以在屏幕顶部选择想让你的项目运行在哪个平台上，如果是第一次在Xbox360平台上运行项目，你需要通过选择“Configuration Manager”添加一个Xbox 360 profile，如图1-4所示。

图1-4 选择部署目标

在弹出的对话框中，点击“Active solution platform”列表并选择。然后，选择Xbox360，关闭所有对话框。

现在，图1-4中的列表中就包含了Xbox360的选项，当想部署到Xbox360平台时就可以选择它。确保Xbox正在运行XNA Game Studio Connect并在等待连接。当按下F5时，文件就会被上传并在Xbox360上执行。


1.4 将你的XNA 3.0游戏部署到Zune

问题

在PC上创建并测试了2D游戏后，你想把它上传到Zune中并运行。

解决方案

XNA 3.0一个主要的新功能就是支持Zune上的2D游戏。显然因为Zune上的显卡无法与你PC上的相比，因此在Zune无法运行XNA 3D程序。但是，完整的SpriteBatch在Zune上是可用的，让你可以创建移动设备的2D游戏。

工作原理

在Xbox360控制台上，你无需对代码进行任何调整就可以将它运行在Zune上，只需将Zune连接到PC，将它设置为激活目标并部署你的游戏即可。

将Zune连接到PC

使用USB线将Zune和PC连接，打开电源。然后点击开始菜单选择程序→Microsoft XNA Game Studio 3.0→XNA Game Studio Device Center打开XNA Game Studio Device Center，点击Add Device按钮，选择Zune。

如果你还没有进行过这个操作，可能会被要求根据下面的链接下载Zune软件的更新版本。如果你已经安装了这个软件，确保Zune的固件已经被更新。这可以通过连接Zune并打开PC上的Zune软件做到。如果PC已经连接到Interne并找到了固件的新版本，Zune软件会显示Device Update屏幕，让你可以通过点击Install按钮（当升级固件时不要关闭电源）升级Zune的固件。

安装了Zune软件、升级了Zune固件并连接了Zune后，当你点击XNA Game Studio Device Center的Zune按钮后Zune就会监听你的屏幕了。选择你的Zune，点击Next，当验证了连接后，你的Zune就会监听XNA Game Studio Device Center的主屏幕。

在XNA Game Studio 3.0中将Zune设置为目标平台

连接了Zune后，进入XNA Game Studio 3.0将2D游戏部署到Zune。在Xbox360控制台上，当你第一次部署Zune时，你需要将它设置为部署目标。这可以通过点击屏幕右上方的Solution Platforms选择Configuation Manager做到，如图1-4所示。

在接下来的对话框中，从屏幕左上方的配置列表中选择，在接下来的屏幕的顶部列表中选择你的Zune，点击了OK后，你可以看到你的Zune已经在顶部中央的窗口中被设置为了目标平台。

将你的XNA 3.0游戏部署到Zune

最后，你需要将XNA PC项目复制到XNA Zune项目中。非常简单，在解决方案资源管理器中右击你的项目选择Create Copy of Project for Zune。这个选项显示在图1-3的列表中。XNA会将这个项目添加到解决方案资源管理器中，只要两者之一有变动，XNA会同步这两个项目。

要将游戏上载到Zune，请确保Zune作为激活的目标平台，并且此时Zune软件没有运行在PC上，然后按下F5初始化部署。

一旦Zune屏幕显示部署已经完成，重启你的Zune，在Zune主菜单中选择Games，就可以开始你的游戏了！


1.5 将你的XNA 3.0游戏部署到另一台PC

问题

你已经完成了自己的游戏，想把它在另一台电脑上展示给你的朋友。但是当你双击. exe文件时，会遇到错误。

解决方案

将游戏发布到不同的PC比起2.0版本来说变得简单了。截止到本书编写的时间，你需要确保另一台机器上安装了两个独立的安装包才能安全地运行你的游戏程序。

  • XNA Framework Redistributable 3.0
  • .NET 3.5 Framework SP1

好消息是XNA现在支持Visual Studio的OnClick技术，让你可以创建一个单独的.exe文件，首先检查这些安装包是否已经安装，如果没有则首先安装这些安装包。但是，如果你想使用XNA 3.0的网络功能（见第八章），你仍需安装完整的XNA Game Studio 3.0安装包。

工作原理

安装XNA 3.0 Framework文件

第一个方法是在PC上安装这两个安装包和你的游戏的二进制文件，这可以通过下载并安装XNA 3.0 Framework Redistributable 3.0做到， 它可以在微软网站http://www.microsoft.com上搜索到。这个安装包非常小，包含了所有XNA 3.0 Framework的基本文件。

安装.NET 3.5 Framework文件

XNA是DirectX的托管代码包装。因为它使用的是一个托管.NET语言(C#)，在系统上还需安装.NET Framework 3.5文件，它也可以从微软网站上搜索到。

复制编译过的文件

在编译并测试了你的游戏后，进入可执行文件夹，这可以是bin\x86\Debug和bin\x64\Release，确保将此文件夹和其子文件夹中的所有文件复制到目标PC。如果你已经安装了上述两个安装包，在双击.exe文件开始游戏时就不会遇到任何错误信息。

创建一个单独的ClickOnce安装包包含所有的先决条件

XNA 3.0新增了ClickOnce安装让你可以创建一个.exe安装文件，它可以安装这两个先决条件。创建安装文件的步骤如下：

  1. 在Solution Explorer中，右击项目选择Properties。
  2. 在打开的窗口中，选择左下方的Pubilsh。
  3. 点击下一个屏幕中的Prerequisites。
  4. 你可以看到可以被打包到你的游戏中的所有Microsoft安装包的列表，确保选择.NET Frameword 3.5(SP1)和XNA Framework Redistributable 3.0。
  5. 最重要的是，选择”Download prereueisties from the same location as my application”。这会将安装包复制到项目发布的文件夹中。
  6. 点击OK并关闭项目的属性窗口。

做完这一切后，你要是想发布你的项目，只需右击项目，选择Publish并点击弹出的对话框中的Finish即可。

注意：不幸的是，目前在Visual Studio中有一个bug，通过这种方式发布项目会导致一些问题，希望在下一次升级或补丁包中能加以解决。所以，现在在第4步中不要选择.NET Framework 3.5(SP1)，而是将它作为添加的安装文件。

为游戏创建一个Setup项目

使用这个更为先进的方法，你可以创建一个有更多自定义选项的安装程序，它可以自动检测两个先决条件的安装包，如有必要，会自动安装它们。

注意：这个方法没有刚才在ClickOnce方法中的bug。

下面是创建Setup项目的步骤：

  1. 打开XNA Game Studio 3.0。
  2. 打开File菜单，选择New→Project。
  3. 在左侧的树状目录中，选择Other Project Types→Setup and Deployment。
  4. 在右方确认选择了Setup Project。
  5. 给项目起一个名称，例如[name]。在安装过程中，这个名称会显示在”Welcome to the [name] Setup Wizard.”中。
  6. 点击OK开始一个新的setup项目。
  7. 在屏幕左侧找到Application Folders。
  8. 在资源管理器中，找到XNA游戏的输出目录，它包含了游戏的.exe文件。选择所有文件及其子文件夹（例如Content）并将它们拖动到第7步中的Application Folders中。
  9. 返回XNA Game Studio 3.0，打开Project菜单选择“[name] properties”。
  10. 点击Prerequesites按钮。
  11. 确保选择Windows 3.1，.NET Frameword 3.5(SP1)和XNA Framework Redistributable 3.0。
  12. 在对话框底部，选择从“my application”相同的位置“Download Prerequesites”。
  13. 点击OK返回setup项目。
  14. 点击Application Folders。
  15. 在屏幕右下方的属性对话框中，从目标目录中移除[Manufacturer]。
  16. 现在，按下F6键生成setup项目解决方案。

这个程序会生成包含所有先决条件和setup.exe的文件夹，setup.exe会安装在另一台机器上运行XNA游戏所需的东西。


1.6 自定义游戏循环时间

问题

你想改变调用Update和Draw方法的默认时间间隔。

解决方案

Update 方法默认是每秒钟更新60次，而Draw方法则没有限制，但最大为屏幕刷新率。通过改变Game类的TargetElapsedTime和IsFixedTimeStep静态属性和GraphicsDevice类的SynchronizeWithVerticalRetrace的属性，你就可以改变默认的行为。

工作原理

改变Update频率

默认情况下Update每秒更新60次，或者说每隔16.667毫秒更新一次。你可以通过改变TargetElapsedTime变量改变更新频率： this.TargetElapsedTime = TimeSpan.FromSeconds(1.0f / 100.0f); 当调用到这行代码时，XNA会以每秒100次的频率调用Update方法。你也可以让XNA不按固定时间间隔调用Update方法，要做到这点，需要将Is FixedTimeStep变量设置为false：

this.IsFixedTimeStep = false;

使用IsRunningSlowly

你可以指定Update的更新频率，但如果频率太高，XNA将无法达到，在这种情况下，gameTime. IsRunningSlowly 变量会被设置为true：

Window.Title = gameTime.IsRunningSlowly.ToString();

注意：你应该将gameTime参数传递到Update方法而不是Draw方法去判断gameTime. IsRunningSlowly。

改变Draw频率

当运行程序时，XNA会以尽可能大的频率调用Draw方法，只由下面两个规则限制：

  • 没有必要将调用Draw的频率大于屏幕刷新频率。如果屏幕刷新频率只有每秒100次，每秒绘制110次是无用的。在PC和Xbox360平台上，，屏幕刷新率是由PC屏幕和它的设置决定的，Zune 30每秒刷新60次，其他Zune设备每秒刷新30次。
  • Update方法每秒调用60次。如果游戏计算量太大，Draw方法调用次数会变少以保证Update方法可以每秒调用60次。

在某些情况中，以最大频率调用Draw方法是有用的，例如当决定你的游戏的最大帧频率时，你可以将graphics.SynchronizeWithVerticalRetrace变量设置为true：

graphics.SynchronizeWithVerticalRetrace = false;

注意：你必须在Game1的构造函数顶部加入这行代码，因为XNA需要在创建GraphicsDevice 之前知道这个设置。

理解Update和Draw方法调用频率的重要性

因为你将更新逻辑放置在Update方法中，Update频率的减少会导致游戏中的所有物体变慢，这非常令人讨厌。

当Draw调用频率小于屏幕刷新频率时，只有游戏的视觉表现会暂时受影响，游戏帧频率暂时由每秒100帧降到80帧不容易察觉。

因此，如前所述，如果必要，XNA会降低Draw的频率以保证Update能以每秒60帧的频率调用。


1.7 使用GameComponents

问题

你想将应用程序分离为GameComponent 类，实现组件的复用。

解决方案

程序的特定部分可以从程序中分离出来。在一个XNA程序中，大多数此种类型的部分都需要被更新和绘制，例如粒子系统和billboard（通常翻译为公告板或广告牌）系统（见教程3－11和3－12）。

正确的做法是为这些特定部分创建一个单独的类。在XNA Game 类中，创建这个类的实例，对它进行初始化，更新和绘制。所以你想让你的新类拥有自己的Initialize， (Un) LoadContent，Update和Draw方法，这让你就可以很容易地在Game类中调用它们了。

如果你想在这个新类中定义这些方法，最好的方法就是从GameComponent类继承。你可以将这个类添加到Game类的Components集合中，这样当Game类初始化完成后就会调用GameComponent类的Initialize方法（如果你已经定义了一个）。 而且，每次Game类的Update方法调用后，GameComponent的Update方法也会自动被调用。

如果你的组件还有绘制某些东西，你可以从DrawableGameComponent类继承而不是从GameComponent类，这样在组件类中还会包含一个Draw method，它会在Game类的Draw方法后被调用。

注意在Game类的Initialize方法最后调用了base. Initialize，就是这个代码行调用了所有GameComponent类的Initialize方法，在其他方法中你也能看到类似的base.Update(gameTime)，base.Draw(gameTime)。

工作原理

例如教程3－11中的billboarding代码封装在了一个GameComponent中。而且因为这个代码还需要绘制一些东西，所以使用的是DrawableGameComponent类。

创建一个新的 (Drawable)GameComponent

通过右击项目并选择Add →New File可以添加一个新的类文件，接着选择Class，我把它命名为BillboardGC。在这个新文件中添加XNA using代码行，只需将Game类的using复制到这个新类即可。

下一步保证这个新类从GameComponent类或DrawableGameComponent类继承，如下面的代码所示。代码显示了如何创建教程3－11的billboarding。其中有些代码如CreateBBVertices 并没有写出，因为这个教程我们关注的是Initialize，LoadContent，Update和Draw方法。

class BillboardGC : DrawableGameComponent
{
    private GraphicsDevice device;
    private BasicEffect basicEffect;
    private Texture2D myTexture;
    private VertexPositionTexture[] billboardVertices;
    private VertexDeclaration myVertexDeclaration;
    private List<Vector4> billboardList = new List<Vector4>();
    public Vector3 camPosition;
    public Vector3 camForward;
    public Matrix viewMatrix;
    public Matrix projectionMatrix;

    public BillboardGC(Game game) : base(game)
    {
    }

    public override void Initialize()
    {
        device = Game.GraphicsDevice;
        base.Initialize();
    }

    protected override void LoadContent()
    {
        basicEffect = new BasicEffect(device, null);
        myTexture = Game.Content.Load<Texture2D>("billboardtexture");
        AddBillboards();
        myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    }

    public override void Update(GameTime gameTime)
    {
        CreateBBVertices();
        base.Update(gameTime);
    }

    . . .

    public override void Draw(GameTime gameTime)
    {
        //draw billboards . . .
    }
}

注意在Initialize方法中，你的component可以获取Game类，这样component就能获取Game类的公共字段，例如Game. GraphicsDevice和Game.Content。

使用GameComponent

定义完GameComponent类后，你就可以将它添加到Game类的GameComponent集合中，一旦添加后，GameComponent的注意方法就会自动被调用。最简单的方法就是创建一个GameComponent的实例并立即添加到Components集合中，最好的位置是在Game类的构造函数中。

public Game1()
{
    graphics = new GraphicsDeviceManager(this);
    Content.RootDirectory = "Content";
    Components.Add(new BillboardGC(this));
}

这样一开始就能调用Initialize和LoadContent方法，并且在Game类的Update和Draw方法完成后调用这个新类的Update和Draw方法。有些情况下，你需要更新一些组件的公有变量。例如在billboarding组件中你需要更新camPosition和camForward变量调整View和Projection矩阵使之能正确地绘制到屏幕。因此，你应在Game类中添加一个组件变量。

BillboardGC billboardGC;

然后存储这个组件并将它添加到Components集合中：

public Game1()
{
    graphics = new GraphicsDeviceManager(this);
    Content.RootDirectory = "Content";
    billboardGC = new BillboardGC(this);
    Components.Add(billboardGC);
}

在Game类的Update方法中，你可以更新组件中的四个变量，在Update方法最后调用所有组件的Update方法，让billboarding组件进行更新：

protected override void Update(GameTime gameTime)
{
     . . .
     billboardGC.camForward = quatCam.Forward;
     billboardGC.camPosition = quatCam.Position;
     billboardGC.viewMatrix = quatCam.ViewMatrix;
     billboardGC.projectionMatrix = quatCam.ProjectionMatrix;
     base.Update(gameTime);
}

Game类的Draw方法更简单，只是在调用所有组件的Draw方法前清除背景：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0);
    base.Draw(gameTime);
}

最后一行代码会调用billboarding 组件的Draw方法，将billboards绘制到屏幕。

代码

GameComponent和Game类的Initialize，LoadContent，Update和Draw方法的代码已经在前面写过了。


1.8 实现GameServices

问题

如在教程1－6中解释的那样，你可以将代码放在可复用的GameComponent类中，这些组件可以是相机、粒子系统，用户输入，billboard等。

使用GameComponent类的一个最主要的好处是你可以很容易在它们之间切换，例如相机模式。要把第一人称相机模式变到四元数相机模式只需在Game类的Initialize方法中包含一条代码即可。

但你必须保证在切换组件时不会改变其余代码（这些代码都要使用相机）。

解决方案

你可以让相机组件拥有同一个接口，例如（自定义）ICameraInterface接口。当初始化相机组件时，你需要让Game类知道从现在开始Game中要包含一个实现ICameraInterface接口的组件。用XNA的话说就是组件将自己注册为一个ICameraInterface类型的GameService。

一旦做完上步，代码的其他部分只需简单地向Game类请求当前的ICameraInterface服务，Game类会返回提供当前ICameraInterface服务的相机。这意味着你的代码无需知道现在的相机模式究竟是第一人称相机模式还是四元数相机模式。

工作原理

接口（interface）用来定义一种程序的协定，一个接口包含功能（就是方法）的列表，当你的类实现一个接口后，就必须实现接口中所定义的方法。

下面的代码是如何定义一个ICameraInterface接口：

interface ICameraInterface
{
    Vector3 Position { get;}
    Vector3 Forward { get;}
    Vector3 UpVector { get;}
    Matrix ViewMatrix { get;}
    Matrix ProjectionMatrix { get;}
}

所有实现ICameraInterface接口的类必须实现这五个getter方法。无论是第一人称相机还是四元数相机，只要是实现这个接口，那么主程序就一定能访问这五个字段，对于其余代码，知道当前相机是第一人称模式还是四元数模式并不重要，唯一重要的是相机能产生View和Projection矩阵，或许还有一些其他的方向向量。所以你应该为Game类实现一个拥有ICameraInterface 的相机。

让GameComponent实现接口

本例中有两个相机组件，因为你不在屏幕上绘制东西，所以没必要使用DrawableGameComponent，所以相机组件是从GameComponent类继承的。你还要在组件上实现接口：

class QuakeCameraGC : GameComponent, ICameraInterface
{
    .
    .
    .
}

注意虽然一个类只能从一个基类继承（本例中是GameComponent类），但它可以实现多个接口。

下一步你必须确定在类中实现接口中定义的方法。在教程2－3和2－4的QuakeCamera类和Quaternion类中已经实现了。

注册ICameraInterface Service

在一个时间你必须有一个而且只能有一个相机类提供ICameraInterface服务。当你激活一个相机组件，必须要让Game类知道这个相机组件是当前的ICameraInterface，这样才能在其他代码请求当前ICameraInterface的提供者时让Game类返回这个相机。

你可以通过将这个相机注册为Game类的Services 集合的一个GameService实现这一步：

public QuakeCameraGC(Game game) : base(game)
{
    game.Services.AddService(typeof(ICameraInterface), this);
}

你将这个对象（新创建的第一人称相机组件）添加到接口集合中并表明提供ICameraInterface 服务。

使用方法

当你想使用相机时（例如获取View和Projection矩阵），你应要求Game类返回当前的ICameraInterface。在返回的对象中，你可以获取所有在ICameraInterface中定义的字段。

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
    ICameraInterface camera; camera = (ICameraInterface)Services.GetService(typeof(ICameraInterface));
    cCross.Draw(camera.ViewMatrix, camera.ProjectionMatrix);
    base.Draw(gameTime);
}

在Draw方法中的代码不知道是第一人称相机还是四元数相机，只知道在ICameraInterface中定义的方法。这种做法的优点是你可以很容易的切换相机模式，因为其他代码并不知道这个变化。

注意在Game类的Initialize方法中你还定义了一个camera作为全局变量存储了当前的ICameraInterface的链接。

使用多个GameComponents

对于保证不同GameComponent类的互操作性来说，GameServices是很有用的。本例中你有一个提供IcameraInterface服务的相机组件，那么其他组件类就可以通过查询IcameraInterface服务的方式获取这个相机。

这意味着你无需提供不同组件间的生硬的链接，例如前面的教程中的丑陋的Update方法。本例中你创建一个提供IcameraInterface服务的相机组件，就可以从任意其他组件访问到这个服务，例如从billboard组件的Initialize方法中：

public override void Initialize()
{
    device = Game.GraphicsDevice;
    camera = (ICameraInterface)Game.Services.GetService(typeof(ICameraInterface));
    base.Initialize();
}

接下来，billboard组件的Update和Draw方法就可以访问到相机的字段了。

Game类需要初始化相机和billboard组件。相机组件会将自己订阅到IcameraInterface服务，允许billboard组件访问相机。

相机会自动更新，之后billboard组件也会自动更新，billboard组件可以通过IcameraInterface服务访问相机，然后绘制自己。整个处理过程需要在Game类中添加两行代码，你可以轻松地在相机的两种模式间切换。

改变GameComponent的更新顺序

本例中你的billboard组件需要相机组件的输出，所以你应保证相机在billboard之前更新。可以在Game类中将组件添加到Components 集合之前进行此步：

public Game1()
{
    graphics = new GraphicsDeviceManager(this);
    Content.RootDirectory = "Content";
    //GameComponent camComponent = new QuakeCameraGC(this);
    GameComponent camComponent = new QuatCamGC(this);
    GameComponent billComponent = new BillboardGC(this);
    camComponent.UpdateOrder = 0;
    billComponent.UpdateOrder = 1;
    Components.Add(camComponent);
    Components.Add(billComponent);
}

首先你创建了相机和billboard组件，在将它们添加到Components集合前，你设置了它们的更新顺序，小的数字表示先更新。

使用GameComponent和GameServices切换相机模式只需改变程序中的一行代码。在前面的代码中， quaternion模式处在激活状态。如果你想切换到Quake模式，只需去掉QuakeCamera GameComponent类前的注释，并在QuatCam GameComponent类前加上注释。其余代码无需改动，因为需要的只是相机组件提供的IcameraInterface服务。

代码

前面显示的Game1构造函数代码就是获取相机并使billboard工作的所有代码，Initialize， (Un)LoadContent，Update和Draw方法为空。当相机组件创建后，它将自己注册到IcameraInterface服务：

public QuakeCameraGC(Game game) : base(game)
{
    game.Services.AddService(typeof(ICameraInterface), this);
}

无论何时其他代码需要使用相机，你只需要求Game类返回ICameraInterface服务即可：

ICameraInterface camera;
camera = (ICameraInterface)Services.GetService(typeof(ICameraInterface));
cCross.Draw(camera.ViewMatrix, camera.ProjectionMatrix);


1.9 将数据保存到文件，从文件读取数据

问题

你需要在游戏中实现保存功能。解决方案虽然保存功能通常是创建游戏的最后一步，但你肯定需要在游戏中实现这个功能。首先，XNA使用默认的.NET文件I/O 功能，这意味着创建/打开/删除文件是很容易的。接着，使用XmlSerializer类能非常容易地将数据保存到文件并在以后加载数据。

唯一的问题是找到在PC平台和Xbox360平台都能保存的位置，要解决这个问题，你可以使用StorageDevice，它必须首先被建立。

注意创建一个StorageDevice的原理可能很复杂，但别被吓倒，因为本教程的其余部分（从“将数据保存到磁盘”） 非常简单和有效，而且这个方法并不局限与在XNA上使用，因为它使用的是默认的.NET功能。

工作原理

在将数据保存到磁盘之前，你需要一个可用的位置进行写入。这可以通过创建一个StorageDevice实现，StorageDevice会询问Xbox360平台上的用户将数据保存到哪儿。

异步创建一个StorageDevice

在这个过程中会打开Xbox Guide并终止程序直到用户关闭Guide。要解决这个问题，可以让这个过程异步处理，原理在教程8－5中解释。

Guide需要将GamerServicesComponent添加到游戏中 (见教程8－1)，所以要将下列代码添加到Game1构造函数中：

public Game1()
{
    graphics = new GraphicsDeviceManager(this);
    Content.RootDirectory = "Content";
    Components.Add(new GamerServicesComponent(this));
}

下一步，当调用Update方法时，你会检查用户是否想保存数据，如果是，则调用Guide. BeginShowStorageDeviceSelector，它会打开一个对话框让用户选择在哪里保存数据。程序并不会在此终止，但会暂停程序直到用户关闭对话框。Guide. BeginShowStorageDeviceSelector方法要求你指定另一个FindStorageDevice方法作为第一个参数，一旦用户关闭对话框这个方法就会被调用：

KeyboardState keyState = Keyboard.GetState();
if (!Guide.IsVisible)
    if (keyState.IsKeyDown(Keys.S))
        Guide.BeginShowStorageDeviceSelector(FindStorageDevice, null);

如果用户按下S键就会打开对话框，但代码并不会终止直到用户关闭对话框。

当对话框显示在屏幕上时你的程序仍在运行，一旦用户做出选择并关闭对话框，作为第一个参数的FindStorageDevice方法就会被调用，这意味着你应事先定义这个方法，否则编译器会报错：

private void FindStorageDevice(IAsyncResult result)
{
    StorageDevice storageDevice = Guide.EndShowStorageDeviceSelector(result);
        if (storageDevice != null)
            SaveGame(storageDevice);
}

BeginShowStorageDeviceSelector的输出结果作为这个方法的参数，如果你将结果传递到Guide. EndShowStorageDeviceSelector方法，就可以获取data storage。但是如果用户取消操作，结果将会是null，所以你应该事先检查。如果StorageDevice可用，你将它传递到SaveGame方法，这个方法一会儿后就会定义。

但如果当用户指定数据位置时允许用户进行第二种操作，例如加载数据，那么就需要定义第二个方法，比方说FindStorageDeviceForLoading。但清晰的方法是指定一个identifier，在FindStorageDevice方法中可以检查这个identifier。你的Update方法应该包含下列代码块：

KeyboardState keyState = Keyboard.GetState();
if (!Guide.IsVisible)
{
    if (keyState.IsKeyDown(Keys.S))
        Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "saveRequest");
    if (keyState.IsKeyDown(Keys.L))
        Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "loadRequest");
}

如你所见，在这两种情况下对话框都会显示，当对话框关闭后就会调用FindStorageDevice方法。但这次的区别是你指定了一个identifier并在FindStorageDevice方法中检查这个identifier：

private void FindStorageDevice(IAsyncResult result)
{
    StorageDevice storageDevice = Guide.EndShowStorageDeviceSelector(result);
    if (storageDevice != null)
    {
        if (result.AsyncState == "saveRequest")
            SaveGame(storageDevice);
        else if (result.AsyncState == "loadRequest")
            LoadGame(storageDevice);
    }
}

根据指定的identity，你将调用SaveGame或LoadGame方法。

将数据保存到磁盘

一旦有了一个正确的StorageDevice，你就可以很容易地为写入数据的文件指定一个名称：

private void SaveGame(StorageDevice storageDevice)
{
    StorageContainer container = storageDevice.OpenContainer("BookCodeWin");
    string fileName = Path.Combine(container.Path, "save0001.sav");
    FileStream saveFile = File.Open(fileName, FileMode.Create);
}

这将创建一个叫做save0001. Sav的文件，如果这个文件已经存在将会被覆盖。

注意在PC上这个文件将会创建在My Documents\SavedGames文件夹中。

一旦你有了正确的文件名并已经打开了这个文件，你就可以使用默认的.NET 方法保存文件，假设你将存储的数据结构如下：

public struct GameData
{
    public int ActivePlayers;
    public float Time;
}

你需要创建一个XmlSerializer，它能够将你的数据转换为XML并保存到磁盘：

XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData));
xmlSerializer.Serialize(saveFile, gameData);
saveFile.Close();

XmlSerializer能够串行化GameData对象，之后使用一个命令就可以将GameData对象的数据流写到文件中。别忘了关闭文件流，否则程序会锁定文件流。

你需要添加System. IO和System.Xml. Serialization命名空间，只需在代码顶部添加下面的代码：

using System.IO;
using System.Xml.Serialization;

最后一行代码需要你添加对System. Xml的引用，要添加这个引用，可以打开Project菜单并选择Add Reference，选择System.Xml，如图1－5所示，然后点击OK。

图1－5 添加对System. Xml的引用

从磁盘加载数据

加载数据的方法与保存数据的方法相同，顺序正好相反。你检查文件是否存在，如果存在就打开它。你仍要创建一个XmlSerializer，但这次你使用XmlSerializer从文件流中反串行化GameData对象，下面的代码加载从文件加载所有数据并把数据转换为GameData对象：

private void LoadGame(StorageDevice storageDevice)
{
    StorageContainer container = storageDevice.OpenContainer("BookCodeWin");
    string fileName = Path.Combine(container.Path, "save0001.sav");
    if (File.Exists(fileName))
    {
        FileStream saveFile = File.Open(fileName, FileMode.Open);
        XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData));
        gameData = (GameData)xmlSerializer.Deserialize(saveFile);
        saveFile.Close();
    }
}

代码

Update方法检查用户是想保存还是加载文件并打开对话框：

protected override void Update(GameTime gameTime)
{
    GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
    if (gamePadState.Buttons.Back == ButtonState.Pressed)
        this.Exit();
    KeyboardState keyState = Keyboard.GetState();
    if (!Guide.IsVisible)
    {
        if (keyState.IsKeyDown(Keys.S))
            Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "saveRequest");
        if (keyState.IsKeyDown(Keys.L))
            Guide.BeginShowStorageDeviceSelector(FindStorageDevice, "loadRequest");
    }
    gameData.Time += (float)gameTime.ElapsedGameTime.TotalSeconds;
    base.Update(gameTime);
}

当用户关闭Guide时就会调用FindStorageDevice方法，这个方法根据异步调用的identity调用SaveData或LoadData方法。你可以在前面的代码中看到整个FindStorageDevice方法，只漏了SaveGame 方法：

private void SaveGame(StorageDevice storageDevice)
{
    StorageContainer container = storageDevice.OpenContainer("BookCodeWin");
    string fileName = Path.Combine(container.Path, "save0001.sav");
    FileStream saveFile = File.Open(fileName, FileMode.Create);
    XmlSerializer xmlSerializer = new XmlSerializer(typeof(GameData));
    XmlSerializer.Serialize(saveFile, gameData);
    saveFile.Close();
    log.Add("Game data saved!");
}


2.0 第二章概述

在3D程序中，相机是一个很重要的组件元素。相机表示用户在3D世界中的视野。XNA将相机看起来的3D世界绘制到2D屏幕上。所以在让XNA绘制3D’场景时，首先需要指定相机的位置和观察方向。

本章首先介绍一些创建3D程序前必须掌握的基本知识。

本章的第二部分涵盖一些高级的例子，诸如相机飞入效果、四叉树、八叉树和post-processing。

本章的教程介绍如下知识：

  • 学习在3D程序中使用相机的目的，如何定义一个相机(教程2-1)
  • 在场景中基于用户输入旋转并移动相机(教程2-2, 2-3, 2-4和2-6)
  • 通过只绘制视野中的场景不浪费显卡的处理能力(教程2-5, 2-9,2-10和2-11)
  • 实现一个平滑的飞入效果(教程2-7)
  • 使用天空盒作为3D世界的背景(教程2-8)
  • 使用post-processing处理最终图像让游戏变得更漂亮(教程2-12和2-13)
  • 编写自定义内容导入器，让你可以在内容管道中使用自定义的素材文件(教程2-14)


2.1 创建一个相机：Position，Target和View Frustum

问题

在将3D世界绘制到屏幕之前，你需要相机的View和Projection矩阵。

解决方案

你可以在一个矩阵中保存相机位置和方向，这个矩阵叫做View矩阵（视矩阵，观察矩阵）。要创建View矩阵，XNA需要知道相机的Position，Target和Up矢量。

你也可以保存视锥体（view frustum），它是3D世界中实际可见的部分，在另一个叫做Projection的矩阵中。

工作原理

View矩阵保存相机位置和观察方向的定义。你可以通过调用Matrix. CreateLookAt方法创建这个矩阵：

viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector);

这个方法需要三个参数：相机的Position, Target和Up向量。Position向量容易理解，它表示在3D空间中的哪个位置放置相机。然后，需要指定另一个点表示相机观察的目标。这已经可以定义一个相机了，但Up向量用来干什么？

看一下这个例子：你的头(事实上是你的眼睛)就是相机。你试着定义一个与头有相同位置和方向的相机。第一个向量很容易找到：Position向量就是头在3D场景中的位置。然后， Target向量也不是很难；假设你看着图2-1中的X，在这种情况中，X的位置就是相机的Target 向量。但有其他方式可以让在同一位置的头看着X！

图2-1 相机的观察目标

只定义了Position和Target向量 ，你也可以绕着双眼之间的点旋转头部，例如，上下颠倒看。如果你这样做，头部的位置和观察目标仍保持不变，但因为所有东西都进行了旋转，观察到的图像会完全不同。你就是为什么需要定义相机的Up向量的原因。

知道了相机的位置，观察目标和相机的up方向，相机就唯一确定了。View矩阵由这三个向量决定，可以使用Matrix. CreateLookAt方法创建一个相机：

Matrix viewMatrix;
Vector3 camPosition = new Vector3(10, 0, 0);
Vector3 camTarget = new Vector3(0, 0, 0);
Vector3 camUpVector = new Vector3(0, 1, 0);
viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector);

注意：相机的Position和Target向量指向3D空间中的真实位置，Up向量表示相机向上方向。例如，一个相机位于点(300,0,0)观察点(200,0,0)。如果相机的Up向量只是简单地向上，只需指定(0,1,0)Up向量，这不是指在3D空间中的点，这个例子中这个3D点为(300,1,0)。

注意：XNA为最常用的向量提供了一个快捷方式，Vector3. Up表示(0,1,0)，Vector3. Forward表示(0,0,-1)，Vector3. Right表示(1,0,0)。为了帮你理解3D向量，本章第一个教程都使用完整的写法。

XNA还需要Projection矩阵。你可以将这个矩阵看成可以映射从3D空间到2D窗口的所有点的一个东西，但我更希望你把它看成包含相机镜头信息的矩阵。

让我们看一下图2-2，左图显示了一个在相机视野中的3D场景，你可以看到它像一个金字塔。右图中你可以看到金字塔的一个2D切面。

图2-2 相机的视锥体

图片左边的切除顶部的金字塔叫做视锥体（view frustum）。只有在视锥体内部的物体才会被绘制到屏幕上。

XNA可以为你创建这样一个视锥体，它存储在Projection矩阵中。你可以调用Matrix. CreatePerspectiveFieldOfView创建这个矩阵：

projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane);

Matrix. CreatePerspectiveFieldOfView方法的第一个参数是观察角度。它对应金字塔顶角的一半，如图2-2右图所示。如果你想知道自己的观察角度，可以将手放在眼睛前面，你会发现这个角度约为90度。因为弧度PI等于180度，90等于PI/2。因为你需要指定观察角度的一半，所以这个参数为PI/4。

注意：通常你想使用一个对应人的视角的视角，但是在某些场景中你可能会指定其他的视角。通常发生在你想将场景绘制到一张纹理的情况中，例如，从光线的视角看来。在光线的情况中，更大的视角表示更大的光照范围。对应的例子可参见教程3-13。

你需要指定的另一个参数与“source,”无关，即与视锥体无关，而和“destination,”有关，即与屏幕有关。它是2D屏幕的长宽比，它实际上对应后备缓冲的长宽比，你可以使用以下代码获取它：

float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio;

当使用一个长和宽相同的正方形窗口时，这个比为1。但是绘制全屏800 × 600窗口时这个比大于1，当绘制到宽屏笔记本或HDTV更大。如果你用错误地用1代替800/600作为800*600窗口的长宽比，图像会水平拉伸。

最后两个参数与视锥体有关。想象一下一个物体非常靠近相机，这个物体会占据整个视野，窗口会被一个单独的颜色占满。要避免这个情况的发生，XNA让你可以定义一个靠近金字塔顶部的平面。位于金字塔顶部和这个平面之间的物体不会被绘制，这个平面叫做近裁平面（near clipping plane），你可以指定相机到这个近裁平面的距离作为CreatePerspectiveFieldOfView方法的第三个参数。

注意：剪裁过程用来表示有些物体无需被绘制以提高程序帧频率。

同理也可以处理离相机非常远的物体；这些物体看起来很小，但仍占用显卡的处理时间。所以，远于第二个平面的物体也会被剪裁。第二个平面叫做远裁平面（far clipping plane），它是视锥体的最远边界。你可以指定相机到这个远裁平面的距离作为CreatePerspectiveFieldOfView方法的最后一个参数。

当心：即使绘制的是一个及其简单的3D场景，也不要把远裁平面设置地过大。例如将远裁平面的距离设置为比较疯狂的100000会导致一些视觉错误。带有16-bit深度缓冲的显卡(可参见本教程的“Z-Buffer (或Depth Buffer)”一节)有2^16 = 65535个深度值。如果两个物体使用同一个像素，而且之间的距离小于100k/65535 = 1.53个单位时，显卡就无法判断哪个物体更加靠近相机。

事实上，这会导致更坏的结果，因为scale is quadratic（？），会导致整个场景的最后三个 quarters（？）看起来离开相机的距离相同。近裁平面和远裁平面间的距离最好小于几百，如果显卡的深度缓冲小于16-bit，这个距离应该更小。

这个问题的典型错误就是你看到的所有对象都有锯齿边缘。

使用方法

你想在程序的更新过程中更新View矩阵，因为相机的位置和方向是基于用户输入的。Projection矩阵只需在窗口的长宽比发生变化时才需要更新，例如，当将窗口切换到全屏模式时。

计算好View和Projection矩阵之后，你需要将它们传递到绘制物体的effect中，可在下面的Draw方法中看到对应代码。这可以让显卡上的shader将所有的顶点转换为窗口的对应像素。

代码

下面的例子显示了如何创建一个View矩阵和一个Projection矩阵。比方说你有一个物体位于(0,0,0)，你想将相机放置在x轴上+10个单位的地方，正y轴作为Up向量。 而且，你想在800 × 600窗口中绘制场景，使所有与相机的距离小于0.5f大于100.0f的三角形被剪裁。下面是代码：

using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Storage;

namespace BookCode
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        ContentManager content;
        BasicEffect basicEffect;
        GraphicsDevice device;

        CoordCross cCross;
        Matrix viewMatrix;
        Matrix projectionMatrix;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            content = new ContentManager(Services);
        }

只有在窗口的长宽比发生变化时才需要更新Projection矩阵。你只需要定义一次Projection矩阵，所以放在程序的初始化过程中。

protected override void Initialize()
{
    base.Initialize();
    float viewAngle = MathHelper.PiOver4;
    float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio;
    float nearPlane = 0.5f;
    float farPlane = 100.0f;

    projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane,farPlane);
}

protected override void LoadContent()
{
    device = graphics.GraphicsDevice; basicEffect = new BasicEffect(device, null);

    cCross = new CoordCross(device);
}

protected override void UnLoadContent()
{
}

你需要改变View矩阵让用户输入移动相机，因此将它放在程序的更新过程中。

protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
        this.Exit();

    Vector3 camPosition = new Vector3(10, 10, -10);
    Vector3 camTarget = new Vector3(0, 0, 0);
    Vector3 camUpVector = new Vector3(0, 1, 0);
    viewMatrix = Matrix.CreateLookAt(camPosition, camTarget, camUpVector);

    base.Update(gameTime);
}

然后将Projection和View矩阵传递到Effect绘制场景：

protected override void Draw(GameTime gameTime)
{
    graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

    basicEffect.World = Matrix.Identity;
    basicEffect.View = viewMatrix;
    basicEffect.Projection = projectionMatrix;

    basicEffect.Begin();
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        cCross.DrawUsingPresetEffect();
        pass.End();
    }
    basicEffect.End();

    base.Draw(gameTime);
}

扩展阅读

只需要用到前面两个矩阵XNA就可以将3D场景绘制到2D屏幕中。从3D转换到2D是个挑战，但XNA已经帮你做到了。但是，要创建和调试更大的3D程序需要深入理解这个操作背后到底发生了什么事。 Z-Buffer (或Depth Buffer) 第一个挑战是指定哪个物体会占据最终图像上的像素。当从3D空间转换到2D屏幕时，有可能多个物体都显示在同一个像素上，如图2-3所示。2D屏幕上的一个像素对应3D空间中的一条射线，解释可参见教程4-14。对一个像素，这条射线如图2-3中的虚线所示，它与两个物体相交。这种情况中，这个像素的颜色会取自物体A，因为A比B更靠近相机。

图2-3 多个物体占据同一个像素

但是，如果首先绘制物体B，frame buffer中对应像素会首先被指定为B的颜色。然后物体A被绘制，显卡需要判断像素是否需要用物体A的颜色覆盖。

解决方法是，在显卡中还储存了第二张图像，它的大小与窗口大小一样。当给frame buffer中的一个像素指定一个颜色时，这个物体和相机间的距离会保存在第二个图像中。这个距离介于0和1之间，0对应近裁平面与相机间的距离，1对应远裁平面与相机间的距离。所以第二个图像叫做depth buffer或z-buffer。

那么如何解决这个问题？当绘制物体B时会检查z-buffe，因为B首先绘制，z-buffer是空的。结果是frame buffer中对应像素的颜色就是B的颜色，在z-buffer的相同像素中获得一个值，对应B物体与相机间的距离。

然后绘制物体A，对应物体A的每个像素，首先检查z-buffer。z-buffer已经包含了物体B的值，但储存在z-buffer中的距离大于物体A与相机间的距离，所以显卡知道需要用物体A的颜色覆盖这个像素！


2.2 指定相机的目标

问题

当定义View矩阵时，你需要指定Target向量作为参数，这个向量设置了相机的旋转。

解决方案

当选转相机时，相机的位置会保持不变。这意味着旋转取决于相机的Target点和Up向量的变化。你可以通过使用 (0,0,-1)Forward向量并将旋转施加到这个向量上获取Target向量，这会产生一个新的Target点。你可以使用相同的方法获取Up向量。

工作原理

如教程2-1所示，一个相机需要Position, Target和Up向量才能唯一确定。如果你想让相机绕着某一点旋转，Position向量保持不变，但Target和Up向量都要发生改变。

给定绕着三根轴的旋转角度，一个方法是处理Target位置，但这会带来复杂的运算，还有一个更加清晰和快速的方法。

最简单的例子：相机在初始位置，绕着Up向量

旋转让我们先来看一下相机在(0,0,0)初始坐标的情况。它朝(0,0,-1) Forward方向观察，以默认的(0,1,0) Up向量作为向上方向。在这个情况中，你可以使用以下代码：

  Vector3 cameraPosition = new Vector3(0, 0, 0);
  Vector3 cameraTarget = new Vector3(0, 0, -1);
  Vector3 cameraUpVector = new Vector3(0, 1, 0);

  viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraTarget, cameraUpVector);

例如你想创建一个绕Up向量旋转45度的View矩阵。如果你的头就是相机，这会让头部向右旋转45度。当计算新View矩阵时，Position向量和Up向量会保持不变，但你需要获取新的Target向量。你可以通过将使用45度旋转“转换”默认的(0,0,-1) Target向量获取新Target向量。这意味着你获取的这个向量是初始Target向量的旋转版本。下面是代码：

  Matrix cameraRotation = Matrix.CreateRotationY(MathHelper.PiOver4);
  Vector3 cameraPosition = new Vector3(0, 0, 0);
  Vector3 cameraUpVector = new Vector3(0, 1, 0);
  Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);

  Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
  viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraRotatedTarget, cameraUpVector);

注意：矩阵是表示某种变换的有力工具。一个矩阵可以表示旋转，平移，缩放或这些变换的组合。你可以在教程4-2中获取更多的例子。

第一行代码创建了一个表示绕Up轴旋转45度的矩阵，对应PI/4弧度。下面的代码通过这个旋转矩阵转换初始(0,0,-1) Target向量，并将旋转过的Target向量保存在cameraRotatedTarget变量中，这个变量用来创建一个新的View矩阵。

注意：这个变换并不神秘；它是向量和矩阵的乘积，只是简单地进行16次乘法和12次加法。

第二个例子：相机在初始位置，任意旋转

现在来看一个有点复杂的例子。你想使相机绕着任意轴旋转而不是绕着Up向量。例如，绕着(1,0,0) Right轴旋转45度，如果你的头是相机，这会导致斜向上45度观察。

因为你仅旋转相机，Position向量保持不变。和前面一个例子一样，Target向量会变化，因为相机需要观察一个不同的位置。

但是这种情况中Up向量也会发生变化。在前面的例子中你将头部转向右边，Up向量并不会变化。这个例子中，将头部向上旋转，Up向量和Target向量都要发生改变。

变换Up向量和变换Forward向量的方法是一样的：你将旋转存储在一个矩阵中并定义初始Up向量。然后，通过这个旋转矩阵变换初始向量获取新的Up向量。下面是代码：

  Matrix cameraRotation = Matrix.CreateRotationX(MathHelper.PiOver4);
  Vector3 cameraPosition = new Vector3(0, 0, 0);
  Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);
  Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);

  Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
  Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
  viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraRotatedTarget, cameraRotatedUpVector);

这里的“任意”旋转矩阵只是一个简单绕x轴的旋转。这个代码也可以处理任何旋转，例如下面的这个情况，它组合了三根轴上的旋转。下面的代码生成一个矩阵，这个矩阵是绕z轴–45度和y轴22.5度，x轴90度旋转的组合：

Matrix cameraRotation = Matrix.CreateRotationX(MathHelper.PiOver2)* Matrix.CreateRotationY(MathHelper.Pi/8.0f)* Matrix.CreateRotationZ(-MathHelper.PiOver4);

当获取新的Forward向量和Up向量时，相机的位置保持不变。

第三个例子：相机在指定位置，任意旋转

在大多数情况中，你想设置任意旋转并指定相机的位置。例如相机在位置(10,20,30)，不旋转。非常简单，相机的Position向量为(10,20,30)。因为没有旋转，所以相机观察(0,0,-1) Forward方向。

注意：记住需要指定Target而不是Target方向！将(0,0,-1)作为Target向量是错误的，因为这会让相机观察点(0,0,-1)。例如你将相机移动到点(-10,20,30)，如果仍然指定(0,0,-1)作为Target向量，相机仍会观察点(0,0,-1)，这个相机观察的方向就会发生变化！

要让位于(10,20,30)相机朝向(0,0,-1)方向，你需要指定(10,20,29)作为Target向量。你可以通过求相机位置和目标方向的和获取这个向量：

  Vector3 cameraPosition = new Vector3(10, 20, 30);
  Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
  cameraTargetPoint = cameraPosition + cameraOriginalTarget;

现在，可以组合前面所学到的东西了。你将定义一个位于(10,20,30)的相机并可以任意旋转。Position向量保持(10,20,30)不变。对Target向量，首先定义为(0,0,-1)方向。要在旋转后获取Forward方向，你需要使用旋转矩阵变换它。最后，要获取Target向量让位于(10,20,30)的相机看向旋转方向，你需要在这个旋转方向上加上(10,20,30)。Up向量用同样的方法获取，下面是最后的代码：

  Matrix cameraRotation =Matrix.CreateRotationX(MathHelper.PiOver2)*  Matrix.CreateRotationY(MathHelper.Pi/8.0f)*Matrix.CreateRotationZ(-MathHelper.PiOver4);
  Vector3 cameraPosition = new Vector3(10, 20, 30);
  Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
  Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

  Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
  Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

  Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
  Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector;

  viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraFinalUpVector);

将旋转过的相机前后/左右移动

现在已经实现了指定位置的相机并朝向正确的方向，新的挑战是前后移动相机。如果你想让相机向前移动，简单地将(0,0,-1) Forward向量添加到Position向量上不会成功，因为首先需要获取对应旋转相机的Forward向量。你已经在本教程中的第一个例子中使用过旋转矩阵变换过(0,0,-1) Forward向量了，有了这个变换过的Forward向量，才可以将它加到Position向量中：

  float moveSpeed = 0.5f;
  Vector3 cameraOriginalForward = new Vector3(0,0,-1);
  Vector3 cameraRotatedForward = Vector3.Transform(cameraOriginalForward, cameraRotation);
  cameraPosition += moveSpeed * cameraRotatedForward;

改变moveSpeed的值可以增加/减少相机移动的速度，因为这个值会乘以旋转过的 Forward方向。

同样的方法也可以让相机左右移动。处理的是(1,0,0) Right向量而不是(0,0,-1) Forward向量，仍然需要首先进行变换以获取对应当前相机旋转的Right向量。

  float moveSpeed = 0.5f;
  Vector3 cameraOriginalRight = new Vector3(1, 0, 0);
  Vector3 cameraRotatedRight = Vector3.Transform(cameraOriginalRight, cameraRotation);
  cameraPosition += moveSpeed * cameraRotatedRight;

代码

这个方法中相机只需保存当前位置和旋转，位置和旋转任意一个发生变化就要更新View 矩阵。这些变化通常来自于用户输入，可以在教程2-3和2-4中看到具体实现。每个矩阵都需要进行初始化，你首先需将cameraRotation矩阵设置为单位矩阵。

  protected override void Initialize()
    ...{
      float viewAngle = MathHelper.PiOver4;
      float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio;
      float nearPlane = 0.5f;
      float farPlane = 100.0f;
      projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane);

      cameraPosition = new Vector3(-5, 7, 14);
      cameraRotation = Matrix.CreateRotationX(-MathHelper.Pi/8.0f)* Matrix.CreateRotationY(-MathHelper.Pi/8.0f);

      UpdateViewMatrix();
      base.Initialize();
  }

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      MoveCameraForward();

      base.Update(gameTime);
  }

  private void MoveCameraForward()
    ...{
      float moveSpeed = 0.05f;
      Vector3 cameraOriginalForward = new Vector3(0, 0, -1);
      Vector3 cameraRotatedForward = Vector3.Transform(cameraOriginalForward, cameraRotation);
      cameraPosition += moveSpeed * cameraRotatedForward;
      UpdateViewMatrix();
  }

  private void UpdateViewMatrix()
    ...{
      Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
      Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

      Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
      Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

      Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
      viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
  }

  protected override void Draw(GameTime gameTime)
    ...{
      graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

      //render coordcross using specified View and Projection matrices
      cCross.Draw(viewMatrix, projectionMatrix);

      base.Draw(gameTime);
  }

扩展阅读

矩阵乘法的顺序

可参见教程4-2。

改变坐标系统

前面使用的Forward (0,0,-1)和Up (0,1,0)方法是“官方的” XNA向量，也可以使用Vector3 . Forward和Vector3 . Up快捷方式获取。但是这只是一个约定，你可以定义一个完全不同的坐标系统。例如也可以使用(0,0,1)作为Up方向，(0,1,0)作为Forward方向，(1,0,0)作为Right方向，这完全取决于你的需要。

但是这三个向量应该符合一个规则。在XNA中x，y和z轴是右手坐标系。意思是一旦你知道了任意两个坐标轴，就可以知道第三个坐标轴的方向。展开你右手的大拇指和食指，然后弯曲中指使它垂直于食指和大拇指，将这三个手指看成坐标轴，如果是右手坐标系，那么x轴就对应大拇指，y轴对应食指，z轴对应中指。

本教程第一段的坐标系统，对应大拇指指向右方，中指指向上方 。要代表“官方”XNA坐标系统，将你的大拇指指向右方(正x轴 = 右方)，食指指向上方 (y轴=上方)。现在你可以看到中指指向后方(正z轴 =后方)， 这就是为什么官方Forward (0,0,-1)向量中有个负号的原因！


2.3 创建一个第一人称射击游戏（FPS）的相机：Quake风格的相机

问题

你想创建一个类似于第一人称射击游戏的相机。你想使用鼠标旋转相机，使用键盘移动相机。

解决方案

首先使用教程2-2中的方法，只要检测到用户输入就更新相机的位置和旋转。相机的旋转矩阵会根据鼠标的移动进行改变，按Up或Down键让相机前后移动，按Left或Right键左右移动。

工作原理

通常，在FPS游戏中，玩家可以自由地上下左右旋转观察，这些运动对应绕Up和Right轴的旋转，或更具体地说，绕(0,1,0)和(1,0,0)方向的旋转。

但是绕Forward向量的旋转是不被允许的，这会导致玩家向左或向右弯曲脖子，在第一人称游戏中不使用，除非玩家被击中倒在地板上。

在进一步学习之前，先看一下教程4-2中的“矩阵乘法的顺序”一节还是很有帮助的。矩阵的乘法顺序是重要的，因为当你组合两个旋转时，第二个旋转轴会被第一个旋转所改变。

第一个旋转影响第二个轴叫做万向节锁（Gimbal lock），有时这是个大麻烦，但有时就是你想要的结果。在FPS相机的例子中，你很幸运：你是首先沿Up轴旋转，然后绕Right向量旋转。第一个旋转也旋转了Right向量，你可以想象成这样一个情况：你站立伸开手臂，如果身体转向右边，手臂也会跟着一起旋转，现在你可以旋转手臂的方向，对应向上或向下转动。

简单地说，你需要存储两个变量：绕Up方向的旋转量和绕Right方向的旋转量。然后，当需要计算总的旋转量时，你使用“在绕Up旋转之后绕Right旋转”组合两个旋转。知道了总旋转，就可以将它传递到前一个教程的代码中去了。

现在你知道了FPS相机后面的原理，仍需要将用户输入和这两个变量联系起来。这个例子中给定鼠标输入，你要获取一个更新周期之内鼠标位置的改变并对应地改变这两个变量。所以，你需要下列变量：

  float leftrightRot;
  float updownRot;
  Vector3 cameraPosition;
  Matrix viewMatrix;
  MouseState originalMouseState;

MouseState包含鼠标的位置和一些bits代表鼠标键是否被按下。在Initialize 方法中给这两个变量一个初始值：

  leftrightRot = 0.0f;
  updownRot = 0.0f;
  cameraPosition = new Vector3(1,1,10);
  UpdateViewMatrix();
  Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
  originalMouseState = Mouse.GetState();

你首先将旋转值设为0，将相机放置在你选择的位置。你等会儿还要创建一个UpdateViewMatrix方法，这里这个方法用来初始化viewMatrix变量。

然后，你将光标设置在屏幕的中心。最后一行代码存储了MouseState，它包含了光标的位置，所以在下一个更新循环中，你可以检测这个状态和新状态的区别，表示鼠标是否移动。

看一下更新过程。如前所述，你首先检查新的MouseState看看它是否与存储在 originalMouseState中的MouseState有所不同。如果不同，旋转值会随之更新，光标会回到屏幕中央。

  float rotationSpeed = 0.005f;
  MouseState currentMouseState = Mouse.GetState();
  if (currentMouseState != originalMouseState)
    ...{
      float xDifference = currentMouseState.X - originalMouseState.X;
      float yDifference = currentMouseState.Y - originalMouseState.Y;
      leftrightRot -= rotationSpeed * xDifference;
      updownRot -= rotationSpeed * yDifference;
      Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
  }
  UpdateViewMatrix();

rotationSpeed变量定义鼠标的旋转速度。你获取鼠标坐标当前位置和前面位置的水平距离和竖直距离，并对应地调整绕Right和Up向量的旋转。

最后一行代码将光标重新放置到窗口中央。

注意：另一个方法是将currentMouseState保存到originalMouseState中，这样它就做好了与下一个更新循环中的新的currentMouseState作比较的准备。但是，如果光标移动到屏幕的边缘时这个方法会变得无用。例如，如果光标位于屏幕的右边缘，当用户将光标在向右移动时X坐标也不会不同，解决的方法是将光标重新定位到屏幕的中央。

最后调用UpdateViewMatrix方法，它会根据新的旋转值更新viewMatrix变量。下面是UpdateViewMatrix方法的代码，除了第一行代码，其余代码在前面的教程中都已经详细解释过了。基于一个存储相机旋转和位置的矩阵，这个方法计算Target和Up 向量，它们用来创建View矩阵：

  Matrix cameraRotation = Matrix.CreateRotationX(updownRot) * Matrix.CreateRotationY(leftrightRot);

  Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
  Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

  Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
  Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

  Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
  Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector;

  viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraFinalUpVector);

第一行代码需要一些解释：这里的Right向量是(1,0,0)，所以它指向x轴，这就是为什么使用CreateRotationX方法创建这个旋转的原因。这里的Up向量是(0,1,0)，所以你使用CreateRotationY方法创建左右旋转。乘法的顺序：M1*M2表示“M1位于M2之后”，这里就是“上/下旋转在左/右旋转之后”。更多有关矩阵乘法顺序的知识，可参见教程4-2。

这会让相机随鼠标的移动发生旋转。然后，当按下Forward键时你想让相机向前移动。在更新过程中，你首先检测按键情况，并由此指定将哪个向量添加到相机位置中：

  KeyboardState keyState = Keyboard.GetState();
  if (keyState.IsKeyDown(Keys.Up))
      AddToCameraPosition(new Vector3(0, 0, -1));
  if (keyState.IsKeyDown(Keys.Down))
      AddToCameraPosition(new Vector3(0, 0, 1));
  if (keyState.IsKeyDown(Keys.Right))
      AddToCameraPosition(new Vector3(1, 0, 0));
  if (keyState.IsKeyDown(Keys.Left))
      AddToCameraPosition(new Vector3(-1, 0, 0));

按下Up键导致将Forward向量添加到相机位置中，按下Down键导致从相机位置减去Forward向量等。但是，在将这些变量添加到相机位置之前，你仍需要通过相机旋转量旋转它们。更多的信息可参见前一个教程。这是由AddToCameraPosition方法处理的：

  private void AddToCameraPosition(Vector3 vectorToAdd)
    ...{
      float moveSpeed = 0.5f;
      Matrix cameraRotation = Matrix.CreateRotationX(updownRot) * Matrix.CreateRotationY(leftrightRot);
      Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation);
      cameraPosition += moveSpeed * rotatedVector;
      UpdateViewMatrix();
  }

首先，你计算相机旋转矩阵，如UpdateViewMatrix方法中所示。然后，根据这个旋转矩阵旋转指定向量，这个旋转过的向量会乘以一个变量，这个变量用来定义相机移动的速度，相机的Position发生改变。最后调用UpdateViewMatrix方法，这个方法根据新的相机位置创建一个新View矩阵。

代码

要创建一个第一人称相机，你需要保存四个变量：绕Up轴和Right轴的旋转量，相机位置和对应窗口中心的光标的MouseState。在更新过程中检测按键或鼠标移动，这会触发这些变量的改变，基于这些变量创建一个新View矩阵。

  protected override void Initialize()
    ...{
      base.Initialize();

      float viewAngle = MathHelper.PiOver4;
      float aspectRatio = (float)this.Window.ClientBounds.Width / (float)this.Window.ClientBounds.Height;
      float nearPlane = 0.5f;
      float farPlane = 100.0f;

     projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane);

      leftrightRot = 0.0f;
      updownRot = 0.0f;
      cameraPosition = new Vector3(1,1,10);
      UpdateViewMatrix();
      Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);
      originalMouseState = Mouse.GetState();
  }

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      float rotationSpeed = 0.005f;
      MouseState currentMouseState = Mouse.GetState();
      if (currentMouseState != originalMouseState)
    ...    {
          float xDifference = currentMouseState.X - originalMouseState.X;
          float yDifference = currentMouseState.Y - originalMouseState.Y;
          leftrightRot -= rotationSpeed * xDifference;
          updownRot -= rotationSpeed * yDifference;
          Mouse.SetPosition(Window.ClientBounds.Width / 2, Window.ClientBounds.Height / 2);

          UpdateViewMatrix();
      }

      KeyboardState keyState = Keyboard.GetState();
      if (keyState.IsKeyDown(Keys.Up))
          AddToCameraPosition(new Vector3(0, 0, -1));
      if (keyState.IsKeyDown(Keys.Down))
          AddToCameraPosition(new Vector3(0, 0, 1));
      if (keyState.IsKeyDown(Keys.Right))
          AddToCameraPosition(new Vector3(1, 0, 0));
      if (keyState.IsKeyDown(Keys.Left))
          AddToCameraPosition(new Vector3(-1, 0, 0));
      base.Update(gameTime);
  }

  private void AddToCameraPosition(Vector3 vectorToAdd)
    ...{
      float moveSpeed = 0.5f;
      Matrix cameraRotation = Matrix.CreateRotationX(updownRot) Matrix.CreateRotationY(leftrightRot);
      Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation);
      cameraPosition += moveSpeed * rotatedVector;
      UpdateViewMatrix();
  }

  private void UpdateViewMatrix()
    ...{
      Matrix cameraRotation = Matrix.CreateRotationX(updownRot) Matrix.CreateRotationY(leftrightRot);

      Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
      Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

      Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
      Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

      Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
      Vector3 cameraFinalUpVector = cameraPosition + cameraRotatedUpVector;

      viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
  }

QuakeCamera类

因为第一人称相机会频繁使用，我将本章的代码分离出来写入一个类中。本书中的很多例子都用到了这个类，而且它也很容易整合到你的项目中去。


2.4 创建一个Freelancer风格的相机：使用四元数的3D旋转

问题

译者注：《Freelancer》应该是指Digital Anvil制作，微软发行的游戏，中文译名《自由枪骑兵》，好像曾获2003年E3大展最佳PC游戏奖，是一个太空冒险游戏。

你想创建一个可以朝任意方向旋转的相机，例如，一个空战游戏。你需要能绕着三根轴旋转才能实现这点，但是因为万向节锁（Gimbal lock）（一个旋转会对影响另一个旋转)的缘故，这非常难，但并不是不可能。

解决方案

绕着多个轴组合一些旋转会导致万向节锁的发生，这会导致不正确的结果。使用四元数（quaternion）存储相机的旋转可以避免这个问题。

工作原理

当你组合绕着两个不同轴的旋转时，会发生万向节锁。发生的原因是第一个旋转会旋转第二个轴，解释可参见教程4-2。在这个教程中，你想让相机绕着x，y和z轴旋转。这会导致第二个轴被第一个旋转影响，第三个轴被前两个旋转的组合影响，所以，最后的结果难以预料。

旋转的一个特点是总存在一个旋转，这个单一旋转是多个旋转的组合。所以，这里需要的技巧是只定义一个旋转轴。这可以是任意轴：不一定是x, y或z轴。这根轴和旋转量被存储到一个变量中，叫做四元数 （quaternion）。

四元数可以很好的储存一个旋转，因为它可以存储一个轴和一个数，这个数保存绕轴旋转的角度。

下一个问题是，如何计算这个轴？答案是你不需要计算这个轴。过程中会自动为你创建。你只需指定一个初始轴，当用户移动鼠标时更新这个轴，你会在下面的例子中看到。

比方说将相机的Up轴作为旋转轴，旋转角度为0，这样你将看向前方。然后，根据用户输入，你想让相机绕着Right向量旋转。Right向量首先用当前相机的旋转值进行旋转，但因为是0度，所以Right向量保持不变。这个Right向量成为当前的旋转轴，相机将绕着这个轴旋转，会向上看。

如果接下来用户想让相机绕着Forward旋转。这根轴首先绕着当前相机的旋转轴旋转，此时已经不为0了而是包含绕Right轴的旋转。组合这两个旋转，旋转轴和旋转量存储到了相机四元数中。现在这个旋转成为了当前相机的旋转。

对用户的每个输入，发生同样的过程：Forward/Right/Up/ Down/. . .向量被相机的当前旋转轴旋转，这个新旋转和当前相机的旋转组合起来在四元数中储存为一个新的相机旋转，之后相机就绕着这个新轴旋转。

幸运的是，整个过程可以转化为一些简单的代码。首先，你只允许绕着一根轴旋转 (绕着Right轴)：

  float updownRotation = 0.0f;
  KeyboardState keys = Keyboard.GetState();
  if (keys.IsKeyDown(Keys.Up))
      updownRotation = 1.5f;
  if (keys.IsKeyDown(Keys.Down))
      updownRotation = -1.5f;

  Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation);
  cameraRotation = cameraRotation * additionalRotation;

首先，用户输入决定相机向上还是向下旋转。然后，创建一个新的四元数保存这个轴和旋转量。最后，这个新旋转和当前相机的旋转组合在一起，将结果储存为一个新的相机旋转。

注意：四元数（quaternion）这个词通常会让程序员望而生畏，我也看到过很多文章和网站将四元数看成黑暗魔法，很难被形象理解。但这不是真的！一个四元数用来存储一个绕一根轴的旋转，所以它需要能够存储这个轴和旋转角度。轴由三个数字定义，旋转角度需要一个。所以，如果你存储了这样一个旋转，最简单的方法是存储四个数字。猜一下一个四元数实际上是什么？很简单，就是四个数字。没什么神秘的。但用到的数学知识非常复杂。

要让相机可以任意旋转，你需要能够绕第二根轴旋转。这个旋转需要存储在additionalRotation变量中，要乘以绕着第二根轴的旋转和第一根轴的旋转的组合：

  float updownRotation = 0.0f;
  float leftrightRotation = 0.0f;
  KeyboardState keys = Keyboard.GetState();

  if (keys.IsKeyDown(Keys.Up))
      updownRotation = 0.05f;
  if (keys.IsKeyDown(Keys.Down))
      updownRotation = -0.05f;
  if (keys.IsKeyDown(Keys.Right))
      leftrightRotation = -0.05f;
  if (keys.IsKeyDown(Keys.Left))
      leftrightRotation = 0.05f;
  Quaternion additionalRotation =Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation)* Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation);
  cameraRotation = cameraRotation * additionalRotation;

注意：使用矩阵乘法时，乘以四元数的顺序很重要。要理解为什么这么重要，可参见教程4-2中的例子。但是对四元数来说，当两个旋转轴相互垂直时，规则有个例外，如在计算 additionalRotation 变量的一行代码中的情况。这意味着无论是先绕(1,0,0) Right向量旋转还是绕(0,1,0) Up向量旋转，或其他旋转，结果都是一样的。但这不是计算cameraRotation变量的情况，因为两个旋转轴并不互相垂直。所以，你写成cameraRotation*additionalRotatio是很重要的。

注意：在矩阵乘法中，“*”的意思是“在……之后”，但在四元数乘法中表示“之前”。所以，代码cameraRotation = cameraRotation * additionalRotation就是cameraRotation在additionalRotation之前， 意思是存储在additionalRotatio变量中的轴会首先绕着存储在cameraRotation 中的轴旋转。

找到了旋转矩阵，相机的View矩阵就可以创建了，可参见教程2-2：

  private void UpdateViewMatrix()
    ...{
      Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
      Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

      Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
      Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

      Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);

      viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
  }

例如，当用户按下箭头键时，移动相机可以通过首先通过相机旋转量旋转移动向量，然后将这个变换添加到当前相机位置上实现：

  float moveSpeed = 0.5f;
  Vector3 rotatedVector =Vector3.Transform(vectorToAdd, cameraRotation);
  cameraPosition += moveSpeed * rotatedVector;
  UpdateViewMatrix();

代码

因为这种相机通常使用在一个太空游戏中，如果你想创建一个太空射击游戏而且3D世界需要从飞船中的飞行员观察时，这个代码可以立即被使用。用户可以通过键盘或鼠标控制相机的旋转，而相机会自动向前移动。键盘的例子已经给出了，你可以在教程2-3中找到鼠标控制的代码。

要实现基于四元数的相机，只需要两个变量：相机的当前位置和旋转。

  protected override void Initialize()
    ...{
      cCross = new CoordCross();

      base.Initialize();

      float viewAngle = MathHelper.PiOver4;
      float aspectRatio = graphics.GraphicsDevice.Viewport.AspectRatio; float nearPlane = 0.5f;
      float farPlane = 100.0f;
      projectionMatrix = Matrix.CreatePerspectiveFieldOfView(viewAngle, aspectRatio, nearPlane, farPlane);

      cameraPosition = new Vector3(-1, 1, 10);
      cameraRotation = Quaternion.Identity;
      UpdateViewMatrix();
  }

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back ButtonState.Pressed)
          this.Exit();

      float updownRotation = 0.0f;
      float leftrightRotation = 0.0f;
      KeyboardState keys = Keyboard.GetState();

      if (keys.IsKeyDown(Keys.Up))
          updownRotation = 0.05f;
      if (keys.IsKeyDown(Keys.Down))
          updownRotation = -0.05f;
      if (keys.IsKeyDown(Keys.Right))
          leftrightRotation = -0.05f;
      if (keys.IsKeyDown(Keys.Left))
          leftrightRotation = 0.05f;

      Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new Vector3(0, 1, 0), leftrightRotation);
      cameraRotation = cameraRotation * additionalRotation;

      AddToCameraPosition(new Vector3(0, 0, -1));

      base.Update(gameTime);
  }

  private void AddToCameraPosition(Vector3 vectorToAdd)
    ...{
      float moveSpeed = 0.05f;
      Vector3 rotatedVector = Vector3.Transform(vectorToAdd, cameraRotation);
      cameraPosition += moveSpeed * rotatedVector;
      UpdateViewMatrix();
  }

  private void UpdateViewMatrix()
    ...{
      Vector3 cameraOriginalTarget = new Vector3(0, 0, -1);
      Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);

      Vector3 cameraRotatedTarget = Vector3.Transform(cameraOriginalTarget, cameraRotation);
      Vector3 cameraFinalTarget = cameraPosition + cameraRotatedTarget;

      Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, cameraRotation);
      viewMatrix = Matrix.CreateLookAt(cameraPosition, cameraFinalTarget, cameraRotatedUpVector);
  }

扩展阅读

跟随一个物体的相机

你可以使用前面的代码创建一个太空游戏，这个游戏中的相机放置在飞船的座舱中。但是，如果你想把相机放置在飞船之后，那么需要其他代码。在这种情况中，你要根据飞船而不是相机存储位置和旋转。相机的位置是基于飞船的位置计算的。对飞船来说，你可以使用前面的代码，这可以让飞船绕任意方向旋转并向前飞行，你想让相机位于飞船之后。记住，要定义相机的View矩阵，需要三个向量：相机的Position，Target和Up向量，你可以参见教程2-1学习相关知识。如果你想将一个相机放置在飞船后面，你需要将相机看着飞船。所以，你已经知道了View矩阵的Target向量。相机的Up方法与飞船相同，这个向量可以取默认的(0,1,0)Up向量，使用飞船的旋转量绕它旋转。要解释的最后一个向量是相机的Position。你想让相机位于飞船后面，因为(0,0,-1)向量通常作为Forward向量，(0,0,1)表示Behind向量。首先，这个向量需要使用飞船的旋转量进行旋转，这样Behind向量变成了相对于飞船的Behind向量。然后，你需要将这个向量添加到飞船的位置中，这样当飞船的位置改变时相机的位置也会发生改变。下面是代码：

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ~ ButtonState.Pressed)
          this.Exit();

      float updownRotation = 0.0f;
      float leftrightRotation = 0.0f;
      KeyboardState keys = Keyboard.GetState();

      if (keys.IsKeyDown(Keys.Up))
          updownRotation = 0.05f;
      if (keys.IsKeyDown(Keys.Down))
          updownRotation = -0.05f;
      if (keys.IsKeyDown(Keys.Right))
          leftrightRotation = -0.05f;
      if (keys.IsKeyDown(Keys.Left))
          leftrightRotation = 0.05f;

      Quaternion additionalRotation = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), updownRotation) * Quaternion.CreateFromAxisAngle(new ~ Vector3(0, 1, 0), leftrightRotation);
      spacecraftRotation = spacecraftRotation * additionalRotation;

      AddToSpacecraftPosition(new Vector3(0, 0, -1));

      base.Update(gameTime);
  }

  private void AddToSpacecraftPosition(Vector3 vectorToAdd)
    ...{
      float moveSpeed = 0.05f;
      Vector3 rotatedVector = Vector3.Transform(vectorToAdd, spacecraftRotation);
      spacecraftPosition += moveSpeed * rotatedVector;
      UpdateViewMatrix();
  }

在UpdateViewMatrix方法中，相机位置作为spacecraftPosition的后面被计算：

  private void UpdateViewMatrix()
    ...{
      Vector3 cameraOriginalPosition = new Vector3(0, 0, 1);
      Vector3 cameraRotatedPosition = Vector3.Transform(cameraOriginalPosition, spacecraftRotation);
      Vector3 cameraFinalPosition = spacecraftPosition + cameraRotatedPosition;

      Vector3 cameraOriginalUpVector = new Vector3(0, 1, 0);
      Vector3 cameraRotatedUpVector = Vector3.Transform(cameraOriginalUpVector, spacecraftRotation);

      viewMatrix = Matrix.CreateLookAt(cameraFinalPosition, spacecraftPosition, cameraRotatedUpVector);
  }

在Draw过程中，你需要根据当前位置和旋转量平移和旋转飞船：

  Matrix worldMatrix = Matrix.CreateScale(0.001f, 0.001f, 0.001f)*Matrix.CreateFromQuaternion(spacecraftRotation)* Matrix.CreateTranslation(spacecraftPosition);
  spacecraftModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
  foreach (ModelMesh mesh in spacecraftModel.Meshes)
    ...{
      foreach (BasicEffect effect in mesh.Effects)
        ...{
          effect.EnableDefaultLighting();
          effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
          effect.View = viewMatrix;
          effect.Projection = projectionMatrix;
      }
      mesh.Draw();
  }

注意看模型的World矩阵是如何取决于存储在四元数的飞船旋转和位置的。你可以在第4章学习通过设置World矩阵在3D空间绘制和放置一个物体。


2.5 检查对象是否可见

问题

你想检查对象是否可见来决定是否要绘制这个物体。

解决方案

XNA拥有BoundingFrustum类支持这个功能，你可以通过传递View和Projection矩阵创建一个BoundingFrustum类，然后就可以很容易地在这个类中检查对象是否包含在视锥体中。

工作原理

视锥体定义了相机可以看到的范围，类似于削去顶部的金字塔，如图2－4所示，它的边是由视域角，近裁平面和远裁平面决定的，更多的信息可见教程2－1。

你应该让XNA只绘制包含在视锥体中的物体，否则，你会浪费显卡的处理能力。所以你需要一个方法用来检测对象是否在视锥体中。

XNA Framework中的BoundingFrustum类包含了实现这个功能完整方法，你只需通过指定相机的观察矩阵和投影矩阵创建BoundingFrustum类即可。

图2－4 相机的视锥体

BoundingFrustum对象允许你使用它的Contain方法，你可以传递一个Vector3和一个BoundingSphere或BoundingBox检查是否包含在相机的视锥中。这个方法会返回一个Containment Type对象，这个对象包含三个值：

  • Contains：完整包含在视锥体中的测试对象。
  • Intersects：部分包含在视锥体中的测试对象，这时这个对象与视锥体相交。
  • Disjoint：不在视锥体中的测试对象。

检查3D空间中的点是否在相机可视范围内

要检查一个点是否可见，只需将这个点传递到Contains方法：

Vector3 pointToTest = new Vector3(0, 0, 0);
BoundingFrustum cameraFrustum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
ContainmentType containmentType = cameraFrustum.Contains(pointToTest);
if (containmentType != ContainmentType.Disjoint)
{
    Window.Title = "Point inside frustrum";
    graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
}
else
{
    Window.Title = "Point outside frustrum";
    graphics.GraphicsDevice.Clear(Color.Red);
}

这个例子中你检查了点(0,0,0)是否在视锥体中，将检查结果写在了窗口标题栏中并相应地改变背景颜色。

检查一个物体是否可见

如果你想检查一个物体是否可见，一个方法是事先检查模型的每个顶点，这样做非常耗时。更快的方法(尽管不太确切)是定义一个包含模型的包围体，然后检查这个包围体是否在视锥体中。能包含模型的最简单的包围体是一个球。

XNA已在BoundingSphere类中支持创建一个包围体，使用教程4－5中介绍的LoadModelWithBoundingSphere方法载入模型，并在Tag属性中保存BoundingSphere：

myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "content/tiny", content);

技巧：因为模型的Tag属性可以存储任何类型的数据结构，你可以用让它存储任何与模型相关的数据，例如BoundingSphere，纹理等。你甚至还可以创建一个包含所有数据的结构并将这个结构存储在Tag属性中。

现在有了包围体你就可以测试它是否在视锥体中了：

BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix* fpsCam.ProjectionMatrix);
ContainmentType containmentType = cameraSight.Contains( (BoundingSphere)myModel.Tag);
if (containmentType != ContainmentType.Disjoint)
{
    Window.Title = "Point inside frustrum";
    graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
    Window.Title = "Point outside frustrum";
    graphics.GraphicsDevice.Clear(Color.Red);
}

检查一个设置了世界矩阵的对象在大多数情况中，你的模型并不在(0,0,0)位置而且还可能旋转，缩放或通过设置世界矩阵放置在其他位置，更多信息可见教程4－2。显然包围体也应该旋转/缩放/移动已匹配模型。这可以通过模型的世界矩阵变换包围体实现：

Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) *Matrix.CreateTranslation(5, 0, 0);
BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
BoundingSphere origSphere = (BoundingSphere)myModel.Tag;
BoundingSphere transSphere = origSphere.Transform(worldMatrix);
ContainmentType containmentType = cameraSight.Contains(transSphere);
if (containmentType != ContainmentType.Disjoint)
{
    Window.Title = "Model inside frustrum";
    graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
    myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
    foreach (ModelMesh mesh in myModel.Meshes)
    {
        foreach (BasicEffect effect in mesh.Effects)
        {
            effect.EnableDefaultLighting();
            effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
            effect.View = fpsCam.ViewMatrix;
            effect.Projection = fpsCam.ProjectionMatrix;
        }
        mesh.Draw();
    }
    Window.Title = "Model outside frustrum";
    graphics.GraphicsDevice.Clear(Color.Red);
}

注意可见教程第三章学习如何绘制一个模型。这个代码中的主要变化是你调用了origSphere. Transform方法，这个方法获取经过世界变换的BoundingSphere。本例中transSphere的大小是origSphere百分之一，中心沿x轴移动了5个单位。只有当模型在视锥体时才会绘制这个模型。

代码

下面的代码会在一个指定位置绘制一个模型，当它在视锥体之外就不会被绘制：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);
    myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms,"content/tiny", content);
}
protected override void Draw(GameTime gameTime)
{
    Matrix worldMatrix = Matrix.CreateScale(0.01f,0.01f,0.01f)*Matrix.CreateTranslation(5, 0, 0);
    BoundingFrustum cameraSight = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
    BoundingSphere origSphere = (BoundingSphere)myModel.Tag;
    BoundingSphere transSphere = origSphere.Transform(worldMatrix);
    ContainmentType containmentType = cameraSight.Contains(transSphere);
    if (containmentType != ContainmentType.Disjoint)
    {
        Window.Title = "Model inside frustrum";
        graphics.GraphicsDevice.Clear(Color.CornflowerBlue);
        //draw model
        myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
        foreach (ModelMesh mesh in myModel.Meshes)
        {
            foreach (BasicEffect effect in mesh.Effects)
            {
                effect.EnableDefaultLighting();
                effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
                effect.View = fpsCam.ViewMatrix;
                effect.Projection = fpsCam.ProjectionMatrix;
            }
            mesh.Draw();
        }
    }
    else
    {
        Window.Title = "Model outside frustrum";
        graphics.GraphicsDevice.Clear(Color.Red);
    }

    //draw coordcross
    cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);
    base.Draw(gameTime);
}

扩展阅读

这个教程提出了一个检查点或物体是否在视锥体中的方法。但是如果场景中有大量的物体，那么要对每个对象进行检查会使程序变得很慢。在教程2－9会介绍一个更好的方法处理大场景。


2.6 检测相机与模型，墙或地形的碰撞

问题

你想检测相机是否已近靠近物体防止它穿过这个物体。

解决方案

要进行相机和模型的碰撞检测，你应将相机表示为一个包围球，使用这个包围球检查与模型的包围球或包围盒的碰撞。对应用户自定义的网格，诸如墙或地形，你必须首先找到墙或地形上的三角形任意位置的精确3D坐标。

工作原理

相机碰撞可分为与模型的碰撞和与已知顶点高度的三角形网格的碰撞。

相机与模型间的碰撞

这种情况中，你将相机作为一个球，只需调整球的半径就可以设置模型和相机间的最小距离。要检测相机球和模型的碰撞，你还需要模型的包围球或包围盒，这个球可以使用LoadModelWithBoundingSphere方法计算，这个方法在教程4－5中介绍，它把包围球存储在模型的Tag属性中。这个Tag属性可以存储模型的任意对象，也适合存储模型的包围球。在LoadContent方法中使用如下代码：

myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tiny",content);

无论是移动相机还是模型都需进行碰撞检测，如果检测到碰撞，你将通过将相机位置恢复到初始值取消相机的最后一次改变。在Update方法中，保证你已经保存了初始值：

protected override void Update(GameTime gameTime)
{
    GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
    if (gamePadState.Buttons.Back == ButtonState.Pressed)
        this.Exit();
    MouseState mouseState = Mouse.GetState();
    KeyboardState keyState = Keyboard.GetState();
    Vector3 lastCamPos = fpsCam.Position;
    fpsCam.Update(mouseState, keyState, gamePadState);
    base.Update(gameTime);
}

更新相机后，你需要创建一个对应相机新位置的包围球，下面的代码中你将包围球的最小距离设置为1个单位：

float minimumDistance = 1.0f;
BoundingSphere cameraSphere = new BoundingSphere(fpsCam.Position, minimumDistance);

有了更新过的相机包围球和模型包围球，就可以检测两者间的相交。如果检测到相交则将相机恢复到之前的位置：

BoundingSphere origModelSphere = (BoundingSphere)myModel.Tag;
BoundingSphere transModelSphere = XNAUtils.TransformBoundingSphere (origModelSphere, worldMatrix);
if (cameraSphere.Contains(transModelSphere) !=ContainmentType.Disjoint)
    fpsCam.Position = lastCamPos;

如果模型通过世界矩阵移动到另一个位置或进行了缩放，你也应该相应地移动/缩放包围球，这可以通过使用TransformBoundingSphere实现。

接下来的代码检测相机的包围球与模型包围球的距离是否小于1个单位。

相机和地形间的碰撞

如果你想检测相机与你自定义的顶点网格（如地形）的碰撞，你需要使用三角形碰撞。本章使用的方法适用于顶点纵横坐标是等间距的情况。

如果顶点的高度信息存储在Y坐标中，这意味着所有顶点的X和Z坐标间的距离是相同的，因为这个原因，你可以知道相机是在哪个正方形的上方。例如，相机位置是(10.3f, 8.7f, 5.1f)，你可以知道相机是在由四个顶点(10, .., 5)，(11, .., 5)， (10, ..., 6)和 (11, ..., 6)构成的正方形上方某高度，括号中的省略号表示这些顶点的高度，这些高度是在创建网格时定义的。你将这些高度存储在一个独立的数组中，或者你也可以从顶点缓冲获取这些高度（这不是首选方法）。

但是，如何知道相机与下方地形的高度差？最简单的方法是将相机位置截取到顶点的最近位置并检查相机是否在顶点的上方，如果相机在地形下方，则要相应调整相机的高度：

protected override void Update(GameTime gameTime)
{
    GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
    if (gamePadState.Buttons.Back == ButtonState.Pressed)
        this.Exit();
    MouseState mouseState = Mouse.GetState();
    KeyboardState keyState = Keyboard.GetState();
    fpsCam.Update(mouseState, keyState, gamePadState);
    float treshold = 3.0f;
    float terrainHeight = terrain.GetClippedHeightAt(fpsCam.Position.X, -fpsCam.Position.Z);
    if (fpsCam.Position.Y < terrainHeight + treshold)
    {
        Vector3 newPos = fpsCam.Position;
        newPos.Y = terrainHeight + treshold;
        fpsCam.Position = newPos;
    }
    base.Update(gameTime);
}

GetClippedHeightAt方法返回地形顶点的高度，相关知识可见教程5-9。你可以通过改变threshold值设置相机和地形间高度差的最小阈值，以上代码会调整相机的高度，使相机与地形之间的高度差保持在threshold。

注意GetClippedHeightAt方法的两个参数都是正值，因为这两个参数要作为查询矩阵的索引，而向前方向是负Z轴方向，所以你需要将Z坐标前加个“－”号使它变为正值。

因为高度是从邻近顶点获取的，因此GetClippedHeightAt方法会返回不同的值，以上代码会导致相机在三角形上移动时产生跳跃。要保证过渡平滑，你可以使用地形对象的GetExactHeightAt方法，这个方法在教程5－9解释，此方法可以获取地形上低于相机位置的点的精确高度：

float terrainHeight = terrain.GetExactHeightAt(fpsCam.Position.X, -fpsCam.Position.Z);

当相机或模型是缓慢地在地形上移动时这个方法更好。

相机和墙之间的碰撞

墙通常只由两个三角形构成，所以它是相机-地形碰撞的简化形式。你可以使用教程5－9中介绍的GetExactHeightAt方法获取相机前方的墙的精确3D点。

代码

前面已经有了Update方法的全部代码了。


2.7 创建一个相机飞入效果

问题

你想平滑地将相机从一个位置移动到另一个位置，当然你也让相机的观察目标也能平滑地移动。

具体的的说，你想要一个功能让相机能沿着一条平滑的曲线从开始位置到结束位置。这个移动过程应该是平滑的，在过程结束后，你想让相机回到常规状态，将它作为一个第一人称相机。

解决方案

这个过程需要一个时间变量，当相机在开始位置时这个变量为0，到达结束位置时为1。

通过指定第三个位置作为相机移动的中点，你可以定义一个贝塞尔曲线（Bezier curve）。贝塞尔曲线是一条通过起点和终点的光滑曲线，非常靠近起点和终点之间指定的额外点（例如第三个点）。通过给一个贝塞尔曲线指定一个介于0和1之间的时间变量的当前值，它会返回对应时间的曲线上的位置。

观察目标的位置会在目标起点和终点间进行线性插值。结果是，相机会沿着起点指向终点的光滑曲线移动，同时观察目标也会沿着目标起点和中点间的直线运动。

要使用第一人称相机的代码，你不能简单地只改变相机的位置和观察目标，因为相机是基于目标位置和绕UP和Right轴的旋转来计算观察目标的。所以你需要调整相机的旋转值。

一切准备就绪后，这个过程的平滑初末位置就可以通过使用MathHelper. SmoothStep 方法获取。

注意：你可以使用包含在XNA框架中的Curve类编写复杂相机路径的脚本，但它还不够完善用以处理相机的飞入，因为它缺少了一些细小的功能。例如，这个类不支持3D点，需要你手动设置Curve类的关键点。总之，本教程可以用Curve功能替换的部分就是Bezier方法，如果你想使用XNA框架的Curve功能代替Bezier方法，本教程90%的部分还是有用的。

工作原理

对贝塞尔相机的飞入效果，你需要保存这些变量：

  float bezTime = 1.0f;
  Vector3 bezStartPosition;
  Vector3 bezMidPosition;
  Vector3 bezEndPosition;
  Vector3 bezStartTarget;
  Vector3 bezEndTarget;

变量bezTime保存飞入过程。当开始一个飞入行为时，将这个变量设为0。当运行时，在更新过程中将它增加到1，这个值超过1表示过程结束。

在飞入过程中，你需要保存一些位置，诸如起点，终点和观察目标，但你还要计算中点获取一个光滑曲线。这些变量都应该在飞入开始时指定。

在下面的代码中，你可以找到触发飞入行为的方法，这个方法需要相机的初始位置和最终位置、观察目标的初始位置和结束位置：

  private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget)
    ...{
      bezStartPosition = startPosition;
      bezEndPosition = endPosition;

      bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f;
      Vector3 midShiftDirecton = new Vector3(1, 1, 0)*2;

      Vector3 cameraDirection = endPosition - startPosition;
      Vector3 perpDirection = Vector3.Cross(upV, cameraDirection);
      perpDirection.Normalize();

      Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection;
      bezMidPosition += cameraDirection.Length() * midShiftDirecton;

      bezStartTarget = startTarget;
      bezEndTarget = endTarget;

      bezTime = 0.0f;
  }

startPosition, endPosition, startTarget和endTarget参数可以立即被存储到对应的变量中。

midPosition是插入到开始的和结束点之间的额外点，它需要被计算。你可以通过首先计算起始点和终点的中点，然后取两者的平均值做到。要从曲线偏离一点距离，你需要通过添加一个垂直于这条线上的方向将这个点偏离相机的起点和终点之间的直线。

你可以通过叉乘这两个方法获取垂直于这两个方向的方向。在本教程中，你想让这个方向垂直于指向相机的方向和Up向量。首先计算指向相机的方向，这和获取其他方向的做法一样：通过获取最终位置并减去初始位置。然后叉乘这个方向和Up方向，就可以获取垂直于这两个方向的方向。

如果你将中点移动到这个方向，如果起点和终点靠得很近时就会获得一个漂亮的曲线。但是如果两点间的距离较大，曲线会非常平。你可以通过通过起点和终点间的距离乘以偏移量解决这个问题，这个距离可以通过找到起点和终点间的矢量长度获取。

最后将bezTime变量设置为0，表示开始飞入行为。

初始化飞入之后，你就可以调用UpdateBezier方法在每帧进行更新：

  private void UpdateBezier()
    ...{
      bezTime += 0.01f;
      if (bezTime > 1.0f)
          return;

      Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, bezTime);
      Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, bezTime);

      float updownRot;
      float leftrightRot;

      AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot);

      fpsCam.UpDownRot = updownRot;
      fpsCam.LeftRightRot = leftrightRot;
      fpsCam.Position = newCamPos;
  }

这个方法首先将bezTime增加一点点。如果这个值大于1，则过程结束返回return。

否则，执行下一行代码。首先，相机的下一个位置通过传递bezTime变量从贝塞尔曲线获取，这个变量表示飞入行为的进度。然后，通过在bezStartTarget和bezEndTarget之间进行插值获取当前目标，可以在教程5-9中学习更多插值的知识。

如果你想和第一人称相机的代码组合在一起，需要计算最终的updownRot和 leftrightRot值，这可以通过调用AnglesFromDirection方法做到。这个方法有三个参数：相机的朝向(如果从目标位置减去相机位置，你就可以获取观察方向)和两个角度。通过作为“out”参数传递这两个角度，这个方法可以改变这两个值，这两个值会被存储到对应的变量中。最后，更新相机的新位置和旋转。

贝塞尔曲线

第一行代码调用Bezier方法，这个方法基于bezTime变量返回曲线上的位置，bezTime变量通常介于0和1之间。这个函数需要三个点定义一条曲线，你已经在InitBezier方法中计算过这些变量了。

对于一条由三个点定义的贝塞尔曲线，你可以使用下列函数计算任意给定时间时的位置：

P(t)=Pstart*(1-t)2+2*Pmid* (1-t)*t+Pfinal*t2

看起来很难，实际上不是。让我们看一下开始t=0时的结果，使用函数 P(0)=Pstart*12+2*Pmid*1*0+Pfinal*02，结果是Pstart！在过程结束时，t=1，使用函数P(1)=Pstart*02+2*Pmid*0*1+Pfinal*12，结果是Pfinal。对所以介于0和1之间的值，结果介于开始位置和结束位置之间，并将这个点稍微拉向中点。在本教程的情况中，t就是bezTime。

下面的代码计算前面的公式：

  private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
    ...{
      float invTime = 1.0f - time;
      float timePow = (float)Math.Pow(time, 2);
      float invTimePow = (float)Math.Pow(invTime, 2);

      Vector3 result = startPoint * invTimePow;
      result += 2 * midPoint * time * invTime;
      result += endPoint * timePow;

      return result;
  }

invTime就是公式中的(1-t)，所以invTimePow就是(1-t)2。结果变量就是函数的最终输出结果，这个位置会返回到调用代码。

获取旋转

当你看一下UpdateBezier方法的最后一行代码时，你会发现相机的下一个位置使用了 Bezier 方法进行了计算，而下一个目标使用了简单的插值。

这用于最简单的飞入行为，因为你可以立即从这个位置和观察目标创建一个View矩阵（见教程2-1）。但是当飞入结束后，你想获取相机的运动情况。相机的位置会自动存储，但旋转变量不会。所以你需要获取刚才计算的相机位置和观察目标位置，找到对应的 leftrightRotation和updownRotation，存储这两个变量，这样就可以用于第一人称相机了。而且，第一人称相机还可以获取飞入结束后的相机的信息，不会出现错误。

这就是在AnglesFromDirection方法中进行的操作。这个方法以相机的朝向为参数，计算updownRot和leftrightRot的值。这两个值都作为这个方法的“out”参数，这意味着它们可以返回到调用代码，所以这个方法做出的改变会存储在调用方法的变量中。

  private void AnglesFromDirection(Vector3 direction, out float updownAngle, out float leftrightAngle)
    ...{
      Vector3 floorProjection = new Vector3(direction.X, 0, direction.Z);
      float directionLength = floorProjection.Length();

      updownAngle = (float)Math.Atan2(direction.Y, directionLength);
      leftrightAngle = -(float)Math.Atan2(direction.X, -direction.Z);
  }

教程4-17具体解释了如何对应一个方向获取旋转角度。本教程中，你必须获取两个角度，因为这个方向是在3D空间中的。首先找到leftrightRot角度。图2-5展示的是包含相机和观察目标的XZ平面。虚线是相机的朝向，线段X和Z是这个向量的X和Z分量。在直角三角形中，如果你想获取顶角，你要做的就是计算对边的反正切值，除以邻边。在本教程中，就是X除以Z。Atan2函数让你可以指定两个值而不是它们的商， 这可以避免出现两个结果。 这就是获取leftrightRot角度的方法。

图2-5 获取leftrightRot角度

要找到updownAngle，你可以使用图2-6。虚线表相机观察的方向。你想获取向上的Y方向和这个方向在XZ平面上的投影之间的角度。所以，将这两个方向传递到Atan2方法中就可以获取updownAngle。

图2-6 获取updownAngle角度

使用方法

确保在Update方法中调用UpdateBezier方法：

UpdateBezier();

现在要开始一个飞越效果，你要做的就是调用InitBezier方法！

  if (bezTime > 1.0f)
      InitBezier(new Vector3(0,10,0), new Vector3(0,0,0), new Vector3(0, 0, -20), new Vector3(0, 0, -10));

当你想将飞越效果和相机结合起来时，你可以从相机的位置和观察目标开始：

  if (bezTime > 1.0f)
      InitBezier(fpsCam.Position, fpsCam.Position + fpsCam.Forward * 10.0f, new Vector3(0, 0, -20), new Vector3(0, 0, -10));

你可以看到，我将开始的观察目标放置在开始位置前方10个单位，这可以给出一个平滑的结果，否则当跟随目标移动时相机会出现一个急速的移动。

平滑开始和加速

通过一个将bezTime均匀地从0增加到1，相机沿曲线的移动速度是一个常量。这会导致一个很不舒服的开始和结束，你真正想要的是让bezTime首先从0慢慢地增加到0.2，然后很快增加到0.8，在最后的0.8到1的部分又慢慢增加。这可以通过MathHelper. SmoothStep方法实现：给定一个介于0和1之间的持续增加的值，这个方法会返回一个具有光滑开始和结束的介于0和1之间的值！

这一步在UpdateBezier方法中进行，所以使用这个代码替换中间的两行代码：

  float smoothValue = MathHelper.SmoothStep(0, 1, bezTime);
  Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue);
  Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue);

smoothValue变量保存这个平滑过的介于0和1之间的值，用来代替均匀增加的bezTime变量传递到方法中。

解决飞入过程结束时的不正确的相机旋转

本教程的这部分要解决在某些情况中会出现的问题。这种情况之一显示在图2-7的左图中。开始时，相机看向左方直到两个曲线相交，在这个交点上，相机会突然看向右方。

你可以通过将曲线的中点切换到左边解决这个问题，通过这种方式，你获得了如右图所示的曲线，开始时相机会看向右方而无需切换到左方。

图2-7 飞入曲线的不正确和正确的情况

你如何知道中点该移向哪个方向？参考一下图2-8的左上图和右上图，它们显示了会导致问题的两种情况。虚线显示了目标路径，实线表示相机的直线路径。

图2-8 判断如何切换中点

在这两种情况中，中点需要切换到另一个方向。这可以通过叉乘这两个方向进行检测。在这两种情况中，叉乘的结果是垂直于平面的向量，但是一种情况中是向上，另一种情况中是向下。假设向上称为upVector。接下来，如果将这个upVector与相机方向叉乘，你会获得一个垂直于相机方向和upVector的向量。最终向量的方向取决于upVector朝上还是朝下，而朝上还是朝下取决于位置和目标路径相交的情况。因为它垂直于位置路径和upVector，可以用来切换中点。

所以，在InitBezier方法中使用下列代码获取曲线正确的中点：

  bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f;
  Vector3 cameraDirection = endPosition - startPosition;
  Vector3 targDirection = endTarget - startTarget;
  Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0, targDirection.Z), new Vector3(cameraDirection.X, 0, cameraDirection.Z));
  Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection);
  perpDirection.Normalize();

upVector可以通过叉乘位置路径和目标路径获取，这两个路径都通过将Y分量设置为0投影到XZ平面上(通过这种方式，你获得了如图所示的直线)。通过将upVector叉乘位置路径获取垂直于upVector的方向。

当你找到这个垂直方向时，你可以将中点切换到这个方向：

  Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection;
  bezMidPosition += cameraDirection.Length() * midShiftDirecton;

但是有一种情况无法正确处理，如果targDirection和cameraDirection平行，或upVector和cameraDirection平行，叉乘方法会出现问题，导致perpDirection变量为(0,0,0)，当归一化这个矢量时会出错。所以，你要检测这种情况，通过设置一个任意值解决这个问题：

  if (perpDirection == new Vector3())
      perpDirection = new Vector3(0, 1, 0);

将这个代码放在归一化perpDirection 代码行之前。

代码

下面的方法初始化变量开始一个新的飞入行为：

  private void InitBezier(Vector3 startPosition, Vector3 startTarget, Vector3 endPosition, Vector3 endTarget)
    ...{
      bezStartPosition = startPosition;
      bezEndPosition = endPosition;

      bezMidPosition = (bezStartPosition + bezEndPosition) / 2.0f;
      Vector3 cameraDirection = endPosition - startPosition;
      Vector3 targDirection = endTarget - startTarget;
      Vector3 upVector = Vector3.Cross(new Vector3(targDirection.X, 0,targDirection.Z), new Vector3(cameraDirection.X, 0, ~ cameraDirection.Z));
      Vector3 perpDirection = Vector3.Cross(upVector, cameraDirection);

      if (perpDirection == new Vector3())
          perpDirection = new Vector3(0, 1, 0);

      perpDirection.Normalize();

      Vector3 midShiftDirecton = new Vector3(0, 1, 0) + perpDirection;
      bezMidPosition += cameraDirection.Length() * midShiftDirecton;

      bezStartTarget = startTarget;
      bezEndTarget = endTarget;

      bezTime = 0.0f;
  }

当运行时，每帧都要调用UpdateBezier方法计算相机的新位置和旋转：

  private void UpdateBezier()
    ...{
      bezTime += 0.01f;
      if (bezTime > 1.0f)
          return;

      float smoothValue = MathHelper.SmoothStep(0, 1, bezTime);
      Vector3 newCamPos = Bezier(bezStartPosition, bezMidPosition, bezEndPosition, smoothValue);
      Vector3 newCamTarget = Vector3.Lerp(bezStartTarget, bezEndTarget, smoothValue);

      float updownRot;
      float leftrightRot;
      AnglesFromDirection(newCamTarget - newCamPos, out updownRot, out leftrightRot);

      fpsCam.UpDownRot = updownRot;
      fpsCam.LeftRightRot = leftrightRot;
      fpsCam.Position = newCamPos;
  }

位置是由Bezier方法计算的：

  private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
    ...{
      float invTime = 1.0f - time;
      float timePow = (float)Math.Pow(time, 2);
      float invTimePow = (float)Math.Pow(invTime, 2);

      Vector3 result = startPoint * invTimePow; result += 2 * midPoint * time * invTime;
      result += endPoint * timePow;

      return result;
  }

旋转由下面的方法获取：

  private Vector3 Bezier(Vector3 startPoint, Vector3 midPoint, Vector3 endPoint, float time)
    ...{
      float invTime = 1.0f - time;
      float timePow = (float)Math.Pow(time, 2);
      float invTimePow = (float)Math.Pow(invTime, 2);

      Vector3 result = startPoint * invTimePow;
      result += 2 * midPoint * time * invTime;
      result += endPoint * timePow;

      return result;
  }


2.8 天空盒

问题

你想在游戏中中添加一个场景，而不是单一颜色的背景。

解决方案

你用不着将物体充满场景的每个部分，只需简单地绘制一个很大的立方体，在立方体内部的六个面上贴上风景或房子内部的纹理，然后把相机放在立方体内部。

工作原理

有几种方法可以将一个天空盒放置在场景中，最简单的方法是导入一个模型文件，这个文件拥有自己的纹理和effect。如果你想获得完全控制权，你也可以自己定义一个立方体。我还展示了一个HLSL示例，介绍如何使用单张TextureCube纹理实现天空盒的方法，而这个纹理使用了HLSL的texCUBE内置函数。

以上两个技术相同点是：

  • 立方体必须总是绘制在相机周围，而相机应该总处于立方体中心。只有这样才能让玩家在移动相机时，保持相机和立方体之间的距离不变，使场景给人一种在无穷远处的印象。
  • 当绘制天空盒时你应该让Z缓冲不可写，这样就无需缩放天空盒以适应场景。但注意不要忘记在绘制完天空盒后重新使Z缓冲可写。

从文件载入天空盒

一个方法是从一个模型文件载入天空盒。你可以使用来自于DirectX SDK的例子，这个例子包含了.x文件和对应的六张纹理。将它们复制到你的项目中，最后在项目中添加skyboxModel和skyboxTransforms变量：

Model skyboxModel;
Matrix[] skyboxTransforms;

在LoadContents方法中加载模型：

skyboxModel = content.Load<Model>("skybox");
skyboxTransforms = new Matrix[skyboxModel.Bones.Count];

然后就像对待普通模型一样绘制天空盒：

skyboxModel.CopyAbsoluteBoneTransformsTo(skyboxTransforms);
foreach (ModelMesh mesh in skyboxModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.World = skyboxTransforms[mesh.ParentBone.Index];
        effect.View = fpsCam.ViewMatrix;
        effect.Projection = fpsCam.ProjectionMatrix;
    }
    mesh.Draw();
}

这样只是简单地将天空盒作为一个盒子绘制，但是，当你移动相机时，你会更加靠近天空盒，纹理会拉近，会使你感到远处的天空是假的。要避免这种情况发生，天空盒必须看起来距离相机无穷远，这样当相机移动时，天空盒的纹理不会变大。

要做到这一点需要保证相机总在天空盒的中心、换句话说，你要通过将天空盒移动到相机位置而使相机总保持处在天空盒中心，你需要这样设置世界矩阵：

effect.World = skyboxTransforms[mesh.ParentBone.Index] *Matrix.CreateTranslation(fpsCam.Position);

现在，当你运行代码，无论你如何移动相机天空盒看起来总停在同样的位置，给人在无穷远处的感觉。

下一个可能会遇到的问题是你可以清晰地看到立方体的边缘，这是因为XNA对靠近边缘的纹理的采样方式，将纹理寻址方式改成Clamp（见教程5－2），可以让立方体边缘的颜色截取至纹理边界的颜色

device.SamplerStates[0].AddressU = TextureAddressMode.Clamp;
device.SamplerStates[0].AddressV = TextureAddressMode.Clamp;

运行后可以发现立方体边缘变得平滑得多了。

在场景中绘制物体

如果立方体中没有物体，你的场景看起来不会很漂亮。当你将物体放置在场景中时，你还要考虑另外一些问题：天空盒的大小。如何让你的天空盒大到能包容场景中的所有物体又不会因为过大而被投影矩阵裁剪掉？

其实大小并不重要，你可以用任意大小绘制天空盒，在在绘制其他物体之前必须保证XNA不能写入深度缓冲。通过这种方式，任何在天空盒之后绘制的物体就想没有天空盒一样被绘制到场景中。

下面是工作原理：无论你是绘制一个最低点是(-1,-1,-1)最高点是(1,1,1)的盒子还是最低点是(-100,-100,-100) 最高点是(100,100,100)的盒子，如果你将相机放置在盒子中心，它们看起来是一样的。所以，你可以放心地以模型原始大小绘制天空盒。但如果盒子太小，会把场景中的物体隐藏到它的后面，这就是为什么你需要在绘制天空盒时关闭写入深度缓冲的原因。通过这种方式，当天空盒作为第一个物体绘制后，深度缓冲仍是空的！所有在此之后绘制的物体并不知道天空盒已近被绘制了，对应物体的像素会使用物体的颜色。关于Z缓冲更多知识请见教程2－1。

所以，你需要在绘制天空盒前加入以下代码：

            device.RenderState.DepthBufferWriteEnable = false;

确保在绘制完天空盒后打开Z缓冲，否则你的模型会混在一起：

device.RenderState.DepthBufferWriteEnable = true;

手动定义天空盒

从一个文件载入天空盒不够灵活。例如，前面一个例子中的天空盒使用了六张纹理使模型要分成六个不同的面，而这六个面都要调用DrawPrimitives 方法只是为了绘制两个三角形。少量的三角形对DrawPrimitives 的大量调用会拖慢XNA程序的速度（见教程3－4和3－11）。

虽然六次调用影响不大，但接下来我们还是想自己建立天空盒的顶点，整个天空盒使用一张纹理只需调用DrawPrimitives一次。我们使用TextureCube代替Texture2D存储天空盒纹理。TextureCube也是一张2D图片，由天空盒的六个面的图像组成，见图2－9。

图2－9 天空盒的六个面组成一张图像

在项目中添加TextureCube，Effect和VertexBuffer变量：

VertexBuffer skyboxVertexBuffer;
TextureCube skyboxTexture;
Effect skyboxEffect;

然后在LoadContent 方法中载入effect和纹理：

effect = content.Load<Effect>("skyboxsample");
skyboxTexture = content.Load<TextureCube>("skyboxtexture");

你将在后面创建skyboxsample effect 文件。

提示：你可以使用DirectX SDK 中的DirectX Texture tool创建自己的TextureCube图像。安装完SDK后，你会在开始菜单中找到这个工具的快捷方式。只需选择File→New并选择Cubemap Texture，然后从View→Cube Map Face菜单中选择要应用的面。你也可以实时定义面的内容，具体信息可见教程3－7。

通过手动定义每个顶点，绘制天空盒只需调用DrawPrimitives一次。当然，顶点需要包含位置信息，而且因为要在三角形上添加纹理，还需要在顶点中添加纹理坐标信息。但是，因为你使用HLSL内置的texCUBE函数从TextureCube中采样颜色数据，所以不需要纹理坐标数据，你会在下面学习更多texCUBE的细节知识。

但XNA Framework无法处理只包含位置信息的顶点，你当然可以使用一个复杂结构，比如VertexPositionColor而将颜色数据保持为空值，但这样做仍会将颜色数据发送到显卡，会浪费带宽。要使程序保持清晰，你可以创建最简单的顶点格式，只包含位置数据。可见教程5－14学习如何自定义顶点格式：

public struct VertexPosition
{
    public Vector3 Position;
    public VertexPosition(Vector3 position)
    {
        this.Position = position;
    }

    public static readonly VertexElement[] VertexElements = {
        new VertexElement( 0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default,
VertexElementUsage.Position, 0 )
    };

    public static readonly int SizeInBytes = sizeof(float) * 3;
}

这个结构定义了一个自定义的顶点格式，只包含一个Vector3存储位置信息。当定义天空盒顶点时，必须要考虑三角形顶点的环绕顺序，否则某些面就会被剔除。要学习更多剔除（culling）的知识可见教程5－6。当相机在天空盒中时，每个面都不能被剔除，所以所有三角形都应该以顺时针的顺序排列，这样才能被相机看到。为了让事情变得简单点，我给立方体的八个角都起了一个相对于相机而言的名称：

Vector3 forwardBottomLeft = new Vector3(-1, -1, -1);
Vector3 forwardBottomRight = new Vector3(1, -1, -1);
Vector3 forwardUpperLeft = new Vector3(-1, 1, -1);
Vector3 forwardUpperRight = new Vector3(1, 1, -1);
Vector3 backBottomLeft = new Vector3(-1, -1, 1);
Vector3 backBottomRight = new Vector3(1, -1, 1);
Vector3 backUpperLeft = new Vector3(-1, 1, 1);
Vector3 backUpperRight = new Vector3(1, 1, 1);

接着开始使用前面定义的Vector3定义三角形。立方体有六个面，每个面由两个三角形组成，所以你需要定义36个顶点。定义完所有顶点后，要将它们放在VertexBuffer（可见教程5－4）中发送到显存中：

VertexPosition[] vertices = new VertexPosition[36];
int i = 0;

//face in front of the camera
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(forwardBottomRight);

//face to the right of the camera
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(backBottomRight);

//face behind the camera vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backBottomRight);
vertices[i++] = new VertexPosition(backUpperRight);

//face to the left of the camera
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(forwardBottomLeft);

//face above the camera
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardUpperRight);

//face under the camera
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(backBottomRight);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(backBottomRight);

skyboxVertexBuffer = new VertexBuffer(device, vertices.Length * VertexPosition.SizeInBytes,
BufferUsage.WriteOnly);
skyboxVertexBuffer.SetData<ertexPosition>(vertices);

HLSL

定义了顶点后，就可以处理HLSL了。TextureCube只是一张2D图像，所以你可以使用普通的texture和sampler定义：

Texture xCubeTexture;
sampler CubeTextureSampler = sampler_state
{
    texture = <xCubeTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = clamp;
    AddressV = clamp;
};

这个纹理将在pixel shader中由texCUBE进行采样，texCUBE接受两个参数。第一个参数是纹理对应的采样器，第二个参数指定从哪里对纹理进行采样。texCUBE沿着从立方体中心指向3D位置的方向获取颜色，而不是通过2D纹理坐标获取颜色，接着texCUBE计算对应的纹理坐标。

这样做有几个优点。因为相机总在立方体中心，这个方向就是像素的3D位置的所需颜色！而且，因为你处理的是一个方向，所以无需进行缩放，例如方向(1,3,-2)和方向(10,30,-20)是相同的。

这样，对于每个立方体的顶点，vertex shader需要将3D位置传送到pixel shader（接着转换为2D屏幕坐标）：

//Technique: Skybox struct SkyBoxVertexToPixel
{
    float4 Position : POSITION;
    float3 Pos3D : TEXCOORD0;
};

SkyBoxVertexToPixel SkyBoxVS( float4 inPos : POSITION)
{
    SkyBoxVertexToPixel Output = (SkyBoxVertexToPixel)0;
    float4x4 preViewProjection = mul (xView, xProjection);
    float4x4 preWorldViewProjection = mul (xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    Output.Pos3D = inPos;
    return Output;
}

你可以看到vertex shader只需每个顶点的位置，这个位置是由你定义在XNA代码中的顶点提供的。将这个位置乘以世界矩阵、观察矩阵和投影矩阵的组合就可以获得2D屏幕坐标。顶点的原始位置在Output.Pos3D中被传递到pixel shader，在texCUBE中需要这个原始位置。

注意：在大多数情况中你需要原始3D位置，但这次3D位置没有乘以世界矩阵，因为你需要获取从立方体中心指向顶点的方向，这个方向等于“顶点位置减去中心位置”。根据你定义立方体顶点的方式，在原始空间（又叫做模型空间）中中心位置是(0,0,0)，所以这个方向就是顶点的位置。

在pixel shader中只获取了这个方向，并找到对应的颜色，最终传递到帧缓冲：

struct SkyBoxPixelToFrame
{
    float4 Color : COLOR0;
};

SkyBoxPixelToFrame SkyBoxPS(SkyBoxVertexToPixel PSIn)
{
    SkyBoxPixelToFrame Output = (SkyBoxPixelToFrame)0;
    Output.Color = texCUBE(CubeTextureSampler, PSIn.Pos3D);

    return Output;
}

既然你已经计算了像素和立方体中心之间的方向，接着可以将它直接传递到texCUBE作为第二个参数，而texCUBE从纹理中采样对应的颜色，最后这个颜色被传递到帧缓冲。

XNA代码

将这个结构放在命名空间顶部：

public struct VertexPosition
{
    public Vector3 Position;
    public VertexPosition(Vector3 position)
    {
        this.Position = position;
    }

    public static readonly VertexElement[] VertexElements = {
        new VertexElement( 0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default,
VertexElementUsage.Position, 0 )
    };

    public static readonly int SizeInBytes = sizeof(float) * 3;
}

这个方法定义了36个顶点：

private void CreateSkyboxVertexBuffer()
{
Vector3 forwardBottomLeft = new Vector3(-1, -1, -1);
Vector3 forwardBottomRight = new Vector3(1, -1, -1);
Vector3 forwardUpperLeft = new Vector3(-1, 1, -1);
Vector3 forwardUpperRight = new Vector3(1, 1, -1);
Vector3 backBottomLeft = new Vector3(-1, -1, 1);
Vector3 backBottomRight = new Vector3(1, -1, 1);
Vector3 backUpperLeft = new Vector3(-1, 1, 1);
Vector3 backUpperRight = new Vector3(1, 1, 1);

VertexPosition[] vertices = new VertexPosition[36];
int i = 0;

//face in front of the camera
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(forwardBottomRight);

//face to the right of the camera
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(forwardUpperRight);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(backBottomRight);

//face behind the camera vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backBottomRight);
vertices[i++] = new VertexPosition(backUpperRight);

//face to the left of the camera
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(forwardBottomLeft);

//face above the camera
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backUpperLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardUpperLeft);
vertices[i++] = new VertexPosition(backUpperRight);
vertices[i++] = new VertexPosition(forwardUpperRight);

//face under the camera
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(backBottomRight);
vertices[i++] = new VertexPosition(backBottomLeft);
vertices[i++] = new VertexPosition(forwardBottomLeft);
vertices[i++] = new VertexPosition(forwardBottomRight);
vertices[i++] = new VertexPosition(backBottomRight);

skyboxVertexBuffer = new VertexBuffer(device, vertices.Length * VertexPosition.SizeInBytes,
BufferUsage.WriteOnly);
skyboxVertexBuffer.SetData<ertexPosition>(vertices);

在Draw 方法中添加以下代码，使用自定义effect 绘制三角形：

graphics.GraphicsDevice.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,
Color.Black, 1, 0);
device.RenderState.DepthBufferWriteEnable = false;
skyboxEffect.CurrentTechnique = skyboxEffect.Techniques["SkyBox"];
skyboxEffect.Parameters["xWorld"].SetValue(Matrix.CreateTranslation(fpsCam.Position));
skyboxEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
skyboxEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
skyboxEffect.Parameters["xCubeTexture"].SetValue(skyboxTexture);

skyboxEffect.Begin();
foreach (EffectPass pass in skyboxEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = new VertexDeclaration(device, VertexPosition.VertexElements);
    device.Vertices[0].SetSource(skyboxVertexBuffer, 0, VertexPosition.SizeInBytes);
    device.DrawPrimitives(PrimitiveType.TriangleList, 0, 12);
    pass.End();
}
skyboxEffect.End();
device.RenderState.DepthBufferWriteEnable = true;

HLSL代码

下面是HLSL代码：

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;

Texture xCubeTexture;
sampler CubeTextureSampler = sampler_state
{
    texture = <xCubeTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = clamp;
    AddressV = clamp;
};

//Technique: Skybox
struct SkyBoxVertexToPixel
{
    float4 Position : POSITION;
    float3 Pos3D : TEXCOORD0;
};

SkyBoxVertexToPixel SkyBoxVS( float4 inPos : POSITION)
{
    SkyBoxVertexToPixel Output = (SkyBoxVertexToPixel)0;
    float4x4 preViewProjection = mul (xView, xProjection);
    float4x4 preWorldViewProjection = mul (xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    Output.Pos3D = inPos;
    return Output;
}

struct SkyBoxPixelToFrame
{
    float4 Color : COLOR0;
};

SkyBoxPixelToFrame SkyBoxPS(SkyBoxVertexToPixel PSIn)
{
    SkyBoxPixelToFrame Output = (SkyBoxPixelToFrame)0;
    Output.Color = texCUBE(CubeTextureSampler, PSIn.Pos3D);
    return Output;
}

technique SkyBox
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 SkyBoxVS();
        PixelShader = compile ps_1_1 SkyBoxPS();
    }
}


2.9 只绘制在相机视野中的物体：八叉树

问题

如果你使用教程2－5的方法检查相机视野中的每个模型，那么由于网格太多会极大地拖慢程序。你需要一个可扩展的方法决定场景的哪些部分需要被绘制。

解决方案

定义一个很大的立方体包含整个3D世界和所有模型。如果这个立方体包含太多模型，那么把这个立方体分割成小的子立方体。如果这些小的子立方体包含太多模型，那么再把这些立方体分割成更小的子立方体，直到立方体内包含的模型数量不超过某个特定值或立方体的大小足够小。

在绘制过程中，大立方体要求小立方体检查它们是否在相机视野中，如果子立方体不在视野中，则无需绘制之内的模型，如果在视野内并且子立方体还有自己的子立方体，则继续处理它的子立方体，直到这个子立方体在视野内并且没有在它下面没有子立方体。结果是，只有在视野内的最后的子立方体才会绘制在其内部的模型。

工作原理

首先你需要决定一个立方体内有多少个模型，本例中使用最多五个模型。只要已经包含了五个模型的立方体再增加一个模型，那么这个立方体必须被分成更小的子立方体，能分成全等的唯一方式就是分成八个小立方体，如图2－10所示。因为八个小立方体能完美地匹配它们的父立方体，而且每个子立方体能分成更小的立方体，形成一个树状结构。因为每个父节点有八个子节点，所以这种结构叫做八叉树。

图2-10 分成八个小立方体的大立方体

图2－11和图2－12显示了操作原理。

看一下图2－11，这个八叉树包含了超过五个模型，所以父立方体会分成八个子立方体，你还能看到有一个子立方体也包含了五个模型。 现在考虑这种情况：在一个已经包含五个模型的子立方体中再添加一个模型，那么这个立方体会分成八个立方体，六个模型会分布到相应的子立方体中，如图2－12所示。

图2－11 在添加了一个模型之前的八叉树

图2－12 添加了一个模型之后的八叉树（译者注：但图中显示添加了2个模型？）

使用八叉树的好处是主程序只需请求绘制根立方体，而立方体会自己判断是否在相机视野内。如果是，立方体会将这个调用传递到子立方体，如果这个子立方体包含模型则会绘制这些模型，或者将调用传递到下一层的子立方体中。通过这种方式，八叉树会自己决定需要绘制哪些节点。

DrawableModel类

为了正确地绘制模型，八叉树的每个立方体需要保存模型的世界矩阵，世界矩阵还包含模型在3D空间中的位置，所以保存世界矩阵和模型就足以让八叉树知道模型属于哪个子立方体了。

使用类可以使八叉树的代码编写变得容易。本例中你要创建一个叫做DrawableModel的新类，这个类会定义一个包含模型以及它的世界矩阵的对象。对每个存储在八叉树中的模型，会创建一个DrawableModel对象，这个对象存储了模型和它的世界矩阵，实际上是这个对象存储在了八叉树的立方体中。你也可以定义一个Draw方法，如果立方体检测到自己在相机视野内就会调用这个方法。

要创建一个新的类，应在Solution Explorer中右击项目名称并选择Add New Item，在对话框中选择Class并输入DrawableModel作为名称。在这个新类顶部添加XNA的引用：

using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Storage;

我已经讨论过DrawableModel类的两个属性：Model和World矩阵。你还要存储变换矩阵实现动画 (见教程4－9)，存储位置，存储ID用来识别对象。创建了DrawableModel类的实例后，这个对象会接受唯一的ID值，而这个ID会被传递到主程序中，允许你改变特定对象的世界矩阵。

所以在类中添加四个变量：

class DrawableModel
{
    private Matrix worldMatrix;
    private Model model;
    private Matrix[] modelTransforms;
    private Vector3 position;
    private int modelID;
}

DrawableModel类的构造函数有三个参数：Model，worldMatrix和ID。位置没有指定，因为位置是包含在世界矩阵中的，世界矩阵还包含缩放和旋转。

在类中添加如下的构造函数：

public DrawableModel(Model inModel, Matrix inWorldMatrix, int inModelID)
{
    model = inModel;
    modelTransforms = new Matrix[model.Bones.Count];
    worldMatrix = inWorldMatrix;
    modelID = inModelID;
    position = new Vector3(inWorldMatrix.M41, inWorldMatrix.M42, inWorldMatrix.M43);
}

你可以看到位置实际上是在世界矩阵的最后一行中。

注意：你也可以通过调用矩阵的Decompose方法获取位置，包含在矩阵中的变换的平移分量对应模型的位置。

接下来添加Draw方法，这将使用世界矩阵和变换矩阵绘制模型：

public void Draw(Matrix viewMatrix, Matrix projectionMatrix)
{
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);
    foreach (ModelMesh mesh in model.Meshes)
    {
        foreach (BasicEffect effect in mesh.Effects)
        {
            effect.EnableDefaultLighting();
            effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
            effect.View = viewMatrix;
            effect.Projection = projectionMatrix;
         }
         mesh.Draw();
     }
}

Draw方法是此类的主要行为，但是，现在主程序还无法访问这些私有变量。你可以将这些变量设置为共有，但这样做不好，因为这样做让外部代码可以读写这些变量。好的方法是使用getter方法：

public Vector3 Position
{
    get{return position; }
}

public Model Model
{
    get{return model; }
}

public int ModelID
{
    get{return modelID; }
}

getter方法属于.NET 2.0，它只是简单地返回对应的属性，下面你就会看到它的使用。从现在开始，外部程序只能通过属性访问这些变量，但不能改变这些变量，这也是我们想要的结果。

还有一个变量不要从外部访问：worldMatrix。你必须提供给主程序一个更新对象世界矩阵的方法。所以不仅要定义getter方法还需定义一个setter方法。本例中setter方法的用处有两个：一是可以改变worldMatrix的值，二是从世界矩阵提取一个新的位置变量并把它存储在位置属性中。

public Matrix WorldMatrix
{
    get { return worldMatrix; }
    set { worldMatrix = value; position = new Vector3(value.M41, value.M42, value.M43); }
}

getter方法允许主程序读取worldMatrix，setter方法从主程序接受新的值，这个值存储在 worldMatrix变量中，而位置信息是从这个变量中提取的。

以上就是定义DrawableModel类的过程。你定义一个类，这个类包含了模型和它的世界矩阵，可以使用当前世界矩阵绘制模型，主程序可以更新世界矩阵。

OcTreeNode类

现在你已定义了一个完整的DrawableModel类，下面就可以处理八叉树了。这个数是由对应八叉树的立方体的节点构成。你将定义第二个类：OcTreeNode，这个类表示了这个节点，每个节点可以存储DrawableModel对象并可以有8个子节点。

创建一个新的类：OcTreeNode。这个类包含一些属性：

private const int maxObjectsInNode = 5;
private const float minSize = 5.0f;
private Vector3 center;
private float size;
List<DrawableModel> modelList;
private BoundingBox nodeBoundingBox;
OcTreeNode nodeUFL;
OcTreeNode nodeUFR;
OcTreeNode nodeUBL;
OcTreeNode nodeUBR;
OcTreeNode nodeDFL;
OcTreeNode nodeDFR;
OcTreeNode nodeDBL;
OcTreeNode nodeDBR; List<OcTreeNode> childList;
private static int modelsDrawn;
private static int modelsStoredInQuadTree;

第一行代码定义了一个立方体可以包含几个模型。在将这个立方体分隔成更小的立方体之前，你需要检查下面的立方体是否太小，所以需要定义第二个属性minSize。

下一步，每个节点都要存储一些东西，诸如中心位置和大小。每个节点可以存储一些（5个）DrawableModel对象，这些对象存储在modelList集合中。要检查立方体是否在相机视野中，还需包含BoundingBox，这样才能进行立方体和相机视锥体的碰撞检测。

接下来是八个子节点的集合，我起了对应的名称，例如nodeUFL表示“node Upper Forward Left（左上角的节点），”nodeDBR表示“node Down Back Right。”当你查找对应位置的子立方体时会发现这种命名方式很有用。

你创建的每个OcTreeNode保存这些变量的一个副本，但最后两个变量是静态变量，这意味着这两个变量是被所有OcTreeNode对象共享的。所以当一个OcTreeNode对象改变了这两个变量，其他对象也会知道这个变化。你使用modelsDrawn变量检查八叉树是否工作，而 modelsStoredInQuadTree用来找到添加到树中的模型的新ID。

在这个类中添加的第一个方法是构造函数，它创建一个OcTreeNode对象，所需的只是新立方体的中心位置和大小：

public OcTreeNode(Vector3 center, float size)
{
    this.center = center;
    this.size = size;
    modelList = new List<DrawableModel>();
    childList = new List<OcTreeNode>(8);
    Vector3 diagonalVector = new Vector3(size / 2.0f, size / 2.0f, size / 2.0f);
    nodeBoundingBox = new BoundingBox(center - diagonalVector, center + diagonalVector);
}

中心和大小是存储在私有变量中的，同时也实例化了modelList和childList。

技巧：因为你知道每个立方体最多包含8个节点，所以你可以在构造函数中指定这个大小。一个List最好的特点是你无需指定它的大小，但如果你指定了，那么当在List中添加一个元素时无需重新改变大小。

最后，你创建了对应立方体的BoundingBox。 BoundingBox的构造函数接受两个点，这两个点是立方体的对角线上的两点，如果立方体的边长是10，中心在(0,0,0)，那么你可以指定坐标为(-5,-5,-5) 和 (5,5,5)的两点。如果中心不在(0,0,0)，你需要在两个点上再加上中心位置，这一步是在构造函数的最后一行代码中实现的。

现在可以定义创建8个子立方体的方法了，这个方法马上就要用到：

private void CreateChildNodes()
{
    float sizeOver2 = size / 2.0f;
    float sizeOver4 = size / 4.0f;
    nodeUFR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, -sizeOver4), sizeOver2);
    nodeUFL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, -sizeOver4), sizeOver2);
    nodeUBR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, sizeOver4), sizeOver2);
    nodeUBL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, sizeOver4), sizeOver2);
    nodeDFR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, -sizeOver4), sizeOver2);
    nodeDFL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, -sizeOver4), sizeOver2);
    nodeDBR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, sizeOver4), sizeOver2);
    nodeDBL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, sizeOver4), sizeOver2);
    childList.Add(nodeUFR);
    childList.Add(nodeUFL);
    childList.Add(nodeUBR);
    childList.Add(nodeUBL);
    childList.Add(nodeDFR);
    childList.Add(nodeDFL);
    childList.Add(nodeDBR);
    childList.Add(nodeDBL);
}

首先创建8个子立方体并添加到childList中。如前所述，要创建一个节点，只需指定中心位置和大小。子立方体的边长是父立方体边长的一半。而找到子立方体的中心位置，你需要将父立方体的中心加上正确的向量。例如，要找到左上角的子立方体的中心，需要加上正Y（上）和负X和负Z的值（对应左和前）。

当父节点创建后，这些子节点不能自动建立，因为这些子节点还会包含自己的子节点。因此这个方法只有在把模型添加到节点并且节点已经包含最大数目的模型时才会被调用。这是在AddDrawableModel方法中处理的，下面是代码：

private void AddDrawableModel(DrawableModel dModel)
{
    if (childList.Count == 0)
    {
        modelList.Add(dModel);
        bool maxObjectsReached = (modelList.Count >maxObjectsInNode );
        bool minSizeNotReached = (size > minSize);
        if (maxObjectsReached && minSizeNotReached)
        {
            CreateChildNodes();
            foreach (DrawableModel currentDModel in modelList)
            {
                Distribute(currentDModel);
            }
            modelList.Clear();
        }
    }
    else
    {
        Distribute(dModel);
    }
}

这个方法以DrawableModel对象作为参数，这个对象需要被添加到节点中。首先，你检查这个节点是否有子节点，这可以从childList是否包含超过一个元素中判断。如果不是，则将DrawableModel对象添加到modelList中。做完此步，还要检查此节点是否包含太多的DrawableModel对象，以及这个立方体是否大到可以分隔成更小的子立方体。

如果以上皆是，你将创建子节点并将每个DrawableModel对象分配到(使用Distribute方法)对应的modelList中。你将在后面创建这个Distribute方法。当所有的模型分配到子节点后，清除modelList。否则，这个节点和它的子节点都会绘制这个模型，会导致模型绘制了两次。

如果这个节点已经分隔成了子节点，那么DrawableModel对象会立即分配到对应的子节点中。

困难的部分是在Distribute方法中进行的。这个方法通过比较传入的DrawableModel 对象的位置与父节点的中心位置将这个对象放置到对应的子节点中。其实不是很难，因为你可以立即访问到DrawableModel的位置：

private void Distribute(DrawableModel dModel)
{
    Vector3 position = dModel.Position;
    if (position.Y > center.Y) //Up
        if (position.Z < center.Z) //Forward
            if (position.X < center.X) //Left
                nodeUFL.AddDrawableModel(dModel);
            else //Right
                nodeUFR.AddDrawableModel(dModel);
        else //Back
            if (position.X < center.X) //Left
                nodeUBL.AddDrawableModel(dModel);
            else //Right
                nodeUBR.AddDrawableModel(dModel);
    else //Down
        if (position.Z < center.Z) //Forward
            if (position.X < center.X) //Left
                nodeDFL.AddDrawableModel(dModel);
            else //Right
                nodeDFR.AddDrawableModel(dModel);
        else //Back
            if (position.X < center.X) //Left
                nodeDBL.AddDrawableModel(dModel);
            else //Right
                nodeDBR.AddDrawableModel(dModel);
}

直到现在你只定义了私有方法，这意味着主程序无法访问节点并添加模型。所以，你需要添加Add方法被主程序调用：

 public int Add(Model model, Matrix worldMatrix)
{
    DrawableModel newDModel = new DrawableModel(model, worldMatrix, modelsStoredInQuadTree++);
    AddDrawableModel(newDModel);
    return newDModel.ModelID;
}

这个方法只是从调用者接受模型和世界矩阵，它会创建一个存储这两个参数的DrawableModel对象，DrawableModel对象还接受它的ID，这来自于modelsStoredInQuadTree变量，当将一个模型添加到八叉树中时这个变量会加一。因为这个变量是静态的，被所有节点共享，所以这个变量总是增加的，每个新添加的DrawableModel都会有一个新的ID，这个新ID总是比前一个DrawableModel大1。接下来，新创建的DrawableModel对被添加到八叉树中。

ID需要返回到主程序，这很重要。

在本教程最后，你会看到一些代码绘制每个立方体的边界，这是要添加的最后一个功能：绘制整个八叉树。这个Draw方法需要viewMatrix，projectionMatrix和cameraFrustum。你可以从两个矩阵计算出cameraFrustum， 每个子立方体都需要进行这个计算，所以计算一次然后把它传递到子立方体会快得多。

public void Draw(Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustum)
{
    ContainmentType cameraNodeContainment = cameraFrustum.Contains(nodeBoundingBox);
    if (cameraNodeContainment != ContainmentType.Disjoint)
    {
        foreach (DrawableModel dModel in modelList)
        {
            dModel.Draw(viewMatrix, projectionMatrix);
            modelsDrawn++;
        }
        foreach (OcTreeNode childNode in childList)
            childNode.Draw(viewMatrix, projectionMatrix, cameraFrustum);
     }
}

你首先检查包围盒的相机视锥体的碰撞。如果没有碰撞，则这个立方体就没必要绘制，当然它的子立方体也无需绘制。

如果发生碰撞，则这个立方体中的所有DrawableModel都需被绘制。如果这个节点还包含子节点，那么Draw还要传递到这些子节点，并检查这些子节点是否在相机视锥体中 。

使用：主程序

这个教程理论上很漂亮，但如何使用呢？很简单，只需在主程序中定义一个变量： ocTreeRoot variable，这是包含所有立方体的父立方体：

OcTreeNode ocTreeRoot;

在Initialize方法中进行初始化：

ocTreeRoot = new OcTreeNode(new Vector3(0, 0, 0), 21);

对于立方体中心，你指定了3D世界的中心：(0,0,0) 。第二个参数是整个3D世界的大小。通常这个值大于我在本例中使用的21。

初始化八叉树后，你就可以添加模型了。这一步可以在加载模型后的任何地方进行。在下列代码中，我在LoadContent方法中完成此步，但你也可以在程序的其他地方写入下面的代码：

myModel = content.Load<Model>("tiny");
int[] modelIDs = new int[8];
modelIDs[0] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 5, 5));
modelIDs[1] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -1));
modelIDs[2] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -1));
modelIDs[3] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 4, -3));
modelIDs[4] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -3));
modelIDs[5] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -3));
modelIDs[6] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(8, 8, -3));
modelIDs[7] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, 8, -3))

上述代码将8个模型添加到八叉树中并将ID存储在modelIDs数组中。绘制过程中只需调用八叉树的Draw方法！这个调用会继续到所有的子立方体直到所用模型都被绘制。记住只有在相机视野中的立方体中的内容才会被绘制。

BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum);

更新模型的世界矩阵

在程序运行时，你还想更小模型的世界矩阵。这也是为什么主程序需要在八叉树中保存模型ID的原因：你需要通过ID改变模型的世界矩阵。要扩展八叉树使它可以改变世界矩阵，需要在OcTreeNode类中添加两个方法，第一个是公有方法可以被主程序调用：

public void UpdateModelWorldMatrix(int modelID, Matrix newWorldMatrix)
{
    DrawableModel deletedModel = RemoveDrawableModel(modelID);
    deletedModel.WorldMatrix = newWorldMatrix;
    AddDrawableModel(deletedModel);
}

注意：这个方法只用到了RemoveDrawableModel和AddDrawableModel方法。实际上，你不想在每次更新世界矩阵时都删除并创建一个新对象，你可以使用一些技巧，例如，将所有的矩阵存储在一个大的集合中，这样你就可以在主程序中直接更新集合中的内容。

如你所见，主程序通过ID和新的世界矩阵指定它想改变的模型。首先，ID被传递到RemoveDrawableModel方法，这个方法在八叉树中搜索具有这个ID的DrawableModel。如果找到，则从立方体的modelList 中移除这个DrawableModel并返回它。然后这个DrawableModel的世界矩阵被更新并重新添加到八叉树中。很好，显然难的是RemoveDrawableModel方法，下面是代码：

private DrawableModel RemoveDrawableModel(int modelID)
{
    DrawableModel dModel = null;
    for (int index = 0; index < modelList.Count; index++)
    {
        if (modelList[index].ModelID == modelID)
        {
            dModel = modelList[index];
            modelList.Remove(dModel);
        }
    }
    int child = 0;
    while ((dModel == null) && (child < childList.Count))
    {
        dModel = childList[child++].RemoveDrawableModel(modelID);
    }
    return dModel;
}

这个方法首先通过ID检查这个模型是否在modelList中，如果是，这个DrawableModel会被存储到dModel变量并从modelList中移除。

注意：本例中不使用foreach循环遍历modelList，这是因为你要改变集合中的内容，而这在foreach中是不允许的。

如果找到模型，那么dModel就不再是null，所以while循环不被触发，DrawableModel被返回。

如果modelList中没有找到DrawableModel，你将检查八个子节点，这一步是在while循环中进行的，只要DrawableModel没有被找到而且八个子立方体没有被搜索，那么就会搜索下一个子节点。

很好，现在你可以在主程序中更新模型的世界矩阵了，这可以在update过程中实现：

time += 0.01f;
Vector3 startingPos = new Vector3(1, 5, 5);
Vector3 moveDirection = new Vector3(0, 0, -1);
Matrix newWMatrix = Matrix.CreateScale(0.01f, 0.01f,0.01f)*Matrix.CreateTranslation(startingPos+time*moveDirection);
int modelToChange = 0;
ocTreeRoot.UpdateModelWorldMatrix(modelToChange, newWMatrix);

上述代码可以让ID为0的模型移动到(0,0,-1)。

检查是否正常工作：绘制的模型数量

现在可以检查程序是否工作正常了。OcTreeNode类包含一个modelsDrawn变量，它是静态的，这意味着这个变量在所有节点中都是一样的，只有在模型被绘制的情况下这个变量才会在Draw方法中增加。

这是个私有变量，所以你无法在类外访问它。你可以将它变成公有，但这里我们使用getter+setter方法，将下列代码添加到OcTreeNode类中：

public int ModelsDrawn
{
    get { return modelsDrawn; }
    set { modelsDrawn = value; }
}

在绘制过程中，你想在绘制八叉树前将这个值设置为0，当绘制完成，这个值会保存实际绘制到屏幕的模型数量并输出，例如，输出到窗口的标题栏中：

ocTreeRoot.ModelsDrawn = 0;
BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum);
Window.Title = string.Format("Models drawn: {0}", ocTreeRoot.ModelsDrawn);

当立方体中的模型离开相机视野时，你会发现这个数值会减小。

检查是否正常工作：绘制立方体的边界

如果你想检查八叉树的结构，你可以使用XNAUtils 类中的DrawBoundingBox方法绘制包围盒的边界：

public void DrawBoxLines(Matrix viewMatrix, Matrix projectionMatrix, GraphicsDevice device, BasicEffect basicEffect)
{
    foreach (OcTreeNode childNode in childList)
        childNode.DrawBoxLines(viewMatrix, projectionMatrix,device, basicEffect);
        if (childList.Count == 0)
            XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, Matrix.Identity, viewMatrix, projectionMatrix);
}

这个方法检查节点是否包含子节点。如果是，将调用传递到每个子节点。当到达最后一个节点时，会使用DrawBoundingBox方法绘制它的包围盒。要绘制所用立方体的边，你只需调用根节点的DrawBoxLines方法，这个方法会深入到所有的子节点：

ocTreeRoot.DrawBoxLines(fpsCam.GetViewMatrix(), fpsCam.GetProjectionMatrix(),basicEffect);

这里使用的绘制立方体包围盒的DrawBoundingBox方法是快捷但丑陋（dirty）的。说它丑陋是因为每帧都要重复的创建顶点和索引。说它快捷是因为不需要在节点中存储额外的变量，因此这个方法很容易被添加和移除，因为在很多场合中绘制包围盒的边界是有用的，所以我把它放在了XNAUtils类中：

public static void DrawBoundingBox(BoundingBox bBox, GraphicsDevice device, BasicEffect basicEffect,
        Matrix worldMatrix,Matrix viewMatrix, Matrix projectionMatrix)
{
    Vector3 v1 = bBox.Min;
    Vector3 v2 = bBox.Max;
    VertexPositionColor[] cubeLineVertices = new VertexPositionColor[8];
    cubeLineVertices[0] = new VertexPositionColor(v1, Color.White);
    cubeLineVertices[1] = new VertexPositionColor(new Vector3(v2.X, v1.Y, v1.Z), Color.Red);
    cubeLineVertices[2] = new VertexPositionColor(new Vector3(v2.X,v1.Y, v2.Z), Color.Green);
    cubeLineVertices[3] = new VertexPositionColor(new Vector3(v1.X, v1.Y, v2.Z), Color.Blue);
    cubeLineVertices[4] = new VertexPositionColor(new Vector3(v1.X, v2.Y, v1.Z), Color.White);
    cubeLineVertices[5] = new VertexPositionColor(new Vector3(v2.X, v2.Y, v1.Z), Color.Red);
    cubeLineVertices[6] = new VertexPositionColor(v2, Color.Green);
    cubeLineVertices[7] = new VertexPositionColor(new Vector3(v1.X, v2.Y, v2.Z), Color.Blue);
    short[] cubeLineIndices = { 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7 };
    basicEffect.World = worldMatrix;
    basicEffect.View = viewMatrix;
    basicEffect.Projection = projectionMatrix;
    basicEffect.VertexColorEnabled = true;
    device.RenderState.FillMode = FillMode.Solid;
    basicEffect.Begin();
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = new VertexDeclaration(device, ertexPositionColor.VertexElements);
        device.DrawUserIndexedPrimitives<VertexPositionColor>(PrimitiveType.LineList, cubeLineVertices,
        0, 8,cubeLineIndices, 0, 12);
        pass.End();
    }
    basicEffect.End();
}

技巧：你可以将这个方法作为使用索引化的LineList的例子。首先，八个顶点存储在一个数组中，然后创建一个对应顶点数组的索引数组。索引数组中每两个数字定义一条线段，所以24个索引定义立方体的12条线段。绘制图元的更多信息请见教程5－1。

代码

DrawableModel类包含绘制3D模型的所有信息和方法：

class DrawableModel
{
    private Matrix worldMatrix;
    private Model model;
    private Matrix[] modelTransforms;
    private Vector3 position;
    private int modelID;

    public Matrix WorldMatrix
    {
        get { return worldMatrix; }
        set
        {
            worldMatrix = value;
            position = new Vector3(value.M41, value.M42, value.M43);
        }
    }

    public Vector3 Position
    {
        get { return position; }
    }

    public Model Model
    {
        get { return model; }
    }

    public int ModelID
    {
        get { return modelID; }
    }

    public DrawableModel(Model inModel, Matrix inWorldMatrix, int inModelID)
    {
        model = inModel;
        modelTransforms = new Matrix[model.Bones.Count];
        worldMatrix = inWorldMatrix;
        modelID = inModelID;
        position = new Vector3(inWorldMatrix.M41, inWorldMatrix.M42, inWorldMatrix.M43);
    }

    public void Draw(Matrix viewMatrix, Matrix projectionMatrix)
    {
        model.CopyAbsoluteBoneTransformsTo(modelTransforms);
        foreach (ModelMesh mesh in model.Meshes)
        {
            foreach (BasicEffect effect in mesh.Effects)
            {
                effect.EnableDefaultLighting();
                effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
                effect.View = viewMatrix;
                effect.Projection = projectionMatrix;
            }
            mesh.Draw();
        }
    }
}

OcTreeNode类存储每个模型的一个链接，也包含检查节点是否在相机视野和添加/移除模型的所有功能：

class OcTreeNode
{
    private const int maxObjectsInNode = 5;
    private const float minSize = 5.0f;
    private Vector3 center;
    private float size;
    List<DrawableModel> modelList;
    private BoundingBox nodeBoundingBox;
    OcTreeNode nodeUFL;
    OcTreeNode nodeUFR;
    OcTreeNode nodeUBL;
    OcTreeNode nodeUBR;
    OcTreeNode nodeDFL;
    OcTreeNode nodeDFR;
    OcTreeNode nodeDBL;
    OcTreeNode nodeDBR;

    List<OcTreeNode> childList;
    public static int modelsDrawn;
    private static int modelsStoredInQuadTree;
    public int ModelsDrawn
    {
        get { return modelsDrawn;}
        set { modelsDrawn = value; }
    }

    public OcTreeNode(Vector3 center, float size)
    {
        this.center = center;
        this.size = size;
        modelList = new List();
        childList = new List(8);
        Vector3 diagonalVector = new Vector3(size / 2.0f, size / 2.0f, size / 2.0f);
        nodeBoundingBox = new BoundingBox(center - diagonalVector, center + diagonalVector);
    }

    public int Add(Model model, Matrix worldMatrix)
    {
        DrawableModel newDModel = new DrawableModel(model, worldMatrix,	modelsStoredInQuadTree++);
        AddDrawableModel(newDModel);
        return newDModel.ModelID;
    }

    public void UpdateModelWorldMatrix(int modelID, Matrix newWorldMatrix)
    {
        DrawableModel deletedModel = RemoveDrawableModel(modelID);
        deletedModel.WorldMatrix = newWorldMatrix; AddDrawableModel(deletedModel);
    }

    private DrawableModel RemoveDrawableModel(int modelID)
    {
        DrawableModel dModel = null;
        for (int index = 0; index < modelList.Count; index++)
        {
            if 	(modelList[index].ModelID == modelID)
            {
                dModel = modelList[index];
                modelList.Remove(dModel);
            }
        }

        int child = 0;
        while ((dModel == null) && (child < childList.Count))
        {
            dModel = childList[child++].RemoveDrawableModel(modelID);
        }
        return dModel;
    }

    private void AddDrawableModel(DrawableModel dModel)
    {
        if (childList.Count == 0)
        {
            modelList.Add(dModel);
            bool maxObjectsReached = (modelList.Count > maxObjectsInNode);
            bool minSizeNotReached = (size > minSize);
            if (maxObjectsReached && minSizeNotReached)
            {
                CreateChildNodes();
                foreach (DrawableModel currentDModel in modelList)
                {
                    Distribute(currentDModel);
                }
                modelList.Clear();
            }
        }
        else
        {
            Distribute(dModel);
        }
    }

    private void CreateChildNodes()
    {
        float sizeOver2 = size / 2.0f;
        float sizeOver4 = size / 4.0f;
        nodeUFR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, -sizeOver4), sizeOver2);
        nodeUFL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, -sizeOver4), sizeOver2);
        nodeUBR = new OcTreeNode(center + new Vector3(sizeOver4, sizeOver4, sizeOver4), sizeOver2);
        nodeUBL = new OcTreeNode(center + new Vector3(-sizeOver4, sizeOver4, sizeOver4), sizeOver2);
        nodeDFR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, -sizeOver4), sizeOver2);
        nodeDFL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, -sizeOver4), sizeOver2);
        nodeDBR = new OcTreeNode(center + new Vector3(sizeOver4, -sizeOver4, sizeOver4), sizeOver2);
        nodeDBL = new OcTreeNode(center + new Vector3(-sizeOver4, -sizeOver4, sizeOver4), sizeOver2);
        childList.Add(nodeUFR);
        childList.Add(nodeUFL);
        childList.Add(nodeUBR);
        childList.Add(nodeUBL);
        childList.Add(nodeDFR);
        childList.Add(nodeDFL);
        childList.Add(nodeDBR);
        childList.Add(nodeDBL);
    }

    private void Distribute(DrawableModel dModel)
    {
        Vector3 position = dModel.Position;
        if (position.Y > center.Y) //Up
            if (position.Z < center.Z) //Forward
                if (position.X < center.X) //Left
                    nodeUFL.AddDrawableModel(dModel);
                else //Right
                    nodeUFR.AddDrawableModel(dModel);
            else //Back
                if (position.X < center.X) //Left
                    nodeUBL.AddDrawableModel(dModel);
                else //Right
                    nodeUBR.AddDrawableModel(dModel);
        else //Down
            if (position.Z < center.Z) //Forward
                if (position.X < center.X) //Left
                    nodeDFL.AddDrawableModel(dModel);
                else //Right
                    nodeDFR.AddDrawableModel(dModel);
            else //Back
                if (position.X < center.X) //Left
                    nodeDBL.AddDrawableModel(dModel);
                else //Right
                    nodeDBR.AddDrawableModel(dModel);
    }

    public void Draw(Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustrum)
    {
        ContainmentType cameraNodeContainment = cameraFrustrum.Contains(nodeBoundingBox);
        if (cameraNodeContainment != ContainmentType.Disjoint)
        {
            foreach (DrawableModel dModel in modelList)
            {
                dModel.Draw(viewMatrix, projectionMatrix);
                modelsDrawn++;
            }
            foreach (OcTreeNode childNode in childList)
                childNode.Draw(viewMatrix, projectionMatrix, cameraFrustrum);
        }
    }

    public void DrawBoxLines(Matrix viewMatrix, Matrix projectionMatrix, GraphicsDevice device, BasicEffect basicEffect)
    {
        foreach (OcTreeNode childNode in childList)
            childNode.DrawBoxLines(viewMatrix, projectionMatrix, device, basicEffect);
            if (childList.Count == 0)
                XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, Matrix.Identity, viewMatrix, projectionMatrix);
}

在XNA项目中，你在八叉树中存储了所有模型以及它们的世界矩阵：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    cCross = new CoordCross(device);
    basicEffect = new BasicEffect(device, null);
    myModel = content.Load<Model>("content/tiny");
    int[] modelIDs = new int[8];
    modelIDs[0] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 5, 5));
    modelIDs[1] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -1));
    modelIDs[2] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -1));
    modelIDs[3] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(1, 4, -3));
    modelIDs[4] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(5, 4, -3));
    modelIDs[5] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, -4, -3));
    modelIDs[6] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(8, 8, -3));
    modelIDs[7] = ocTreeRoot.Add(myModel, Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(10, 8, -3));
}

将模型存储到八叉树后，你可以通过一个简单的调用绘制它们。传递一个视锥体，如果不在相机视野内，八叉树就不会绘制模型。

BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
ocTreeRoot.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum);

如果你想更新模型的世界矩阵，只需使用前面已经定义的UpdateModelWorldMatrix 方法：

ocTreeRoot.UpdateModelWorldMatrix(modelToChange, newWorldMatrix);

扩展阅读

本教程的八叉树是基本的教程，帮助你理解空间分隔的思想。代码中还有一点小缺陷，例如，改变一个不存在的modelID的模型的世界矩阵会引发错误。

DeleteDrawableModel方法也可以加以改进，因为整个八叉树会一直搜索直到最后一个对象。更好的方法是将所有模型的位置存储在一个集合中，这样八叉树可以以一个更有效率的方式找到模型。而且，ID已经包含了应该朝哪个方向搜索的信息。

而且，这个方法让你只存储（绘制）包含effect的模型，因为DrawableModel的Draw方法是非常具体的。更普遍的方法是定义一个虚拟DrawableObj类，这个类可以存储在八叉树中，其他类可以从这个类继承。

再次说，本教程是帮助你理解八叉树的概念的，只需添加更严格的功能，这个结构己经足以介绍八叉树了。

对象剔除的知识非常广泛。本教程展示了一个基本例子，因为这个例子只剔除不在相机视野中的对象，所以方法不是很难。更先进的解决方案是闭塞剔除（occlusion culling），一个3D空间分隔成对应的3D空间本身。例如，将物体放在房子内，会使用房子对应的空间而不是对称的立方体。这些房间彼此以房门连接。通过这种方式，你可以检查房间是否在视野内或者检查墙或房门是否阻挡了视线。


2.10 使用四叉树隐藏不在视野中的部分网格

问题

地形绘制是游戏中的一个重要组成部分。但是，使用教程5-8中的方法，当绘制一个很大的地形时会导致帧数下降。

解决方案

你可以使用一个四叉树简化绘制大地形的工作流程，四叉树类似于八叉树，因为你要将地形分割成更小的方块直到这些方块不大于指定的大小。

这个处理过程如图2-13左图所示。一个16 × 16大小的方块被分割成四个方块，每个小方块再被分割成四个方块。

使用四叉树的主要优点是当绘制地形时，你只需绘制在相机视野中的那些方块，这可以通过检测哪些方块与相机视景体相交做到，显示在图2-13的右图中，看得见的方块用灰色表示。

图2-13 将一个大方块分割成小方块（左图），与相机视景体相交的方块(右图)

四叉树是八叉树的简化版本。只不过一个方块只需要被分割成四个小方块，而一个八叉树节点需要被分割成八个子立方体。八叉树需要保存其中的所有物体的位置，而四叉树无需进行这个操作。

工作原理

创建一个新的类表示一个方块，即四叉树的一个节点：

  namespace BookCode
    ...{
      class QTNode
    ...    {
          private BoundingBox nodeBoundingBox;

          private bool isEndNode;
          private QTNode nodeUL;
          private QTNode nodeUR;
          private QTNode nodeLL;
          private QTNode nodeLR;

          private int width;
          private int height;
          private GraphicsDevice device;
          private BasicEffect basicEffect;
          private VertexBuffer nodeVertexBuffer;
          private IndexBuffer nodeIndexBuffer;
          private Texture2D grassTexture;

          public static int NodesRendered;
      }
  }

下面讨论一下每个节点需要的变量。第一个是包围盒，它非常重要。它是最小的盒子，当前节点的所有顶点都在这个盒子中。每一帧中你都要检测当前盒子是否在相机的视野中，只有结果为true才会在Draw方法中绘制这个节点中的所有子节点。

当创建一个四叉树时，你必须指定节点的最大大小。变量isEndNode表示当前节点是否大于这个最大大小，如果大于，这个节点必须创建四个子节点，存储在nodeUL到nodeLR中，其中UL表示左上方的节点，LR表示右下方的节点。

如果当前节点没有子节点，当isEndNode为true时这个节点会绘制某些三角形，要进行这个操作，你需要知道它的长和宽。你还需要一个指向图形设备和BasicEffect的链接。此外，你还需要一个VertexBuffer、一个IndexBuffer和一个Texture用来绘制三角形(可参见教程5-8)。

调用节点的构造函数创建一个新节点：

  public QTNode(VertexPositionNormalTexture[,] vertexArray, GraphicsDevice device, Texture2D grassTexture, int maxSize)
    ...{
      this.device = device;
      this.grassTexture = grassTexture;
      basicEffect = new BasicEffect(device, null);

      width = vertexArray.GetLength(0);
      height = vertexArray.GetLength(1);
      nodeBoundingBox = CreateBoundingBox(vertexArray);

      isEndNode = width <= maxSize;
      isEndNode &= height <= maxSize;
      if (isEndNode)
        ...{
          VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray);
          int[] indices = TerrainUtils.CreateTerrainIndices(width, height);
          TerrainUtils.CreateBuffers(vertices, indices, out nodeVertexBuffer, out nodeIndexBuffer, device);
      }
      else
    ...    {
          CreateChildNodes(vertexArray, maxSize);
      }
  }

一个节点需要一个2D数组包含所有的顶点，还有指向设备和纹理的链接，一个大小，当节点的大小小于这个大小时就会停止将自己分割成子节点。

指向设备和纹理的链接会立即存储在节点中。当前节点的长和宽从包含顶点的2D数组中获取。给定这个节点的所有顶点，你可以使用下面就有创建的CreateBoundingBox方法计算包围盒。

最后，你检查当前节点的宽和高是否小于最大大小。如果是，则从节点的顶点中创建一个VertexBuffer和一个IndexBuffer。如果大于最大大小，则将这个节点分割成四个子节点。

创建包围盒

给定包含顶点的2D数组，你可以很容易地将这些位置存储在一个集合中。BoundingBox 类的CreateFromPoints方法可以从位置集合中生成一个包围盒：

  private BoundingBox CreateBoundingBox(VertexPositionNormalTexture[,] vertexArray)
    ...{
      List<Vector3> pointList = new List<Vector3>();
      foreach (VertexPositionNormalTexture vertex in vertexArray)
          pointList.Add(vertex.Position);

      BoundingBox nodeBoundingBox = BoundingBox.CreateFromPoints(pointList);
      return nodeBoundingBox;
  }

生成VertexBuffer和IndexBuffer

你可以使用教程5-8中的CreateVertices和CreateIndices方法，这两个方法根据给定的1D数组创建了一个VertexBuffer和一个IndexBuffer。这需要你首先将2D数组转换为一个1D数组，可以使用如下方法：

  private T[] Reshape2Dto1D<T>(T[,] array2D)
    ...{
      int width = array2D.GetLength(0);
      int height = array2D.GetLength(1);

      T[] array1D = new T[width * height];

      int i = 0;
      for (int z = 0; z < height; z++)
          for (int x = 0; x < width; x++)
              array1D[i++] = array2D[x, z];

      return array1D;
  }

这个泛型方法接受一个类型为T（本例中为VertexPositionNormalTexture）的2D数组，获取它的大小，并将它的内容复制到一个1D数组中。

注意：泛型功能是在.NET 2.0中引入的，当调用一个泛型方法时，你需要在括号内指定需要用哪种类型替换T，如下所示：

VertexPositionNormalTexture[] vertices = Reshape2Dto1D<VertexPositionNormalTexture>(vertexArray);

将节点分割成四个子节点

如果节点太大，它应该被分割成四个子节点。这里的难点是所有的子节点不都总是一样大小的。在图2-13的左图中，你看到一个16 × 16网格被分成四个8 × 8网格。如果每个子节点只存储8 × 8个顶点，就无法绘制在第8和第9行（列）之间的三角形，会留下空隙。在图2-13中，可以通过让第一行（列）的左上角节点大于其他节点解决这个问题。

对于不成对的方块，这个问题不存在：9 × 9方块可以被分成四个包含5 × 5顶点的方块，如图2-13的左上角方块所示。

剩下的问题是，你如何计算存储在每个方块中的顶点数量？比如，你怎么知道16应该分成9和8，而9分成5和5？第一个值可以将父节点的大小除以2获得，取小的整数(除以一个整数获得的结果就是你所期望的)再将结果加1。第二个值可以将父节点的大小除以2，取小的整数，然后从父节点的大小中减去这个结果。

例如，16除以2为8，加1为9，即第一个值。然后16除以2为8，16减8为8。

第二个例子中：9除以2为4.5，取4再加1为5，即第一个值。然后，9除以2为4.5，取4，将9减去4为5。

知道了子节点的大小，你就可以使用下列代码创建子节点了。对每个子节点，你首先复制顶点的大小，创建一个QTNode对象：

  private void CreateChildNodes(VertexPositionNormalTexture[,] vertexArray, int maxSize)
    ...{
      VertexPositionNormalTexture[,] ulArray = new VertexPositionNormalTexture[width / 2 + 1, height / 2 + 1];
      for (int w = 0; w < width / 2 + 1; w++)
          for (int h = 0; h < height / 2 + 1; h++)
              ulArray[w, h] = vertexArray[w, h];
      nodeUL = new QTNode(ulArray, device, grassTexture, maxSize);

      VertexPositionNormalTexture[,] urArray = new VertexPositionNormalTexture[width - (width / 2), height / 2 + 1];
      for (int w = 0; w < width - (width / 2); w++)
          for (int h = 0; h < height / 2 + 1; h++)
              urArray[w, h] = vertexArray[width / 2 + w, h];
      nodeUR = new QTNode(urArray, device, grassTexture, maxSize);

      VertexPositionNormalTexture[,] llArray = new VertexPositionNormalTexture[width / 2 + 1, height - (height / 2)];
      for (int w = 0; w < width / 2 + 1; w++)
          for (int h = 0; h < height - (height / 2); h++)
              llArray[w, h] = vertexArray[w, height / 2 + h];
      nodeLL = new QTNode(llArray, device, grassTexture, maxSize);

      VertexPositionNormalTexture[,] lrArray = new VertexPositionNormalTexture[width - (width / 2), height - (height / 2)];
      for (int w = 0; w < width - (width / 2); w++)
          for (int h = 0; h < height - (height / 2); h++)
              lrArray[w, h] = vertexArray[width / 2 + w, height / 2 + h];
      nodeLR = new QTNode(lrArray, device, grassTexture, maxSize);
  }

绘制四叉树

实现了创建和分割功能后，你就做好了绘制四叉树的准备。你需要确保只绘制在相机视野中的方块。在主程序中，你需要只调用四叉树的根节点的Draw方法绘制所有在相机视野中的节点。

需要检查根节点是否在相机视野中。如果不是，无需进行任何操作。如果是，这个根节点需要传递到四个子节点的Draw调用中。

每个子节点进行同样的操作：检测是否在相机视野中，如果是，将它们传递到他们的子节点的调用中直至最小的节点也被绘制。如果在视野中，会从它们的顶点中绘制一个网格：

  public void Draw(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix, BoundingFrustum cameraFrustum)
    ...{
      BoundingBox transformedBBox = XNAUtils.TransformBoundingBox(nodeBoundingBox, worldMatrix);
      ContainmentType cameraNodeContainment = cameraFrustum.Contains(transformedBBox);
      if (cameraNodeContainment != ContainmentType.Disjoint)
    ...    {
          if (isEndNode)
            ...{
              DrawCurrentNode(worldMatrix, viewMatrix, projectionMatrix);
              nodeUL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum);
              nodeUR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum);
              nodeLL.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum);
              nodeLR.Draw(worldMatrix, viewMatrix, projectionMatrix, cameraFrustum);
          }
      }
  }

这个方法需要传递相机的视景体，这个视景体用来检测相机是否与节点的包围盒相交，表示节点是否在相机视野中。

如果这个调用到达了最后一个节点，会调用DrawCurrentNode方法，这个方法会绘制指定节点。这个代码来自于教程5-8：

  private void DrawCurrentNode(Matrix worldMatrix, Matrix viewMatrix, Matrix projectionMatrix)
    ...{
      basicEffect.World = worldMatrix;
      basicEffect.View = viewMatrix;
      basicEffect.Projection = projectionMatrix;
      basicEffect.Texture = grassTexture;
      basicEffect.VertexColorEnabled = false;
      basicEffect.TextureEnabled = true;
      basicEffect.EnableDefaultLighting();
      basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 1);
      basicEffect.DirectionalLight0.Enabled = true;
      basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f);
      basicEffect.DirectionalLight1.Enabled = false;
      basicEffect.DirectionalLight2.Enabled = false;
      basicEffect.SpecularColor = new Vector3(0, 0, 0);
      basicEffect.Begin();
      foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    ...    {
          pass.Begin();
          device.Vertices[0].SetSource(nodeVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes);
          device.Indices = nodeIndexBuffer;
          device.VertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
          device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, (width * 2 * (height - 1) - 2));
          pass.End();
      }
      basicEffect.End();

      NodesRendered++;

      //XNAUtils.DrawBoundingBox(nodeBoundingBox, device, basicEffect, worldMatrix, viewMatrix, projectionMatrix);
  }

每次当代码执行时，NodeRendered变量会加1。因为它是一个静态变量，被四叉树的所有节点共享，所以在绘制过程的最后，它会包含实际绘制的节点的数量。

你可以注释掉最后一行代码，它可以绘制所有被绘制的节点的包围盒对象的边框。

初始化四叉树

完成了QTNode类后，你就做好了创建四叉树的准备。大部分代码与教程5-8中的相似，从一个包含高度数据的2D纹理开始，然后创建一个VertexPositionNormalTexture元素的1D数组。因为你想使用GenerateNormalsFromTriangleStrip方法，解释请见教程5-7，需要添加正确的法线，首先需要创建一个索引集合将下列代码添加到LoadContent方法中：

  Texture2D grassTexture = content.Load<Texture2D>("grass");
  Texture2D heightMap = content.Load<Texture2D>("heightmap");
  int width = heightMap.Width;
  int height = heightMap.Height;

  float[,] heightData = TerrainUtils.LoadHeightData(heightMap);
  VertexPositionNormalTexture[] vertices = ~ TerrainUtils.CreateTerrainVertices(heightData);
  int[] indices = TerrainUtils.CreateTerrainIndices(width, height);
  vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices);
  VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height);

  rootNode = new QTNode(vertexArray, device, grassTexture, 64);

最后你获得一个1D的顶点数组。但是QTNode的构造函数需要一个2D数组，所以最后一行代码调用Reshape1Dto2D方法：

  private T[,] Reshape1Dto2D<T>(T[] vertices, int width, int height)
    ...{
      T[,] vertexArray = new T[width, height];
      int i=0;
      for (int h = 0; h < height; h++)
          for (int w = 0; w < width; w++)
              vertexArray[w, h] = vertices[i++];

      return vertexArray;
  }

这仍是一个泛型方法，让你可以将1D数组转换为一个2D数组。有了顶点的2D数组，将最后一行代码添加到LoadContent方法中：

rootNode = new QTNode(vertexArray, device, grassTexture, 64);

这行代码生成整个四叉树。你传入一个顶点2D数组和大小为64的最大大小，只要方块的大小大于64，它们就会被分割成子节点。

使用四叉树

在XNA项目中，你可以在Draw方法中放置以下代码：

  QTNode.NodesRendered = 0;
  BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
  rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum);
  Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered);

第一行和最后一行代码只用于调试，因为它们会将实际绘制的方块数量显示在窗口的标题栏中。

第二行代码创建了相机的视景体，用于检测四叉树的节点是否在相机视野中。第三行代码初始化Draw调用，Draw方法会遍历所有节点并绘制可见的节点。

代码

你可以在前面看到QTNode类的所有代码，下面是XNA主项目中的LoadContent方法：

  protected override void LoadContent()
    ...{
      device = graphics.GraphicsDevice;
      cCross = new CoordCross(device);

      Texture2D grassTexture = content.Load<Texture2D>("grass");
      Texture2D heightMap = content.Load<Texture2D>("heightmap");
      int width = heightMap.Width;
      int height = heightMap.Height;

      float[,] heightData = TerrainUtils.LoadHeightData(heightMap);
      VertexPositionNormalTexture[] vertices = TerrainUtils.CreateTerrainVertices(heightData);
      int[] indices = TerrainUtils.CreateTerrainIndices(width, height);
      vertices = TerrainUtils.GenerateNormalsForTriangleStrip(vertices, indices);

      VertexPositionNormalTexture[,] vertexArray =Reshape1Dto2D<VertexPositionNormalTexture>(vertices, width, height);

      rootNode = new QTNode(vertexArray, device, grassTexture, 64);
  }

Draw方法会绘制所有可见节点：

  protected override void Draw(GameTime gameTime)
    ...{
      graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

      cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);

      QTNode.NodesRendered = 0;
      BoundingFrustum cameraFrustrum = new BoundingFrustum(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
      rootNode.Draw(Matrix.CreateTranslation(-250,-20,250), fpsCam.ViewMatrix, fpsCam.ProjectionMatrix, cameraFrustrum);
      Window.Title = string.Format("{0} nodes rendered", QTNode.NodesRendered);

      base.Draw(gameTime);
  }

扩展阅读

这个教材介绍了四叉树的基本知识，但这里用到的四叉树有几个缺点：

  • 加载时间过长。
  • DrawPrimitives调用次数很大，拖慢了程序。
  • 如相机在地形上平视，仍会绘制大量的方块。

以上问题有多个解决方法，我没法一一列举，这样可能又可以写一本书了。但是，我会讨论产生这些问题的原因。

加载时间过长是由每次将一个方块分割成四个小方块时将顶点复制到一个小数组中引起的，重建过程会导致巨大的开销。这个问题可以通过在内容管道中生成四叉树加以解决，这样在运行过程中只需从二进制文件中读取顶点缓冲和索引缓冲流。可见教程5-13学习如何（反）串行化地形。

第二个问题的原因是显卡喜欢进行持续的工作不要被打搅，一次绘制一百万的三角形比1000次绘制1000个三角形好得多。你可以通过减少DrawPrimitives的调用次数解决这个问题。一个方法是检测一个父节点的所有四个子节点是否都可见，如果都可见，那么绘制父节点而不是四个子节点，这样绘制的三角形数量是一样的，但只需调用DrawPrimitives一次而不是四次。

最后一个问题会导致绘制极大数量的三角形，因为离开相机非常远的方块也会绘制与离相机非常近的方块相同数量的三角形，你可以通过以低细节绘制远处的方块解决这个问题，就是level-of-detail (LOD)算法，它是一个挑战。

另一个处理巨大地形的方法可参见下一个教程，请记住你可以组合这两个教程的技术，因为大多数地形引擎使用四叉树、ROAM引擎或两者的组合。例如，你可以将地形分成小块（patch），使用四叉树控制这些小块，然后使用下一个教程的ROAM算法绘制它们。这样就组合了四叉树的控制和ROAM的算法。


2.11 创建一个ROAM地形

问题

虽然你可以使用前一个教程的四叉树技术让XNA只绘制在相机视野中的地形小块，这个方法还不是最好的。那些远离相机的方块仍会以离相机很近的方块的同样细节进行绘制，而有些三角形在屏幕上的绘制大小不超过一个像素。

因此，你想使用Lecvel-Of-Detail(LOD)方法让远离相机的方块以低细节绘制，当使用不同的细节层次绘制地形时，你会发现在边界上会出现破裂。

译者注：ROAM是Real-Time Camera-Dependant Optimally Adapting Mesh的缩写，可翻译为依赖相机的实时优化网格。

解决方案

本教程介绍一个强大的技术，这个技术从覆盖整个屏幕的两个三角形开始。在算法的迭代中，每个三角形计算离开相机的距离并判断它是否需要分割成两个三角形，最后，你获得一个最匹配当前相机位置的网格。

只要相机的位置或旋转发生改变就需要更新这个地形网格，要对相机的新状态保持优化，每个三角形还需要判断是否需要和邻近的三角形合并以再次减少细节层次。

工作原理

本教程的目标是从覆盖整个屏幕的两个三角形开始，创建一个算法让每个三角形可以根据相机判断是否需要分割或合并。

在实际开始之前，你需要找到一个三角形布局可以很容易地进行分割和合并。针对处理地形的情况，我使用一个基于方块的固定网格。如果你将这个由两个三角形组成的方块分割以增加细节时，每个方块需要被分割成由8个三角形组成的4个方块，如图2-14的右图所示。添加额外的六个三角形是前进过程中的一大步。

图2-14 在基于方块的布局中增加细节层次

但是两个方块的边界处会出现破裂，如图2-15所示。显示了图2-14右图的3D表示，你需要进行拼接修复这个破裂，这并不简单。

图2-15 基于方块的地形的不同细节层次会产生破裂

因此，这个教程会使用一个不同的三角形布局，不是处理每个方块，而是处理每个三角形。当一个三角形需要增加细节层次时，你只需将它分割成两个，如图2-16所示。这可以让你将两个三角形变成四个三角形。

图2-16 基于三角形布局增加细节层次 这个分割不会导致破裂。

在某些情况中，你可以继续使用这个方式分割三角形而不会产生破裂。例如，图2-17左图显示了另三个安全的分割。

图2-17 三个安全的分割（左），导致出现破裂的一次分割

但是，图2-17的右图显示了将三角形B分割成C和D会导致地形上出现破裂。图2-18显示了这个破裂的3D显示。

图2-18 由基于三角形布局的不同细节层次引起的破裂

幸运的是，你可以避免这个情况的发生，要避免在分割三角形时出现破裂，你可以进行以下操作：

  • 确保父三角形也被分割
  • 将共享长边的三角形也进行分割

图2-19显示了分割三角形A的过程。在左上图中，虚线表示你想进行的分割。根据第二条规则，共享长边的三角形（三角形B）需要首先被分割，如右上图的虚线所示。但是，根据第一条规则，在分割三角形B之前，你需要首先将三角形B的父三角形（三角形D）分割成B和C，如左中图的虚线所示。但在分割D之前，你需要首先分割三角形E。幸运的是，三角形E没有父三角形，所以你可以分割三角形E，之后分割D，如中右图所示。现在可以安全地分割B，如下左图所示，最后分割A，如下右图所示。

图2-19 在逐三角形布局中的分割过程

这就是分割过程。要使地形在相机移动时保持最优化（即使用尽可能少的三角形），除了可以分割三角形还需要可以合并它们。当两个三角形A和B合并在一起时，对面的两个三角形C和D也应该合并以避免产生破裂，如图2-20所示。

图2-20 逐三角形布局的合并过程

初始化

现在开始编写代码。首先加载包含高度图的图像并基于这个图像创建一个VertexBuffer。在这个教程中，图像的长和宽必须是（2的整数幂+1）。你要么创建一个这个大小的高度图，要么开始时使用大小为2的整数幂的高度图，然后手动添加最后一行和一列。为了将注意力放在后面的操作上，本教程假设你使用的就是（2的整数幂+1）大小的高度图，虽然你也可以在本教程的代码中发现LoadHeightDataAndCopyLastRowAndColumn方法。

在LoadContent方法中添加以下代码，加载图像并创建对应的VertexBuffer，解释可见教程5-8：

  Texture2D heightMap = Content.Load<Texture2D>("heightmap");
  heightData = LoadHeightData(heightMap);

  myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
  VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices();
  int[] terrainIndices = CreateTerrainIndices();
  terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices);
  terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * terrainVertices.Length, BufferUsage.WriteOnly);
  terrainVertexBuffer.SetData(terrainVertices);

Triangle类

你将创建一个Triangle类，它用来自己进行判断并作出动作。通过右击解决方案管理器中的项目并选择Add→New Item在你的项目中创建一个新类。在对话框中选择Class，命名为Triangle.cs。

首先需要定义一些变量，这些变量存储三个邻近、子和父的链接。

Triangle lNeigh;
Triangle rNeigh;
Triangle bNeigh;
Triangle parent;
Triangle lChild;
Triangle rChild;

图2-21显示了它们之间的关系。

图2-21 三角形之间的关系

你还需要另外一些变量：

  Vector3 lPos;
  Vector3 apexPos;

  int tInd;
  int lInd;
  int rInd;

  public bool splitted = false;
  public bool addedToMergeList = false;

两个Vector3保存left点和right点的3D位置（见图2-21），用来判断是否需要分割这个三角形。索引用来绘制三角形。Spiltted表示三角形是否分割，addedToMergeList用来确保某些计算不要在同一个三角形上重复两次。

还有一些变量在构造函数中定义，构造函数在创建一个新的Triangle对象时调用：

  public Triangle(Triangle parent, Vector2 tPoint, Vector2 lPoint, Vector2 rPoint, float[,] heightData)
    ...{
      int resolution = heightData.GetLength(0);
      tInd = (int)(tPoint.X + tPoint.Y * resolution);
      lInd = (int)(lPoint.X + lPoint.Y * resolution);
      rInd = (int)(rPoint.X + rPoint.Y * resolution);

      lPos = new Vector3(lPoint.X, heightData[(int)lPoint.X, (int)lPoint.Y], -lPoint.Y);
      Vector2 apex = (lPoint + rPoint) / 2;
      apexPos = new Vector3(apex.X, heightData[(int)apex.X, (int)apex.Y], -apex.Y);

      this.parent = parent;
      if (Vector2.Distance(lPoint, tPoint) > 1)
        ...{
          lChild = new Triangle(this, apex, tPoint, lPoint, heightData);
          rChild = new Triangle(this, apex, rPoint, tPoint, heightData);
      }
  }

如你所见，当创建一个新Triangle对象时，需要指定它的父、左上点和右上点。而且，因为三角形结构用来绘制一个地形，它的每个点都代表一个3D点。所以当创建一个Triangle对象时，你还需要指定一个包含整个地形高度值的2D数组。这个数组首先用来获取三个角的索引（可见教程5-8学习如何获取一个网格顶点的索引），然后是对应左边角和中心点的3D位置。

最后，存储指向父三角形的链接，检测当前三角形是否可以分割。如果三角形足够大，你将创建两个子三角形并存储它们的链接。注意，中心点就是两个子三角形的顶部的点，可参见图2-21。

定义两个基三角形

定义了Triangle类的通用结构后，你就做好了定义两个基三角形的准备，这两个基三角形覆盖整个屏幕。首先在第一帧绘制这两个三角形。然后，但调用Update方法时，判断是否需要分割前一帧绘制的三角形。因此，你需要保存当前绘制的三角形的集合。在代码顶部添加这行代码：

List<Triangle> triangleList;

然后转到LoadContent方法的最后，在那里我们将创建Triangle结构。首先定义一个resolution变量，它只是一个调试变量。通常，你想绘制一个与高度图一样大小的地形，但在调试时，只使用高度图一部分是很有用的。虽然本教程使用的高度图大小为1025×1025，但现在只使用33×33。

下面的代码定义左边的三角形：

  int terrainSize = 32;
  Triangle leftTriangle = new Triangle(null, new Vector2(0, 0), new Vector2(terrainSize, 0), new Vector2(0, terrainSize), heightData);

基三角形没有父三角形。你将三角形的三个顶点作为第二、第三、第四个参数传递，对应整个地形的顶点。最后，传递包含高度值的2D数组。

注意当代码被执行时，Triangle类的构造函数会被调用。这意味着这个三角形会自己创建两个子三角形，这两个子三角形也会创建自己的两个子三角形。这个过程持续到所有三角形的大小小于两个创建的高度图上的点。

右边的三角形以同样的方法定义，只是顶角的位置不同：

  Triangle rightTriangle = new Triangle(null, new Vector2(terrainSize, terrainSize), new Vector2(0, terrainSize), new Vector2(terrainSize, 0), heightData)

添加链接

对于所有在Triangle类中进行的判断和动作，每个Triangle都需要访问它的父、邻居和子三角形。因此，下一步就是添加它们的链接。

每个Triangle已经存储了指向父和子三角形的链接，但还需要知道邻近三角形的链接。技巧是知道三个邻近三角形就可以知道它的子三角形的三个邻近三角形。这个方法可以自动计算结构中所有三角形的邻近三角形。首先在Triangle类中添加这个方法：

  public void AddNeighs(Triangle lNeigh, Triangle rNeigh, Triangle bNeigh)
    ...{
      this.lNeigh = lNeigh;
      this.rNeigh = rNeigh;
      this.bNeigh = bNeigh;
  }

Triangle需要你将链接传递到三个邻近三角形中并存储。然后，如果Triangle拥有子，你想找到子三角形的邻近三角形，这样你可以将调用传递到子三角形中，然后依次传递到它们的子三角形的邻近三角形中，直到整个结构的Triangles都存储了指向三个邻近三角形的链接。

  if (lChild != null)
    ...{
      Triangle bNeighRightChild = null;
      Triangle bNeighLeftChild = null;
      Triangle lNeighRightChild = null;
      Triangle rNeighLeftChild = null;

      if (bNeigh != null)
        ...{
          bNeighLeftChild = bNeigh.lChild;
          bNeighRightChild = bNeigh.rChild;
      }
      if (lNeigh != null)
          lNeighRightChild = lNeigh.rChild;
      if (rNeigh != null)
          rNeighLeftChild = rNeigh.lChild;

      lChild.AddNeighs(rChild, bNeighRightChild, lNeighRightChild);
      rChild.AddNeighs(bNeighLeftChild, lChild, rNeighLeftChild);
  }

你要做的就是通过在两个基三角形上调用这个方法初始化这个过程。在LoadContent方法的最后添加下面的代码：

leftTriangle.AddNeighs(null, null, rightTriangle);
rightTriangle.AddNeighs(null, null, leftTriangle);

每个基三角形是另一个的唯一邻近三角形。这两个简单的调用会遍历整个结构直到所有的Triangle都存储了正确的指向邻近三角形的链接。

TriangleList和IndexBuffer

当初始化过程结束后，你就做好了继续前进的准备。对ROAM算法的每次迭代，都从上一帧绘制的三角形集合开始。在迭代过程中，你都需要更新这个集合，将需要更高细节的三角形进行分割（被它们的两个子三角形替代），细节太高的三角形与对应的邻近三角形合并（见图2-20）。当集合中的每个Triangle将它们的索引存储到一个集合后迭代结束，然后将索引集合上传到显卡的缓冲中。因此，在Triangle中需要一个小方法可以将三角形的索引添加到一个集合中：

  public void AddIndices(ref List<int> indicesList)
    ...{
      indicesList.Add(lInd);
      indicesList.Add(tInd);
      indicesList.Add(rInd);
  }

让我们回到主程序。因为你会频繁地上传索引集合，所以需要使用一个DynamicIndexBuffer（可见教程5-5）。因此，你需要在indicesList中保存本地索引的副本。在代码顶部添加以下三个变量：

  List<Triangle> triangleList;
  List<int> indicesList;
  DynamicIndexBuffer dynTerrainIndexBuffer;

然后，通过在集合中添加两个基三角形开始处理过程，通过将以下代码添加到LoadContent方法中将它们的索引存储到另一个集合中：

  triangleList = new List<Triangle>();
  triangleList.Add(leftTriangle);
  triangleList.Add(rightTriangle);

  indicesList = new List<int>();
  foreach (Triangle t in triangleList)
  t.AddIndices(ref indicesList);

现在有了索引集合，你就做好了将集合发送到DynamicsIndexBuffer的准备：

  dynTerrainIndexBuffer = new DynamicIndexBuffer(device, typeof(int), indicesList.Count, BufferUsage.WriteOnly);
  dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard);
  dynTerrainIndexBuffer.ContentLost += new EventHandler(dynIndexBuffer_ContentLost);

如教程5-5的解释，你需要在设备丢失时调用一个指定的方法。这意味着你还需要指定dynIndexBuffer_ContentLost方法，它可以将索引从本地内存复制到显卡中：

  private void dynIndexBuffer_ContentLost(object sender, EventArgs e)
    ...{
      dynTerrainIndexBuffer.Dispose();
      dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard);
  }

绘制TriangleList中的三角形

以后，你会从triangleList中添加或移除三角形。现在，首先定义一个简单的方法绘制包含在triangleList的三角形。解释请见教程5-8。

  private void DrawTerrain()
    ...{
      int width = heightData.GetLength(0);
      int height = heightData.GetLength(1);

      device.RenderState.FillMode = FillMode.Solid;
      device.RenderState.AlphaBlendEnable = false;

      basicEffect.World = Matrix.Identity;
      basicEffect.View = fpsCam.ViewMatrix;
      basicEffect.Projection = fpsCam.ProjectionMatrix;
      basicEffect.Texture = grassTexture;
      basicEffect.TextureEnabled = true;

      basicEffect.Begin();
      foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    ...    {
          pass.Begin();
          device.Vertices[0].SetSource(terrainVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes);
          device.Indices = dynTerrainIndexBuffer;
          device.VertexDeclaration = myVertexDeclaration;
          int noTriangles = triangleList.Count;
          device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, width * height, 0, noTriangles);
          pass.End();
      }
      basicEffect.End();
  }

别忘了在Draw方法中调用这个方法：

DrawTerrain();
device.Indices = null;

需要最后一行代码的原因是：当在显卡上将DynamicIndexBuffer设置为active时你无法改变其中的内容。

ROAM迭代

完成了初始化三角形结构和将triangleList中的三角形绘制到屏幕后，你就做好了进入ROAM的准备。每次迭代过程中，你首先从上一个迭代中绘制的三角形开始，需要根据当前相机的位置判断三角形是否需要分割还是合并。下面是包含在ROAM迭代中的步骤。

注意在迭代开始时除了triangleList这些集合是空的，显然不包括triangleList。看起来好像可以将一些步骤合并在一起，实际上不能，理由会在下面的段落中解释。

  1. 首先从前一次迭代中计算的triangleList开始。
  2. 对集合中的每个三角形，检测是否需要被分割成两个子三角形，将它们添加到splitList集合中。保存不需要分割的三角形到remainderList集合。
  3. 分割splitList中的三角形，每个三角形会将它的两个子三角形添加到newTriangleList中，在迭代最后newTriangleList会成为新的triangleList。
  4. 检测remainderList中的三角形是否需要合并，如果是，将它的父三角形添加到mergeList中。将其他的三角形添加到leftoverList中。
  5. 将mergeList中的每个父三角形添加到newTriangleList中。
  6. 如果需要，将leftoverList中的每个三角形添加到newTriangleList中。
  7. 在triangleList中保存newTriangleList做好用于下一次迭代的准备。
  8. 将triangleList中的三角形的索引传递到DynamicIndexBuffer中，将这些三角形绘制到屏幕上。

整个过程（除了第8步）由UpdateTriangles方法处理。显然你需要编写用于UpdateTriangles方法的方法，UpdateTriangles方法需要放置在游戏主类中，因为它需要访问triangleList。

  private void UpdateTriangles()
    ...{
      List<Triangle> splitList = new List<Triangle>();
      List<Triangle> mergeList = new List<Triangle>();
      List<Triangle> remainderList = new List<Triangle>();
      List<Triangle> leftoverList = new List<Triangle>();
      List<Triangle> newTriangleList = new List<Triangle>(triangleList.Count);

      Matrix worldViewProjectionMatrix = Matrix.Identity * fpsCam.ViewMatrix * fpsCam.ProjectionMatrix;
      BoundingFrustum cameraFrustum = new BoundingFrustum(worldViewProjectionMatrix);

      foreach (Triangle t in triangleList)
          t.CreateSplitList(ref splitList, ref remainderList, ref worldViewProjectionMatrix, ref cameraFrustum);

      foreach (Triangle t in splitList)
          t.ProcessSplitList(ref newTriangleList);

      foreach (Triangle t in remainderList)
          t.CreateMergeList(ref mergeList, ref leftoverList, ref worldViewProjectionMatrix, ref cameraFrustum);

      foreach (Triangle t in mergeList)
          t.ProcessMergeList(ref newTriangleList, ref worldViewProjectionMatrix, ref cameraFrustum);

      foreach (Triangle t in leftoverList)
          t.ProcessLeftovers(ref newTriangleList);

      triangleList = newTriangleList;
      triangleList.TrimExcess();
  }
创建splitList

在创建splitList前，每个Triangle需要判断本身是否需要分割。一个Triangle是基于自己左顶点和中心点之间的屏幕距离的（见图2-21）。屏幕距离是两点间的屏幕像素大小，靠近相机的三角形这个距离会比远离相机的三角形大。只有这个屏幕距离大于某个阈值时这个三角形才需被分割，这是由ShouldSplit方法决定的，它应该放置在Triangle类中：

  public bool ShouldSplit(ref Matrix wvp, ref BoundingFrustum bf)
    ...{
      bool shouldSplit = false;
      if (bf.Contains(apexPos) != ContainmentType.Disjoint)
    ...    {
          Vector4 lScreenPos = Vector4.Transform(lPos, wvp);
          Vector4 aScreenPos = Vector4.Transform(apexPos, wvp);
          lScreenPos /= lScreenPos.W;
          aScreenPos /= aScreenPos.W;

          Vector4 difference = lScreenPos - aScreenPos;
          Vector2 screenDifference = new Vector2(difference.X, difference.Y);

          float threshold = 0.1f;
          if (screenDifference.Length() > threshold)
              shouldSplit = true;
      }
      return shouldSplit;
  }

首先检查中心点是否在相机视野中（见教程2-5）。如果不是，无需分割这个三角形。如果是，你找到左顶点和中心点的屏幕位置，这可以在每个3D顶点shader中进行：通过使用WorldViewProjection矩阵转换3D位置。因为结果是一个Vector4，你需要将XYZ分量除以W分量。结果中的XY坐标包含了2D屏幕位置的坐标，位于[-1,1]区间。所以，你获取了XY坐标，通过将左顶点和中心点相减获取屏幕距离，screenDifference.Length()。

只有当screenDifference.Length()大于指定的阈值时才需要分割三角形。这意味着小于threshold变量的值会增加地形的细节。

注意：在这种情况中，如果距离小于屏幕大小的1/20，三角形会分割。这个阈值还是比较高。可见教程的最后可以下关于这点的讨论。

现在，每个三角形都判断了自己是否需要分割，你几乎已经做好了创建splitList的准备了。但是，你还需记得本教程前面介绍中的讨论：当三角形需要分割时，你还需要分割它的邻近三角形和它的父三角形（见图2-19）。可以使用下面的方法做到这点，这个方法应该添加到Triangle类中：

  public void PropagateSplit(ref List<Triangle> splitList)
    ...{
      if (!splitted)
    ...    {
          splitted = true;
          splitList.Add(this);
            if (bNeigh != null)
              bNeigh.PropagateSplit(ref splitList);
          if (parent != null)
              parent.PropagateSplit(ref splitList);
      }
  }

这个方法将split变量设为true，将当前的三角形添加到splitList中，然后递归调用其底部三角形和父三角形。

因为递归，所以步骤CreateSplitList和ProcessSplits无法同时进行；否则，你会在newTriangleList中添加一个三角形，而因为这个三角形的底部三角形或子三角形要进行分割，导致这个三角形也会进行分割。这会导致这个三角形和它的子三角形都会被绘制。

注意：首先检查split变量是否为false，只要可能就将它设置为true是很重要的。否则，这个方法会一直循环下去，因为共享长边的两个三角形会一直互相调用这个方法。

现在，你终于做好了编写CreateSplitList方法的准备，它由程序主类中的UpdateTriangles方法调用：

  public void CreateSplitList(ref List<Triangle> splitList, ref List<Triangle> remainderList, ref Matrix wvp, ref BoundingFrustum bf)
    ...{
      bool hasSplit = false;
      if ((lChild != null) && (!splitted))
        ...{
          if (ShouldSplit(ref wvp, ref bf))
    ...        {
              PropagateSplit(ref splitList);
              hasSplit = true;
          }
      }
      if (!hasSplit)
          remainderList.Add(this);
  }

如果三角形还没有分割，可以被分割而且应该被分割，则调用它的PropagateSplit方法。否则将它添加到remainderList中。

处理splitList

下一步获取需要被分割的三角形的集合，编写ProcessSplitList方法很简单。每个需要分割的三角形需要将它的两个子三角形添加到newTriangleList中。但是，因为递归的缘故，可能一个或两个子三角形会被自己分割。这样的子三角形不应该被添加到newTriangleList中，因为当调用子三角形的ProcessSplitList方法时它会自己处理自己的子三角形。

在Triangle类中添加ProcessSplitList方法：

  public void ProcessSplitList(ref List<Triangle> toDrawList)
    ...{
      if (!rChild.splitted)
          toDrawList.Add(rChild);
      if (!lChild.splitted)
          toDrawList.Add(lChild);
  }
创建meigeList

接下来，在remainderList中的每个三角形需要检测它的父三角形是否需要合并。根据教程前面的介绍和图2-20，父三角形首先需要进行一些检测判断它是否可以被合并。这是由CanMerge方法决定的，要在Triangle类中添加这个方法：

  public bool CanMerge()
    ...{
      bool cannotMerge = false;
      if (lChild != null)
          cannotMerge |= lChild.splitted;
      if (rChild != null)
          cannotMerge |= rChild.splitted;
      if (bNeigh != null)
        ...{
          if (bNeigh.lChild != null)
              cannotMerge |= bNeigh.lChild.splitted;
          if (bNeigh.rChild != null)
              cannotMerge |= bNeigh.rChild.splitted;
      }
      return !cannotMerge;
  }

根据介绍中的讨论，当子三角形被分割或子三角形的底部三角形被分割时，父三角形不允许被合并。

下面的方法检查一个三角形是否可以合并：

  public bool CheckMerge(ref List<Triangle> mergeList, ref Matrix wvp, ref BoundingFrustum bf)
    ...{
      bool shouldMerge = false;
      if (!addedToMergeList)
    ...    {
          if (CanMerge())
            ...{
              if (!ShouldSplit(ref wvp, ref bf))
    ...            {
                  shouldMerge = true;
                  if (bNeigh != null)
                      if (bNeigh.ShouldSplit(ref wvp, ref bf))
                          shouldMerge = false;
              }
          }
      }
  }

因为实际上是子三角形决定父三角形是否可以合并，在迭代过程中这个方法会被调用两次。因此，shouldMerge变量用来表示三角形是否已经被添加到了mergeList中。

如果不是，你首先检测三角形是否可以合并，如果允许，检测它是否应该合并，换句话说，它是否不应该被分割。

最后，因为四舍五入的错误，有可能三角形决定要分割而底部三角形却没有，这会导致出现破裂。因此，你要确保两个当前三角形和它的底部三角形对合并的判断是相同的（见图2-20）。

如果三角形应该被合并，则执行下面的代码，应该把它放在CheckMerge方法的最后：

  if (shouldMerge)
    ...{
      addedToMergeList = true;
      mergeList.Add(this);
      if (bNeigh != null)
        ...{
          bNeigh.addedToMergeList = true;
          mergeList.Add(bNeigh);
      }
  }
  return addedToMergeList;

开始时将addedToMergeList变量设为true，这样当三角形被它的第二个子三角形调用时就不会重复执行这个方法。然后，将三角形添加到mergeList中。之后，如果三角形有底部邻近三角形，你还要将这个邻近三角形添加到集合中以防止产生破裂。你还要将它的addedToMergeList变量设为true，这样方法才不会重复进行。

在某些情况中，你已经将addedToMergeList设为true了，但仍需要在迭代的一开始将它重置为false。理想的地方是放在CreateSplitList方法中，因为这个方法被triangleList中的所有三角形调用，将这行代码添加到CreateSplitList方法的顶部：

addedToMergeList = false;

定义了两个方法后，很容易编写CreateMergeList的代码，它被主程序代码调用，将它添加到Triangle类中：

  public void CreateMergeList(ref List<Triangle> mergeList, ref List<Triangle> leftoverList, ref Matrix wvp, ref BoundingFrustum bf)
    ...{
      bool cannotMerge = true;
      if (parent != null)
          cannotMerge = !parent.CheckMerge(ref mergeList, ref wvp, ref bf);
      if (cannotMerge)
          leftoverList.Add(this);
  }

如果当前三角形有一个父，则调用它的CheckMerge方法。如果允许并且必须，父三角形和底部邻近三角形会被添加到MergeList中。如果不是，子三角形会被添加到leftoverList中。

处理mergeList

幸运的是，处理需要合并的父三角形要干的事不多，只需将它们的split变量重置为false，将它们添加到newTriangleList中：

  public void ProcessMergeList(ref List<Triangle> toDrawList, ref Matrix wvp, ref BoundingFrustum bf)
    ...{
      splitted = false;
      toDrawList.Add(this);
  }
处理leftoverList

处理leftoverList还要简单，因为只需将它们添加到newTriangleList中即可。但是，因为split的递归，可能会发生当前三角形同时在分割，所以首先要检查一下：

  public void ProcessLeftovers(ref List<Triangle> toDrawList)
    ...{
      if (!splitted)
          toDrawList.Add(this);
  }
更新索引

前面的部分实现了全部的ROAM功能，现在要绘制的三角形存储在triangleList中。你还需要获取它们的索引并将它们传递到显卡上的DynamicIndexBuffer中。在程序主类中添加一些代码：

  private void UpdateIndexBuffer()
    ...{
      indicesList.Clear();
      foreach (Triangle t in triangleList)
          t.AddIndices(ref indicesList);

      if (dynTerrainIndexBuffer.SizeInBytes / sizeof(int) < indicesList.Count)
    ...    {
          dynTerrainIndexBuffer.Dispose();
          dynTerrainIndexBuffer = new DynamicIndexBuffer(device, typeof(int), indicesList.Count, BufferUsage.WriteOnly);
      }
      dynTerrainIndexBuffer.SetData(indicesList.ToArray(), 0, indicesList.Count, SetDataOptions.Discard);
  }

第一行代码获取索引。然后，将它们传递到DynamicIndexBuffer中。但是，需要花点时间重新建立DynamicIndexBuffer。因此，首先检查当前缓冲是否可以容纳索引集合。只有当缓冲太小时才需要重新建立一个新的DynamicIndexBuffer。你将缓冲的大小除以一个整数值占据的大小知道这个缓冲可以容纳多少个整数。

注意：可参见教程5-5学习更多关于创建和使用DynamicIndexBuffer的细节。

使用方法

在Update方法中，确保调用UpdateTriangles方法更新triangleList，调用UpdateIndexBuffer方法将索引传递到显卡：

UpdateTriangles();
UpdateIndexBuffer();

这就是基本ROAM的实现方法。本教程余下的部分会教你如何使用正交投影矩阵调试三角形结构，还会讨论到性能。

正交投影矩阵

因为ROAM三角形比其他方法更加复杂，你需要一个强大的方法实时显示究竟发生了什么，想实现如图2-22所示的效果。

图2-22 绘制在ROAM地形之上的正交网格

在图2-22中你可以看到两样东西。首先是一个延伸到很远处的ROAM地形，靠近相机的山有一个漂亮的细节。第二，在其之上白色的三角形边框，它们是与绘制地形相同的三角形，但有两个不同：

  • 它们只绘制边框
  • 它们是从地形顶部观察的

看一下白色边框，你可以说出相机在地形的何处并看向何方：靠近相机的地方，绘制了大量的三角形，对应左下角的白色区域。在相机视野中的地形会随着离开相机的距离增大而减少细节。相机视野之外的地形会以尽可能少的三角形被绘制。

在本节中，你将学习如何绘制网格。因为你已经知道了三角形的索引，只需再次绘制同样的三角形即可，只不过使用一个不同的视矩阵和投影矩阵（见教程2-1）。

你只需简单地将相机的位置定义在地形中央的高处看向下方，但这样做不会获得图2-22中的效果。理由是当使用一个常规的投影矩阵时，靠近屏幕边缘的三角形会比靠近中央的三角形看起来小，这是由相机视锥体的金字塔形状导致的，如图2-23左图所示。这个投影导致物体靠近相机时会变大。在左图中的两点，靠近相机的那个点看起来会比视景体后部的那个点大。如果观察的是网格，一个常规的投影矩阵会导致靠近屏幕边缘的网格发生弯曲。

图2-23 常规相机视景体（左）和正交相机视景体（右）

你想要的视景体如图2-23的右图所示，叫做正交视景体。在这个视景体中相同大小的物体占据屏幕上的相同大小的区域：图2-23右图中的两个点在屏幕上看起来的大小是一样的。

创建视矩阵和投影矩阵

首先在代码顶部添加这两个矩阵：

Matrix orthoView;
Matrix orthoProj;

因为相机不会改变位置和朝向，你可以安全地在LoadContent方法中定义这两个矩阵无需改变它们：

orthoView = Matrix.CreateLookAt(new Vector3(terrainSize / 2, 100, -terrainSize / 2), new Vector3(terrainSize / 2, 0, -terrainSize / 2), Vector3.Forward);
orthoProj = Matrix.CreateOrthographic(terrainSize, terrainSize, 1, 1000);

因为实际上只改变相机的镜头，视矩阵定义与往常一样：你将相机放置在地形中央的上方，让它看向地形中央。因为你会使用一个正交投影矩阵，相机离开地形的高度无关紧要：三角形大小一样，与离开相机的位置无关。

如你所见，定义一个正交投影矩阵非常简单：只需将视景体的宽和高设置为World的大小，至于后面的两个值，你只需保证地形位于两者之间即可。

定义了两个矩阵后，准备好三角形和索引后，就可以绘制网格了。

绘制正交网格

因为绘制地形的三角形与绘制网格的三角形是一样的，你可以使用与绘制地形几乎一样的方法绘制网格，最重要的改变时你使用的是新建的视矩阵和投影矩阵。而且，将FillMode设置为WireFrame，这样你只需要绘制三角形的边框：

  private void DrawOrthoGrid()
    ...{
      int width = heightData.GetLength(0);
      int height = heightData.GetLength(1);

      device.RenderState.FillMode = FillMode.WireFrame;
      basicEffect.World = Matrix.Identity;
      basicEffect.View = orthoView;
      basicEffect.Projection = orthoProj;
      basicEffect.TextureEnabled = false;

      device.RenderState.AlphaBlendEnable = true;
      float color = 0.4f;
      device.RenderState.BlendFactor = new Color(new Vector4(color, color, color, color));
      device.RenderState.SourceBlend = Blend.BlendFactor;
      device.RenderState.DestinationBlend = Blend.InverseBlendFactor;

      basicEffect.Begin();
      foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    ...    {
          pass.Begin();
          device.Vertices[0].SetSource(terrainVertexBuffer, 0, VertexPositionNormalTexture.SizeInBytes);
          device.Indices = dynTerrainIndexBuffer;
          device.VertexDeclaration = myVertexDeclaration;
          int noTriangles = indicesList.Count / 3;
          device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, width * height, 0, noTriangles);
          pass.End();
      }
      basicEffect.End();
      device.RenderState.AlphaBlendEnable = false;
  }

因为白色容易干扰视线，你想与地形进行颜色混合。这里BlendFactor渲染状态非常有用：因为你不能定义每个顶点的alpha值（或者你不得不定义一个自定义VertexFormat和顶点着色器），只能对所有三角形使用固定的alpha值。BlendFactor接受一个颜色，每个颜色都会被BlendFactor颜色添加透明效果。别忘了在Draw方法中绘制了地形之后调用这个方法：

DrawOrthoGrid();

代码

所有的代码前面已经写过了。

扩展阅读

关于LOD地形算法可以写一本书。虽然前面的代码已经提供了一个完整的ROAM功能，我还是可以进行大量的改进。为了让你可以思考一些东西，我会简短的讨论一些明显的性能改进。

在前面的代码中，ROAM过程开始于Update方法。这意味着整个过程每秒执行60次，这太频繁了。这有在相机移动或旋转时才需要执行ROAM，大多数情况中，这样做会将计算开销降低到一个可以忍受的水平。

而且，ROAM过程最好分成不同的步骤。在第一个周期中，你可以创建splitList，在下一个周期中处理这个splitList等。复杂的过程（例如创建splitList）可以分成多个周期。这样可以让你指定每帧用于计算的最大时间，这可以让你将资源合理分配给ROAM。

最后，因为分割的原理，ROAM算法的每一步都可以分割成多个部分，可以在不同的线程中并行处理，这样算法就可以从多核处理器中获益。

这个方法也可以和四叉树组合起来，ROAM可以用来判断地形是否以高细节还是低细节绘制。


2.12 创建一个Post-Processing Framework

问题

你想在最终的图像上添加一个2D post-processing effect，诸如模糊，扭曲，摇晃，变焦，边缘检测等。

解决方案

首先将2D或3D场景绘制到屏幕，在Draw过程的最后，在后备缓冲的内容还没有发送到屏幕前，你需要将这个后备缓冲的内容存储在一张2D图像中，解释请见教程3-8。

然后，将这张2D图像绘制到屏幕上，但通过一个自定义的pixel shader实现，这也是本教程中有趣的部分。在pixel shader中，

你可以单独处理图像中的每个像素。你可以通过使用一个简单的SpriteBatch实现以上操作(见教程3-1)，但是SpriteBatch不支持多个pass的alpha混合(如下一个教程介绍的effect)。要解决这个问题并创建一个支持所有post-processing effect的框架，你需要手动定义覆盖整个屏幕的三角形，在这个三角形上施加最终的图像。通过这种方式，你可以使用任意的pixel shader处理最终图像的像素。

如果你想组合多个post-processing effect，你可以在每个effect之后将结果图像绘制到一个RenderTarget2D变量中而不是绘制到后备缓冲中。这样最后一个effect的最终结果才会被绘制到后备缓冲中。

工作原理

首先需要在程序中添加一些变量，这些变量包括ResolveTexture2D，它用来获取后备缓冲的内容(可见教程3-8)，RenderTarget2D，它用来对多个effects进行排列。你还需要一个effect文件保存post-processing technique(s)。

VertexPositionTexture[] ppVertices;
RenderTarget2D targetRenderedTo;
ResolveTexture2D resolveTexture;
Effect postProcessingEffect;
float time = 0;

因为你要定义两个三角形覆盖整个屏幕，所以需要定义顶点：

  private void InitPostProcessingVertices()
    ...{
      ppVertices = new VertexPositionTexture[4]; int i = 0;
      ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, 1, 0f), new Vector2(0, 0));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(1, 1, 0f), new Vector2(1, 0));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, -1, 0f), new Vector2(0, 1));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(1, -1, 0f), new Vector2(1, 1));
  }

这个方法定义了矩形的四个顶点，用来绘制TriangleStrip (可参见教程5-1)形式的两个三角形。请记住屏幕坐标的范围是[-1,1]，而纹理坐标的范围是[0,1]。

你可以看到刚才定义的位置就是屏幕坐标，点(-1,-1)对应窗口的左上角，点(1,1)对应右下角。你也可以指定纹理坐标的左上角(0,0)位于窗口的左上角(-1,-1)，纹理坐标的右下角(1,1)位于窗口右下角。如果将这个矩形绘制到这个屏幕中，图像就会覆盖整个窗口。

注意：因为窗口是2D的，在顶点的位置中可以无需第三个坐标。但是，对屏幕的每个像素，XNA会将距离相机的位置保存到深度缓冲中，这实际上就是第三个坐标。通过将这个距离指定为0，表示将图像绘制到尽可能离相机近的地方(更确切的说，将图像绘制在近裁平面上)。

别忘了在Initialize方法中调用这个方法：

InitPostProcessingVertices();

最后三个变量应在LoadContent方法中进行初始化：

PresentationParameters pp = GraphicsDevice.PresentationParameters;
targetRenderedTo = new RenderTarget2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format);
resolveTexture = new ResolveTexture2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format);
postProcessingEffect = content.Load<Effect>("content/postprocessing");

可参见教程3-8学习更多有关渲染目标的知识。这种情况中重要的是新的渲染目标和窗口的属性一样，它需要有相同的宽度，高度，颜色格式，这些都可以从图形设备的PresentationParameters结构中获取。通过这种方式，你可以很简单地获取纹理，然后对它进行post-process，将结果发送到屏幕，而无需做任何缩放和颜色映射的操作。因为你使用的是完全尺寸的纹理，无需任何mipmaps (可参见教程3-7的注释)。这意味着你只需一个mipmap level，就是纹理的原始大小。你还要加载包含post-processing technique(s)的effect文件。

加载了变量后，就可以开始下面的工作了。在与以往一样绘制了场景后，你想调用一个方法可以获取后备缓冲中的内容，然后对它进行处理，将结果发送到后备缓冲。这就是PostProcess方法要进行的操作：

  private void PostProcess()
    ...{
      device.ResolveBackBuffer(resolveTexture, 0);
      Texture2D textureRenderedTo = resolveTexture;
  }

第一行代码将后备缓冲中的当前内容转换到一个ResolveTexture2D，即本例中的resolveTexture变量。这个变量包含了要绘制到屏幕中的场景。你将这个变量存储为一个普通的Texture2D，叫做textureRenderedTo。

然后，使用post-processing effect将这个textureRenderedTo绘制到覆盖整个窗口的矩形中。在这个简单教程中，你将定义一个叫做Invent的effect，它可以将图像中的每个像素的颜色反相。

  postProcessingEffect.CurrentTechnique = postProcessingEffect.Techniques["Invert"];
  postProcessingEffect.Begin();
  postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo);
  foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes)
    ...{
       pass.Begin();
       device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
       device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2);
       pass.End();
  }
  postProcessingEffect.End();

你首先选择了用来将最终图像绘制到屏幕的post-processing technique，开始这个effect。

然后，将textureRenderedTo传递到显卡上，这样effect可以从中进行采样。最后，对post-processing technique的每个pass，你让显卡绘制覆盖整个屏幕的两个三角形。你需要编写effect的代码让两个三角形显示这个图像，以你选择的方式进行处理。

注意：当在3D世界中绘制物体时，你总要设置World, View和Projection矩阵。这些矩阵让显卡中的vertex shader将3D坐标映射到屏幕的对应像素上。但在这个例子中，你已经在屏幕空间中定义了两个三角形的位置，所以无需设置这些矩阵，因为现在vertex shader不会改变顶点的位置，只是简单地将它们传递到pixel shader中。

别忘了在调用Draw方法中调用这个方法：

PostProcess();

HLSL

只剩最后一步了：在HLSL中定义post-processing technique。不要担心，因为这里使用的HLSL非常简单。所以，打开一个新文件，命名为postprocessing. fx。

  texture textureToSampleFrom;
  sampler textureSampler = sampler_state
    ...{
      texture = <textureToSampleFrom>;
      magfilter = POINT;
      minfilter = POINT;
      mipfilter = POINT;
  };

  struct PPVertexToPixel
    ...{
      float4 Position : POSITION;
      float2 TexCoord    : TEXCOORD0;
  };

  struct PPPixelToFrame
    ...{
      float4 Color    : COLOR0;
  };

  PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0,float2 inTexCoord: TEXCOORD0)
    ...{
      PPVertexToPixel Output = (PPVertexToPixel)0;
      Output.Position = inPos;
      Output.TexCoord = inTexCoord;
      return Output;
  }

  //    PP Technique: Invert
  PPPixelToFrame InvertPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      float4 colorFromTexture = tex2D(textureSampler, PSIn.TexCoord);
      Output.Color = 1-colorFromTexture;

      return Output;
  }

  technique Invert
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_1_1 InvertPS();
      }
  }

这个代码还可以再短一点，但我想和前面教程中的HLSL代码的结构保持一致。在technique 定义的底部，表示的是technique的名称和使用的vertex shader和pixel shader。在它之上是vertex shader和pixel shader，在代码顶部是可以从XNA程序中设置的变量。对这个简单例子，你只需设置从后备缓冲获取的2D图像。

然后，在显卡中创建一个纹理采样器，这也是后面的pixel shader从中进行颜色采样的变量。你将这个采样器连接到刚才定义的纹理，然后声明如果代码要求的一个坐标的颜色不是100%对应一个像素时应该进行的操作，这里你指定采样器提取最近像素的颜色。

注意：纹理坐标是一个float2，X和Y值在0和1之间。因为这些数字是floats，对它们的任何运算几乎都会导致一个四舍五入的误差。这意味着当你使用这样一个坐标从纹理采样时，大多数纹理坐标不会精确地对应纹理上的一个像素，但会非常接近。这就是为什么你需要指定纹理采样器应该怎样做的原因。

然后，你定义了两个结构：一个保存从vertex shader发送到pixel shader的信息。这个信息只包含屏幕坐标和纹理到哪采样获取像素的颜色。第二个结构保存pixel shader输出。对每个像素，pixel shader只需要计算颜色。

vertex shader让你处理发送到显卡的每个顶点的数据。3D程序中vertex shader最重要的任务之一就是将3D坐标转换为2D屏幕坐标。在post-processing effects的情况中，vertex shader并不真正有用，因为你已经定义了两个三角形的顶点的屏幕坐标！所以，你只需让vertex shader将输入的位置传递到输出就可以了。

然后，在pixel shader中才是post-processing effect的处理。对绘制到屏幕的每个像素，调用这个方法，让你可以改变像素的颜色。在pixel shader中，首先创建一个空的叫做Outputde 输出结构, 然后，这个颜色从textureSampler进行采样。如果pixel shader只是简单地输出这个颜色，那么输出的图像与原始图像是一样的，因为窗口每个像素都是从原始图像的原始位置采样它的颜色的。所以你想改变采样的坐标或从原始图像获取的颜色，这会在下一段中进行这个操作。

colorFromTexture变量包含四个介于0和1之间的值(红，绿，蓝和alpha)。本例中，通过从1减去这些值将它们反相。将这个反相过的颜色保存到Output结构中并返回。

当运行代码时，场景会被保存到textureRenderedTo纹理中，每个像素的颜色会在绘制到屏幕前被反相。

多个Post-Processing Effects队列

再加一些代码让你可以处理多个post-processing effects队列。在Draw方法中，你将创建一个集合包含要施加的post-processing techniques，然后将这个集合传递到PostProcess方法中：

List<string> ppEffectsList = new List<string>();
ppEffectsList.Add("Invert");
ppEffectsList.Add("Invert");
PostProcess(ppEffectsList);

现在你只定义了一个Invert technique，所以这个简单例子中你使用了这个technique 两次。通过反相一个反相过的图像，结果是再次获得了原始图像，这有什么令人激动的？

你要调整PostProcess方法让它接受effect集合作为参数。如你所见，这个方法的开始部分被扩展为可以处理多个 Post-Processing Effects：

public void PostProcess(List<string> ppEffectsList)
    ...{
      for (int currentTechnique = 0; currentTechnique < ppEffectsList.Count; currentTechnique++)
    ...    {
          device.SetRenderTarget(0, null);
          Texture2D textureRenderedTo;

          if (currentTechnique == 0)
    ...        {
              device.ResolveBackBuffer(resolveTexture, 0);
              textureRenderedTo = resolveTexture;
          }
         else
    ...       {
             textureRenderedTo = targetRenderedTo.GetTexture();
         }

         if (currentTechnique == ppEffectsList.Count - 1)
             device.SetRenderTarget(0, null);
         else
             device.SetRenderTarget(0, targetRenderedTo);

          postProcessingEffect.CurrentTechnique= postProcessingEffect.Techniques[ppEffectsList[currentTechnique]];

          postProcessingEffect.Begin();
          postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo);

          foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes)
            ...{
              pass.Begin();
              device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
              device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2);
              pass.End();
              postProcessingEffect.End();
          }
      }
  }

这个方法的思路如图2-14所示。对集合中的每个effect，你将渲染目标中的内容保存到一张纹理中，然后使用当前的effect再次将它绘制到渲染目标中。这个规则有两个例外。

首先，对第一个effect，获取后备缓冲中的内容，而不是RenderTarget的内容。最后，对最后一个effect，将结果绘制到后备缓冲，这样它会被绘制到屏幕。这个过程如图2-14所示。

图2-14 多个post-processing effects队列

前面的代码显示了工作流程。如果是第一个technique，则将后备缓冲中的内容存储到textureRenderedTo，否则，将渲染目标的内容存储到textureRenderedTo。无论哪种方式， textureRenderTo都会包含最终要绘制的内容。如教程3-8的解释，在调用RenderTarget 的GetTexture前，你必须激活另一个渲染目标，这是由这个方法的第一行代码实现的。

然后检查当前technique是否是集合中的最后一个，如果是，通过在device. SetRenderTarget方法中传递null(你也可以不使用这行代码，因为在方法顶部已经做了这个操作)将后备缓冲设置为当前渲染目标。否则，将自定义的渲染目标作为当前渲染目标。

代码的其他部分保持不变。

作为post-processing technique的第二个简单例子，你可以根据时间改变颜色值。将这个代码添加到. fx文件的顶部：

float xTime;

这个变量可以在XNA程序中设置，在HLSL代码中读取。将这行代码添加到. fx文件的最后：

  //    PP Technique: TimeChange
  PPPixelToFrame TimeChangePS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      Output.Color = tex2D(textureSampler, PSIn.TexCoord);
      Output.Color.b *= sin(xTime);
      Output.Color.rg *= cos(xTime);
      Output.Color += 0.2f;

      return Output;
  }

  technique TimeChange
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 TimeChangePS();
      }
  }

对图像的每个像素，蓝色通道会乘以由xTime变量决定的正弦值，红色和绿色乘以余弦值。记住，正弦和余弦产生一个介于–1和+1之间的波形，颜色通道的负值会被截取到0。

使用这个technique绘制最终图像：

List<string> ppEffectsList = new List<string>();
ppEffectsList.Add("Invert");
ppEffectsList.Add("TimeChange");
postProcessingEffect.Parameters["xTime"].SetValue(time);
PostProcess(ppEffectsList);

注意你将xTime变量设置为time，需要在XNA代码中指定这个time变量：

float time;

在Update方法中更新变量：

time += gameTime.ElapsedGameTime.Milliseconds / 1000.0f;

当运行代码时，你会看到图像的颜色会随时间发生变化。还不是很漂亮，但是你可以只基于它们的原始颜色改变像素的颜色。在下一个教程中，还要考虑像素周围的颜色决定最终颜色。

代码

下面的代码定义顶点，这些顶点构成矩形用来显示最终图像：

  private void InitPostProcessingVertices()
    ...{
      ppVertices = new VertexPositionTexture[4];
      int i = 0;
      ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, 1, 0f), new Vector2(0, 0));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(1, 1, 0f), new Vector2(1, 0));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(-1, -1, 0f), new Vector2(0, 1));
      ppVertices[i++] = new VertexPositionTexture(new Vector3(1, -1, 0f), new Vector2(1, 1));
  }

在Draw方法中，你想往常一样绘制场景。在绘制之后，定义使用哪个post-processing effects，并将集合传递到PostProcess方法中：

  protected override void Draw(GameTime gameTime)
    ...{
      device.Clear(ClearOptions.Target|ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

      //draw model
      Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) * Matrix.CreateTranslation(0, 0, 0); myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
      foreach (ModelMesh mesh in myModel.Meshes)
    ...    {
          foreach (BasicEffect effect in mesh.Effects)
    ...        {
              effect.EnableDefaultLighting();
              effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
              effect.View = fpsCam.ViewMatrix;
              effect.Projection = fpsCam.ProjectionMatrix;
          }
          mesh.Draw();
      }

      //draw coordcross
      cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);

      List<string> ppEffectsList = new List<string>();
      ppEffectsList.Add("Invert");
      ppEffectsList.Add("TimeChange");
      postProcessingEffect.Parameters["xTime"].SetValue(time);
      PostProcess(ppEffectsList);

      base.Draw(gameTime);
  }

在Draw方法的最后，调用PostProcess方法，这个方法获取后备缓冲， 使用一个或多个post-processing effects 将图像绘制到屏幕中：

  public void PostProcess(List<string> ppEffectsList)
    ...{
      for (int currentTechnique = 0; currentTechnique < ppEffectsList.Count; currentTechnique++)
        ...{
          device.SetRenderTarget(0, null);
          Texture2D textureRenderedTo;

          if (currentTechnique == 0)
    ...        {
              device.ResolveBackBuffer(resolveTexture, 0);
              textureRenderedTo = resolveTexture;
           }
           else
    ...         {
               textureRenderedTo = targetRenderedTo.GetTexture();
            }

            if (currentTechnique == ppEffectsList.Count - 1)
                device.SetRenderTarget(0, null);
            else
                device.SetRenderTarget(0, targetRenderedTo);

            postProcessingEffect.CurrentTechnique = postProcessingEffect.Techniques[ppEffectsList[currentTechnique]];
            postProcessingEffect.Begin();
            postProcessingEffect.Parameters["textureToSampleFrom"]. SetValue(textureRenderedTo);

            foreach (EffectPass pass in postProcessingEffect.CurrentTechnique.Passes)
    ...          {
  pass.Begin();
  device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
  device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, ppVertices, 0, 2);
  pass.End();
  }
  postProcessingEffect.End();
  }
  }

在HLSL文件中，确保将纹理采样器连接到textureToSampleFrom变量上：

  float xTime;

  texture textureToSampleFrom;
  sampler textureSampler = sampler_state
    ...{
      texture = <textureToSampleFrom>;
      magfilter = POINT;
      minfilter = POINT;
      mipfilter = POINT;
  }

  struct PPVertexToPixel
    ...{
      float4 Position : POSITION;
      float2 TexCoord    : TEXCOORD0;
  };

  struct PPPixelToFrame
    ...{
       float4 Color    : COLOR0;
  };

  PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
    ...{
       PPVertexToPixel Output = (PPVertexToPixel)0;
      Output.Position = inPos;
      Output.TexCoord = inTexCoord;
      return Output;
  }

  //    PP Technique: Invert
  PPPixelToFrame InvertPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      float4 colorFromTexture = tex2D(textureSampler, PSIn.TexCoord);
      Output.Color = 1-colorFromTexture;

      return Output;
  }

  technique Invert
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_1_1 InvertPS();
      }
  }

  //    PP Technique: TimeChange
  PPPixelToFrame TimeChangePS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      Output.Color = tex2D(textureSampler, PSIn.TexCoord);
      Output.Color.b *= sin(xTime);
      Output.Color.rg *= cos(xTime);
      Output.Color += 0.2f;

      return Output;
  }

  technique TimeChange
    ...{
      pass Pass0
        ...{
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 TimeChangePS();
      }
  }


2.13 创建一个模糊（Blur），（发光）Glow Post-Processing Effect

问题

你想在最终图像上添加模糊效果，或给场景中的物体添加光泽。

解决方案

这两个effect都是post-processing effect。可参见前面的教程学习如何建立一个post-processing框架。

你可以通过计算每个像素和一些周围像素的颜色值平均值并用这些值替换像素的原始颜色实现模糊效果。例如，如果图2-25中的格子是一张需要模糊的图像，你该如何计算像素9的最终颜色呢？

图2-25 计算2D纹理中的像素颜色的平均值

你可以通过将9个数字相加并除以9得到这9个数字的平均数，计算颜色平均值的方法是一样的：将2-4像素、8 到10像素、14到16像素的颜色相加并除以像素的数量：这里是9。

如果除了像素9之外其他像素的颜色都是白色的，如图2-16左图的情况，这会导致像素9的颜色扩展到像素2到4，8到10和14到16，使像素9“扩散”了。但是，像素9本身的颜色变暗了，因为它也被周围颜色的平均值替换了，如图2-26中图所示。要获取一个发光效果，你要混合原始图像，在这个图像的某处存储了像素9的原始颜色。所以，最后像素9拥有自己的原始颜色，而像素9周围的像素拥有一点像素9的颜色，就好像像素9有了一个发光效果，如图2-26的右图所示。

图2-26 创建一个发光(glow)效果

工作原理

下面是一些说明。要获取一个漂亮的模糊效果，你需要对中心像素周围的大量像素取平均值。更好的方法是首先通过对中心像素同一行中的一些像素取平均值进行水平方向的模糊，然后，通过对中心像素同一列的一些像素取平均值对刚才得到的模糊进行竖直方向的模糊。这会获得两个1D的平均值而不是2D，而2D的平均值要处理多得多的像素才能获得漂亮的结果。

第二，你需要判断对哪些像素进行平均。要获得最好的结果，你需要给靠近中心像素的像素更多的“关注”，而远处的像素“关注”较少，这叫做施加更多的“权重（weight）”。为了你的方便，我已经计算了对应高斯模糊的一些偏移量和权重，高斯模糊对应通常情况下的模糊。

看一下下面的数组，它包含距离中心像素的距离信息。第一个数据为0偏移量，表示中心像素本身。第二个数据为0.005偏移量，对应距离中心像素非常近的采样像素。要保持对称，你需要采样距离中心像素左右两侧0.005偏移量的两个像素，你给这两个像素颜色都施加了一个0.102大小的权重，这可以在第二个数组中看到。然后，你采样距离中心像素左右距离为0.0117的两个像素，施加一个较小的0.0936的权重。以此类推直至距离中心像素较远的像素 ，而且权重依次减少。

  float positions[] =
    ...{
      0.0f,
      0.005,
      0.01166667,
      0.01833333,
      0.025,
      0.03166667,
      0.03833333,
      0.045,
  };

  float weights[] =
    ...{
      0.0530577,
      0.1028506,
      0.09364651,
      0.0801001,
      0.06436224,
      0.04858317,
      0.03445063,
      0.02294906,
  };

如果你将所有点的权重相加，结果为1，这样才不会让图像中的颜色损失或增加。

注意：相对于其他像素，中央像素的权重看起来较小，但是，因为+0和-0是相同的，中央像素会被处理两次，使它的权重加倍，导致在最终结果中它的影响最大。

你还要用到一个xBlurSize变量，可以从XNA程序中调整模糊效果的宽度：

  float xBlurSize = 0.5f;

你已经基于上一个教程创建了一个空的HorBlur effect：

  //    PP Technique: HorBlur
  PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      return Output;
  }

  technique HorBlur
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 DefaultVertexShader();
          PixelShader = compile ps_2_0 HorBlurPS();
      }
  }

实际的effect定义在pixel shader中。对每个前面的数组中定义的周围像素，你需要采样颜色并乘以它的权重。基于对称，操作是从中央像素两侧进行的。最后，将所有颜色相加。

从中央像素的TexCoord加上来自于positions数组中的值（这个值作为水平纹理坐标）可以获取周围像素的位置：

  PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      for (int i = 0; i < 8; i++)
    ...    {
          float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(positions[i], 0)*xBlurSize); samplePos *= weights[i];
          float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord -float2(positions[i], 0)*xBlurSize); sampleNeg *= weights[i];
          Output.Color += samplePos + sampleNeg;
      }
      return Output;
  }

你可以看到xBlurSize变量用来增加/减少中心像素和周围像素的距离。

现在在XNA程序中，你只需简单地给这个变量赋一个值并开启这个effect：

  List<string> ppEffectsList = new List<string>();
  ppEffectsList.Add("HorBlur");
  postProcessor.Parameters["xBlurSize"].SetValue(0.5f);
  postProcessor.PostProcess(ppEffectsList);

垂直模糊

有了水平模糊，垂直模糊也很容易创建。现在不是将positions数组中的值添加到水平纹理坐标，而是添加到竖直坐标，选择同一列的像素作为处理的像素：

  //    PP Technique: VerBlur
  PPPixelToFrame VerBlurPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      for (int i = 0; i < 8; i++)
    ...    {
          float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(0, positions[i])*xBlurSize);
         samplePos *= weights[i];
         float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(0, positions[i])*xBlurSize);
         sampleNeg *= weights[i];
        Output.Color += samplePos + sampleNeg;
      }

      return Output;
  }

  technique VerBlur
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 VerBlurPS();
      }
  }

组合水平和垂直模糊你可以获得一个漂亮的模糊图像：

  List<string> ppEffectsList = new List<string>();
  ppEffectsList.Add("HorBlur");
  ppEffectsList.Add("VerBlur");

  postProcessor.Parameters["xBlurSize"].SetValue(0.5f);
  postProcessor.PostProcess(ppEffectsList);

发光效果

如教程介绍中解释的那样，你可以通过将模糊过的图像与原始图像混合获取发光效果，通过这个方法，原始的轮廓会锐化。

这需要后备缓冲中存在模糊过的图像，这样在第二个pass中，你可以与原始图像进行混合。所以，你需要定义一个新technique，这个technique将垂直模糊作为第一个pass， blend-in作为第二个pass：

  technique VerBlurAndGlow
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 VerBlurPS();
      }

      pass Pass1
    ...    {
          AlphaBlendEnable = true;
          SrcBlend = SrcAlpha;
          DestBlend = InvSrcAlpha;
          PixelShader = compile ps_2_0 BlendInPS();
      }
  }

第二个pass是新的。你可以看到在pass开始时改变了三个渲染状态：开启alpha混合和设置alpha函数。马上你就会学到这个alpha函数。Effect由第一个pass开始，这样模糊图像会保存在后备缓冲中。然后，开始第二个pass，让你将原始图像混合到后备缓冲中的图像。

下面是第二个pass的pixel shader：

  //    PP Technique: VerBlurAndGlow
  PPPixelToFrame BlendInPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      float4 finalColor = tex2D(originalSampler, PSIn.TexCoord);
      finalColor.a = 0.3f;

      Output.Color = finalColor;

      return Output;
  }

这里，你采样原始图像中像素的颜色并调整它的alpha(透明度)值。当在XNA进行混合时，使用如下规则：

finalColor=sourceBlend*sourceColor+destBlend*destColor

在technique定义中，你将sourceBlend设置为SourceAlpha。这意味着sourceBlend等于你要混合的颜色的alpha值，这里是0.3f，是你在前面的代码中定义的。而且，你将destBlend设置为InvSourceAlpha，意思是1–SourceAlpha，所以destBlend为1–0.3f = 0.7f。

总的来说，最终颜色的70%会取自后备缓冲中的颜色(即第一个pass存储的模糊图像)，剩下的30%取自你想写入到后备缓冲中的新颜色，本例中是原始图像。

你还没有定义originalSampler，在effect文件的顶部添加它：

  texture originalImage;
  sampler originalSampler = sampler_state
    ...{
      texture = <originalImage>;
      magfilter = LINEAR;
      minfilter = LINEAR;
      mipfilter = LINEAR;
  };

在XNA项目中，你需要将后备缓冲中获取的原始图像存储到originalImage变量中。幸运的是，使用前一个教程的框架，在第一个effect中很容易做到：

  if (currentTechnique == 0)
    ...{
      device.ResolveBackBuffer(resolveTexture, 0);
      textureRenderedTo = resolveTexture;
      ppEffect.Parameters["originalImage"].SetValue(textureRenderedTo);
  }

最后一行代码将原始图像发送到HLSL effect中。

现在所有effect准备就绪，代码会首先对图像进行垂直模糊，之后，通过VerBlurAndGlow effect的第一个pass对结果进行水平模糊，在第一个pass之后，模糊过的图像保存在帧缓冲中，让你可以混合到原始图像中！

  List<string> ppEffectsList = new List<string>();
  ppEffectsList.Add("HorBlur");
  ppEffectsList.Add("VerBlurAndGlow");

  postProcessor.Parameters["xBlurSize"].SetValue(0.5f);
  postProcessor.PostProcess(ppEffectsList);

代码

为了能够处理所有的post-processing effects，你需要将纹理采样器stage绑定到包含场景的图像上。发光效果还需要在originalImage变量中存储原始图像。

  float xTime;
  float xBlurSize = 0.5f;

  texture textureToSampleFrom;
  sampler textureSampler = sampler_state
    ...{
      texture = <textureToSampleFrom>;
      magfilter = LINEAR;
      minfilter = LINEAR;
      mipfilter = LINEAR;
  }

  texture originalImage;
  sampler originalSampler = sampler_state
    ...{
      texture = <originalImage>;
      magfilter = LINEAR;
      minfilter = LINEAR;
      mipfilter = LINEAR;
  };

  float positions[] =
    ...{
      0.0f,
      0.005,
      0.01166667,
      0.01833333,
      0.025,
      0.03166667,
      0.03833333,
      0.045,
  };

  float weights[] =
    ...{
      0.0530577,
      0.1028506,
      0.09364651,
      0.0801001,
      0.06436224,
      0.04858317,
      0.03445063,
      0.02294906,
  };

  struct PPVertexToPixel
    ...{
      float4 Position : POSITION;
      float2 TexCoord    : TEXCOORD0;
  };

  struct PPPixelToFrame
    ...{
      float4 Color    : COLOR0;
  };

  PPVertexToPixel PassThroughVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
    ...{
      PPVertexToPixel Output = (PPVertexToPixel)0;
      Output.Position = inPos;
      Output.TexCoord = inTexCoord;

      return Output;
  }

  //    PP Technique: HorBlur
  PPPixelToFrame HorBlurPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      for (int i = 0; i < 8; i++)
        ...{
          float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(positions[i], 0)*xBlurSize);
          samplePos *= weights[i];
          float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(positions[i], 0)*xBlurSize);
          sampleNeg *= weights[i];
          Output.Color += samplePos + sampleNeg;
      }
      return Output;
  }

  technique HorBlur
    ...{
      pass Pass0
        ...{
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 HorBlurPS();
      }
  }

  //    PP Technique: VerBlur
  PPPixelToFrame VerBlurPS(PPVertexToPixel PSIn) : COLOR0
    ...{
      PPPixelToFrame Output = (PPPixelToFrame)0;

      for (int i = 0; i < 8; i++)
    ...    {
          float4 samplePos = tex2D(textureSampler, PSIn.TexCoord + float2(0, positions[i])*xBlurSize);
          samplePos *= weights[i];
          float4 sampleNeg = tex2D(textureSampler, PSIn.TexCoord - float2(0, positions[i])*xBlurSize);

          sampleNeg *= weights[i];
          Output.Color += samplePos + sampleNeg;
      }
      return Output;
  }

  technique VerBlur
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 VerBlurPS();
      }

      //    PP Technique: VerBlurAndGlow
      PPPixelToFrame BlendInPS(PPVertexToPixel PSIn) : COLOR0
    ...    {
          PPPixelToFrame Output = (PPPixelToFrame)0;

          float4 finalColor = tex2D(originalSampler, PSIn.TexCoord);
          finalColor.a = 0.3f;

          Output.Color = finalColor;
          return Output;
      }

  technique VerBlurAndGlow
    ...{
      pass Pass0
    ...    {
          VertexShader = compile vs_1_1 PassThroughVertexShader();
          PixelShader = compile ps_2_0 VerBlurPS();
      }
      pass Pass1
    ...    {
          AlphaBlendEnable = true;
          SrcBlend = SrcAlpha;
          DestBlend = InvSrcAlpha;
          PixelShader = compile ps_2_0 BlendInPS();
      }
  }

扩展阅读

每一个完工的游戏都包含一组post-processing effects，因为它们可以在最终图像上施加额外的处理。模糊效果相对来说是简单的，一些模糊或发光常用来掩盖3D物体边缘的锯齿。

post-processing effects牵涉的东西很多，但通过使用多个pass和alpha混合的发光效果，你可以知道基本的原理。


2.14 编写自定义内容导入器

问题

你想通过内容管道将一个自定义格式的文件导入到XNA项目中。因为你的文件的扩展名和/或结构与XNA支持的文件格式不同，默认XNA内容管道无法知道如何导入并处理你的文件。

解决方案

你需要编写一个自定义内容导入器，它可以从磁盘中读取文件，将有用的数据格式化为一个对象，做好被内容处理器处理的准备。因此，这个教程假设你知道如何创建自己的内容处理器，可以参见教程3-9了解内容管道的知识。

这个教程只关心自定义导入器。ContentIonmporter需要从磁盘读取数据并将它格式化为一个对象。本教程中，你需要读取存储在一个由逗号分隔值的文件（common-separated values，简称CSV文件）中的两个Vector3并将它们转换为一个矩阵。选择这个例子的理由是因为一个View矩阵需要经过一些计算才能被构建，XNA有一个默认的TypeWriter和TypeReader可以串行化和反串行化Matrix对象，这样本教程就可以只关心从磁盘加载文件，编写内容导入器，从自定义内容处理器获取数据。

看一下图2-27。自定义导入器读取一个CSV文件的数据并创建一个CSVImporterToProcessor对象存储从文件中获取的有用数据。自定义CSVMatrixProcessor处理CSVImporterToProcessor对象中的数据并保存到一个Matrix对象。然后，XNA默认的处理Matrix的TypeWriter和TypeReader会进行串行化和反串行化操作。可参见教程4-15和4-16学习如何编写自己的TypeWriter和TypeReader。

因为导入器和处理器都在编译时调用，你可能想知道将导入器和处理器分开处理的好处。例如，为什么不能更加简单地只定义一个方法读取CSV文件立即将它存储在Matrix对象中，而不是编写两个方法和一个中间CSVMatrixProcessor对象？

将处理过程分开的优点是你可以为多个导入器重用处理器（反之亦然）。例如，XNA框架可以将一个NodeContent对象转换为一个ModelContent对象。如果你从一个不是.x或.fbx格式的文件中导入一个模型，你要做的就是编写一个导入器为你的文件创建一个NodeContent对象，之后就可以使用默认的ModelProcessor了。

图2-27 定义一个内容导入器和它在内容管道中的位置

工作原理

首先使用一个文本编辑器创建一个CSV，下面是我的CSV文件：

-5;5;8;
0;2;0;

这些是将在教程中构建的视矩阵的位置的观察目标。

中间CSVImporterToProcessor类

接着，看一下教程3-9中的步骤清单。我调用自己的内容管道项目CSVToViewMatrixPlpeline。在教程3-9的第4步中不进行任何操作；你需要编写一些额外的方法。在本教程的最后，你可以看到内容管道的所有代码。

首先，你需要一个对象用来存储从磁盘中读取的数据。这个对象作为导入器的输出和处理器的输入（这会使用它的内容构建一个视矩阵）。添加一个自定义CSVImporterToProcessor类，它存储两个Vectors：

  public class CSVImporterToProcessor
    ...{
      private Vector3 position;
      private Vector3 target;
        public Vector3 Position ...{ get ...{ return position; } }
        public Vector3 Target ...{ get ...{ return target; } }

      public CSVImporterToProcessor(Vector3 position, Vector3 target)
        ...{
          this.position = position;
          this.target = target;
      }
  }

这个类可以存储两个Vectors3。两个getter方法让处理器可以访问这个数据。当调用构造函数时，导入器需要提供所有数据。

ContentImporter

ContentImporter用来从文件中读取数据，所以确保在文件的顶部已经添加了包含它们的命名空间（解释请见教程3-9的第3步）：

using System.IO;

做好了编写自定义导入器的准备，添加以下代码：

  [ContentImporter(".csv", DefaultProcessor = "CSVMatrixProcessor")]
  public class CSVImporter : ContentImporter<CSVImporterToProcessor>
    ...    {
          public override CSVImporterToProcessor Import(string filename, ContentImporterContext context)
    ...        {
          }
  }

第一行的属性表示这个类可以处理.csv文件，它的输出默认被CSVMatrixProcessor处理（下面就要创建）。在下面的代码中，你指定这个导入器会生成一个CSVImporterToProcessor对象。

在编译时，导入器会导入一个.csv文件并将它转换为一个CSVImporterToProcessor对象。首先打开文件读取第一行，这是由下面代码中的前两行中进行的。要分割;之间的值，你需要使用一个简单的Split方法，这会返回一个三个字符串的数组，每个字符串包含相机位置的数字，接下去的三行代码将这三个字符串转换为浮点数，用来创建最后的位置Vector3。

  StreamReader file = new StreamReader(filename);

  string line = file.ReadLine();
  string[] lineData = line.Split(';');
  float x = float.Parse(lineData[0]);
  float y = float.Parse(lineData[1]);
  float z = float.Parse(lineData[2]);
  Vector3 position = new Vector3(x,y,z);

从文件中读取第二行，用同样的方法并将它转换为一个观察目标Vector3：

  line = file.ReadLine();
  lineData = line.Split(';');
  x = float.Parse(lineData[0]);
  y = float.Parse(lineData[1]);
  z = float.Parse(lineData[2]);
  Vector3 target = new Vector3(x,y,z);

现在，有了数据就可以创建一个CSVImporterToProcessor对象了：

CSVImporterToProcessor finalData = new CSVImporterToProcessor(position,target);
return finalData;

这个对象被发送到用户选择的处理器。显然，处理器应该可以将CSVImporterToProcessor对象作为输入。

在内容处理器中接受数据

因为CSVImporterToProcessor类是自定义类，你需要创建一个自定义处理器。这个处理器会将CSVImporterToProcessor对象转换为一个Matrix对象。看一下本章第一个教程了解如何根据相机的位置和观察目标构建一个视矩阵：

  [ContentProcessor]
  public class CSVMatrixProcessor : ContentProcessor<CSVImporterToProcessor, Matrix>
    ...{
      public override Matrix Process(CSVImporterToProcessor input, ContentProcessorContext context)
    ...    {
          Vector3 up = new Vector3(0, 1, 0);
          Vector3 forward = input.Target - input.Position;
          Vector3 right = Vector3.Cross(forward, up);
          up = Vector3.Cross(right, forward);

          Matrix viewMatrix = Matrix.CreateLookAt(input.Position, input.Target, up);
          return viewMatrix;
      }
  }

你声明这个处理器可以将一个CSVImporterToProcessor对象转换为一个Matrix对象。CSVImporterToProcessor对象中的position和target用来创建视矩阵。XNA知道如何从二进制文件串行化/反串行化一个Matrix对象，并将Matrix对象加载到XNA项目中，所以你无需编写自定义的TypeWriter和TypeReader。

使用方法

当你确认已经完成了教程3-9中的9个步骤后，你就可以将.csv文件导入到项目中去了。当在解决方案资源管理器中选择一个.csv文件时，你应该可以在属性窗口中注意到这个文件由CSVImporter导入，如图2-28所示，因为你将CSVImporter声明为默认导入器，CSVMatrixProcessor作为处理器。

图2-28 选择内容导入器和处理器

导入.csv文件后，你要做的就是使用下面的代码从.csv文件中加载一个视矩阵：

protected override void LoadContent()
{
    viewMatrix = Content.Load<Matrix>("camerasettings");
}

代码

因为内容管道很短，我把它放在了一起。第一个代码块是命名空间。命名空间中的第一个类是CSVImporterToProcessor类，它可以存储所有数据。接下来是ContentImporter类，它可以从一个.csv文件中读取并存储CSVImporterToProcessor对象中的数据集。最后的代码块是内容处理器，可以基于CSVImporterToProcessor对象中的内容构建一个视矩阵。因为XNA自带可以处理Matrix对象的TypeWriter和TypeReader，所以上面的代码就是一个完整功能的内容管道了。如果你的处理器创建了一个自定义类对象，可参见教程4-15和4-16学习如何创建自己的TypeWriter和TypeReader。

  using System;
  using System.Collections.Generic;
  using System.Linq;
  using Microsoft.Xna.Framework;
  using Microsoft.Xna.Framework.Graphics;
  using Microsoft.Xna.Framework.Content.Pipeline;
  using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
  using Microsoft.Xna.Framework.Content.Pipeline.Processors;
  using System.IO;
  using Microsoft.Xna.Framework.Content;
  using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;

  namespace CVSToViewMatrixPipeline
    ...{
      public class CSVImporterToProcessor
    ...    {
          private Vector3 position;
          private Vector3 target;

            public Vector3 Position ...{ get ...{ return position; } }
            public Vector3 Target ...{ get ...{ return target; } }

          public CSVImporterToProcessor(Vector3 position, Vector3 target)
    ...        {
              this.position = position;
              this.target = target;
          }
      }

      [ContentImporter(".csv", DefaultProcessor = "CSVMatrixProcessor")]
      public class CSVImporter : ContentImporter<CSVImporterToProcessor>
    ...    {
          public override CSVImporterToProcessor Import(string filename, ContentImporterContext context)
    ...        {
              StreamReader file = new StreamReader(filename);

              string line = file.ReadLine();
              string[] lineData = line.Split(';');
              float x = float.Parse(lineData[0]);
              float y = float.Parse(lineData[1]);
              float z = float.Parse(lineData[2]);
              Vector3 position = new Vector3(x,y,z);

              line = file.ReadLine();
              lineData = line.Split(';');
              x = float.Parse(lineData[0]);
              y = float.Parse(lineData[1]);
              z = float.Parse(lineData[2]);
              Vector3 target = new Vector3(x,y,z);

              CSVImporterToProcessor finalData = new CSVImporterToProcessor(position, target);
              return finalData;
          }
      }

      [ContentProcessor]
      public class CSVMatrixProcessor : ContentProcessor<CSVImporterToProcessor, Matrix>
        ...{
          public override Matrix Process(CSVImporterToProcessor input, ContentProcessorContext context)
            ...{
              Vector3 up = new Vector3(0, 1, 0);
              Vector3 forward = input.Target - input.Position;
              Vector3 right = Vector3.Cross(forward, up);
              up = Vector3.Cross(right, forward);

              Matrix viewMatrix = Matrix.CreateLookAt(input.Position, input.Target, up);
              return viewMatrix;
          }
      }
  }


3.0 第三章概述

无论游戏是2D还是3D的，总要使用大量的图像。一个2D游戏需要背景图像和角色图像，3D游戏需要图像覆盖在三角形上构成物体。

还有许多场合需要用到图像。给用户提供菜单，将游戏截图保存到磁盘只是其中的两个例子。特殊的图像还可以作为高级效果的输入。

本章介绍如何加载、创建、显示和生成图像。第一个教程关心2D游戏，因为2D游戏主要就是在屏幕上绘制2D图像。你还可以在两个教程中学习内容管道——一个XNA框架提供的强大组件。更高级的教程显示了一些3D游戏世界中的2D图像的用法，它们被HLSL支持。

本章包含以下内容：

  • 使用SpriteBatch类加载、缩放、旋转、绘制和混合2D图像(教程3-1, 3-2, 3-3和3-4)
  • 在屏幕上绘制文字(教程3-5)
  • 添加菜单让游戏更吸引人(教程3-6)
  • 通过手动定义图像颜色、将图像保存到磁盘、将后备缓冲中的内容保存到图像中掌握Texture2D类的高级功能(教程3-7和3-8)
  • 知道为什么内容管道是XNA框架的重要组件，如何从中获益；扩展一个已存在的内容处理器(教程3-9和3-10)
  • 使用billboarding和粒子系统在3D世界中添加2D图像产生的漂亮效果(教程3-11和3-12)
  • 学习如何使用投影纹理将一个相机看到的场景绘制到另一个相机看到的场景中(教程3-13)


3.1 使用SpriteBatch类显示2D图像：加载和绘制图像

问题

你想将一张2D图像绘制到屏幕中以创建一个2D游戏或作为3D游戏的用户界面。你想有一个简单界面可以指定图像显示在屏幕上的位置。

解决方案

XNA Framework已经以SpriteBatch类的形式提供了这个功能，它可以以一个有效率的方式绘制图像。

虽然SpriteBatch的设计以易用性为优先考虑，但它也允许你可以选择一些优化性能的方法，你会在这个教程中看到详细介绍。

注意：因为每个图像是矩形的，所以可以用两个三角形绘制，用一张2D图像作为纹理覆盖其上。SpriteBatch类会自动帮你进行这个操作。因为你实际上绘制的是三角形，使用SpriteBatch类让你可以充分利用硬件加速。

工作原理

在图形编程中，2D图像通常叫做sprite。但是，当一张2D图像用来覆盖在一个表面时，叫做纹理。所以当讨论屏幕上的图像时，我指的是sprite。当讨论绘制sprite要用的颜色时，我指的是纹理。

加载一张纹理

XNA’s Game Studio (GS)支持拖放功能，你可以简单地将纹理拖动到项目中。GS支持很多图像格式。首先，在解决方案浏览器中找到项目的Content文件夹，你可以简单地将一张图像从资源管理器拖动到Content文件夹上。或者也可以通过右击解决方案中的Content文件夹并选择Add→Add Existing Item手动添加，如图3-1所示。

图3-1 在XNA项目中添加一个新图像

然后需要在项目中添加一个变量，给新导入的图像起一个名称，这让你可以从项目中访问这个图像。在类变量中添加以下代码：

Texture2D myTexture;

在代码顶部添加以下代码行：

namespace BookCode
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        GraphicsDevice device;
        SpriteBatch spriteBatch;
        Texture2D myTexture;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

注意：变量device是为GraphicsDevice创建的快捷方式，因为你将频繁地使用它。因为需要在每次程序重新获得焦点后重新连接GraphicsDevice(例如，在最小化操作之后)，所以你需要在LoadContent方法中添加device = graphics.GraphicsDevice; 代码行。

然后，将变量连接到图像，这是在LoadContent方法中进行的。如果图像的文件名是image1. Jpg或image1. Png，那么素材（asset）名称为image1。

myTexture = content.Load<Texture2D>("image1");

内容管道（content pipeline）(可参见教程3-9)从磁盘加载图像文件，处理后将它传递到一个变量中。

将图像放置在屏幕上

在XNA中，将图像放置在屏幕上很简单，使用一个SpriteBatch对象可以实现这个功能。 为了对初学者更加容易使用，一个SpriteBatch对象默认已经在创建新XNA项目时创建了，并且已经在LoadContent方法中实例化了。

当心：创建一个新的SpriteBatch对象需要花费大量的时间和资源，所以不要每帧创建一个新的SpriteBatch对象，应该重用在程序开头创建的SpriteBatch对象。

在Draw方法中，你想让SpriteBatch对象绘制纹理：

spriteBatch.Begin();
spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White);
spriteBatch.End();

上面的代码可以在屏幕上绘制图像。第二个参数让你可以指定放置在屏幕上的位置。你可以定义图像的左上角应该被放置到离窗口多少像素的位置。窗口的左上角对应(0,0)点，所以本例中你的图像离开窗口左边缘50个像素，离开窗口上边缘100个像素。在教程的最后你会学到最后一个参数的更多知识。

在屏幕上绘制多个图像

如果你想在屏幕上绘制多个sprite，显然首先需要将纹理导入到项目中并将它们连接到变量。有了对应的名称，一个单独的SpriteBatch对象可以绘制一批sprite。本例中使用两张图像，将它们连接到两个纹理变量。每个纹理被绘制到屏幕的两个不同位置，导致屏幕上绘制了四个sprite：

spriteBatch.Begin();
spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White);
spriteBatch.Draw(anotherTexture, new Vector2(70, 100), Color.White);
spriteBatch.Draw(anotherTexture, new Vector2(70, 200), Color.White);
spriteBatch.Draw(myTexture, new Vector2(100, 200), Color.White);
spriteBatch.End();

将图像绘制到一个矩形中

使用spriteBatch.Draw的另一个重载方法，你可以指定屏幕上的目标矩形，这个矩形表示图像绘制到哪儿。如果目标矩形与图像的大小不同，则图像会自动缩放匹配这个目标矩形。

下面的代码定义了一个拥有与窗口同样大小的目标矩形，起点是左上角的(0,0)。结果是，这个图像会扩展到整个屏幕，这对绘制背景是很有用的：

spriteBatch.Begin();
PresentationParameters pp = device.PresentationParameters;
spriteBatch.Draw(myTexture, new Rectangle(0, 0, pp.BackBufferWidth, pp.BackBufferHeight), Color.White);
spriteBatch.End();

颜色调整

直到现在，你都是用Color. White作为spriteBatch. Draw调用的一个参数。在图像绘制到屏幕之前，它的每个像素的颜色会与这个值相乘。

例如，如果你指定Color. Blue，每个像素的红色和绿色通道会乘以0，蓝色通道会乘以1。这样就会将图像中的红色和绿色分量完全移除。

如果图像只由红色像素构成，所有像素会变成黑色，因为红色通道会乘以0。如果你指定了Color. Purple，红色通道会乘以1，绿色乘以0，蓝色乘以1。所以如果是一张红色图像，它的颜色保持不变。

Color. White参数由红、绿、蓝组成，所以红色，绿色，蓝色通道会乘以1。结果是，你的图像会以初始颜色绘制到屏幕上。

你可以指定一个不为Color. White的值，稍微减弱或增强一个颜色的强度。例如，通过减少红色和绿色通道的颜色强度增加蓝色的强度。

在3D程序中使用SpriteBatch类

无论何时使用SpriteBatch绘制图像，SpriteBatch都会调整显卡的一些设置。如果之后你还要绘制一些3D物体，那么很有可能这些新设置会对3D绘制过程产生无法预料的影响。所以，你想让SpriteBatch保存显卡的初始设置，这样在SpriteBatch完成后可以恢复这些设置：

spriteBatch.Begin(SpriteBlendMode.None, SpriteSortMode.Texture, SaveStateMode.SaveState);

代码

下面的简单程序在屏幕上绘制了一张图像。注意myTexture变量的定义和初始化，以及如何在Draw方法中绘制这个图像：

using System; using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Net;
using Microsoft.Xna.Framework.Storage;

namespace BookCode
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {

        GraphicsDeviceManager graphics;
        GraphicsDevice device;
        SpriteBatch spriteBatch;

        Texture2D myTexture;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

        protected override void Initialize()
        {
            base.Initialize();
        }

        protected override void LoadContent()
        {
            device = graphics.GraphicsDevice;
            spriteBatch = new SpriteBatch(GraphicsDevice);
            myTexture = Content.Load<Texture2D>("smile");
        }

        protected override void UnloadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
                this.Exit();

            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
            spriteBatch.Begin();
            spriteBatch.Draw(myTexture, new Vector2(50, 100), Color.White);
            spriteBatch.End();
            base.Draw(gameTime);
        }
    }
}


3.2 旋转，缩放和镜像一张图像

问题

在将图像绘制到屏幕前你想对它进行旋转，缩放或镜像操作。

解决方案

SpriteBatch 类（见前一个教程）的Draw方法的不同重载可以让你很简单地进行上述操作。

工作原理

旋转/缩放/镜像一个图像

SpriteBatch.Draw方法有几个有用的重载。下面是最复杂的一个重载方法，参数会在后面加以讨论：

public void Draw (Texture2D texture,Vector2 position,Nullable<Rectangle>
        sourceRectangle,Color color,float rotation,Vector2 origin,Vector2 scale,SpriteEffects effects,float layerDepth)

前两个参数和第四个参数在上一个教程中已经讨论过了，它们可以指定在屏幕上绘制哪个纹理和图像的左上角相对于屏幕的位置。

参数sourceRectangle让你可以只绘制图像的一部分。如果你将多个图像存储在一张图像中，如教程3-3中所做，那么这个参数是很有用的。如果你只想显示整个图像，那么应将这个参数设为null。

旋转

使用rotation 参数你可以旋转一个图像。你需要使用弧度制指定这个旋转角度，所以如果你想让图像顺时针旋转20度，你可以使用MathHelper.ToRadians(20)，它可以帮你完成这个转换。

注意：2*PI弧度对应360角度，PI弧度对应180角度。所以如果你想旋转20度，你也可以使用MathHelper.Pi/180.0f*20.0f作为参数。

参数origin可以指定你想让图像上的哪个点位于屏幕上你在第二个参数中指定的位置上。例如，如果你将两个参数都指定为(0,0)，那么图像的左上角将位于屏幕的左上角，如图3-2中的左上图所示。

图3-2 不同origin (偏移量)参数对应的情况

如果一张64 × 64大小的图像，你将屏幕位置指定为(0,0)，origin指定为(32,32)，那么图像的中心点将位于屏幕的左上角，如图3-2的中上图所示。

如果两个参数都是(32,32)，那么图像的中心点将位于屏幕的(32,32)位置，如图3-2的右上图所示。这时的结果与左上图一样。

更重要的是，你指定的这个origin参数还将作为旋转的中心点。在左上图的情况中，(0,0)为图像的origin，这个图像会围绕这个点旋转，如图3-2中的左下图所示。如果你指定(32,32)为图像的origin，这个图像会围绕它的中心点旋转，如图3-2的右下图所示。

缩放

如果你想放大/缩小图像，可以设置参数scale。因为这个参数是一个Vector2，你可以对图像的水平方向和竖直方向施加不同的缩放值。例如，设置为(0.5f, 2.0f)将会使宽度变为原始宽度的一半，高度变为原始高度的2倍。

镜像

参数effects让你可以水平或竖直翻转图像。通过flags，你可以使用SpriteEffects.FlipHorizontally|SpriteEffects.FlipVertically同时进行这两个操作，这和将图像旋转180度的效果是相同的。

层深度

最后一个参数让你可以指定图像位于哪个层，当你想将多个图像在各自的顶部时层的概念是很有用的，这会在后面两个教程中详细介绍。

代码

下面的简单代码使用了所有的默认effects：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

    spriteBatch.Begin();
    spriteBatch.Draw(myTexture, new Vector2(50, 100), null, Color.White, MathHelper.ToRadians(20),
    new Vector2(32, 32), new Vector2(0.5f, 2.0f), SpriteEffects.FlipVertically, 0);
    spriteBatch.End();

    base.Draw(gameTime);
}

这两个教程中涵盖的内容让你可以制作一些基本的2D游戏。你可以到我的网站www.riemers.net中XNA教程中的2D系列，里面有一个完整的2D游戏。


3.3 使用层绘制透明图像

问题

在大多数情况中，你想在多个图像的上面绘制另一张图像。主要问题是，如果所有的图像都是完全覆盖屏幕的，那么它们就会覆盖前面的一张图像。你还要确保首先绘制背景图像。

解决方案

使用XNA，你可以指定层（layer），或者说图像的绘制深度。位于最大深度的图像会被首先绘制。

大多数图像格式支持透明。这意味着除了包含红、绿、蓝的颜色信息，它还包含alpha信息。当开启透明时，图像的透明区域会显示图像下面的东西。

在Xna中，你还可以指定一个颜色变为透明。这对那些没有包含透明信息的图像是很有用的。

工作原理

简单的透明

SpriteBatch类对象可以处理包含透明信息的图像。这在大多数情况中是必须的，例如，当绘制一片草地时，你想在草地上面再绘制一块石头。石头图像是一张简单的白色图像，中间是一块石头。

如果你将这张图像绘制在草地上，那么还可以看到石头图像的白色背景。所以，你需要将图像背景变成透明。通过这种方式，你将石头绘制在草地上，而石头图像的透明部分显示的则是底下草地的颜色。

如果你想让XNA处理图像的透明，需要使用SpriteBatch . Begin () 方法开启alpha混合：

spriteBatch.Begin(SpriteBlendMode.AlphaBlend);
spriteBatch.Draw(myTexture, Vector2.Zero, Color.White);
spriteBatch.End();

使用颜色键透明

对那些没有透明信息的图像，你可以指定一个颜色作为透明色。这可以通过设置图像属性中的color key实现。在解决方案浏览器中选择图像，让它的属性显示在窗口的右下方。如图3-3所示。找到Color Key Color，将它改变为你想要的颜色。图像中的这个颜色就会当做透明处理。请确保将Color Key Enabled设置为True。

图3-3 设置纹理的Color Key Color属性

使用多个层

如果你想混合多张图像，需要指定哪张图像在最上面。在XNA中，对每张要绘制的图像，你可以以一个介于0到1之间的数字指定层。层1表示这个层首先绘制，层0表示最后绘制，在所有其它层之上。你可以将这个值作为SpriteBatch . Draw方法的最后一个参数。

你需要通过正确设置SpriteBatch.Begin方法的SpriteSortMode开启层排序。

例如，下面的代码首先绘制覆盖整个屏幕的草地。因为这个底层的纹理，你可以安全地将这些图像的层值设置为1。然后绘制一个峭壁，它位于草地之上，所以它们的层值小于1：

Rectangle grassRec = new Rectangle(240, 121, 40, 40);
Rectangle leftRec = new Rectangle(40, 121, 80, 40);
Rectangle topleftRec = new Rectangle(40, 0, 80, 80);
Rectangle topRec = new Rectangle(240, 0, 40, 80);
Rectangle toprightRec = new Rectangle(320, 0, 80, 80);
Rectangle rightRec = new Rectangle(320, 121, 80, 40);
Rectangle bottomrightRec = new Rectangle(320, 281, 80, 120);
Rectangle bottomRec = new Rectangle(240, 281, 40, 120);
Rectangle bottomleftRec = new Rectangle(40, 281, 80, 120);
Rectangle centerRec = new Rectangle(240, 201, 80, 40);

spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None);
for (int x = 0; x < 10; x++)
    for (int y = 0; y < 10; y++)
        spriteBatch.Draw(myTexture, new Vector2(x * 40, y * 40), grassRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 1);

spriteBatch.Draw(myTexture, new Vector2(40, 120), leftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(40, 40), topleftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(120, 40), topRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(160, 40), toprightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(160, 120), rightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(160, 160), bottomrightRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(120, 160), bottomRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(40, 160), bottomleftRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);
spriteBatch.Draw(myTexture, new Vector2(120, 120), centerRec, Color.White, 0, Vector2.Zero, 1, SpriteEffects.None, 0.5f);

spriteBatch.End();

使用两个循环绘制了100个草地块。九张图像构成了峭壁，这九张图像和草地图像都存储在一张图像文件之中，如图3-4左图所示。所以，代码首先声明子图像在图像中所处的矩形。通过将这个矩形作为第三个参数，XNA可以从整张图像中切割出正确的子图像，最终的结果显示在图3-4的右图中。

图3-4 包含多个图像的一张图像(左图)和在一个草地表面上将多张图像混合在一起(右图)

因为草地的层值为1而峭壁图像的层值为0.5，XNA知道应该首先绘制草地，然后才是峭壁。

确保在SpriteBatch . Begin 方法中将SpriteSortMode设置为BackToFront，这样XNA才能知道在绘制它们前需要根据层进行排序：

spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None);

Alpha混合

像素的透明值不是非要“全部或没有”。你也可以设置透明值，例如，设置为70%。例如当一辆车驶过草地时，你可以将灰色的车窗设置为70%透明。通过这种方式，当绘制汽车时，车窗的最终颜色将会是30%灰色，70%草地的额颜色。在教程2-13中可以获取更多知识，这叫做alphablending。

SpriteBlendModes

默认情况下，使用SpriteBatch类进行绘制时alpha混合是开启的。但是，你可以关闭alpha混合(尝试一些看看有什么不同！)或将模式设置为Additive blending。

在后一种情况中，你在像素中绘制的每个颜色都会添加到像素已有的颜色中。所以使用这种模式时，当你在一个红色像素上绘制蓝色像素时，结果是一个紫色像素。如果在这个紫色像素上绘制一个绿色矩形，结果是一个包含白色像素的绿色矩形。这个模式用在诸如火焰或爆炸的效果中，这些效果都是由许多sprites组成的并附加混合在一起，如教程3-12所示。

XNA可以将设置SpriteBatch类的混合模式作为一个SpriteBatch . Begin方法中的参数，你可以指定SpriteBlendMode中的一个：

  • SpriteBlendMode. None关闭混合，忽略任何透明信息将图像绘制到屏幕上，这样会导致覆盖任何屏幕上层深度更大的图像。
  • SpriteBlendMode.AlphaBlend使用部分已存在的颜色和部分新图像中包含的颜色，对应指定在图像中的alpha值。这对应刚才所举的车窗的例子。
  • SpriteBlendMode.Additive将新图像的颜色添加到帧缓冲中已经存在的颜色之中。

代码

Draw 方法中的所有代码前面已经写过了。


3.4 使用SpriteBatch类时的性能考虑

问题

当使用SpriteBatch类不当，绘制大量图像时程序会变得很慢。

解决方案

教程3-1已经提到，每帧创建一个新的SpriteBatch类，或绘制每张图像时开启和关闭SpriteBatch类会极大地影响性能。但是还有更多微妙的东西需要考虑到。

工作原理

性能优化：Sprite Sorting Modes

SpriteBatch类的Begin方法让你可以设置SpriteSortMode。在讨论到不同模式前，你需要知道显卡喜欢的工作方式是什么，不喜欢的是什么。

在屏幕上绘制一张2D图像是通过绘制两个三角形并将它们填充为从一张纹理采样的颜色实现的。显卡喜欢一次性地绘制大量的三角形不要被打搅。但是当你需要改变纹理时，你就打搅了显卡。换句话说，从同一张纹理绘制100张图像比从100张纹理绘制100张图像快得多。所以，尽可能少地改变当前纹理可以改进性能。

有了这个概念，下面你可以学习可以作为SpriteBatch . Begin方法的一个参数指定的sprite排序模式了：

  • Deferred：这是默认的SpriteSortMode。虽然你几乎从不会用到它，因为它没有排序。 只要通过SpriteBatch . Draw方法将一个sprite添加到batch，这个sprite只是简单地添加到堆栈的顶部。当调用SpriteBatch . End方法时，堆栈中所有的sprite会根据进入堆栈的顺序进行绘制。
  • Texture：通常基于性能优化和易用性考虑，你会使用这个模式。当调用SpriteBatch . End方法时，在XNA实际要求显卡绘制图像前，堆栈中的所有图像据纹理进行排序。通过这种方式，显卡可以在一个轮次中使用同一张纹理绘制许多三角形，你可以减少干扰显卡改变纹理的次数。但是，当使用alpha透明时这个模式会遇到麻烦，你需要用到下面几个模式中的一个。
  • BackToFront：当使用alpha混合时，你想让距离最远的物体最先绘制。要知道为什么可见前一个教程。添加图像到SpriteBatch 类的SpriteBatch . Draw方法有一个重载方法可以接受layerDepth为参数。使用这个float参数，你可以指定图像在哪个层，1.0f表示最远的层(例如，包含草地)，0.0f表示最近层(例如，在这个层绘制岩石)。你可以指定两者之间的任何值。当使用BackToFront模式时，只要调用SpriteBatch . End方法，在SpriteBatch中的图像就会进行排序让最远层最先被绘制。
  • FrontToBack：这个模式正好与前一个模式相反，所以最近层的图像最先被绘制。因为屏幕上所有被绘制的像素永远不会被覆盖(因为所有接下来的图像都在这个之后)，这会导致最好的性能。但是，使用alpha混合无法工作(见前面的教程)并且只有在所有图像使用相同的纹理的情况下才会获得最好的性能！如果绘制最近层需要交换10次纹理，性能相比使用Texture模式会有更大的下降。
  • Immediate：相对于其他模式，在这个模式中XNA不会等待调用SpriteBatch . End方法绘制所有图像。只要你调用SpriteBatch. Draw方法将使用相同纹理的图像添加到SpriteBatch，SpriteBatch就会将它们推入堆栈中。当你使用另一个纹理绘制一个sprite时，当前堆栈中的sprite会立即被绘制。

使用前四个模式，只有在调用SpriteBatch . End方法时，sprite才会被排序，渲染状态被设置，三角形和纹理被传送到显卡。这让你可以使用多个SpriteBatch 类对象，以随机顺序添加新sprite，甚至是使用相同渲染状态的3D对象。但在使用Immediate模式时，渲染状态会在调用SpriteBatch. Begin方法时调用，sprite会在调用SpriteBatch . Draw方法之后被绘制。这意味着你可以在两个方法间改变渲染状态！通过这种方式，你可以访问更多的alpha混合模式或使用一个自定义的pixel shader 绘制sprite！但是使用Immediate模式绘制图像时，你无法在绘制其他东西，例如一个3D对象，因为这时你可能会改变渲染状态。当你想用SpriteBatch继续绘制sprite时，渲染状态还没有复位，因此你将使用3D物体的渲染状态和pixel shader 绘制sprite。

性能优化：在一个图像文件中排序多个图像

如前所述，当绘制大量sprite时你想尽可能少地改变当前纹理。SpriteSortingMode. Texture可以帮很多忙，但在一个完整的游戏中你往往需要几百张不同的图像，这会导致显卡在纹理间切换几百次。

一个有用的方法是将互相联系的图像存储在一张大图像中，如图3-4的左图所示。通过这种方式，你可以使用SpriteBatch . Draw 方法的sourceRectangle参数指定绘制大图像中的哪一部分，如教程3-3的代码所示。

如图3-4所示，每张子图像为40 × 40像素。教程3-3的代码定义了一些矩形表示在大图像中的位置。将这些矩形作为SpriteBatch . Draw方法的第三个参数导致在屏幕上只绘制这个子图像。

使用多个SpriteBatche

SpriteBatch是一个强大的对象，你可以将整个游戏的图像一次性地添加到一个单独的SpriteBatch中，进行排序并绘制。但是，有些情况中你可能还想使用多个SpriteBatch类。

例如创建一个空战游戏，飞机间互射导弹。当然你有多个种类的飞机和导弹。你可以将飞机和导弹的所有旋转情况存储在一张大纹理中，这可以减少所需纹理的数量。

你还想在飞机引擎的后部有烟雾，并会慢慢消散，在导弹轨迹上也有大量的烟雾。如果使用一个SpriteBatch绘制所有这些图像，会遇到一个问题：你想让所有图像根据纹理排序获得优化的性能。但是，飞机和导弹应该首先绘制，这样才能之后绘制烟雾sprite。而这需要 sprite根据深度排序！

解决方法是使用两个SpriteBatch类对象：planeBatch和smokeBatch。在Draw方法的开头，调用两个SpriteBatch类的Begin方法。然后，对每个飞机和导弹，将飞机和导弹图像添加到planeBatch中，飞机和导弹之后的烟雾图像添加到smokeBatch中。

planeBatch排序模式是Texture，smokeBatch使用BackToFront模式。当所有图像添加到batch之后，首先调用planeBatch的End方法，这个会让所有的飞机和导弹根据纹理排序，会以最优的性能绘制到屏幕。然后。调用smokeBatch的End方法，这会导致烟雾sprite很好地混合在图像上。

这样，你以优化的性能绘制了飞机，而且混合正确。


3.5 显示文字

问题

你想绘制一些文字，例如，显示一些操作说明或当前得分。

解决方案

本章前四个教程中学习的SpriteBatch类也可以绘制文字。做法和绘制纹理几乎是一样的，只不过不是导入一个Texture2D，这次导入的是一个SpriteFont，它包含了你想使用的文字大小和类型。然后就可以使用SpriteBatch . DrawString方法绘制文字了。

工作原理

首先需要创建一个SpriteFont文件。右击XNA项目的Content文件夹并选择Add→New item。从对话框中选择SpriteFont，起一个名称(比如ourFont)，然后点击Add。

现在看到一个XML页面。最重要的一行是设置FontName属性，可以将这个属性改成你想要绘制的字体。接下去一行可以设置字体大小，你也可以在以后缩放字体大小。

注意：你可以使用任何安装在你的计算机上的TrueType字体。要看到已经安装的字体，可以打开C:\WINDOWS\FONTS目录(例如点击Start按钮，选择Run，并输入这个文件夹地址)。FontName列显示了可以指定的所有字体。例如，如果字体名称为Times New Roman (TrueType)，你指定Times New Roman作为FontName 属性。

注意：当在另一台电脑上编译代码时，那台电脑上也必须装有这个字体，否则会报错。

当心：当发布你的程序时，请确保你检查了字体的版权。

创建了SpriteFont文件后，在类中添加一个SpriteFont变量：

SpriteFont myFont;

在LoadContent方法中进行初始化：

myFont = Content.Load<SpriteFont>("ourFont");

加载了SpriteFont对象后，就可以在Draw方法中绘制文本了：

spriteBatch.Begin();
string myString = "Elapsed seconds: " + gameTime.TotalGameTime.Seconds.ToString();
spriteBatch.DrawString(myFont, myString, new Vector2(50, 20), Color.Tomato);
spriteBatch.End();

SpriteBatch.DrawString有一些重载方法接受与SpriteBatch.Draw方法相同的参数，可见教程3-1见到具体解释。

当心：如果你使用的是另一个SpriteBatch.Begin的重载方法，请确保指定 SpriteBlendMode.AlphaBlend作为第一个参数。否则，文字周围的像素会变得不透明，将以你指定的颜色绘制文字。

字符串长度

你可以使用spriteFont查询一个字符串实际占据的像素。这个信息对缩放和截取太长的字符串是很有用的。你可以同时获取字符串的水平和竖直大小：

Vector2 stringSize=myFont.MeasureString(myString);

重载方法

SpriteBatch.DrawString有一些重载方法可以可以接受与SpriteBatch.Draw方法相同的参数。解释可见教程3-2。

spriteBatch.DrawString(myFont, secondString, new Vector2(50,100), Color.White,0,new Vector2(0,0),0.5f,SpriteEffet.None,0);

StringBuilder

SpriteBatch.DrawString还可以使用一个StringBuilder对象代替string。如果你需要对字符串进行大量的修改应该使用StringBuilder对象，例如在一个循环中附加大量的字符串。首先在代码顶部添加一个StringBuilder变量，这样就无需每帧重新创建这个对象了：

StringBuilder stringBuilder =new StringBuilder();

当你想使用这个方法时，首先将长度设为0清空StringBuilder。然后创建字符串，将它StringBuilder传递到SpriteBatch.DrawString方法中：

stringBuilder.Length=0;
stringBuilder.Append("stringBuilder Example:");
for(int i=0;i<10;i++)
    stringBuilder.Append(i);
spriteBatch.DrawString(myFont, stringBuilder, new Vector2(50,180), Color.White,0,new Vector2(0,0),0.5f,SpriteEffet.None,0);

代码

SpriteFont对象和SpriteBatch在LoadContent方法中进行初始化：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    spriteBatch = new SpriteBatch(GraphicsDevice);
    myFont = Content.Load<SpriteFont>("ourFont");
}

这两个对象用来在Draw方法中绘制一些文字：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

    spriteBatch.Begin();
    string myString = "Elapsed seconds: " + gameTime.TotalGameTime.Seconds.ToString();
    spriteBatch.DrawString(myFont, myString, new Vector2(50, 20), Color.Tomato);
    spriteBatch.End();

    base.Draw(gameTime);
}


3.6 创建2D菜单界面

问题

你想创建一个2D菜单界面，让你可以容易地添加新的菜单和指定它们的菜单选项。这个菜单允许用户使用控制器/键盘切换不同的选项和菜单，当用户从一个菜单切换到另一个菜单时还可以定义漂亮的过渡效果。

解决方案

你将创建一个新的类，MenuWindow，这个类保存所有与菜单相关的东西，诸如菜单的当前状态，菜单项，背景图像等。这个类让主程序可以容易地创建多个MenuWindow实例并将菜单项添加到这些实例中。菜单项可以使用你的系统中安装的字体用2D文字的方式显示。

要实现过渡效果，一个窗口将拥有Starting、Ending、Active和Inactive状态。controller/keyboard状态会被传递到Active MenuWindow，可以通过传递选择的菜单让主程序知道用户是否选择了一个菜单项。

让MenuWindow可以存储和显示背景图像可以增强最终效果，这还可以使用后期处理效果加以改进(见教程2-12)。

工作原理

主程序会创建几个MenuWindow对象，每个菜单项都会链接到另一个MenuWindow对象。所以首先定义一个MenuWindow类。 MenuWindow类创建一个叫做MenuWindow的新类。每个菜单能够存储它的菜单项。对每个菜单项，必须存储文字和指向的菜单。所以，在MenuWindow类中定义下述结构：

private struct MenuItem
{
    public string itemText;
    public MenuWindow itemLink;
    public MenuItem(string itemText, MenuWindow itemLink)

    {
        this.itemText = itemText;
        this.itemLink = itemLink;
    }
}

每个菜单总是处于下面四个状态之一：

  • Starting：菜单刚刚被选择，正在淡入。
  • Active：此菜单为屏幕上显示的唯一一个菜单并处理用户输入。
  • Ending：此菜单中的一个菜单项被选择，因此它正在淡出。
  • Inactive：如果此菜单不在前面三个状态之中，那么它不被绘制。

所以你需要一个枚举表示状态，枚举应放在类的外部：

public enum WindowState
{
    Starting,
    Active,
    Ending,
    Inactive
}

然后是使类正常工作所需的变量：

private TimeSpan changeSpan;
private WindowState windowState;
private List<MenuItem> itemList;
private int selectedItem;
private SpriteFont spriteFont;
private double changeProgress;

changeSpan表示淡入淡出持续的时间。然后你需要一些变量保存菜单的当前状态、菜单项的集合和当前选择的菜单项。变量changeProgress保存一个介于0和1之间的值表示在Starting或Ending状态时淡入淡出处在过程的何处。

构造函数只是简单地初始化这些变量：

public MenuWindow(SpriteFont spriteFont)
{
    itemList = new List<MenuItem>();
    changeSpan = TimeSpan.FromMilliseconds(800);
    selectedItem = 0;
    changeProgress = 0;
    windowState = WindowState.Inactive;
    this.spriteFont = spriteFont;
}

你指定了两个菜单间的过渡持续800毫秒的时间，菜单开始时的状态为Inactive。你可以在前一个教程中学到SpriteFont类和如何绘制文字。

然后你需要一个方法添加菜单项：

public void AddMenuItem(string itemText, MenuWindow itemLink)
{
    MenuItem newItem = new MenuItem(itemText, itemLink);
    itemList.Add(newItem);
}

当用户选择菜单项时，菜单项上的文字和菜单需要被激活，被主程序传递。一个新的菜单项被创建并被添加到itemList中。你还需要一个方法激活一个Inactive菜单：

public void WakeUp()
{
    windowState = WindowState.Starting;
}

像XNA程序中的大多数组件一样。这个类需要被更新：

public void Update(double timePassedSinceLastFrame)
{
    if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending))
        changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds;
    if (changeProgress >= 1.0f)
    {
        changeProgress = 0.0f;
        if (windowState == WindowState.Starting)
            windowState = WindowState.Active;
        else if (windowState == WindowState.Ending)
            windowState = WindowState.Inactive;
    }
}

这个方法接受上一个update调用以来经历的毫秒数为参数(通常这个参数为1000/60 毫秒，见教程1-5)。如果菜单正在过渡模式中，变量changeProgress进行更新，导致在经过了存储在changeSpan (800，前面你已经定义了)中的毫秒后，这个值达到1。

当这个值达到1，过渡结束，状态要么从Starting变换到Active，要么从Ending变换为Inactive。

最后，你需要一些代码绘制菜单。当菜单处于Active状态时，必须显示菜单项，例如从位置(300,300)开始，每个菜单项位于前一个之下30个像素。

当菜单在Starting状态时，菜单项应该淡入(它们的alpha值应该从0增加到1)并且从屏幕的左边移动至最终的位置。如果处于Ending状态，菜单项应该淡出 (它们的alpha值应该减少)并且移动到右方。

public void Draw(SpriteBatch spriteBatch)
{
    if (windowState == WindowState.Inactive)
        return;

    float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress);
    int verPosition = 300;
    float horPosition = 300;
    float alphaValue;

    switch (windowState)
    {
        case WindowState.Starting:
            horPosition -= 200 * (1.0f - (float)smoothedProgress);
            alphaValue = smoothedProgress;
            break;
        case WindowState.Ending:
            horPosition += 200 * (float)smoothedProgress;
            alphaValue = 1.0f - smoothedProgress;
            break;
        default:
            alphaValue = 1;
            break;
    }

    for (int itemID = 0; itemID < itemList.Count; itemID++)
    {
        Vector2 itemPostition = new Vector2(horPosition, verPosition);
        Color itemColor = Color.White;

        if (itemID == selectedItem)
            itemColor = new Color(new Vector4(1,0,0,alphaValue));
        else
            itemColor = new Color(new Vector4(1,1,1,alphaValue));
        spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0);
        verPosition += 30;
    }
}

当处于Starting或Ending状态时，changeProgress值会线性地从0增加到1，它工作正常但无法在开始或结束时产生平滑的效果。MathHelper. SmoothStep方法平滑曲线，让开始和结束时都能平滑过渡。当菜单处于Starting或Ending状态时，case结构中调整菜单项的水平位置和alpha值。然后，对每个菜单项，其上的文字被正确地绘制到屏幕上。绘制文字更多的信息可见前一个教程。如果菜单项没有被选择，文字是白色的，当选择时变为红色。

以上就是MenuWindow类的基础！

在主程序中，你只需一个集合存储所有的菜单：

List<MenuWindow> menuList;

在LoadContent方法中，你可以创建菜单并将它们添加到menuList中。然后，你可以将菜单项添加到菜单中，让你可以在用户选择菜单项时指定激活哪个菜单。

MenuWindow menuMain = new MenuWindow(menuFont, "Main Menu", backgroundImage);
MenuWindow menuNewGame = new MenuWindow(menuFont, "Start a New Game", bg);
menuList.Add(menuMain);
menuList.Add(menuNewGame);
menuMain.AddMenuItem("New Game", menuNewGame);
menuNewGame.AddMenuItem("Back to Main menu", menuMain);
menuMain.WakeUp();

以上操作创建了两个菜单，每个菜单包含一个链接到另一个菜单的菜单项。初始化菜单结构后，激活mainMenu，使它处于Starting状态。现在，你需要在程序更新循环中更新所有菜单：

foreach (MenuWindow currentMenu in menuList)
            currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds);

并在Draw方法中绘制菜单：

spriteBatch.Begin();

foreach (MenuWindow currentMenu in menuList)
    currentMenu.Draw(spriteBatch);
spriteBatch.End();

当运行代码时，主菜单会从左侧淡入，因为你还没有处理用户输入，所以无法切换到其他菜单。

允许用户通过菜单项导航

你将在MenuWindow类中添加一个方法处理用户输入。注意这个方法只能被当前激活的菜单调用：

public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState)
{
    if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down))
        selectedItem++;
    if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up))
        selectedItem--;
    if (selectedItem < 0)
        selectedItem = 0;
    if (selectedItem >= itemList.Count)
        selectedItem = itemList.Count-1;
    if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter))
    {
        windowState = WindowState.Ending;
        return itemList[selectedItem].itemLink;
    }
    else if (lastKeybState.IsKeyDown(Keys.Escape))
        return null;
    else
        return this;
}

这里有许多有趣的东西。首先，你检查up或down键是否被按下。当用户按下一个键时，只要这个键一直被按着，那么这个键的IsKeyUp为true！因此，你需要检查上一帧这个键是否已经被按下。

如果up或down键被按下，你需要对应地改变selectedItem变量。如果超出边界，需要将它返回到一个合理的位置。

接下去的代码包含整个导航逻辑。你应该注意到这个方法需要返回一个MenuWindow对象到主程序中。因为这个方法只会被当前激活的菜单调用，这允许当前菜单将新选择的菜单返回到主程序。如果用户没有选择任何菜单项，当前菜单会保持激活并返回自己，这就是最后一行代码的操作。通过这种方式，主菜单在处理输入后知道哪个菜单是激活菜单。

当用户按下Enter键后，当前激活菜单会从Active转到Ending状态，被选择菜单项链接的菜单被返回到主程序。如果用户按下Escape键，返回null，这回被后面的退出程序捕捉到。如果什么都没选，返回当前菜单自身，告知主程序当前菜单仍保持激活。

这个方法需要从主程序调用，主程序需要两个变量：

MenuWindow activeMenu;
KeyboardState lastKeybState;

第一个变量保存当前激活的菜单，在LoadContent 方法中进行初始化。lastKeybState 变量在Initialize方法中进行初始化。

private void MenuInput(KeyboardState currentKeybState)
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState);

    if (newActive == null)
        this.Exit();
    else if (newActive != activeMenu)
        newActive.WakeUp();
    activeMenu = newActive;
}

这个方法调用当前激活菜单的ProcessInput方法，并传递前一个和当前的键盘状态。如前所述，如果用户按下Escape键这个方法会返回null，所以在这种情况中，应用程序会退出。否则，如果这个方法返回一个不同于当前菜单的菜单，说明用户做出了一个选择。在这种情况中，新选择的菜单会通过调用它的WakeUp方法从Inactive变化到Starting状态。如果两种情况都不是，则返回当前菜单，存储在activeMenu变量中。

请确保在Update方法内部调用这个方法。运行这个代码让你可以在两个菜单间自由切换。

在菜单中添加标题和背景图像

菜单现在已经可以正常工作了，但没有背景图片菜单还不够完美。在MenuWindow类中添加两个变量：

private string menuTitle;
private Texture2D backgroundImage;

这两个变量需要在Initialize方法中赋值：

public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage)
{
    //...
    this.menuTitle = menuTitle;
    this.backgroundImage = backgroundImage;
}

显示标题很简单。但是，如果菜单使用不同的背景图片，那么在绘制背景图片时会遇到些麻烦。你想要的是在Active和Ending状态下显示图片。当处于Starting状态时，新的背景会混合在前一个的上面。当在第一张图片上混合第二张图片时，你想要保证第一张图像首先被绘制！这并不容易，因为这涉及到改变菜单的绘制顺序。

一个简单的方法是使用SpriteBatch . Draw方法的layerDepth参数(见教程3-3)。当处于Active或Ending状态时，背景图像会在距离1绘制，即“最深的”层。在Starting模式中，图像会在距离0.5绘制，所有文字会在距离0绘制。当使用SpriteSortMode. BackToFront时，首先在深度1的Active或Ending菜单会被绘制。然后，Starting菜单被绘制(混合在已经存在的图像上)，最后绘制所有文字。

在MenuWindow的Draw方法中，创建两个变量：

float bgLayerDepth;
Color bgColor;

这两个变量保存背景图像的layerDepth和透明颜色值，在switch中设置这两个变量：

switch (windowState)
{
    case WindowState.Starting:
        horPosition -= 200 * (1.0f - (float)smoothedProgress);
        alphaValue = smoothedProgress;
        bgLayerDepth = 0.5f;
        bgColor = new Color(new Vector4(1, 1, 1, alphaValue));
        break;
    case WindowState.Ending:
        horPosition += 200 * (float)smoothedProgress;
        alphaValue = 1.0f - smoothedProgress;
        bgLayerDepth = 1;
        bgColor = Color.White;
        break;
    default: alphaValue = 1;
        bgLayerDepth = 1;
        bgColor = Color.White;
        break;
}

Color. White与Color(new Vector4(1, 1, 1, 1))相同，表示完全alpha值。如果一个菜单处于Starting或Ending状态，会计算alphaValue。然后，使用透明颜色值绘制标题和背景图像。

Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue));

spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth);
spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0);

标题文字被缩放到1.5f，比菜单项的文字大。

最后，你需要确保在主程序的Draw方法中将SpriteSortMode设置为BackToFront：

spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None);

从菜单移动到游戏

至此你创建了一些漂亮的菜单，但如何创建一些菜单项开始游戏？这可以使用dummy 菜单，这个菜单应该存储在主程序中。例如，如果你想在Start New Game菜单中包含start an Easy，a Normal或a Hard game的菜单项，应该将下列代码添加到菜单中：

MenuWindow startGameEasy;
MenuWindow startGameNormal;
MenuWindow startGameHard;
bool menusRunning;

在LoadContent方法中，你可以使用null参数实例化这些变量并将它们链接到menuNewGame中的菜单项上：

startGameEasy = new MenuWindow(null, null, null);
startGameNormal = new MenuWindow(null, null, null);
startGameHard = new MenuWindow(null, null, null);
menuNewGame.AddMenuItem("Easy", startGameEasy);
menuNewGame.AddMenuItem("Normal", startGameNormal);
menuNewGame.AddMenuItem("Hard", startGameHard);
menuNewGame.AddMenuItem("Back to Main menu", menuMain);

这会在New Game菜单中添加四个菜单项。接下去你要做的就是检测哪一个dummy菜单被选择。因此，需要扩展一下MenuInput方法：

private void MenuInput(KeyboardState currentKeybState)
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState);

    if (newActive == startGameEasy)
    {
        //set level to easy
        menusRunning = false;
    }
    else if (newActive == startGameNormal)
    {
        //set level to normal
        menusRunning = false;
    }
    else if (newActive == startGameHard)
    {
        //set level to hard
        menusRunning = false;
    }
    else if (newActive == null)
        this.Exit();
    else if (newActive != activeMenu)
        newActive.WakeUp();
    activeMenu = newActive;
}

你可以使用menusRunning变量保证当用户在游戏中时不更新/绘制菜单：

if (menusRunning)
{
    spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None);

    foreach (MenuWindow currentMenu in menuList)
        currentMenu.Draw(spriteBatch);
    spriteBatch.End();
    Window.Title = "Menu running ...";
}
else
{
    Window.Title = "Game running...";
}

代码

MenuWindow类

下面是简单、必须的方法：

public MenuWindow(SpriteFont spriteFont, string menuTitle, Texture2D backgroundImage)
{
    itemList = new List<MenuItem>();
    changeSpan = TimeSpan.FromMilliseconds(800);
    selectedItem = 0;
    changeProgress = 0;
    windowState = WindowState.Inactive;
    this.spriteFont = spriteFont;
    this.menuTitle = menuTitle;
    this.backgroundImage = backgroundImage;
}

public void AddMenuItem(string itemText, MenuWindow itemLink)
{
    MenuItem newItem = new MenuItem(itemText, itemLink);
    itemList.Add(newItem);
}

public void WakeUp()
{
    windowState = WindowState.Starting;
}

然后是更新菜单的方法。注意只有当前激活的菜单才会调用ProcessInput方法。

public void Update(double timePassedSinceLastFrame)
{
    if ((windowState == WindowState.Starting) || (windowState == WindowState.Ending))
        changeProgress += timePassedSinceLastFrame / changeSpan.TotalMilliseconds;
    if (changeProgress >= 1.0f)
    {
        changeProgress = 0.0f;
        if (windowState == WindowState.Starting)
            windowState = WindowState.Active;
        else if (windowState == WindowState.Ending)
            windowState = WindowState.Inactive;
     }
}

public MenuWindow ProcessInput(KeyboardState lastKeybState, KeyboardState currentKeybState)
{
    if (lastKeybState.IsKeyUp(Keys.Down) && currentKeybState.IsKeyDown(Keys.Down))
        selectedItem++;
    if (lastKeybState.IsKeyUp(Keys.Up) && currentKeybState.IsKeyDown(Keys.Up))
        selectedItem--;
    if (selectedItem < 0)
        selectedItem = 0;
    if (selectedItem >= itemList.Count)
        selectedItem = itemList.Count-1;
    if (lastKeybState.IsKeyUp(Keys.Enter) && currentKeybState.IsKeyDown(Keys.Enter))
    {
        windowState = WindowState.Ending;
        return itemList[selectedItem].itemLink;
    }
    else if (lastKeybState.IsKeyDown(Keys.Escape))
        return null;
    else
        return this;
}

最后是绘制菜单的方法：

public void Draw(SpriteBatch spriteBatch)
{
    if (windowState == WindowState.Inactive)
        return;
    float smoothedProgress = MathHelper.SmoothStep(0,1,(float)changeProgress);
    int verPosition = 300;
    float horPosition = 300;
    float alphaValue;
    float bgLayerDepth;
    Color bgColor;

    switch (windowState)
    {
        case WindowState.Starting:
            horPosition -= 200 * (1.0f - (float)smoothedProgress);
            alphaValue = smoothedProgress;
            bgLayerDepth = 0.5f;
            bgColor = new Color(new Vector4(1, 1, 1, alphaValue));
            break;
        case WindowState.Ending:
            horPosition += 200 * (float)smoothedProgress;
            alphaValue = 1.0f - smoothedProgress;
            bgLayerDepth = 1;
            bgColor = Color.White;
            break;
        default:
            alphaValue = 1;
            bgLayerDepth = 1;
            bgColor = Color.White;
            break;
     }

     Color titleColor = new Color(new Vector4(1, 1, 1, alphaValue));
     spriteBatch.Draw(backgroundImage, new Vector2(), null, bgColor, 0, Vector2.Zero, 1, SpriteEffects.None, bgLayerDepth);
     spriteBatch.DrawString(spriteFont, menuTitle, new Vector2(horPosition, 200), titleColor,0,Vector2.Zero, 1.5f, SpriteEffects.None, 0);

     for (int itemID = 0; itemID < itemList.Count; itemID++)
     {
         Vector2 itemPostition = new Vector2(horPosition, verPosition);
         Color itemColor = Color.White;

         if (itemID == selectedItem)
             itemColor = new Color(new Vector4(1,0,0,alphaValue));
         else
             itemColor = new Color(new Vector4(1,1,1,alphaValue));
         spriteBatch.DrawString(spriteFont, itemList[itemID].itemText, itemPostition, itemColor, 0, Vector2.Zero, 1, SpriteEffects.None, 0);
         verPosition += 30;
     }
}

主程序

你可以在LoadContent方法中创建菜单结构。更新方法必须调用每个菜单的更新方法和MenuInput方法：

protected override void Update(GameTime gameTime)
{
    if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
        this.Exit();
    KeyboardState keybState = Keyboard.GetState();
    if (menusRunning)
    {
        foreach (MenuWindow currentMenu in menuList)
            currentMenu.Update(gameTime.ElapsedGameTime.TotalMilliseconds);
        MenuInput(keybState);
    }
    else
    {
    }

    lastKeybState = keybState;
    base.Update(gameTime);
}

MenuInput方法将用户输入传递到当前激活的菜单，当输入处理后接受返回的激活菜单：

private void MenuInput(KeyboardState currentKeybState)
{
    MenuWindow newActive = activeMenu.ProcessInput(lastKeybState, currentKeybState);
    if (newActive == startGameEasy)
    {
        //set level to easy
        menusRunning = false;
    }
    else if (newActive == startGameNormal)
    {
        //set level to normal
        menusRunning = false;
    }
    else if (newActive == startGameHard)
    {
        //set level to hard
        menusRunning = false;
    }
    else if (newActive == null)
        this.Exit();
    else if (newActive != activeMenu)
        newActive.WakeUp();
    activeMenu = newActive;
}

扩展阅读

虽然对一个基础菜单系统这个方法已经足够了，但对一个完整的游戏来说，MenuWindow类应该是一个抽象类，这样菜单不能作为这个类的实例。作为替代，你应该为菜单和游戏各创建一个新类，这两个类都从MenuWindow类继承。通过这种方式，键盘处理和绘制完全被这个方法处理，无需丑陋的menuRunning变量了。这也是http ://creators . xna . com网站上菜单示例的基础（译者注：这应该是指示例Game State Management）。


3.7 创建一张纹理，定义每个像素的颜色，将纹理保存到一个文件

问题

你想创建一张新纹理并手动定义每个像素的颜色。当你想让用户创建一个新图像或生成诸如深度贴图之类的人工图像时是很有用的。

你想将这张纹理存储到文件中，例如，生成游戏截图或为了调试的目的。

解决方案

设置一张图像的颜色和将纹理用你选择的格式保存到一个文件中被XNA Framework直接支持。

你可以通过调用纹理的SetData方法改变它的内容，这个方法以一个包含每个像素颜色值的数组为参数。

你可以使用纹理的Save方法将它保存到一个文件中。

工作原理

首先创建一个数组，保存图像中每个像素的颜色：

int textureWidth = 512;
int textureHeight = 512;
Color[] textureColors = new Color[textureWidth* textureHeight];

int i = 0;
for (int ver = 0; ver < textureHeight; ver++)
    for (int hor=0; hor<textureWidth; hor++)
    {
        float red = (float)hor / (float)textureWidth;
        float green = 0;
        float blue = (float)ver / (float)textureHeight;
        float alpha = 1;
        textureColors[i++] = new Color(new Vector4(red, green, blue, alpha));
    }

上述代码首先创建一个可以为一个分辨率为512×512的纹理存储颜色的数组。然后使用两个for循环分别填充每个像素。使用这个填充顺序，你首先从左上角开始，沿着从左向右，从上到下的顺序遍历像素。

最后，你需要实际创建纹理并将数组中的内容加载到纹理内存中：

Texture2D newTexture = new Texture2D(device, textureWidth, textureHeight, 1, TextureUsage.None, SurfaceFormat.Color);

然后，在device和图像分辨率之后，你需要指定mipmap的数量(见后面的注意事项)。下一个参数让你可以设置TextureUsage。通过选择TextureUsage.AutoGenerateMipMap，XNA会自动生成你指定的mipmaps的数量。记住，如果你想使用mipmap，图像的宽和高必须是2的整数幂。

注意：一个mipmapped图像包含图像的多个分辨率，这在很多情况下是很有用得。当绘制一个3D场景时，如果一个带纹理的盒子距相机如此之远以至于在窗口中只有一个像素的大小。要确定盒子的颜色，显卡仍需要计算从哪个纹理坐标采样纹理。当稍微移动一下相机，纹理坐标可能会有少许不同，但可能对应纹理中一个截然不同的颜色。结果是，稍微一动一个相机也会导致盒子的颜色发生变化，导致屏幕上像素的闪烁。一个解决方法是在图像中存储不同分辨率的多个版本。对一个64 × 64像素的图像，如果你开启mipmapping，XNA还会在图像中创建32 × 32，16 × 16，8 × 8，4 × 4，2 × 2直到1 × 1的版本， 1 × 1图像的单个像素就是整个纹理的颜色。现在，在刚才很远的盒子的情况中，XNA会使用图像的1 × 1。这样，纹理坐标的变化不会导致颜色的变化，闪烁的像素会保持一个不变的颜色！

最后一个参数指定了纹理中像素的格式，这对XNA分配足够的内存存储纹理是必须的。 而且，当获取纹理中的值时，这也是你预期的格式，当你将数据写入到纹理时，必须提供这个格式的数据。

最后，只需简单地将Color数组存储在纹理中：

newTexture.SetData<Color>(textureColors);

将纹理保存到文件还要简单：

myTexture.Save("savedtexture.jpg", ImageFileFormat.Jpg);

最后一个参数指定文件的压缩格式，确保文件扩展名对应这个格式，否则试图从一个外部图像浏览器打开这个文件会遇到麻烦。

当运行这个代码时，文件会创建到与可执行文件相同的位置中。默认是在\bin\x86\Debug文件夹中。

代码

下面的代码创建一个纹理，使用颜色数据进行填充并返回这个纹理：

private Texture2D DefineTextureColors()
{
    int textureWidth = 512;
    int textureHeight = 512;
    Color[] textureColors = new Color[textureWidth* textureHeight];

    int i = 0;
    for (int ver = 0; ver < textureHeight; ver++)
        for(int hor=0; hor<textureWidth; hor++)
        {
            textureColors[i++] = new Color(new Vector4((float)hor / (float)textureWidth, 0, (float)ver / (float)textureHeight, 1));
        }

        Texture2D newTexture = new Texture2D(device, textureWidth, textureHeight, 1, ResourceUsage.None, SurfaceFormat.Color);
        newTexture.SetData<Color>(textureColors);

        return newTexture;
}

这个方法需要从LoadContent方法中调用，因为它需要设备实例化：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    spriteBatch = new SpriteBatch(GraphicsDevice);
    myTexture = DefineTextureColors();
    myTexture.Save("savedtexture.jpg", ImageFileFormat.Jpg);
}


3.8 将场景绘制到纹理

问题

你想将屏幕上的内容保存在一个纹理文件中，这样可以实现屏幕截图，或者将场景绘制到一个纹理，而这个纹理用于深度贴图/折射贴图或作为post-processing effect的输入。

解决方案

最简单的方法是使用device. ResolveBackBuffer方法，这个方法将当前后备缓冲中的内容写入到一个纹理中。如果你只想将场景绘制到一个纹理，或指定大小的纹理，你需要使用device.SetRenderTarget 方法将渲染目标从屏幕变为你在内存中定义的那个目标。一旦场景已经完整地绘制到了那个目标，你就可以将它的内容存储在一张纹理中了。

工作原理

最简单的方法是将场景绘制到屏幕并将后备缓冲中的内容复制到ResolveTexture2D中。这样你首先要创建一个ResolveTexture2D对象用来存储后备缓冲的内容。这意味着这个ResolveTexture2D对象的大小和格式要和后备缓冲一样。在LoadContent方法中添加以下代码创建一个ResolveTexture2D对象：

PresentationParameters pp = device.PresentationParameters;
resolveTexture = new ResolveTexture2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format);

第一行代码获取当前PresentationParameters，这个PresentationParameters包含了关于图像设备当前配置的所有细节，这些细节包括当前宽度、高度和后备缓冲的数据格式。第二行代码基于PresentationParameters创建了一个ResolveTexture2D对象。创建完ResolveTexture2D对象后，你就可以使用它存储当前后备缓冲的内容了。在Draw方法中，在你想提取后备缓冲的地方加入以下代码：

device.ResolveBackBuffer(resolveTexture); resolveTexture.Save("output.bmp",
        ImageFileFormat.Bmp);

上面的代码会将当前后备缓冲的内容存储在resolveTexture变量中。因为ResolveTexture2D是从Texture2D类继承的，你可以把这个ResolveTexture2D当成普通的Texture2D处理。在前面的例子中，我们将这个纹理保存为一个文件。

注意：因为硬件限制，在调用完device. ResolveBackBuffer方法后，后备缓冲中的内容会被丢弃！

使用与屏幕一样大小的自定义渲染目标（Render Target）

你可以自定义一个RenderTarget2D并在绘制前activate这个RenderTarget2D，而不是绘制到后备缓冲。渲染目标——顾名思义：是一段可以用来绘制其上的内存。当所有绘制完成后，你再次将渲染目标的内容复制到一个纹理中。添加一个变量：

RenderTarget2D renderTarget;

然后在LoadContent方法中将它实例化：

PresentationParameters pp = device.PresentationParameters;
renderTarget = new RenderTarget2D(device, pp.BackBufferWidth, pp.BackBufferHeight, 1, device.DisplayMode.Format);

你定义了渲染目标的宽和高，指定了mipmap的级别为1（见教程3－6），设置了数据格式。在这个例子中，你使用和后备缓冲相同的值。

注意：mipmap只有在纹理的宽/高是2的整数幂时才能执行，当你指定其他大于1的mipmap发生错误时，原因往往就是纹理尺寸不是2的整数幂。

当你初始化渲染目标后，你可以在Draw方法中使用下面的代码将它设置为active渲染目标。在这行代码之后的所有东西都会绘制到这个自定义的渲染目标：

device.SetRenderTarget(0, renderTarget);

你需要在Draw方法调用Clear前加入这行代码，因为渲染目标的内容需要在绘制其他东西前进行清除。然后，你绘制整个场景，当场景绘制完后，将渲染目标的内容复制到一张纹理。在这之前，你还要通过activating另一个渲染目标deactivate这个渲染目标。在这个代码中，你通过将第二个参数设置为null将后备缓冲作为渲染目标：

device.SetRenderTarget(0, null);
Texture2D resolvedTexture = renderTarget.GetTexture();

既然你已经activat默认的后备缓冲，那么之后渲染的所有东西又和以前一样被绘制到屏幕上了。

最后一行代码将自定义渲染目标中的内容载入到resolvedTexture变量中。

设置一个尺寸不等于屏幕大小的自定义渲染目标

你也可以选择绘制到一个与屏幕大小不同的渲染目标。这个很有用，例如可以用在post-processing effect的中间过程中。在模糊、intensity和边缘检测shader中可以使用一张小一点的图像减轻显卡的负担。

如果长宽比例与窗口相同，这不是问题。如果你想设置一个长宽比和窗口不同的渲染目标，你的投影矩阵对渲染目标来说将不正确，因为这个投影矩阵是使用窗口长宽比创建的（见教程2－1），所以你需要定义一个新的对应渲染目标的投影矩阵：

PresentationParameters pp = device.PresentationParameters;
int width = pp.BackBufferWidth / 2; int height = pp.BackBufferHeight / 4;
renderTarget = new RenderTarget2D(device, width, height, 1,device.DisplayMode.Format);
rendertargetProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4,
    (float)width / (float)height, 0.5f, 100.0f);

首先你定义了渲染目标的分辨率并创建了对应的渲染目标和投影矩阵。新的渲染目标的宽设置为窗口宽的一半，高为四分之一，这样渲染目标的长宽比与窗口是不同的。如果你还是使用老的投影矩阵，那么场景看起来好像是在竖直方向被压扁了。现在只要你是在把场景渲染到渲染目标内，你就得使用rendertargetProjectionMatrix取代对应窗口的Projection矩阵。代码初始化一个RenderTarget2D对象并设置为你想要的分辨率。注意如果你将内容保存在纹理中，这个纹理的大小必须和渲染目标的大小是一样的。

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);
    spriteBatch = new SpriteBatch(device);
    PresentationParameters pp = device.PresentationParameters;
    int width = pp.BackBufferWidth / 2;
    int height = pp.BackBufferHeight / 4;
    renderTarget = new RenderTarget2D(device, width, height, 1, device.DisplayMode.Format);
    rendertargetProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(MathHelper.PiOver4,
         (float)width / (float)height, 0.5f, 100.0f);
}

在Draw方法中，你activate自定义渲染目标，将场景渲染其中，然后deactivate这个渲染目标，保存其中的内容到一个纹理。注意使用rendertargetProjectionMatrix将3D物体绘制到渲染目标。而这个纹理也可以使用SpriteBatch显示在屏幕上：

protected override void Draw(GameTime gameTime)
{
    device.SetRenderTarget(0, renderTarget);
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0);
    cCross.Draw(fpsCam.ViewMatrix, rendertargetProjectionMatrix);
    device.SetRenderTarget(0, null); Texture2D resolvedTexture = renderTarget.GetTexture();
    graphics.GraphicsDevice.Clear(Color.Tomato);
    spriteBatch.Begin();
    spriteBatch.Draw(resolvedTexture, new Vector2(100, 100), Color.White);
    spriteBatch.End();
    base.Draw(gameTime);
}


3.9 扩展图像内容处理器

问题

你想扩展默认的图像内容导入器控制像素，或者你想学习内容管道（content pipeline）。

解决方案

因为XNA已经提供了一个内容导入器将一个图像文件作为源并最终将它创建为一个Texture2D对象，你要做的只是扩展这个内容导入器。本教程中，你可以调用PixelBitmapContent辅助类的ReplaceColor方法，它是由内容管道框架提供的。

注意：如果你只对alpha颜色感兴趣，图像内容处理器会自动将品红色的像素变为透明。本教程主要是介绍如何扩展内容导入器。

工作原理

本教程主要介绍内容管道和如何扩展已经存在的内容处理器，所以，对内容管道的工作流程有所认识是很重要的，如图3－5所示。

内容（素材）管道（Content Pipeline）

在可以使用一个图像文件前，你需要首先加载它，加载过程包括读取字节，选择有用的数据，如有必要还要解压缩数据。

其他素材如3D模型也是如此，模型的数据从一个文件加载后，经过大量的数据处理，基于这些数据创建一个模型对象。

整个处理过程由磁盘上的一个文件开始，最终生成XNA可用的由内容管道管理的对象。事实上，每种素材都有各自的内容管道，如图3－5所示，一个完整的内容管道是由导入器 （importer），处理器（processor），串行化器（serializer）和反串行化器（deserializer）组成的。

图3-5 内容管道工作流

在编译时(按F5可以进行编译)，源文件会从磁盘读取，它的内容会被处理，最终结果会被串行化至一个. xnb二进制文件。你可以在. exe 文件所在目录的Content子目录下找到这些.xnb文件。当游戏运行时，这个. xnb二进制文件会被反串行化，这样所有有用的信息变得可用而无需进一步的处理。这种方法最明显的优点是所有处理过程只需进行一次（在编译时），而不是在游戏运行时每一次都要进行处理。第二个优点是. xnb文件是平台无关的，你可以同时用在PC和Xbox 360平台上。

让我们分析一下编译过程，因为这个过程在每次编译项目时都会执行，此过程分成三个子过程：

  • 导入器：读取源文件并提取有用的数据，这个数据是存储在一个指定的标准格式中的。例如对一个模型，标准格式是NodeContent对象，对一个图像，标准格式是TextureContent 对象。这种标准格式叫做DOM对象，你可以在表3－1中看到一个默认DOM对象的表格。
  • 处理器：处理包含在DOM对象中的数据并生成在游戏中可用的对象。例如对模型来说，处理器可以添加法线数据，计算切线，设置effect等。
  • 串行化器或TypeWriter：定义了如何从处理器的输出生成.xnb二进制文件。

这种方法的一个额外优点是当你想在编译过程改变点什么，你可能只需改变其中一个子过程。例如，你想创建一个XNA内置不支持的格式的模型，你只需写一个新的导入器，这个导入器读取文件并创建一个NodeContent对象，你可以把其他工作留给默认的内容管道组件，因为NodeContent 的默认处理器会从那儿获取你的对象。

在运行时，只有一个小过程需要被执行：

  • 反串行化器或TypeReader：定义了如何从存储在.xnb文件的二进制数据流中构建游戏对象。因为这里不需要处理计算，相对于编译过程来说这个过程几乎不花费时间。

XNA自带了很多默认内容导入器和处理器。组合TextureImporter和TextureProcessor，你可以导入几乎任何图像格式。组合Ximporter或FbxImporter和ModelImporter，你可以导入. x 或. Fbx模型。

注意：分离导入器和处理器被证明很有用。Ximporter和FbxImporter都能从磁盘导入数据并将它们格式化为一个简单的NodeContent对象，这两种情况中都传递到ModelProcessor，由ModelProcessor进行繁重的工作。

表3-1 XNA Framework中的默认内容导入器和处理器

内容导入器 | 输入(文件) | 输出(DOM对象)
TextureImporter | bmp, .dds, .dib, .hdr, .jpg, .pfm, .png,. ppm, . tga | TextureContent
XImporter | .x | NodeContent
FbxImporter | . fbx | NodeContent
EffectImporter | . fx | EffectContent
FontDescriptionImporter | .spritefont | FontDescription
XmlImporter | .xml | User-defined
XACT Project | .xap | Audio project

内容处理器 | 输入(DOM对象) | 输出(Game OM)
TextureProcessor | TextureContent | TextureContent
ModelProcessor | NodeContent | ModelContent
EffectProcessor | EffectContent | CompiledEffect
PassThroughProcessor | Any | Any
FontDescriptionProcessor | FontDescription | SpriteFontContent
FontTextureProcessor | TextureContent | SpriteFontContent
FontTextureProcessor | Texture2DContent | SpriteFontContent

XNA内容管道带有在编译时将这些对象写入二进制文件的默认串行化器和运行时从一个二进制文件重建对象的反串行化器。化器。

使用默认内容管道框架的组件

写入/扩展一个内容处理器的关键是尽可能地复用已经存在于内容管道中的组件。

本教程中，你将扩展默认TextureProcessor使你可以在图像数据加载到XNA项目之前对它做出改变。在编译时，想从文件读取图像，将内容存储在一个2D数组中形成图像，你还想改变一些像素并将结果放在一个.xnb文件中。

当运行程序时，.xnb文件会从文件读取，并包含了你施加的改变。

首先，从磁盘读取文件并将它们转换为一个2D的颜色数组(无论图像文件的格式是什么)，你应该使用默认的导入器。

然后，你需要扩展TextureProcessor。因为本教程关心的是如何设置一个自定义的内容管道，所以你只需将所有的黑色变成白色。更高级的应用请见下一个教程。

你要确保处理器的输出是一个TextureContent对象，这样你可以使用默认的串行化器将它保存为. xnb文件并可以使用默认反串行化器加载这个. Xnb文件。

图 3-6显示了整个过程。找到你将扩展的处理器，和从XNA中借用的默认组件。

图3-6 你将重写的内容管道处理器的位置

本书中，每次当你处理内容管道时我都会显示一张类似于图3-6的图片，这样你可以清楚地知道你将自己处理哪一部分。扩展一个已存在的内容处理器要扩展一个已存在的内容处理器，需要先进行几个步骤。虽然这些步骤对于一个有经验的.NET 程序员来说是很简单的，我还是将它们罗列出来，以后的教程中你可以参考这些步骤，本章接下来的部分会解释这些步骤。

  1. 在解决方案中添加一个新的内容管道项目。
  2. 在新项目中，添加对Microsoft.XNA. Framework.Content. Pipeline的引用。
  3. 添加管道命名空间。
  4. 指定要扩展的部分(即你要复写的方法)。
  5. 编译新的内容管道项目。
  6. 在主项目中添加新建的这个项目的引用。
  7. 对素材选择新建的处理器。
  8. 设置项目依赖项。
  9. 初始化所有东西后，在第4步中创建的方法中编写代码。

在解决方案中添加一个新的内容管道项目

要扩展或创建一个新的内容导入器/处理器，你需要在解决方案中添加一个新项目。右击你的解决方案选择Add→New Project，如图3-7所示。在弹出的对话框中，选择Content Pipeline Extension Library并起一个合适的名称，如图3-8所示。

图3-7 在解决方案中添加一个新项目

图3-8 创建一个content pipeline extension library

项目中有一个新文件，包含了你定义的命名空间，还包括一个默认的Processor类。新项目已经添加到了解决方案中，如图3-9所示。

图3-9 添加到解决方案中的Content pipeline项目

添加对Microsoft.XNA.Framework.Content.Pipeline的引用

在新项目中，确保对Microsoft. XNA. Framework. Content. Pipeline (version 2.0.0.0)的引用。打开Project菜单选择Add Reference。从列表中添加正确的引用，如图3-10所示。

图3-10 选择XNA pipeline引用

使用代码块添加Pipeline的命名空间

你还要使编译器链接到新的命名空间，添加下列代码块：

using System.IO;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;

指定要扩展的部分

文件已经包含了一个ContentProcessor的模板，用以下代码替换这些代码，指定新处理器的输入和输出。所有这些自定义处理器使用它的基类(默认纹理处理器)处理输入，所以这个新ContentProcessor工作方式与默认纹理处理器是一样的：

namespace MyImagePipeline
{
    [ContentProcessor(DisplayName = "ExtendedExample")]
    public class ExtentedTextureProcessor : TextureProcessor
    {
        public override TextureContent Process(TextureContent input, ContentProcessorContext context)
        {
            return base.Process(input, context);
        }
    }
}

本教程中我们将扩展默认TextureProcessor，所以你需要从TextureProcessor类继承。由于这个原因，你的Process方法接受一个TextureContent对象并将一个TextureContent对象作为输出。

等一会儿你将编写一个真正的Process方法，但现在这个方法只从默认TextureProcessor类继承。

注意：ExtendedExample是你的新自定义处理器的名称。如果你省略了DisplayName标签，你的处理器会使用类的名称，本例中是ExtendedTextureProcessor。

注意：请确保将这个DisplayName放在[ContentProcessor]中。如果不是，XNA不会把这个类当成内容处理器。结果是当浏览可用内容处理器列表时你的处理器不会显示。

编译新的内容管道项目

在下一步你要将这个新项目的引用添加到主项目中，所以你需要首先编译这个项目。按F6可以进行编译，而按F5会编译并运行整个解决方案。

注意：这个编译过程要花几秒钟的时间；你可以在窗口的左下角看到这个处理过程的信息。

将新建的引用添加到主程序中

编译了自定义处理器后，你还需要将它应用到主程序中。在解决方案中找到Content，右击References并选择Add Reference，如图3-11所示。

图3-11 添加到自定义内容管道项目的引用

在弹出的对话框中，找到Projects选项卡，并从列表中选择你的内容管道项目，如图3-12所示。

图3-12 选择内容管道项目

注意：如果内容管道项目不在列表中，你肯定遗漏了前面的编译步骤。

选择新建的处理器处理一个图像文件

当你将一个图像文件导入到项目中时，你可以选择新建的内容处理器处理这个图像文件，如图3-13所示。

图3-13 选择自定义的内容处理器处理图像

现在当你编译项目时，你的自定义处理器就可以用来处理图像文件了！

设置项目依赖项

你对自定义内容处理器进行的每次改变都需手动重新编译项目。要解决这个问题，你可以让主项目依赖于内容管道项目，这样当你每次重新编译主项目时，内容管道项目会首先编译(如果在上次编译后你进行了某些改变)。你可以通过右击主项目选择Project Dependencies设置依赖项。在弹出的对话框中，你需要选择主程序依赖于第二个项目，如图3-14所示。这样，当你按F5编译主程序时，内容管道项目的. dll文件会首先被编译。

图3-14 选择项目依赖项

扩展默认纹理处理器

现在所有东西都进行了初始化，你可以编写自定义代码了。

你已经创建了一个ExtendedTextureProcessor类，这个类从默认的TextureProcessor类继承，你也声明了将复写Process方法。所有内容处理器这个方法都叫做Process并接受一个DOM 对象(在TextureProcessor的情况中是TextureContent对象，如表3-1所示) 和一个ContentProcessorContext对象作为参数。这个context对象用来创建多个生成（nested builds）。例如，当导入一个Model时，这个对象会包含所有纹理文件的名称，这些名称也需要和模型一起被加载，在教程4-12可学到更多这方面的知识。

如表格3-1所示，在TextureProcessor情况中，你需要返回一个TextureContent对象。这种情况中纹理作为处理器的输入和输出，现在的处理器只是将输入传递到基类中：

[ContentProcessor(DisplayName = "ExtendedExample")]
public class ExtentedTextureProcessor : TextureProcessor
{
    public override TextureContent Process(TextureContent input, ContentProcessorContext context)
    {
        return base.Process(input, context);
    }
}

这一步使用TextureProcessor 类的Process方法处理输入(因为你从默认的TextureProcessor 类继承而来)并返回结果图像，所以这也做好了扩展这个处理过程的准备。直到现在，你只是简单地将输入直接传递到输出，所以你的处理器会获得和默认TextureProcessor同样的结果。

注意：这种情况下的TextureProcessor是特殊的，因为输入和输出对象的类型是一样的。在更复杂的情况下，你想让base. Process方法可以将输入对象转换为一个输出模型。例如，如果你扩展了模型处理器，你会首先让base. Process方法将输入的NodeContent对象转换为一个ModelContent对象，这包含大量的工作。一旦你有了默认的ModelContent对象，你就可以方便地扩展/改变它们。可见教程4-12学习一个扩展模型处理器的例子。

在本教程中，你不想让输入立即传递到输出，你想改变一些颜色值。使用标准DOM对象的一个好处是你可以使用已经定义好的默认内容管道。例如，TextureContent类有一个有用的ConvertBitmapType方法可以改变纹理内容的格式，下面的代码改变了texContent 对象内容的颜色：

TextureContent texContent = base.Process(input, context);
texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>));

因为TextureContent类是抽象类，如果是一个2D图像，texContent对象会实例化为一个TextureContent2D对象（也可以是一个TextureContent3D或TextureContentCube对象）。这意味着一张图像可以有多个face和多个mipmap (可见教程3-7了解mipmap的知识)。下面的代码选择第一个face和第一个mipmap，一个简单的2D纹理只有一个face一个mipmap level。

PixelBitmapContent<Color> image = (PixelBitmapContent<Color>)input.Faces[0][0];

PixelBitmapContent类有一个有用的ReplaceColor方法，这个方法可以将图像中指定颜色替换成另一个颜色：

Color colorToReplace = Color.Black;
image.ReplaceColor(Color.Black, Color.White);

这就是你的自定义处理器所做的工作，最后返回TextureContent对象：

return texContent;

在编译过程中，这个对象会被传递到串行化器，串行化器将这个对象保存为二进制文件。在运行过程中这个二进制文件有反串行化器加载并创建一个Texture2D对象。现在确保在XNA项目中导入一个图像，选择自定义处理器处理这张图像，并在LoadContent方法中加载这个Texture2D对象：

myTexture = Content.Load<Texture2D>("image");

多个Face/Mipmap

本例中你从TextureContent处理器继承，这个处理器会生成mipmap (可以代替SpriteTextureProcessor)。关于mipmap的更多信息可见教程3-7。如果导入的图像是一张立方图像，那么这个纹理有六个面。要使教程完整，还需要遍历face和mipmap的代码：

for (int face = 0; face < texContent.Faces.Count; face++)
{
    MipmapChain mipChain = texContent.Faces[face];
    for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++)
    {
        PixelBitmapContent<Color> image = (PixelBitmapContent)input.Faces[face][mipLevel];
        image.ReplaceColor(Color.Black, Color.White);
    }
}

Faces属性的第一个索引是图像的face。标准2D图像只有一个face，而立方纹理有六个 face。第二个索引是mipmap level，不使用mipmapp的图像只有一个level。这个代码对纹理的每个face的每个mipmap level将你选择的颜色(本例中是黑色)变成白色（译者注：实际代码中是将小女孩蓝色的头发替换成黄色）。

代码

下面是扩展默认内容处理器的所有代码，记住要使代码正常工作，你需要在新项目中添加对Microsoft.XNA. Framework. Content. Pipeline的引用。

using System; using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline;
using Microsoft.Xna.Framework.Content.Pipeline.Graphics;
using Microsoft.Xna.Framework.Content.Pipeline.Processors;
using System.IO;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Compiler;

namespace MyImagePipeline
{
    [ContentProcessor(DisplayName = "ExtendedExample")]
    public class ExtentedTextureProcessor : TextureProcessor
    {
        public override TextureContent Process(TextureContent input, ContentProcessorContext context)
        {
            TextureContent texContent = base.Process(input, context);
            texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>));

            for (int face = 0; face < texContent.Faces.Count; face++)
            {
                MipmapChain mipChain = texContent.Faces[face];
                for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++)
                {
                    PixelBitmapContent<Color> image = (PixelBitmapContent<Color>) input.Faces[face][mipLevel];
                    image.ReplaceColor(Color.Black, Color.White);
                }
            }
            return texContent;
        }
    }
}


3.10 扩展图像内容处理器：灰度变换和处理器参数

问题

你想扩展图像处理器用来处理每个独立像素的颜色。你还想从XNA主项目中改变处理器的参数。

解决方案

在前面的教程中，你知道了如何扩展内容处理器，让你可以处理并改变独立像素的值。

在Processor类中声明的所有公共变量都可以在素材的属性面板中设置。

工作原理

开始的初始化步骤已经在教程3-9中解释过了。复制下列代码让你可以处理图像文件中的所有图像(有些图像文件，例如texCube，包含多个图像)：

namespace GrayContentPipeline
{
    [ContentProcessor(DisplayName = "GrayScaleProcessor")]
    public class ExtentedTextureProcessor : TextureProcessor
    {
        public override TextureContent Process(TextureContent input, ContentProcessorContext context)
        {
            TextureContent texContent = base.Process(input, context);
            texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>));
            for (int face = 0; face < input.Faces.Count; face++)
            {
                MipmapChain mipChain = input.Faces[face];
                for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++)
                {
                    ...
                }
            }
            return texContent;
        }
    }
}

在内部的循环中你处理了图像的所有face。首先将当前face转化为一个PixelBitmapContent对象让你可以独立地处理每个像素。你可以立即创建第二个PixelBitmapContent对象，在第二个中存储新的颜色：

PixelBitmapContent<Color> oldImage = (PixelBitmapContent<Color>)input.Faces[face][mipLevel];
PixelBitmapContent<Vector4> grayImage = new PixelBitmapContent<Vector4>(oldImage.Width, oldImage.Height);

注意：对于这个例子来说，你可以简单地用新的颜色值覆盖旧的颜色值。但是，对于很多图像处理技术来说，为了计算像素的新颜色，你往往还需要访问周围像素的原始颜色，如果你能从旧图像分离出一张新图像就不会有任何问题。

如你所见，你可以自由地定义你想接受的颜色格式。本例中，你指定想要读取的原始颜色的格式是Color，但制定新颜色格式为Vector4。

下一步，你遍历图像的所有像素并获取原始颜色：

for (int x = 0; x < oldImage.Width; x++)
    for (int y = 0; y < oldImage.Height; y++)
    {
        Color oldColor = oldImage.GetPixel(x, y);
    }

一旦知道了原始颜色，你就可以定义对应的灰度颜色了。不要简单的将三个颜色通道取平均值，因为人眼对绿色更加敏感。而且每个颜色通道使用一个介于0至255之间的整数表示的，但是，当你创建一个新颜色时，你需要使用介于0与1之间的浮点数表示颜色值。这意味着你需要将初始颜色值除以255：

Color oldColor = oldImage.GetPixel(x, y);
float grayValue = oldColor.R * 0.299f / 255.0f;
grayValue += oldColor.G * 0.596f / 255.0f;
grayValue += oldColor.B * 0.211f / 255.0f;
float alpha = oldColor.A / 255.0f;
Vector4 grayColor = new Vector4(grayValue, grayValue, grayValue, alpha);
grayImage.SetPixel(x, y, newColor);

这可以将图像的所有像素替换为对应的灰度颜色。在两次循环后，确保将新颜色复制到当前的图像face中：

input.Faces[face][mipLevel] = grayImage;

调试内容处理器

你常常需要调试内容管道。这比调试普通的项目难，因为在编译时往往会忽略断点，你不得不手动将一个调试器连接到内容项目中。例如，你想调试项目中的图像中心的像素，可以使用以下代码：

if ((x == oldImage.Width / 2) && (y == oldImage.Height / 2))
{
    System.Diagnostics.Debugger.Launch();
}

当编译项目时，Visual Studio会提醒你指定使用哪个调试器，选择New Instance of Visual Studio。

一旦调试器开始运行，你可以在任何你想插入断点的地方调用System. Diagnostics. Debugger. Break( )。

定义处理器参数

通过在中添加共有变量你可以让你的处理器变得更加灵活。例如，将下列代码添加到Processor 类的顶部： public float interpolation = 0.8f; 当你编译项目时，你可以从XNA主项目中设置这个值，如图3-15所示。

图3-15 设置自定义处理器的参数

你可以将这个变量用在内容处理器的任何地方。本例中，我们可以使用这个变量在普通的带颜色的图像和新的灰度图像间进行插值，如果interpolation的值为0，则将带颜色的像素存储在新图像中，如果这个值为1，则存储灰度图像。

Vector4 grayColor = new Vector4(grayValue, grayValue, grayValue, alpha);
Vector4 origColor = oldColor.ToVector4();
Vector4 newColor = Vector4.Lerp(origColor, grayColor, interpolation);
grayImage.SetPixel(x, y, newColor);

代码

下面是灰度图像处理器的完整代码：

namespace GrayContentPipeline
{
    [ContentProcessor(DisplayName = "GrayScaleProcessor")]
    public class ExtentedTextureProcessor : TextureProcessor
    {
        private float interpolation = 0.8f;

        public float Interpolation
        {
            get { return interpolation; }
            set { interpolation = value; }
        }

        public override TextureContent Process(TextureContent input, ContentProcessorContext context)
        {
            TextureContent texContent = base.Process(input, context);
            texContent.ConvertBitmapType(typeof(PixelBitmapContent<Color>));

            for (int face = 0; face < input.Faces.Count; face++)
            {
                MipmapChain mipChain = input.Faces[face];
                for (int mipLevel = 0; mipLevel < mipChain.Count; mipLevel++)
                {
                    PixelBitmapContent<Color> oldImage = PixelBitmapContent<Color>)input.Faces[face][mipLevel];
                    PixelBitmapContent<Vector4> grayImage = new PixelBitmapContent<Vector4> (oldImage.Width, oldImage.Height);
                    for (int x = 0; x < oldImage.Width; x++)
                        for (int y = 0; y < oldImage.Height; y++)
                        {
                            Color oldColor = oldImage.GetPixel(x, y);
                            float grayValue = oldColor.R * 0.299f / 255.0f;
                            grayValue += oldColor.G * 0.596f / 255.0f;
                            grayValue += oldColor.B * 0.211f / 255.0f;
                            float alpha = oldColor.A / 255.0f;
                            Vector4 grayColor = new Vector4(grayValue,grayValue, grayValue, alpha);
                            Vector4 origColor = oldColor.ToVector4();
                            Vector4 newColor = Vector4.Lerp(origColor, grayColor, interpolation);
                            grayImage.SetPixel(x, y, newColor);
                        }
                        input.Faces[face][mipLevel] = grayImage;
                }
            }
            return texContent;
        }
    }
}


3.11 Billboarding：在3D世界绘制2D图像使它们总是面向相机

问题

要使画面给人留下深刻印象，一个3D世界需要包含许多物体，特别是处理室外场景时。例如，如果没有绘制成百上千棵树，树林看起来就会显得不真实。但是，以3D模型的形式绘制几百颗树是不可能的，因为这样做会极大地拖慢应用程序。

你也可以使用少量的billboarding（译者注：billboard可以翻译为公告板，广告牌），例如，绘制激光束或子弹。这通常和一个粒子引擎组合在一起(见教程3-12)。

解决方案

你可以通过使用一张2D图像代替3D物体解决这个问题。但是当相机在2D图像旁边时，观察者很容易看出这是一张2D图像，如图3-16的左图所示，左图中有5张2D图像放置在3D世界中。

图3-16 3D场景中的2D图像。没有bilboarded (左图)，有billboarded (右图)。

要解决这个问题，对每个图像你想在3D空间中定义两个三角形显示这个图像，而且你想旋转这些三角形使图像能够朝向相机。如图3-16中的右图所示，有与左图相同的5张2D图像，但经过旋转朝向相机。如果这些图像包含树木，它们的边界会使用透明色，可以实现一个漂亮的效果。

XNA Framework包含计算每个图像两个三角形的六个顶点的旋转位置的功能，这个功能由Matrix. CreateBillboard方法提供，但是在vertex shader中进行这些计算会获得极大的性能提升，这会在本教程的第二部分解释。

工作原理

作为树林或粒子引擎的共同部分，你想只定义2D图像的中心的3D位置和它的大小。所以，你想使用一个集合包含每个2D图像的3D位置和大小：

List<Vector4> billboardList = new List<Vector4>();
Texture2D myTexture;
VertexPositionTexture[] billboardVertices;

可以看到billboards存储为一个Vector4：三个浮点数存储中心的3D位置，另一个浮点数保存billboard的大小。

对每个billboarded 2D图像，你需要计算图像的两个三角形的六个顶点。这些顶点存储在最后一行代码定义的billboardVertices变量中。myTexture变量保存用来给显卡采样颜色的纹理。

技巧：如教程3-4中所述，绘制多个纹理的最快方法是将它们存储在一张大纹理中，这样显卡就无需切换纹理了。

在LoadContent方法中添加以下代码将纹理加载到myTexture变量中：

myTexture = content.Load<Texture2D>("billboardtexture");

然后添加一个简单的方法让你可以容易地将billboard添加到场景中：

private void AddBillboards()
{
    billboardList.Add(new Vector4(-20, -10, 0, 10));
    billboardList.Add(new Vector4(0, 0, 0, 5));
    billboardList.Add(new Vector4(20, 10, 0, 10));
}

现在这个方法只添加了三个billboard。第四个分量表示你想让第二个billboard大小只有另两个的四分之一，因为边长为另两个的二分之一。别忘了在Initialize方法中调用这个方法：

AddBillboards();

然后，你需要计算六个顶点的位置。Billboarding有两种主要方式：球面billboarding和圆柱面billboarding。球面billboarding主要用于粒子引擎，圆柱面billboarding主要用于绘制树木，人等。

为球面Billboarding计算六个顶点位置

技巧：如果你想使用HLSL实现billboard，可以略过此节直接看本教程的“性能考虑：第二部分”，因为在GPU上进行billboarding计算可以给程序带来极大的提升。“性能考虑：第一部分”也值得一读。

现在你已经定义了billboard的中心位置，可以计算中心点周围的六个顶点了：

private void CreateBBVertices()
{
    billboardVertices = new VertexPositionTexture[billboardList.Count * 6];

    int i = 0;
    foreach (Vector4 currentV4 in billboardList)
    {
        Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z);
        float scaling = currentV4.W;

        //add rotated vertices to the array
    }
}

上述代码首先创建了一个数组存储每个billboard的两个三角形的六个顶点。然后，对存储在billboardList 中的每个billboard，获取它的中心位置和大小。for循环中的其余部分是计算六个顶点位置的实际代码。

对每张2D图像，你需要定义两个三角形用以在3D空间定义一个矩形。这意味着你需要定义六个顶点，但其中四个是独立的。

因为需要显示一张纹理，所以使用VertexPosit ionTexture。因为billboardList保存每个矩形的中心位置，你想让所有的顶点到中心的距离相同。在本例中，需要到/从中心的X和Y分量中增加/减少0.5f的偏移量获取顶点的位置。

例如，下面的三个位置保存离开一个三角形中心位置的偏移量，DL指左下，UR指右上：

Vector3 posDL = new Vector3(-0.5f, -0.5f, 0);
Vector3 posUR = new Vector3(0.5f, 0.5f, 0);
Vector3 posUL = new Vector3(-0.5f, 0.5f, 0);

如果你将中心位置添加到上述位置中，就获得了一个三角形的顶点位置。但是，无论相机的位置如何，这些位置将保持不变。所以当你移动相机时，就会看到这个三角形的侧面，如图3-16的左图所示。而你想让三角形始终朝向相机，因此需要根据相机的位置对这些偏移量进行某种形式的旋转。这需要数学知识，幸运的是，XNA可以立即生成一个矩阵用来进行这种变换。在for循环的顶部添加以下代码：

Matrix bbMatrix = Matrix.CreateBillboard(center, quatCam.Position, quatCam.UpVector, quatCam.Forward);

要能创建这个矩阵用来旋转三角形使图像始终朝向相机，XNA需要知道矩形和相机的位置，相机的Forward向量。因为你想让球形billboarding旋转图像使它根据相机朝向上方，你还需要指定Up向量。

注意：当你颠倒相机时，矩形也会随之颠倒。要展示这种情况，本例中使用了一个向上箭头作为纹理。而且，为了让你不受限制地旋转相机，使用了教程2-4中的四元数相机而不是教程2-3中的quake相机。

有了这个矩阵，就可以获取旋转偏移量：

Vector3 posDL = new Vector3(-0.5f, -0.5f, 0);
Vector3 billboardedPosDL = Vector3.Transform(posDL*scaling, bbMatrix);
billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1));

第一行代码定义了一个静态偏移量，并没有考虑相机的位置。你还需指定矩形的大小，将偏移量乘以scaling值，这样矩形会在 AddBillboards 方法中成为你指定的大小。现在知道了顶点相对于中心的偏移量，使用矩阵进行变换。最后，在顶点数组中存储这个变换过的位置和纹理坐标。

如果对三角形的三个顶点都进行这样的操作，那么三角形就会始终朝向相机。

注意：你只对偏移量进行变换：相对于中心的顶点平移会通过矩阵变换自动实现！这也是创建bbMatrix时需要指定矩形中心位置的原因之一。

你需要对每个偏移量都进行这个变换。因为两个三角形共享两个两个点，因此只需进行四次变换。下面是CreateBBVertices方法：

private void CreateBBVertices()
{
    billboardVertices = new VertexPositionTexture[billboardList.Count * 6];

    int i = 0;
    foreach (Vector4 currentV4 in billboardList)
    {
        Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z);
        float scaling = currentV4.W;
        Matrix bbMatrix = Matrix.CreateBillboard(center, quatCam.Position, quatCam.UpVector, quatCam.Forward);

        //first triangle
        Vector3 posDL = new Vector3(-0.5f, -0.5f, 0);
        Vector3 billboardedPosDL = Vector3.Transform(posDL * scaling, bbMatrix);
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1));
        Vector3 posUR = new Vector3(0.5f, 0.5f, 0);
        Vector3 billboardedPosUR = Vector3.Transform(posUR * scaling, bbMatrix);
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosUR, new Vector2(0, 0));
        Vector3 posUL = new Vector3(-0.5f, 0.5f, 0);
        Vector3 billboardedPosUL = Vector3.Transform(posUL * scaling, bbMatrix);
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosUL, new Vector2(1, 0));

        //second triangle: 2 of 3 corner points already calculated!
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosDL, new Vector2(1, 1));
        Vector3 posDR = new Vector3(0.5f, -0.5f, 0);
        Vector3 billboardedPosDR = Vector3.Transform(posDR * scaling, bbMatrix);
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosDR, new Vector2(0, 1));
        billboardVertices[i++] = new VertexPositionTexture(billboardedPosUR, new Vector2(0, 0));
    }
}

对billboardList中的每个项目，这个方法都会计算四个点，让矩形朝向相机。

确保在相机的位置或旋转发生变化时调用这个方法，因为所有billboard的旋转都会相应地进行调整！为了安全起见，在Update方法的最后调用这个方法。

从顶点数组绘制

有了包含3D位置和纹理坐标的所有顶点，你就可以在Draw方法中绘制它们了，解释请见教程5-1：

//draw billboards
basicEffect.World = Matrix.Identity;
basicEffect.View = quatMousCam.ViewMatrix;
basicEffect.Projection = quatMousCam.ProjectionMatrix;
basicEffect.TextureEnabled = true;
basicEffect.Texture = myTexture;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2);
    pass.End();
}
basicEffect.End();

你想从billboardVertices数组进行绘制，数组中的顶点表示三角形的集合。因为每个2D图像都是一个矩形，需要绘制两个三角形，所有你需要绘制总共billboard List . Count*2 个三角形。因为使用的是TriangleList，这个数量等于billboardVertices. Length/3。

性能考虑：第一部分

当使用billboard时，很容易碰到需要绘制几千个billboard的情况。例如你可以像以下代码一样定义几千个billboards：

private void AddBillboards()
{
    int CPUpower = 10;
    for (int x = -CPUpower; x < CPUpower; x++)
        for (int y = -CPUpower; y <CPUpower; y++)
            for (int z = -CPUpower; z < CPUpower; z++)
                billboardList.Add(new Vector4(x, y, z, 0.5f));
}

上述代码会使用边长为0.5f的billboard填充从(-10, -10, -10)到(9, 9, 9)范围内的立方体，billboard 间的距离为1个单位，总共有8000个billboards！如果在你的PC上运行得不流畅，可以试着减少CPU power值，这样绘制的billboard更少。

你的PC之所以可以绘制这么多的billboard只是因为你使用了一个很大的顶点数组存储了所有biillboard。通过这种方式，显卡可以一次性在单一的过程中处理16000个三角形。 这个代码与前面的代码相比还有一个小变化。

在前面的代码中，对每个billboard都会创建一个新的顶点数组并填充了六个顶点，与前面的方式一样。然后，对每个billboard，显卡从六个顶点绘制两个三角形，整个过程周而复始。 对CPU来说几乎同样的工作量，显卡需要被调用8000次绘制两个三角形，在这个任务中显卡被打搅了7999次，因此显卡很“生气”。

使用这个方法，我可以将CPUpower设为3减少帧率的下降，这种情况下只绘制了216个！

所以底线是让显卡在一次性在单一过程中做自己的工作，这也是显卡喜欢的工作方式。请总是从尽可能少的顶点缓冲/纹理中绘制场景。

为圆柱形Billboarding计算六个顶点

在某些情况中，你并不想让矩形朝向相机。例如，如果使用一张树的2D图像创建一片树林，你想让每张图像绕着树木的主干旋转。使用球形billboarding会导致绕着三个轴旋转，使它们完全朝向相机。

想象一下这种情况，你有一些树木的图像，相机在它们上面。使用球形billboarding，结果如图3-17的左上所示。在图3-17的左下，你可以看到为了让矩形朝向相机，树木以一种不自然的方式进行了旋转，当相机移动时会产生一些奇怪的效果。

你想要的效果是让矩形只绕着树木的Up向量旋转，如图3-17的右下所示。在3D视角中，会产生如图3-17中右上图所示的效果。

图3-17 球形billboarding (左)，圆柱形billboarding (右)

注意：这种billboarding之所以称之为圆柱形的，是因为如果你将相机放置在如此多的billboard图像之内，它们会成为一个圆柱体隧道。导致这个现象的原因是它们只被允许沿着一个方向旋转。图3-18显示了这样一个隧道。

计算每个billboard顶点的代码与前面的几乎一样。你只需使用另一个billboarding矩阵，这个矩阵可以通过使用Matrix.CreateConstrainedBillboard方法获取：

Matrix bbMatrix = Matrix.CreateConstrainedBillboard(center,quatCam.Position, new Vector3(0, 1, 0), quatCam.Forward, null);

作为一个有约束的旋转，现在你可以指定billboard 绕着那条边旋转。对一颗树来说，这条边是(0,1,0) Up向量。为使当相机非常靠近物体时旋转更加精确，你也可以指定相机的Up向量。

图3-18 大量的圆柱形billboarding

性能考虑：第二部分

本教程的第一部分展示了如何使用XNA代码创建一个billboarding引擎。但是，这个方法会给CPU带来极大地压力，而对显卡利用效率不高。

想象一下在3D场景中绘制1000个billboard图像。只要相机改变位置，就需要重新计算图像四个顶点的位置使它们可以重新朝向相机。若更新频率为60次/秒，需要每秒计算60乘以4000个3D位置，这都会在CPU中进行。而且，每帧都需要创建一个6000个顶点的数组保存所需的三角形用来显示每个billboard。

CPU是通用目的的处理器，并没有对这种计算进行优化。这样的任务会拖慢CPU，限制了CPU做其他任务，例如处理游戏逻辑。

而且，每一帧这个更新过的顶点缓冲都要被发送到显卡中！这会让显卡将这个数据放置在系统内存中，在PCI-express (或 AGP)总线上进行大量的搬运工作。

如果你一次性地将每个billboard的中心位置和大小存储在显存中，让GPU(显卡的计算单元)代替CPU进行所有的billboarding计算，性能会有很大的提升。记住显卡对顶点的操作是经过优化的，这些运算在GPU中要比在CPU中要快得多。有以下几个优点：

  • 在PCI-express (或AGP) 总线中只需进行一次传输
  • 计算时间大大缩短(因为GPU对这种计算进行过优化)
  • CPU不进行billboarding计算，让CPU可以做其他更重要的事情

针对HLSL Billboarding所做的代码准备

你仍可以使用一个集合存储billboard，所以AddBillboards方法无需改动。但是CreateBBVertices方法进行了简化，因为所有的billboarding计算在GPU的vertex shader中进行。

一个billboard仍使用两个三角形绘制，因此每对个billboard仍需传递六个顶点至GPU。 那么，你想让vertex shader做什么呢？你想让vertex shader计算六个顶点的3D旋转坐标使billboard可以朝向相机。要做到这步，vertex shader需要知道每个顶点的如下信息：

  • 顶点所属的billboard的中心的3D位置
  • 当前顶点位于billboard的哪个位置，这样vertex shader才可以计算对应的偏移量
  • 纹理坐标，它们被传递到pixel shader用来从纹理中采样正确的位置

注意：显然在旋转billboard之前vertex shader还需要知道相机在3D空间中的位置。相机位置对每个顶点来说都是一样的，因此应该被设置为一个XNA-to-HLSL变量，而不是在每个顶点中存储这个位置。

这意味着属于一个billboard的六个顶点所携带的部分信息是相同的：billboard中心的3D位置。因为定义顶点的信息是四个顶点之一，你可以，例如，传递一个介于0和3之间的数字，0表示左上角顶点，3表示右下角顶点。

但是，你已经将这个信息通过纹理坐标的方式传递到vertex shader中了。事实上，(0,0)纹理坐标表示当前顶点为左上角顶点，(1,0)纹理坐标表示右上角顶点。

总而言之：对你想绘制的每个billboard，你需要绘制两个三角形，所以需要传递六个顶点到vertex shader中。这六个顶点携带了同样的位置信息：billboard的中心点位置。每个顶点都携带各自的纹理坐标，用来对两个三角形正确地施加纹理，但在这种情况中纹理坐标还被用vertex shader来确定当前顶点是哪个位置的顶点，所以vertex shader可以计算当前顶点离开 billboard中心点的偏移量。

所以在XNA代码中，需要调整CreateBBVertices方法使它生成这些顶点并将这些顶点存储在一个数组中：

private void CreateBBVertices()
{
    billboardVertices = new VertexPositionTexture[billboardList.Count * 6];

    int i = 0;
    foreach (Vector4 currentV4 in billboardList)
    {
        Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z);

        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 0));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1));
        billboardVertices[i++] = new VertexPositionTexture(center, new Vector2(0, 0));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1));
        billboardVertices[i++] = new VertexPositionTexture(center, new Vector2(0, 1));
    }
}

对集合中的每个billboard，在数组中添加六个顶点。每个顶点包含billboard中心点的位置信息和纹理坐标。

你只需在程序的开头调用这个方法一次，因为相机位置发生变化时数组的内容并不需要更新！所以在Initialize方法中在AddBillboards方法之后调用这个方法：

AddBillboards();
CreateBBVertices();

现在顶点已经做好了传递到GPU的准备，可以开始vertex shader的编写了。让我们从圆柱形billboarding开始，因为这个比球形billboarding容易点。

注意：将这个数据存储在显存中可以让你获益，因为你无需更新它的内容。这可以通过从billboardVertices数组创建一个VertexBuffer实现，可见教程5-4学习如何创建一个 VertexBuffer。

用于圆柱形Billboarding的Vertex Shader

首先定义变量，纹理和vertex/pixel shader输出结构：

//XNA interface
float4x4 xView;
float4x4 xProjection;
float4x4 xWorld;
float3 xCamPos;
float3 xAllowedRotDir;

//Texture Samplers
Texture xBillboardTexture;
sampler textureSampler = sampler_state
{
    texture = <xBillboardTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = CLAMP;
    AddressV = CLAMP;
};

struct BBVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
};

struct BBPixelToFrame
{
    float4 Color : COLOR0;
};

和往常一样要将3D世界绘制到2D屏幕中，需要World，View和Projection矩阵。要进行billboarding计算，你还需要知道相机的当前位置。因为你编写的是圆柱形billboarding，你还需要定义一个图像绕着旋转的轴。

你只需要一个纹理，将图像绘制在两个三角形中。对每个顶点，vertex shader会计算对应的2D屏幕坐标和纹理坐标。对每个像素，pixel shader将输出颜色。

在billboarding的情况中，vertex shader用来计算偏离中心位置的偏移量。让我们从编写添加偏移量的代码开始，billboarding是建立在这个代码的基础上的：

// Technique: CylBillboard
BBVertexToPixel CylBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
{
    BBVertexToPixel Output = (BBVertexToPixel)0;

    float3 center = mul(inPos, xWorld);
    float3 upVector = float3(0,1,0);
    float3 sideVector = float3(1,0,0);
    float3 finalPosition = center;

    finalPosition += (inTexCoord.x-0.5f)*sideVector;
    finalPosition += (0.5f-inTexCoord.y)*upVector;

    float4 finalPosition4 = float4(finalPosition, 1);
    float4x4 preViewProjection = mul (xView, xProjection);

    Output.Position = mul(finalPosition4, preViewProjection);
    Output.TexCoord = inTexCoord;

    return Output;
}

对每个顶点，vertex shader接受定义在XNA项目中的位置和纹理坐标。注意这个位置是 billboard 中心点的3D位置。与World矩阵的乘积让你可以定义一个全局World矩阵旋转/缩放/平移所有billboards。最后的中心位置存储在一个叫做center的变量中，对一个billboard中的六个顶点都是相同的。

然后，定义一个静态的Side向量和Up向量。这些向量用来对顶点相对于billboard 中心点进行偏移。在图3-19中看一下如何偏移六个顶点，图中显示了一个billboard的两个三角形。注意看外部四个纹理坐标和三角形顶角上的六个顶点索引。

图3-19 六个顶点相对于中心位置的偏移量

接下来的代码计算指定顶点的3D位置。首先从中心位置开始然后，你想知道当前向量是否需要偏移到(-1,0,0)向左方向或(1,0,0)向右方向。你可以通过观察图3-19中的X纹理坐标进行这个判断：纹理坐标为(0,0)的顶点位于左上方，所以它需要移动到左边。纹理坐标为(1,0)的顶点在右上方，它需要被移动到右方。

以上的操作只需使用一行代码：首先将X纹理坐标减0.5，如果顶点在左侧结果是–0.5f，如果在右侧则为+0.5f。将结果乘以(+1,0,0)向量，左边的顶点结果为(–0.5f,0,0)，右边的顶点结果为(+0.5f,0,0)！

同样的方法也用在Y纹理坐标上：纹理坐标(0,0)代表左上角顶点，(0,1)代表左下角顶点。所以如果Y纹理坐标为0，就是顶部顶点；如果为1则为底部顶点。

将正确的Side和Up偏移作用在中心位置上，你就获得了指定顶点的3D位置。

现在你要做的就是像往常一样使用ViewProjection矩阵将这个3D位置转换到2D屏幕空间中。在使用一个4 × 4矩阵将这个float3进行转换前，还需要使它变为一个float4，只需将1作为第四个坐标就可以了。

当使用pixel shader时，所有billboard都是漂亮的矩形，但是它们是互相平行的。这是因为你还没有使用代码中的相机位置计算偏移量，而这个偏移量用来旋转三角形使它们朝向相机。

显然在vertex shader中使用静态的Up和Side向量是无法实现上述功能的。圆柱形 billboarding容易些，因为你已经定义了billboard的Up向量：它是允许的旋转方向，通过XNA程序中的xAllowedRotDir变量被传递到shader中。

图3-20中的左图显示了两个billboard的例子，例如两棵树。因为你在XNA程序中指定了旋转方向作为树的Up向量，所以在vertex shader中已经知道了Up向量。

图3-20 两个圆柱形bilboard

Billboard的Side向量预先不知道，但是你可以获取这个向量。你需要知道Eye向量，这个向量从眼睛指向billboard的中心，如图3-20中的虚线所示。你知道Side向量垂直于Eye向量和Up向量，如图3-20右图所示，显示的情况与左图相同，只是观察角度不同。

你可以通过叉乘两个向量获取同时垂直于这两个向量的向量(见教程4-18)，所以这就是获取Side向量的方法。

现在的vertex shader如下所示：

float3 center = mul(inPos, xWorld);
float3 eyeVector = center - xCamPos;
float3 upVector = xAllowedRotDir;
upVector = normalize(upVector);
float3 sideVector = cross(eyeVector, upVector);
sideVector = normalize(sideVector);

float3 finalPosition = center;
finalPosition += (inTexCoord.x-0.5f)*sideVector;
finalPosition += (0.5f-inTexCoord.y)*upVector;

你可以通过B-A获取任何从点A指向点B的向量，这个方法可以获取Eye向量。前面已经说过，billboard的Up向量是允许旋转的方向，是在XNA代码中指定的。因为Side向量垂直于Eye向量和Up向量，你可以通过叉乘这两个向量获取Side向量。

你需要确保Side和Up向量是单位长度(否则你无法控制billboard的大小)，因此需要归一化这两个向量。

知道了billboard的Up和Side向量后，你可以重用前面变换3D位置的代码。现在vertex shader使用相机的当前位置计算顶点的最终位置，只要相机发生移动顶点就会改变它们的位置。

完成Billboarding：Pixel Shader和定义Technique

现在vertex shader可以计算顶点的3D坐标了，你要做的就是在pixel shader中将纹理施加在三角形上：

BBPixelToFrame BillboardPS(BBVertexToPixel PSIn) : COLOR0
{
    BBPixelToFrame Output = (BBPixelToFrame)0;

    Output.Color = tex2D(textureSampler, PSIn.TexCoord);

    return Output;
}

当然还要定义technique：

technique CylBillboard
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 CylBillboardVS();
        PixelShader = compile ps_1_1 BillboardPS();
    }
}

现在你要做的就是将这个HLSL文件(即bbEffect. fx)导入到项目中，在LoadContent方法中加载到一个变量中：

bbEffect = content.Load<Effect>("bbEffect");

在Draw中，设置所需的XNA-to-HLSL变量并绘制billboards！

bbEffect.CurrentTechnique = bbEffect.Techniques["CylBillboard"];
bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
bbEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix);
bbEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix);
bbEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position);
bbEffect.Parameters["xAllowedRotDir"].SetValue(new Vector3(0,1,0));
bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture);

bbEffect.Begin();
foreach (EffectPass pass in bbEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2);
    pass.End();
}
bbEffect.End();

这里将(0,1,0) Up方向作为billboards旋转方向。

如果你的PC有一块独立显卡的话，你可以绘制比XNA-only版本多得多的billboard。

用于球形Billboarding的Vertex Shader

球形billboarding和圆柱形billboarding的区别在于球形billboarding中每个billboard都是完全朝向相机的。对vertex shader代码来说，Up和Side向量都要垂直于Eye向量(在圆柱形billboarding中，只有Side向量垂直于Eye向量)。

球形billboarding的挑战是你还需要获取Up向量，因为这个向量预先是不知道的。要解决这个问题，你首先需要知道相机的Up向量。

因为你想让billboard的整个面都朝向相机，所以需要将billboard的Side向量同时垂直于Eye向量和相机的Up向量。要展示这个情况，我在图3-21的左图中将Eye和CamUp向量所在平面涂成了灰色，这样你就可以看出Side向量是垂直于这个平面的。因为它们是垂直的，你已经知道如何获取Side向量了：叉乘Eye向量和CamUp向量：

float3 sideVector = cross(eyeVector,xCamUp);
sideVector = normalize(sideVector);

图3-21 为球形bilboarding找到Side和Up向量

知道了Side向量，你还可以获取 billboard 的Up向量：它垂直于Side向量和Eye向量。我在图3-21的右图中将Side和Eye向量所在平面涂成了灰色，让你可以将它形象化。这意味着billboard的Up向量就是Eye和Side向量的叉乘：

float3 upVector = cross(sideVector,eyeVector);
upVector = normalize(upVector);

这就是球形billboarding相对于圆柱形billboarding需要改变的地方！别忘了定义xCamUp variable . . .

float3 xCamUp;

. . .和一个新technique：

technique SpheBillboard
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 SpheBillboardVS();
        PixelShader = compile ps_1_1 BillboardPS();
    }
}

pixel shader是相同的。

确保在XNA项目中调用正确的technique并设置xCamUp参数：

bbEffect.CurrentTechnique = bbEffect.Techniques["SpheBillboard"];
bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
bbEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix);
bbEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix);
bbEffect.Parameters["xCamPos"].SetValue(quatCam.Position);
bbEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector);
bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture);

代码

注意你可以看到五种不同类型的billboarding的代码：

  • XNA—only代码：球形billboarding
  • XNA—only代码：圆柱形billboarding
  • XNA + HLSL代码：球形billboarding
  • XNA + HLSL代码：圆柱形billboarding
  • XNA + HLSL代码：可变大小的球形billboarding

下面的代码是XNA+HLSL球形billboarding。下面的方法定义在哪放置billboards:

private void AddBillboards()
{
    int CPUpower = 10;
    for (int x = -CPUpower; x < CPUpower; x++)
        for (int y = -CPUpower; y <CPUpower; y++)
            for (int z = -CPUpower; z < CPUpower; z++)
                billboardList.Add(new Vector4(x, y, z, 0.5f));
}

下面的代码将集合转换为数组：

private void CreateBBVertices()
{
    billboardVertices = new VertexPositionTexture[billboardList.Count * 6];

    int i = 0;
    foreach (Vector4 currentV4 in billboardList)
    {
        Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z);

        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 1));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(1, 0));

        billboardVertices[i++]= new VertexPositionTexture(center, new Vector2(1, 1));
        billboardVertices[i++]= new VertexPositionTexture(center, new Vector2(0, 1));
        billboardVertices[i++] = new VertexPositionTexture(center,new Vector2(0, 0));
    }
}

下面的代码调用正确的technique，设置它的参数，并从数组进行绘制：

bbEffect.CurrentTechnique = bbEffect.Techniques["SpheBillboard"];
bbEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
bbEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix);
bbEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix);

bbEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position);
bbEffect.Parameters["xAllowedRotDir"].SetValue(new Vector3(0,1,0));
bbEffect.Parameters["xBillboardTexture"].SetValue(myTexture);
bbEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector);

bbEffect.Begin();
foreach (EffectPass pass in bbEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleList, billboardVertices, 0, billboardList.Count*2);
    pass.End();
}
bbEffect.End();

在HLSL部分，首先是vertex shader：

BBVertexToPixel SpheBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
{
    BBVertexToPixel Output = (BBVertexToPixel)0;

    float3 center = mul(inPos, xWorld);
    float3 eyeVector = center - xCamPos;
    float3 sideVector = cross(eyeVector,xCamUp);
    sideVector = normalize(sideVector);

    float3 upVector = cross(sideVector,eyeVector);
    upVector = normalize(upVector);

    float3 finalPosition = center;
    finalPosition += (inTexCoord.x-0.5f)*sideVector*0.5f;
    finalPosition += (0.5f-inTexCoord.y)*upVector*0.5f;
    float4 finalPosition4 = float4(finalPosition, 1);

    float4x4 preViewProjection = mul (xView, xProjection);
    Output.Position = mul(finalPosition4, preViewProjection);
    Output.TexCoord = inTexCoord;

    return Output;
}

然后是简单的pixel shader：

BBPixelToFrame BillboardPS(BBVertexToPixel PSIn) : COLOR0
{
    BBPixelToFrame Output = (BBPixelToFrame)0;

    Output.Color = tex2D(textureSampler, PSIn.TexCoord);

    return Output;
}

传递额外的信息

如果你想设置vertex shader生成的billboard的大小或将额外信息从XNA程序传递到 vertex shader中，你需要将这个信息添加到每个顶点中。你可以通过，例如，VertexPositionNormalTexture和在Normal向量的一个分量中存储大小信息达到以上目的。

但是，这样做虽然可以解决问题，当你需要传递顶点中的真实法线数据时仍会碰到同样的问题。因此为了保持通用性，你可以创建自己的顶点格式，它可以保存纹理和额外数据。关于自定义顶点格式的更多信息可参加教程5-14。

public struct VertexBillboard
{
    public Vector3 Position;
    public Vector2 TexCoord;
    public Vector4 AdditionalInfo;
    public VertexBillboard(Vector3 Position, Vector2 TexCoord,Vector4 AdditionalInfo)
    {
        this.Position = Position;
        this.TexCoord = TexCoord;
        this.AdditionalInfo = AdditionalInfo;
    }

    public static readonly VertexElement[] VertexElements = new VertexElement[]
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0),
        new VertexElement(0, 12, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0),
        new VertexElement(0, 20, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1),
    };

    public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 4);
}

这个格式接受一个额外的叫做Add itionalInfo的Vector4。你将这个Vector4作为另一个TextureCoordinate。要区别这两个TextureCoordinate，请注意最后一个参数索引的区别。这意味着真实的纹理坐标在vertex shader中作为TEXCOORD0，而额外的Vector4作为TEXCOORD1 (见教程5-14)。

然后，使用新定义的VertexBillboarding替换VertexPositionTexture(你可以在XNA Game Studi中使用Ctrl+H进行这个操作)。最后，调整CreateBBVertices方法将四个额外的float值添加到顶点中：

private void CreateBBVertices()
{
    billboardVertices = new VertexBillboard[billboardList.Count * 6];

    int i = 0;
    foreach (Vector4 currentV4 in billboardList)
    {
        Vector3 center = new Vector3(currentV4.X, currentV4.Y, currentV4.Z);
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 1), new Vector4(0.8f, 0.4f, 0, 0));
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 0), new Vector4(0.8f, 0.4f, 0, 0));
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 0), new Vector4(0.8f, 0.4f, 0, 0));
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(1, 1), new Vector4(0.8f, 0.4f, 0, 0));
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 1), new Vector4(0.8f, 0.4f, 0, 0));
        billboardVertices[i++] = new VertexBillboard(center, new Vector2(0, 0), new Vector4(0.8f, 0.4f, 0, 0));
    }
}

在本例中，你可以改变让billboard的大小。Vector4中的第一个参数，这里是0.8f，用来缩放billboard的宽度，第二个参数，这里是0.4f，缩放高度。这会导致billboard的宽度是高度的两倍。

以上是XNA代码；你要做的就是在vertex shader中将这个Vector4作为TEXCOORD1：

BBVertexToPixel SpheBillboardVS(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)

现在你可以以inExtra 变量的形式访问Vector4，使用它缩放billboard的宽和高：

finalPosition += (inTexCoord.x-0.5f)*sideVector*inExtra.x;
finalPosition += (0.5f-inTexCoord.y)*upVector*inExtra.y;

这个例子只使用了float4的x和y值，如果需要你也可以访问z和w值。

译者注：在http://creators.xna.com/en-US/sample/billboard上也有一个示例可供研究，比较复杂，使用内容管道生成billboard图像，在HLSL中还实现了风吹动的效果。


3.12 创建一个3D爆炸效果，简单的粒子系统

问题

你想在3D世界中创建一个漂亮的3D爆炸效果。

解决方案

你可以通过混合大量的小火焰图像（叫做粒子）创建一个爆炸效果,，如图3-22所示。注意一个单独的粒子很暗，但是，如果你添加大量的粒子，就会获得一个漂亮的爆炸效果。

图3-22 单个爆炸粒子

在爆炸的开始阶段，所有粒子都位于爆炸的初始位置，因为所有图像的颜色都叠加在了一起，所以会形成一个明亮的火球，绘制粒子时你需要使用additive blending才能获得这个效果。

随着时间流逝，粒子会从初始位置离开。而且，在离开中心的过程中还要使每个粒子图像变得更小更暗(淡出)。

一个漂亮的3D爆炸需要用到50至100个粒子。因为这些图像只是显示在3D世界中的2D图像，需要用到billboard，所以这个教程是建立在教程3-11基础上的。

你将创建一个基于shader的实时粒子系统。每一帧中，计算每个粒子的存活时间（age），它的位置、颜色和大小是基于存活时间计算的。当然，应该在GPU中进行这些计算，让CPU可以完成更重要的工作。

工作原理

如前所述，本章会重用教程3-11的代码，所以需要理解球形billboarding的原理。对每个粒子，你仍需定义六个顶点，而且只需定义一次，将它们传递到显卡。对每个顶点，需要包含下列数据用于vertex shader：

  • 爆炸的初始位置的billboard中心点3D位置(用于billboarding) (Vector3 = 三个浮点数)
  • 纹理坐标(用于billboarding) (Vector2 = 两个floats)
  • 粒子创建的时间(一个浮点数)
  • 粒子的存活时间(一个浮点数)
  • 粒子移动的方向(Vector3 = 三个浮点数)
  • 一个随机数，用于给每个粒子施加不同的行为(一个浮点数)

从这些数据，GPU可以基于当前时间计算所需的所有东西。例如，它可以计算粒子已经存活了多少时间。当你将这个存活时间乘以粒子的方向时，就可以获取粒子离开爆炸初始位置的距离。

你需要一个顶点格式存储这个数据，使用一个Vector3存储位置；一个Vector4存储第二，第三，第四个数据；另一个Vector4存储最后两个数据。创建自定义顶点格式可参见教程5-14：

public struct VertexExplosion
{
    public Vector3 Position;
    public Vector4 TexCoord;
    public Vector4 AdditionalInfo;

    public VertexExplosion(Vector3 Position, Vector4 TexCoord, Vector4 AdditionalInfo)
    {
        this.Position = Position;
        this.TexCoord = TexCoord;
        this.AdditionalInfo = AdditionalInfo;
    }

    public static readonly VertexElement[] VertexElements = new VertexElement[]
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0),
        new VertexElement(0, 12, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0),
        new VertexElement(0, 28, VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1),
    };

    public static readonly int SizeInBytes = sizeof(float) * (3 + 4 + 4);
}

这看起来与教程3－11中的很像，除了用一个Vector4代替Vector2作为第二个参数，让额外的两个浮点数可以传递到vertex shader。要创建随机方向，需要使用一个randomizer，所以在XNA类中添加以下变量：

Random rand;

在Game类的Initialize方法中进行初始化：

rand = new Random();

现在可以创建顶点了。下面的方法基于教程3－11生成顶点：

private void CreateExplosionVertices(float time)
{
    int particles = 80;
    explosionVertices = new VertexExplosion[particles * 6];

    int i = 0;
    for (int partnr = 0; partnr < particles; partnr++)
    {
        Vector3 startingPos = new Vector3(5,0,0);
        float r1 = (float)rand.NextDouble() - 0.5f;
        float r2 = (float)rand.NextDouble() - 0.5f;
        float r3 = (float)rand.NextDouble() - 0.5f;
        Vector3 moveDirection = new Vector3(r1, r2, r3);
        moveDirection.Normalize();

        float r4 = (float)rand.NextDouble();
        r4 = r4 / 4.0f * 3.0f + 0.25f;

        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection,r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4));
    }
}

这个方法需要在初始化一个新爆炸时调用，它接受当前时间为参数。首先定义了爆炸中使用的粒子数量，创建了一个数组保存每个粒子的六个顶点。然后，创建这些顶点。对每个顶点，首先储存startingPos，即爆炸的中心点。

然后，你想让每个粒子都有不同的方向。这可以通过生成两个[0,1]区间的随机数实现，但需要减去0.5使它们落在[–0.5f, +0.5f]区间。基于这个随机值创建一个Vector3，归一化这个Vector3让随机方向具有相同的长度。

技巧：推荐使用归一化，因为向量(0.5f, 0.5f, 0.5f)比向量(0.5f, 0, 0)长。所以，当你增加第一个向量的位置时，如果使用第二个向量作为运动方向，粒子会运动得更快。结果是，爆炸会变得像个立方体而不是球形。

然后，获取另一个随机值，用来给每个粒子施加各自的效果，这是因为粒子的速度和大小都要由这个值进行调整。要求有一个介于0和1之间的随机数，然后缩放到[0.25f, 1.0f]区间(谁想要一个速度为的粒子？)。

最后，将每个粒子的六个顶点添加到顶点数组中。注意每六个顶点都包含相同的信息，除了纹理坐标，纹理坐标用来在vertex shader定义每个顶点偏离中心位置的偏移量。

HLSL

现在准备好了顶点，可以编写vertex和pixel shader了。

首先定义XNA-HLSL变量，纹理采样器，vertex shader和pixel shader的输出结构：

//XNA interface
float4x4 xView;
float4x4 xProjection;
float4x4 xWorld;
float3 xCamPos;
float3 xCamUp;
float xTime;

//Texture Samplers
Texture xExplosionTexture;
sampler textureSampler = sampler_state
{
    texture = <xExplosionTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = CLAMP;
    AddressV = CLAMP;
};

struct ExpVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
    float4 Color : COLOR0;
};

struct ExpPixelToFrame
{
    float4 Color : COLOR0;
};

因为爆炸中的每个粒子都要施加billboard，你需要相同的变量。这次，你还需要在XNA程序的xTime变量中存储当前时间，让vertex shader可以计算每个粒子的生存时间。

如教程3－11所述，vertex shader将2D屏幕位置和纹理坐标传递到pixel shader中。因为你想实现粒子的淡出效果，所以还要从vertex shader中传递颜色信息。和往常一样，pixel shader只计算每个像素的颜色。

Vertex Shader

vertex shader对顶点施加billboard，所以使用以下代码，这个代码来自于教程3－11中的球形billboarding：

//Technique: Explosion
float3 BillboardVertex(float3 billboardCenter, float2 cornerID, float size)
{
    float3 eyeVector = billboardCenter - xCamPos;
    float3 sideVector = cross(eyeVector,xCamUp);
    sideVector = normalize(sideVector);
    float3 upVector = cross(sideVector,eyeVector);
    upVector = normalize(upVector);
    float3 finalPosition = billboardCenter;
    finalPosition += (cornerID.x-0.5f)*sideVector*size;
    finalPosition += (0.5f-cornerID.y)*upVector*size;

    return finalPosition;
}

简而言之，你将billboard的中心位置，纹理坐标(用来区分当前顶点)和billboard大小传递到这个方法，这个方法返回顶点的3D位置。更多的信息可参见教程3-11。

下面是vertex shader，它将使用刚才定义的方法：

ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)
{
    ExpVertexToPixel Output = (ExpVertexToPixel)0;

    float3 startingPosition = mul(inPos, xWorld);
    float2 texCoords = inTexCoord.xy;
    float birthTime = inTexCoord.z;
    float maxAge = inTexCoord.w;
    float3 moveDirection = inExtra.xyz;
    float random = inExtra.w;
}

vertex shader接受存储在每个顶点中的一个Vector3和两个Vector4。首先，将数据中的内容存储在一些变量中。Billboard中心位置存储在Vector3中。你已经定义了第一个Vector4中的x和y分量包含纹理坐标，第三第四个分量包含粒子创建的时间和存活时间。第二个Vector4包含粒子移动的方向和一个额外的随机浮点数。

现在，你可以将当前时间减去birthTime获取粒子已经存在的时间，存储在xTime变量中。但是，当使用time span时，你想用0和1之间的相对值表示这个时间，0表示开始时刻，1表示结束时刻。所以要将age除以maxAge， maxAge表示此时粒子应该“死亡”。

float age = xTime - birthTime; float relAge = age/maxAge;

当粒子是新的时，xTime与birthTime相同，relAge 为0。当接近于结束，age几乎等于maxAge，relAge接近于1。

首先根据粒子的age 调整它的大小。你想让每个粒子开始时最大然后慢慢变小。但是，你不想让它完全消失，所以需要使用以下代码：

float size = 1-relAge*relAge/2.0f;

看起来有点难以理解，如果用图表示可以帮助你理解这个代码，如图3－22左图所示。对横轴上的每个粒子的Age，你可以在在纵轴上找到对应的大小。例如，relAge = 0时大小为1，relAge = 1时大小为0.5f。

图3-23 Size－relAge函数； displacement－relAge函数

但是，当relAge为1时会继续减小。这意味着size会变为负值(如下面的代码所见，这会导致图像会在反方向扩展)。所以，你需要将这个值 处于0和1之间。

因为大小为1的粒子太小，只需简单地将它们乘以一个数字进行缩放，这里是5。要让每个粒子各不相同，你还要将大小乘以一个随机数：

float sizer = saturate(1-relAge*relAge/2.0f);
float size = 5.0f*random*sizer;

现在粒子的大小随着时间的流逝会变小，下一步是计算粒子中心的3D位置。你已经知道初始3D位置(爆炸的中心位置)和粒子的移动方向。你需要知道粒子已经沿着这个方向移动了多远，当然，这个距离对应粒子的生存时间。一个简单的方法是让粒子以一个不变的速度移动，但是实际情况中这个速度会减小。

看一下图3－23中的右图，水平轴表示粒子的生存时间，竖直轴表示粒子离开中心位置的距离。你看到距离一开始是线性增加的，但过了一会儿，这个距离增加速度减小，最后不变。这意味着开始时速度保持不变最后为0。

幸运的是，这个曲线只是简单的正弦函数的四分之一。对于一个任意给定的位于0和1之间的relAge，你可以使用这个函数找到对应的曲线上的距离：

float totalDisplacement = sin(relAge*6.28f/4.0f);

一个完整的正弦曲线周期是从0到2*pi=6.28。因为你只想要四分之一周期，所以需要除以4。现在对于0和1之间的relAge，totalDisplacement拥有了一个对应图3－23右图所示的曲线的值。

你还要将这个值乘以一个因子使粒子离开中心一点儿，本例中这个因子为3比较合适。更大的值导致更大的爆炸。这个值还要乘以一个随机值，使每个粒子都有自己的速度：

float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random;

一旦知道了粒子沿着运动方向运动的距离，就可以很简单地获取粒子的当前位置：

float3 billboardCenter = startingPosition + totalDisplacement*moveDirection;
billboardCenter += age*float3(0,-1,0)/1000.0f;

最后一行代码给粒子施加一个重力。因为xTime变量包含当前的以毫秒为单位的时间，这行代码会每秒让粒子下降一个单位。

技巧：如果你想对一个移动的物体施加爆炸效果，例如一架飞机，你只需简单地添加一条代码让所有粒子移向物体移动的方向。你可以在顶点的一个额外的TEXCOORD2向量中传递这个方向。

现在有了billboard 中心的3D位置和大小，你就做好了将这些值传递到billboarding方法中的准备：

float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size);
float4 finalPosition4 = float4(finalPosition, 1);
float4x4 preViewProjection = mul (xView, xProjection);
Output.Position = mul(finalPosition4, preViewProjection);

这个代码来自于教程3-11。首先计算billboarded位置。和往常一样，这个3D位置需要乘以一个 4 × 4矩阵转换为2D屏幕位置，但在这之前，float3需要被转换到float4。你将最终的2D位置存储在Output结构的Position变量中。

以上代码已经可以给出一个漂亮的结果，但是当粒子接近结束时再施加淡出效果会更棒。当粒子刚刚生成时，你想让它完全可见，当达到relAge = 1时，你想让它完全透明。

要实现这个效果，你可以使用线性减少，但是如果使用图3－23左图中的曲线效果会更好。这次，你想从1到0，所以无需除以2：

float alpha = 1-relAge*relAge;

现在就可以定义粒子的调制颜色了：

Output.Color = float4(0.5f,0.5f,0.5f,alpha);

在pixel shader中，你将每个像素的颜色乘以这个颜色。这会将像素的alpha值调整为这里计算的值，这样粒子就会慢慢变得透明。颜色的RGB分量也通过因子2柔化，不然你会很容易看到独立的粒子。

别忘了将纹理坐标传递到pixel shader，这样才能知道billboard的顶点对应纹理的哪个顶点：

Output.TexCoord = texCoords;
return Output;

Pixel Shader

幸运的是，pixel shader相对于vertex shader来说很简单：

ExpPixelToFrame ExplosionPS(ExpVertexToPixel PSIn) : COLOR0
{
    ExpPixelToFrame Output = (ExpPixelToFrame)0;

    Output.Color = tex2D(textureSampler, PSIn.TexCoord)*PSIn.Color;

    return Output;
}

对每个像素，你从纹理中获取对应的颜色，将这个颜色乘以在vertex shader计算的调制颜色。这个调制颜色会减少亮度，对应粒子的生存时间设置alpha值。

下面是technique定义：

technique Explosion
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 ExplosionVS();
        PixelShader = compile ps_1_1 ExplosionPS();
    }
}

在XNA中设置Technique参数

别忘了在XNA代码中设置所有XNA-to-HLSL变量：

expEffect.CurrentTechnique = expEffect.Techniques["Explosion"];
expEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
expEffect.Parameters["xProjection"].SetValue(quatMousCam.ProjectionMatrix);
expEffect.Parameters["xView"].SetValue(quatMousCam.ViewMatrix);
expEffect.Parameters["xCamPos"].SetValue(quatMousCam.Position);
expEffect.Parameters["xExplosionTexture"].SetValue(myTexture);
expEffect.Parameters["xCamUp"].SetValue(quatMousCam.UpVector);
expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds);

Additive Blending（附加混合）

因为这个technique依赖于混合，你需要在绘制前设置绘制状态，alpha混合的更多信息可参加教程2-12和3-3。

在本例中，你想使用additive blending让所有的颜色都叠加在一起。这可以通过设置渲染状态实现：

device.RenderState.AlphaBlendEnable = true;
device.RenderState.SourceBlend = Blend.SourceAlpha;
device.RenderState.DestinationBlend = Blend.One;

第一行代码开启alpha混合。对每个像素，它的颜色决定于以下规则：

finalColor=sourceBlend*sourceColor+destBlend*destColor

根据前面设置的渲染状态，这个规则会变成以下公式：

finalColor=sourceAlpha*sourceColor+1*destColor

显卡会绘制每个像素多次，因为你将绘制大量的爆炸图像(需要关闭写入depth buffer)。所以当显卡已经绘制了80个粒子中的79个，并开始绘制最后一个时，会对这个粒子的每个像素进行以下操作：

1．找到存储在frame buffer中的像素的当前颜色，将三个颜色通道乘以1(对应前面规则中的1*destColor)。

2．获取在pixel shader中计算的这个粒子的新颜色，将这个颜色的三个通道乘以alpha通道，alpha通道取决于粒子的当前生存时间(对应上述规则中的sourceAlpha*sourceColor)。

3. 将两个颜色相加，将结果保存到frame buffer中。

在粒子的开始时刻(relAge=0)，sourceAlpha等于1，所以additive blending会产生一个大爆炸。在粒子的结束时刻(relAge=1)，newAlpha为0，粒子不产生效果。

注意：这个混合规则的第二个例子可参加教程2-12。

关闭写入到Depth Buffer

你还要考虑到最后一个方面。当离开相机最经的粒子首先被绘制时，所有在它之后的粒子将不会被绘制(这会导致不发生混合)！所以绘制粒子时，你需要关闭z-buffer测试，并在将粒子作为场景中的最后一个元素被绘制。但这并不是一个好方法，因为当爆炸离开相机很远时且相机和爆炸之间有一个建筑物时，爆炸仍会被绘制，就好像之间没有这个建筑物似的！

更好的方法是先绘制场景。然后关闭z-buffer写入将粒子作为最后一个元素进行绘制。通过这个方法，你可以解决这个问题：

  • 每个粒子的每个像素都会与z-buffer中的当前内容(包含深度信息)作比较。如果在相机和爆炸之间已经有一个物体被绘制了，那么爆炸的粒子就通不过z-buffer测试，不会被绘制。
  • 因为粒子不会改变z-buffer，就算第二个粒子在第一个粒子之后也会被绘制(当然，是在相机和爆炸间没有另一个物体的情况下)。

下面是代码：

device.RenderState.DepthBufferWriteEnable = false;

然后，绘制爆炸使用的三角形：

expEffect.Begin();

foreach (EffectPass pass in expEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements);
    device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3);
    pass.End();
}
expEffect.End();

在绘制完爆炸后，你需要再次开启z-buffer写入：

device.RenderState.DepthBufferWriteEnable = true;

当你想开始一个新的爆炸时，调用CreateExplosionVertices方法！本例中，当用户按下空格键时会调用这个方法：

if ((keyState.IsKeyDown(Keys.Space)))
            CreateExplosionVertices((float) gameTime.TotalGameTime.TotalMilliseconds);

代码

下面是生成爆炸所需顶点的方法：

private void CreateExplosionVertices(float time)
{
    int particles = 80;
    explosionVertices = new VertexExplosion[particles * 6];

    int i = 0;
    for (int partnr = 0; partnr < particles; partnr++)
    {
        Vector3 startingPos = new Vector3(5,0,0);
        float r1 = (float)rand.NextDouble() - 0.5f;
        float r2 = (float)rand.NextDouble() - 0.5f;
        float r3 = (float)rand.NextDouble() - 0.5f;
        Vector3 moveDirection = new Vector3(r1, r2, r3);
        moveDirection.Normalize();

        float r4 = (float)rand.NextDouble();
        r4 = r4 / 4.0f * 3.0f + 0.25f;

        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 0, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(1, 1, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 1, time, 1000), new Vector4(moveDirection, r4));
        explosionVertices[i++] = new VertexExplosion(startingPos, new Vector4(0, 0, time, 1000), new Vector4(moveDirection, r4));
    }
}

下面是完整的Draw方法：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0);
    cCross.Draw(quatCam.ViewMatrix, quatCam.ProjectionMatrix);

    if (explosionVertices != null)
    {
        //draw billboards
        expEffect.CurrentTechnique = expEffect.Techniques["Explosion"];
        expEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
        expEffect.Parameters["xProjection"].SetValue(quatCam.ProjectionMatrix);
        expEffect.Parameters["xView"].SetValue(quatCam.ViewMatrix);
        expEffect.Parameters["xCamPos"].SetValue(quatCam.Position);
        expEffect.Parameters["xExplosionTexture"].SetValue(myTexture);
        expEffect.Parameters["xCamUp"].SetValue(quatCam.UpVector);
        expEffect.Parameters["xTime"]. SetValue((float)gameTime.TotalGameTime.TotalMilliseconds);

        device.RenderState.AlphaBlendEnable = true;
        device.RenderState.SourceBlend = Blend.SourceAlpha;
        device.RenderState.DestinationBlend = Blend.One;
        device.RenderState.DepthBufferWriteEnable = false;

        expEffect.Begin();
        foreach (EffectPass pass in expEffect.CurrentTechnique.Passes)
        {
            pass.Begin();
            device.VertexDeclaration = new VertexDeclaration(device, VertexExplosion.VertexElements);
            device.DrawUserPrimitives<VertexExplosion> (PrimitiveType.TriangleList, explosionVertices, 0, explosionVertices.Length / 3);
            pass.End();
        }
        expEffect.End();

        device.RenderState.DepthBufferWriteEnable = true;
    }
    base.Draw(gameTime);
}

下面是vertex shader代码：

ExpVertexToPixel ExplosionVS(float3 inPos: POSITION0, float4 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)
{
    ExpVertexToPixel Output = (ExpVertexToPixel)0;

    float3 startingPosition = mul(inPos, xWorld);
    float2 texCoords = inTexCoord.xy;
    float birthTime = inTexCoord.z;
    float maxAge = inTexCoord.w;
    float3 moveDirection = inExtra.xyz;
    float random = inExtra.w;

    float age = xTime - birthTime;
    float relAge = age/maxAge;
    float sizer = saturate(1-relAge*relAge/2.0f);
    float size = 5.0f*random*sizer;

    float totalDisplacement = sin(relAge*6.28f/4.0f)*3.0f*random;
    float3 billboardCenter = startingPosition + totalDisplacement*moveDirection;
    billboardCenter += age*float3(0,-1,0)/1000.0f;
    float3 finalPosition = BillboardVertex(billboardCenter, texCoords, size);
    float4 finalPosition4 = float4(finalPosition, 1);

    float4x4 preViewProjection = mul (xView, xProjection);
    Output.Position = mul(finalPosition4, preViewProjection);

    float alpha = 1-relAge*relAge;
    Output.Color = float4(0.5f,0.5f,0.5f,alpha);

    Output.TexCoord = texCoords;

    return Output;
}

vertex shader使用前面定义的BillboardVertex方法，在教程的最后你还可以看到简单的pixel shader和technique定义。

译者注：在XNA官方网站上http://creators.xna.com/en-US/sample/particle3d也有一个粒子系统的例子，不过它的实现方法是使用点精灵（point sprite），这个方法没有使用billboard那样具有弹性，但是它的优点是只使用一个顶点就可以绘制一个粒子，而在billboard需要六个，这可以减少发送到显卡的数据量。


3.13 创建一面镜子：投影纹理

问题

你想在场景中创建一面镜子。例如，在一个赛车游戏中创建一面后视镜。你也可以使用这个技术创建一个反射贴图。

解决方案

首先需要将镜子中看到的场景绘制到一张纹理中。然后，绘制相机中见到的场景(包括空镜子)，最后将这张纹理贴在镜子上。

要绘制从镜中看起来的场景，你需要定义第二个相机，叫做镜像相机（mirror camera）。你可以通过镜像第一个普通相机的Position，Target和Up向量获取镜像相机的Position，Target和Up向量。当通过镜像相机观看场景时，你看到的与镜子中看到的一样。图3-24 显示了这个原理。

图3-24 镜像原理

将这个结果储存到纹理中之后，你将绘制在普通相机中看到的场景和使用投影纹理将纹理绘制到镜子上，投影纹理将正确的像素映射到镜子的对应位置。如果镜像相机和镜子之间有物体存在，这个方法不适用。

你可以通过定义一个镜像剪裁平面解决这个问题，这样镜子之后的物体都会被剪裁掉。

工作原理

首先在项目添加以下变量：

RenderTarget2D renderTarget;
Texture2D mirrorTexture;
VertexPositionTexture[] mirrorVertices; Matrix mirrorViewMatrix;

你需要将镜子中看到的场景绘制到一个自定义的渲染目标中，所以需要renderTarget和mirrorTexture变量。要创建镜像相机，需要定义一个镜像View矩阵。因为你需要将镜子添加到场景中，所以需要一些顶点定义镜子的位置。

变量renderTarget在LoadContent方法中进行初始化。创建和使用自定义渲染目标更多的信息可参见教程3－8 。

PresentationParameters pp = device.PresentationParameters;
int width = pp.BackBufferWidth;
int height = pp.BackBufferHeight;
renderTarget = new RenderTarget2D(device, width, height, 1, device.DisplayMode.Format);

技巧：你也可以减少渲染目标的宽和高。通过这个方式，显卡的开销更少，但镜中的图像看起来比较粗糙。

初始化渲染目标后，定义镜子的位置：

private void InitMirror()
{
    mirrorVertices = new VertexPositionTexture[4];

    int i = 0;
    Vector3 p0 = new Vector3(-3, 0, 0);
    Vector3 p1 = new Vector3(-3, 6, 0);
    Vector3 p2 = new Vector3(6, 0, 0);
    Vector3 p3 = p1 + p2 - p0;

    mirrorVertices[i++] = new VertexPositionTexture(p0, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p1, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p2, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p3, new Vector2(0,0));

    mirrorPlane = new Plane(p0, p1, p2);
}

你可以使用这个方法创建任意形状的镜子，本例中，创建的是一个简单的矩形镜子。使用镜子平面显示自定义渲染目标中的内容。你需要使用TriangleStrip绘制两个三角形定义这个矩形，所以只需四个顶点。

在3D空间中，你只需三个点就可以定义一个矩形。这个方法让你可以指定镜子的顶点p0, p1和p2，代码会计算点p3，使第四个点也在同一平面内，对应的是这行代码：

Vector3 p3 = p0 + (p1 – p0) + (p2 - p0);

从这四个位置创建四个顶点。这个方法无需将纹理坐标传递到(见后面的vertex shader)，但是因为我懒得定义一个VertexPosition结构，所以只是简单地传递了一些任意的纹理坐标，例如(0,0)，因为它们不会被用到(可参见教程5-14学习如何创建自定义顶点格式)。

注意：因为本例中所有Z坐标为0，所以镜子在XY平面中。

构建镜像相机的View矩阵

下一步，你想创建一个镜像View矩阵，用来绘制从镜子中看起来的场景。要创建这个镜像View矩阵，你需要镜像相机的Position, Target和Up向量。镜像View 矩阵的Position, Target和Up向量是普通相机的关于镜子平面的镜像，如图3－24所示。

private void UpdateMirrorViewMatrix()
{
    Vector3 mirrorCamPosition = MirrorVector3(mirrorPlane, fpsCam.Position);
    Vector3 mirrorTargetPosition = MirrorVector3(mirrorPlane, fpsCam.TargetPosition);
    Vector3 camUpPosition = fpsCam.Position + fpsCam.UpVector;
    Vector3 mirrorCamUpPosition = MirrorVector3(mirrorPlane, camUpPosition);
    Vector3 mirrorUpVector = mirrorCamUpPosition - mirrorCamPosition;
    mirrorViewMatrix = Matrix.CreateLookAt(mirrorCamPosition, mirrorTargetPosition, mirrorUpVector);
}

Position和TargetPosition可以简单地被镜像，因为它们是在绝对3D空间中的。但是，Up向量表示一个方向，无法立即被镜像。你需要首先将Up方向转化为一个3D位置，在相机上方的某处，这可以通过将Up方向添加到相机的3D位置中做到。

因为这是一个3D位置，你可以取它的镜像了。你获取的是一个位于镜像相机上方的3D位置，所以减去镜像相机的位置就可以获取镜像相机的Up方向了。

知道了镜像相机的Position, Target和Up方向，你就可以创建View矩阵了。

MirrorVector3方法会根据传入的mirrorPlane参数对Vector3进行镜像。因为本教程创建的镜子位于XY平面，要找到镜像位置只需要改变Z分量的符号：

private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3)
{
    Vector3 mirroredV3 = originalV3;
    mirroredV3.Z = -mirroredV3.Z;

    return mirroredV3;
}

等会你会学习如何关于任意平面进行镜像，但是这个方法中的数学原理会分散你的注意力。现在从Update 方法中调用UpdateMirrorViewMatrix方法：

UpdateMirrorViewMatrix();

绘制从镜中看到的场景

有了镜像View矩阵，你就可以使用这个矩阵绘制从镜子中看到的场景了。因为需要绘制场景两次(一次从镜子看到，一次从普通相机中看到)，好主意是重构你的代码，将场景绘制放到一个独立的方法中：

private void RenderScene(Matrix viewMatrix, Matrix projectionMatrix) { Matrix
            worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f) *
            Matrix.CreateTranslation(0, 0, 5);
            myModel.CopyAbsoluteBoneTransformsTo(modelTransforms); foreach (ModelMesh mesh
            in myModel.Meshes) { foreach (BasicEffect effect in mesh.Effects) {
            effect.EnableDefaultLighting(); effect.World =
            modelTransforms[mesh.ParentBone.Index] * worldMatrix; effect.View = viewMatrix;
            effect.Projection = projectionMatrix; } mesh.Draw(); } //draw other objects of
            your scene ... }

这个方法使用View和Projection矩阵作为参数绘制场景。

在Draw方法中，添加这个代码，开启自定义渲染目标并通过镜像View矩阵将镜子中看到的场景绘制到这个渲染目标中。之后，通过将后备缓冲设置为当前渲染目标关闭这个自定义渲染目标(见教程3-9)，然后将自定义渲染目标中的内容储存到一个纹理中：

//render scene as seen by mirror into render target
device.SetRenderTarget(0, renderTarget);
graphics.GraphicsDevice.Clear(Color.CornflowerBlue);

RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix);

//deactivate custom render target, and save its contents into a texture
device.SetRenderTarget(0, null);
mirrorTexture = renderTarget.GetTexture();

注意：在镜像的情况中，你想使用与普通场景相同的投影矩阵绘制自定义渲染目标。如果，例如，你的普通投影矩阵的角度大于渲染目标的矩阵，在shader中计算的坐标会混合在一起。

保存了纹理后，你将清除后备缓冲，然后绘制普通相机中的场景，换句话说，使用普通 View矩阵：

//render scene + mirror as seen by user to screen
graphics.GraphicsDevice.Clear(Color.Tomato);
RenderScene(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);
RenderMirror();

最后一行代码调用的方法将镜子添加到场景中。在本例中，镜子只是一个由两个三角形定义的简单矩形，它的颜色从包含镜子中看到的场景的纹理中采样。因为镜子要显示纹理正确部分，你不能简单地将图像放置在矩形上，而是应该创建一个HLSL technique。

HLSL

首先定义XNA－HLSL变量，纹理采样器，vertex和pixel shader的输出结构：

//XNA interface
float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float4x4 xMirrorView;

//Texture Samplers
Texture xMirrorTexture;
sampler textureSampler = sampler_state
{
    texture = <xMirrorTexture>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = CLAMP;
    AddressV = CLAMP;
};

struct MirVertexToPixel
{
    float4 Position : POSITION;
    float4 TexCoord : TEXCOORD0;
};

struct MirPixelToFrame
{
    float4 Color : COLOR0;
}

和往常一样，你需要World, View和Projection矩阵计算每个3D顶点的2D屏幕位置。而且还需要镜像相机的View矩阵用来在vertex shader中计算镜子中每个顶点的对应纹理坐标。

Technique需要包含镜中看的的场景的纹理，它会通过采用这个纹理获取镜子中的每个像素的颜色。

vertex shader的输出结构是这个纹理坐标和当前顶点的2D屏幕坐标。而pixel shader只计算像素的颜色。

Vertex Shader

和往常一样，vertex shader计算每个顶点的2D屏幕坐标，这可以通过将3D位置乘以 WorldViewProjection矩阵做到。

//Technique: Mirror
MirVertexToPixel MirrorVS(float4 inPos: POSITION0)
{
    MirVertexToPixel Output = (MirVertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
}

在镜像technique中，对镜子的每个顶点，vertex shader还要计算顶点对应xMirrorTexture中的哪个像素。比方说你想找到xMirrorTexture中的哪个像素对应镜子的左上顶点，找到答案的关键在于从镜像相机中看镜子。你需要获取镜像相机保存在哪个xMirrorTexture 中的顶点对应哪个2D坐标，这实际上就是你通过镜像相机的WorldViewProjection矩阵转换的3D坐标。

float4x4 preMirrorViewProjection = mul (xMirrorView, xProjection);
float4x4 preMirrorWorldViewProjection = mul(xWorld, preMirrorViewProjection);
Output.TexCoord = mul(inPos, preMirrorWorldViewProjection);

return Output;

注意：代码中的单词Mirror不表示一个附加矩阵；只是表示属于镜像相机的矩阵。例如，xMirrorView不是表示Mirror矩阵乘以View矩阵；只是表示镜像相机的View矩阵。

Pixel Shader

现在在pixel shader中，对镜子的四个顶点，你有了对应的纹理坐标。接下来的问题是哪个范围是你不想要的。而你知道纹理坐标介于0和1之间，如图3－25左图所示。而屏幕坐标的范围在–1至1之间，如图3－25右图所示。

图3-25 纹理坐标(左)，屏幕坐标(右)

幸运的是，从[-1,1]映射到[0,1]很简单。例如，你可以先除以2，这样范围变为[-0.5,0.5]，然后加0.5，范围变为[0,1]。而且，因为你处理的是一个float4 (齐次)坐标，在使用前三个分量前，需要将它们除以第四个坐标。这就是pixel shader的第一部分进行的操作：

MirPixelToFrame MirrorPS(MirVertexToPixel PSIn) : COLOR0
{
    MirPixelToFrame Output = (MirPixelToFrame)0;

    float2 ProjectedTexCoords;
    ProjectedTexCoords[0] = PSIn.TexCoord.x/PSIn.TexCoord.w/2.0f +0.5f;
    ProjectedTexCoords[1] = -PSIn.TexCoord.y/PSIn.TexCoord.w/2.0f +0.5f;

    Output.Color = tex2D(textureSampler, ProjectedTexCoords);

    return Output;
}

计算第二个纹理坐标的代码中的“–”号是必须的，因为场景需要上下颠倒绘制到帧缓冲中，所以你需要进行调整。最后一行代码查询xMirrorTexture中的对应颜色，这个颜色返回到pixel shader中。

注意：前两个分量表示2D屏幕坐标；你需要将前三个分量除以第四个分量，但第三个坐标是什么？它实际上是2D深度。换句话说，这个值在显卡z缓冲中，介于0和1之间，表示顶点在近裁平面，1表示在远裁平面。当调用pixel shader计算像素颜色时，显卡首先根据像素在z缓冲中的深度值判断这个像素是否应该被绘制 。更多的信息可参见教程2－1的最后一部分。

下面是technique定义：

technique Mirror
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 MirrorVS();
        PixelShader = compile ps_2_0 MirrorPS();
    }
}

在XNA中使用Technique

你还需要在XNA项目中定义DrawMirror方法，这个方法使用刚才创建的technique绘制矩形：

private void RenderMirror()
{
    mirrorEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
    mirrorEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
    mirrorEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
    mirrorEffect.Parameters["xMirrorView"].SetValue(mirrorViewMatrix);
    mirrorEffect.Parameters["xMirrorTexture"].SetValue(mirrorTexture);

    mirrorEffect.Begin();
    foreach (EffectPass pass in mirrorEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
        device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, mirrorVertices, 0, 2);
        pass.End();
    }
    mirrorEffect.End();
}

设置World, View和Projection矩阵，还有xMirrorView矩阵和包含镜中看的的场景的xMirrorTexture。矩形的两个三角形以TriangleStrip的形式绘制。你需要在XNA项目导入. fx文件并将它连接到mirrorEffect变量。

任意镜像平面

在前面的例子中，选择了一个特殊位置的镜像平面，因此很容易对点进行镜像。但是在真实情况中，你还想定义任意镜像平面。这需要改进MirrorVector3方法，让它可以在任意镜像平面上对任意3D点进行镜像：

private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3)
{
    float distV3ToPlane = mirrorPlane.DotCoordinate(originalV3);
    Vector3 mirroredV3 = originalV3 - 2 * distV3ToPlane * mirrorPlane.Normal;

    return mirroredV3;
}

首先你想知道点与平面间的最短距离，这可以通过镜子平面的DotCoordinate方法进行计算(这个最短距离就是点垂直于平面的距离)。如果你将平面法线乘以这个距离，从这个点减去刚才计算的结果向量，你就会位于平面上。但你不想在平面上，你想移动两倍距离！所以，你需要将这个结果向量加倍并从初始点坐标中减去这个向量。

这个代码让你可以基于任意三点使用一面镜子。

定义一个镜子剪裁平面

仍有一个大问题：当物体在镜子后面时，这些物体会被镜子相机看到并存储到mirrorTexture中。当Mirror pixel shader从这个纹理采样颜色时，这些物体会被绘制到镜子上，但实际上这些物体是在镜子之后的，不应该被显示。

解决方法是定义一个用户剪裁平面，这可以通过定义一个平面并告知XNA在镜子另一边的物体无需绘制做到。当然，这个平面应该是镜子所处平面，所以在镜子之后的物体无需绘制。

但是，剪裁平面的四个系数必须定义在剪裁空间中(这样你的显卡可以容易地判断哪些物体需要被绘制哪些需要被剪裁)。要将系数从3D空间映射到剪裁空间，你需要通过ViewProjection的反置（inverse-transpose）矩阵变换它们，如下列代码所示：

private void UpdateClipPlane()
{
    Matrix camMatrix = mirrorViewMatrix * fpsCam.ProjectionMatrix;
    Matrix invCamMatrix = Matrix.Invert(camMatrix);
    invCamMatrix = Matrix.Transpose(invCamMatrix);
    Vector4 mirrorPlaneCoeffs = new Vector4(mirrorPlane.Normal, mirrorPlane.D);
    Vector4 clipPlaneCoeffs = Vector4.Transform(-mirrorPlaneCoeffs, invCamMatrix);
    clipPlane = new Plane(clipPlaneCoeffs); }

首先计算反置矩阵。然后，接受镜子平面的定义在3D空间中的四个系数，通过反置矩阵将它们映射到剪裁空间，然后使用结果创建剪裁平面。

注意：“–”表示平面的哪一个面需要被剔除。与平面的法线方向有关，是根据定义点p0, p 1, p2和p3的顺序定义的。

因为clipPlane变量取决于viewMatrix，而viewMatrix在相机位置改变时需要更新，所以在Update方法中调用下面的方法：

UpdateClipPlane();

接下来要做的就是将剪裁平面传递到显卡中，在绘制镜子中看到的场景前开启它，记住在绘制普通相机看到的场景前关闭它，因为镜子后面的物体可以被普通相机看到，应该被显示：

//render scene as seen by mirror into render target
device.SetRenderTarget(0, renderTarget);
device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.CornflowerBlue, 1, 0);
device.ClipPlanes[0].Plane  = clipPlane;
device.ClipPlanes[0].IsEnabled = true;
RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix);
device.ClipPlanes[0].IsEnabled = false;

注意：如果你近距离观察，镜子中的图像相比原始图像看起来有些模糊。这是因为被计算的纹理坐标通常不会精确地对应一个像素，所以显卡会取最近像素的平均，平均化的过程对应模糊化的操作(见教程2-12)。

代码

要加载technique，需要将. Fx文件加载到一个Effect变量中，还需要定义镜子：

private void InitMirror()
{
    mirrorVertices = new VertexPositionTexture[4];

    int i = 0;
    Vector3 p0 = new Vector3(-3, 0, 1);
    Vector3 p1 = new Vector3(-3, 6, 0);
    Vector3 p2 = new Vector3(6, 0, 0);
    Vector3 p3 = p1 + p2 - p0;

    mirrorVertices[i++] = new VertexPositionTexture(p0, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p1, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p2, new Vector2(0,0));
    mirrorVertices[i++] = new VertexPositionTexture(p3, new Vector2(0,0));

    mirrorPlane = new Plane(p0, p1, p2);
}

当相机位置发生改变时，你需要更新mirrorViewMatrix和clipPlane变量，因为它们取决于普通View矩阵：

private void UpdateMirrorViewMatrix()
{
    Vector3 mirrorCamPosition = MirrorVector3(mirrorPlane, fpsCam.Position);
    Vector3 mirrorTargetPosition = MirrorVector3(mirrorPlane,fpsCam.TargetPosition);
    Vector3 camUpPosition = fpsCam.Position + fpsCam.UpVector;
    Vector3 mirrorCamUpPosition = MirrorVector3(mirrorPlane, camUpPosition);
    Vector3 mirrorUpVector = mirrorCamUpPosition - mirrorCamPosition;
    mirrorViewMatrix = Matrix.CreateLookAt(mirrorCamPosition, mirrorTargetPosition, mirrorUpVector);
}
private Vector3 MirrorVector3(Plane mirrorPlane, Vector3 originalV3)
{
    float distV3ToPlane = mirrorPlane.DotCoordinate(originalV3);
    Vector3 mirroredV3 = originalV3 - 2 * distV3ToPlane * mirrorPlane.Normal;

    return mirroredV3;
}
private void UpdateClipPlane()
{
    Matrix camMatrix = mirrorViewMatrix * fpsCam.ProjectionMatrix;
    Matrix invCamMatrix = Matrix.Invert(camMatrix);
    invCamMatrix = Matrix.Transpose(invCamMatrix);
    Vector4 mirrorPlaneCoeffs = new Vector4(mirrorPlane.Normal, mirrorPlane.D);
    Vector4 clipPlaneCoeffs = Vector4.Transform(-mirrorPlaneCoeffs, invCamMatrix);

    clipPlane = new Plane(clipPlaneCoeffs);
}

在绘制过程中，你首先将从镜像相机中看到的场景绘制到一张纹理中，然后清除屏幕，绘制从普通相机中看到的场景。之后绘制镜子：

protected override void Draw(GameTime gameTime)
{
    //render scene as seen by mirror into render target
    device.SetRenderTarget(0, renderTarget);
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

    device.ClipPlanes[0].Plane = clipPlane;
    device.ClipPlanes[0].IsEnabled = true;
    RenderScene(mirrorViewMatrix, fpsCam.ProjectionMatrix);
    device.ClipPlanes[0].IsEnabled = false;

    //deactivate custom render target, and save its contents into a texture
    device.SetRenderTarget(0, null);
    mirrorTexture = renderTarget.GetTexture();

    //render scene + mirror as seen by user to screen
    graphics.GraphicsDevice.Clear(Color.Tomato);
    RenderScene(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);
    RenderMirror();

    base.Draw(gameTime);
}

镜子使用mirror technique绘制为一个简单的矩形，它的颜色来自于mirrorTexture变量：

private void RenderMirror()
{
    mirrorEffect.Parameters["xWorld"].SetValue(Matrix.Identity);
    mirrorEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
    mirrorEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
    mirrorEffect.Parameters["xMirrorView"].SetValue(mirrorViewMatrix);
    mirrorEffect.Parameters["xMirrorTexture"].SetValue(mirrorTexture);

    mirrorEffect.Begin();
    foreach (EffectPass pass in mirrorEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
        device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, mirrorVertices, 0, 2);
        pass.End();
    }
    mirrorEffect.End();
}

对镜子的每个顶点，vertex shader计算2D屏幕位置和xMirrorTexture中对应的位置：

MirVertexToPixel MirrorVS(float4 inPos: POSITION0)
{
    MirVertexToPixel Output = (MirVertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    float4x4 preMirrorViewProjection = mul (xMirrorView, xProjection);
    float4x4 preMirrorWorldViewProjection = mul(xWorld, preMirrorViewProjection);

    Output.TexCoord = mul(inPos, preMirrorWorldViewProjection);

    return Output;
}

pixel shader使用将这个坐标除以其次坐标，将位置从 [–1,1] 渲染目标区间映射到[0,1]纹理坐标区间。使用结果纹理坐标，采样xMirrorTexture对应位置的颜色并返回：

MirPixelToFrame MirrorPS(MirVertexToPixel PSIn) : COLOR0
{
    MirPixelToFrame Output = (MirPixelToFrame)0;

    float2 ProjectedTexCoords;
    ProjectedTexCoords[0] = PSIn.TexCoord.x/PSIn.TexCoord.w/2.0f +0.5f;
    ProjectedTexCoords[1] = -PSIn.TexCoord.y/PSIn.TexCoord.w/2.0f +0.5f;
    Output.Color = tex2D(textureSampler, ProjectedTexCoords);

    return Output;
}


4.0 第四章概述

一个没有物体的游戏能干什么呢？但是，手动定义所有模型是不可能的。幸运的是，XNA可以加载3D应用程序做好的模型。在将模型加载到XNA程序之后，你就可以移动，旋转模型并施加动画了。

本章第一部分解释了如何从一个文件加载模型，如何将它放置在3D世界的正确位置。然后，详细讨论一个模型对象的结构，让你可以对模型施加动画。

然后，本章介绍了一些不同的碰撞检测方法，因为大多数游戏都需要用到它。从快速检测到基于体（volumn-based）的更加精确的逐三角形检测。

因为有些高级教程需要用到模型的内容处理器，本章介绍了如何扩展默认的模型处理器，使用自定义TypeWriter和TypeReader传递自定义类对象。

你肯定需要一些模型让3D世界变得更生动。但是，如果使用正交投影矩阵(见教程2-11)，一个2D游戏也可以使用模型。

本章的内容如下所示：

  • 从文件加载模型，在3D世界中缩放、旋转、移动、绘制它们(教程4-1, 4-2和4-6)
  • 通过让模型始终朝向运动方向并添加加速度实现一个漂亮的模型移动(教程4-3和4-4)
  • 使用光标选择模型(教程4-19)
  • 可视化模型结构，在模型上施加动画(教程4-8和4-9)
  • 使用快速方法检查模型间的碰撞(教程4-10和4-11)
  • 学习如何通过逐三角形检测实现更精确的碰撞检测(教程4-18)
  • 因为BasicEffect 类不能满足你的需要，使用自己的HLSL绘制模型(教程4-7和4-12)
  • 扩展模型处理器让你可以在XNA项目中访问更多的有用数据 (教程4-13, 4-14, 4-15和4-16)
  • 通过编写自定义TypeWriter和TypeReader，学习如何在自定义模型处理器的加载和处理过程中存储自定义类对象(教程4-15和4-16)
  • 让模型可以匹配地形的倾斜(教程4-17)


4.1 使用BasicEffect类载入模型

问题

因为手动定义一个复杂3D对象的所有点几乎是不可能的，所以这些3D对象应该是由艺术家在3D建模程序中制作，模型可以保存为一个文件。你想从文件加载模型并在场景中绘制这个模型。

解决方案

XNA Framework已经包含了所需方法。XNA提供了一个默认的Model素材管道可以从.x和.fbx文件加载模型。如教程3－1的图片所示，你只需将模型拖动到Solution Explorer的Content文件夹即可，然后将它设置为XNA代码中的一个变量。这个Model变量包含绘制模型的方法。

工作原理

首先将. x或. fbx文件导入到XNA Game Studio中。你可以通过在Windows Explorer中选中这个文件将它拖入到XNA Game Studio的Solution Explorer的Content文件夹中。也可以右击Content文件夹选择Add Existing Item，如图4－1所示，然后浏览到. x或. fbx文件并选择它。

注意：当你点击新添加的模型文件时，在Solution Explorer的右下角Properties框中会显示XNA使用默认的模型导入器和处理器。可见教程4-12至4-16获取如何使用自定义的模型处理器的知识。

添加好模型后，你可以将下面的变量添加到代码中：

Model myModel;

图4-1 将一个模型添加到项目中

然后将模型绑定到这个变量。这一步适合在LoadContent方法中进行：

myModel = Content.Load("tank");

注意：这个例子中素材的名称是tank，默认就是模型文件的没有扩展名的名称。你也可以通过选择Solution Explorer中的源文件改变Asset Name属性。

现在你加载了模型，可以通过在Draw 方法中添加以下代码绘制它了，这是将模型绘制到场景所需的所有代码，本教程后面会介绍此代码的背景知识。

//draw model
modelTransforms = new Matrix[myModel.Bones.Count];
Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f);
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
        effect.View = fpsCam.ViewMatrix;
        effect.Projection = fpsCam.ProjectionMatrix;
    }
}
mesh.Draw();

模型的结构

在大多数情况下，你可以旋转和移动模型的一部分，例如，移动人的手臂。要能实现这个功能，模型应该要被分成几个成员。对每个成员，你需要知道两件事：

  • 几何数据：你想知道顶点，顶点包含了组成模型成员的所有三角形顶点的信息，这些信息包括位置，颜色，法线等。
  • 成员与它们的父成员如何连接：以人的手臂为例，你要指定手臂是连接在肩膀上的。

每个成员的几何数据以ModelMesh对象的形式存储，而ModelMesh对象是在Model的 ModelMeshCollection中，在这个对象你可以找到它的Meshes属性。成员的位置数据以Bone对象的形式存储，而Bone对象在Model的ModelBoneCollection中，在这个对象中你可以找到模型的Bones集合。每个ModelMesh对象包含指向Bone对象的引用，而一个Bone对象包含指向父Bone的引用，它必须连接到这个父Bone。通过这种方式，你可以将所有Bone对象连接到一起，具体细节可见教程4-8和4-9。在教程4-9中，你可以找到CopyAbsoluteBoneTransformsTo方法的解释。

ModelMeshes和Bones

一个ModelMesh包含模型的一个成员的几何信息，这个成员无法再分成更小的成员。例如，一台笔记本电脑不是一个好的ModelMesh，因为你想翻起/关闭液晶屏，打开/关闭DVD托盘。好的办法是对电脑底座使用一个ModelMesh，液晶屏使用第二个ModelMeshcreen，而DVD托盘使用第三个ModelMesh。

因为所有的ModelMesh都需要他连接到一个Bone上，下一步你的三个ModelMeshes还需要三个Bone。你需要将连接到电脑底座的ModelMesh的Bone作为root Bone，因为电脑底座可以看成电脑的初始位置。连接到液晶屏ModelMesh的表示与底座连接的位置。同理连接DVD托盘的ModelMesh的Bone也要指向root Bone，表示托盘相对于底座的位置。

ModelMeshes和ModelMeshParts

你已经为液晶屏定义了一个ModelMesh和对应的Bone，这样很完美，因为液晶屏不包含可动部分。但是，你可能还想使用一个固定纹理的effect绘制液晶屏的塑料外壳，使用另一个effect绘制LCD，例如，这个效果会从另一张纹理采样颜色或你还想添加一点反光效果。

这就需要用到ModelMeshParts。每个ModelMesh可以包含多个ModelMeshParts，每个ModelMeshParts可以使用不同的纹理，材质或effect进行绘制。这意味着每个ModelMeshPart 都包含各自的几何数据和对应几何数据的effect。

注意：如果你对effect不了解，可以把它们想象成像素的颜色定义，像素是否反光，是否透明，是否从一张图像中获取颜色？可见第6章的例子学习像素如何正确地与光线作用。教程3-13包含了从图像获取颜色的例子。

两个循环嵌套

Model的结构解释了为什么我们需要在绘制模型时使用两个循环。首先，你遍历Model的ModelMeshes，每个ModelMesh包含一个或多个ModelMeshPart，每个ModelMeshPart都有自己的effect。所以，你需要第二个循环遍历当前ModelMesh的所有ModelMeshPart，设置各自effect的参数。但是如果多个ModelMeshParts使用同样的effect，这会导致同样的effect被设置了两次，显然只是浪费时间。要避免这种情况，ModelMesh会通过它的ModelMeshPart保存所有独立的effect，在第二个循环中使用这个effecf集合。

最后，在设置好ModelMesh的所有effect后，你调用ModelMesh对象的Draw方法，这会使用ModelMesh对象的所有ModelMeshParts使用各自的effect进行绘制。

注意：教程4-8的例子中遍历了ModelMesh的ModelMeshParts，而不是effects。

小小的性能提升

前面的代码在每次调用Draw方法时都会重新实例化Bones数组。更好的办法是在加载模型后只实例化并填充数组一次。所以，将以下代码放置到LoadContent方法中：

myModel = content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];

代码

首先在LoadContent方法中加载Model并初始化Bone数组：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    myModel = Content.Load<Model>("tank");
    modelTransforms = new Matrix[myModel.Bones.Count];
}

然后在Draw 方法中绘制模型：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

    //draw model
    Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f);
    myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
    foreach (ModelMesh mesh in myModel.Meshes)
    {
        foreach (BasicEffect effect in mesh.Effects)
        {
            effect.EnableDefaultLighting();
            effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
            effect.View = fpsCam.ViewMatrix;
            effect.Projection = fpsCam.ProjectionMatrix;
        }
        mesh.Draw();
    }
    base.Draw(gameTime);
}


4.2 对不同对象设置不同的世界矩阵，组合世界矩阵

问题

你想在绘制模型之前独立地移动、旋转并/或缩放它们。

解决方案

你可以设置对象的世界矩阵实现这个功能，而矩阵是可以存储任何变换（平移、旋转或缩放等操作）的对象。你可以简单地使用XNA框架提供的基本方法创建一个变换矩阵：

  • Matrix.CreateTranslation
  • Matrix.CreateScale
  • Matrix.CreateRotationX-Y-Z

第一个方法创建一个平移矩阵，你可以定义将模型沿着X、Y和Z方向移动的距离。第二个方法让你可以缩放模型，第三个方法返回绕X、Y和Z轴旋转的矩阵。

你可以乘以这些矩阵组合多个变换，但必须按照正确的顺序，这会在本教程中讨论到。

工作原理

当将模型绘制到屏幕上时，会将它的初始位置放置在3D空间的(0,0,0)点上，而最常见的操作就是让模型在3D场景中移动。

这很简单。在Draw方法中定义一个时间变量代表程序开始以来经过的秒数（精确起见使用毫秒，然后除以1000）。然后定义一个矩阵保存沿X轴方向的平移，而平移量由当前时间决定：

float time = (float)gameTime.TotalRealTime.TotalMilliseconds/1000.0f;
Matrix worldMatrix = Matrix.CreateTranslation(time, 0, 0);

当将这个矩阵设置为世界矩阵时，可以让模型以每秒一个单位的速度沿x方向移动。下面的代码使用这个矩阵绘制模型：

myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
        effect.View = fpsCam.GetViewMatrix();
        effect.Projection = fpsCam.GetProjectionMatrix();
    }
    mesh.Draw();
}

要让模型中的所有部分都在正确的位置，需要使Bone变换作为世界矩阵的一部分 (可见教程4-9学习bone变换的更多知识)。但这次，你通过将bone变换矩阵与平移矩阵相乘将两者组合起来，一定要将世界矩阵放置在乘式的右边，因为在矩阵乘法中顺序是很重要的。当你运行这个代码时，模型会缓缓地沿着x轴运动。

对于缩放和旋转方法类似。试着使用下面得世界矩阵：

Matrix worldMatrix = Matrix.CreateScale(time);

开始时time = 0，模型看不见，随后慢慢变大。一秒后达到原始大小，之后会继续变大。接下来使用这个矩阵：

Matrix worldMatrix = Matrix.CreateRotationY(time);

模型会持续地绕y轴旋转。

组合多个变换

通常情况下，你会组合多个变换作为世界矩阵。例如，你想将一个角色移动到一个新位置，将它旋转到行走的方向并对它进行缩放。

因为这是一个小例子，所以使用下列世界矩阵：

Matrix worldMatrix = Matrix.CreateTranslation(time, 0, 0) * 	Matrix.CreateRotationY(time/10.0f);

这个组合矩阵将作为模型的世界矩阵。但是你可以用另一种方式组合这两个矩阵：

Matrix worldMatrix = Matrix.CreateRotationY(time / 10.0f) * Matrix.CreateTranslation(time, 0, 0);

当你运行程序后会发现结果是不同的。前面已经提到，在矩阵数学中，乘法的顺序是很重要的，接下来我将讨论这个问题。

矩阵乘法的顺序

在矩阵数学中，将矩阵M1乘以矩阵M2的结果通常与将M2乘以M1的结果是不同的，在下面的章节中，我会讨论所有可能的组合。

有一个规则(或者说是技巧)你必须记得：在矩阵乘法中，M1*M2就是“M1在M2之后”的意思。

旋转与另一个旋转的组合

旋转矩阵乘法的顺序是很重要的，这是因为当你首先绕A1轴旋转然后绕A2轴旋转，那么绕A1轴的旋转会在绕A2轴旋转前改变A2轴！

例如，M1表示绕向右轴旋转90度，M2表示绕向上轴旋转90度。

首先看一下M1*M2的情况，它的意思是“在绕向上轴之后绕向右轴旋转。”想一下结果会如何。在这种情况中，你的右手臂始终对着向右方向。你首先绕向上轴旋转90度，这样你会面向左方，如图4-2左图所示。当你看一下右手臂，你会发现它跟着你一起旋转！所以当你绕向右轴旋转90度后，你会面朝下躺倒在地，如图4-2的右图所示。

图4-2 先绕Up轴旋转后绕Right轴旋转

下面看一下第二种情况M2*M1，表示“在绕Right轴旋转后绕Up轴旋转。”你首先绕你的向右方向旋转，结果是面朝下，如图4-3的左图所示。你的向上方向变成了水平，即世界坐标系的向前方向。然后当你绕这个向前方向旋转时，结果是你面朝侧面，如图4-3的右图所示！

图4-3 先绕Right轴旋转后绕Up轴旋转

如你所见，M1*M2和M2*M1会导致不同的结果，这是因为当组合两个旋转时，第二个旋转轴会受到第一个旋转地影响。

旋转与平移的组合

在这种情况中，乘法的顺序仍是重要的。在这个例子中，M3表示绕Up轴旋转90度，M4表示沿x轴方向移动10个单位。 M3*M4表示“平移后旋转，”模型会首先移动到新位置。然后绕Up轴旋转，这两步如图4-4所示。

图4-4 先平移后旋转

M4*M3的情况不同。首先整个坐标系（包括模型和x轴）绕Up轴旋转90度，如图4-5左图所示。然后，模型沿着旋转过的x轴移动10个单位。但初始的x轴已经旋转了，本来它位于你的右方，但现在在你的前方！这意味着你实际上是沿世界坐标系向前的z轴在做平移。

图4-5 先旋转后平移

缩放与平移的组合

缩放和平移的乘法顺序还是重要的。本例中。M5表示缩放0.5倍，M6表示沿x轴平移10个单位。 M5*M6表示先平移后缩放。所以首先模型向右沿x轴平移10个单位，然后缩小，如图4-6所示。

图4-6 先平移后缩放

M6*M5的情况中你首先将整个坐标系（包括模型和x轴）缩小，然后缩小的模型沿着缩小的x轴移动10个单位。因为x轴也缩小了一半，所以只移动了相当于5个单位！结果是模型移动的不够远，如图4-7所示。

图4-7 先缩放后平移

安全的组合

幸运的是，有些变换无需考虑乘法的顺序。例如，组合两个平移矩阵是安全的，因为模型只是简单地移动了两次。两个缩放变换的组合也是安全的。

例如，先放大2倍再缩小10倍和先缩小10倍再放大2倍结果是一样的。最后，缩放不影响旋转，反之亦然。这是因为只会缩放坐标轴，但轴之间的角度仍保持90度不变。所以当缩放矩阵和旋转矩阵相乘时，你可以不用考虑乘法的顺序。

译者注：在《Microsoft XNA Game Studio Creator’s Guide》一书的第5章提到一个小技巧：使用I.S.R.O.T.作为矩阵组合的顺序通常就是你想要的结果，此处I.S.R.O.T. 分别代表Identity，Scale，Revolve，Orbit，Translate。

代码

下面的代码组合了旋转和平移矩阵：

//draw model
float time = (float)gameTime.TotalRealTime.TotalMilliseconds/1000.0f;
Matrix worldMatrix = Matrix.CreateScale(0.005f)* Matrix.CreateRotationY(time / 10.0f) * Matrix.CreateTranslation(time, 0, 0);
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
        effect.View = fpsCam.ViewMatrix;
        effect.Projection = fpsCam.ProjectionMatrix;
    }
    mesh.Draw();
}


4.3 找到对应一个方向的旋转角

问题

通过绘制模型前设置世界矩阵（见前一个教程）时，你可以在场景的任意位置放置模型。当你将模型从A点移至B点，你需要确保它转向正确的方向。

这个旋转角通常通过一个自然的方式获取，例如，当处理一个包含旋转的世界矩阵。但是，如果你只是存储模型的前一个位置和当前位置的方法移动模型的话，那么必须手动旋转这个模型。

解决方案

你需要首先计算模型运动的方向。从这个方向的X轴和Z轴开始，很容易找到这个角度。

工作原理

给定一个方向，你想知道需要旋转模型多少角度才能让它指向这个方向。给定如图4-8右边所示的方向，你想找到这个方向与Z轴的夹角。

如果你还记得三角形的基本知识那么这个解决方案是很简单的。给定如图4-8左边所示的三角形，你可以通过计算S除以L的反正切值（atan）得到a角，对于右图的情况，你可以通过计算方向的X分量除以Z分量的反正切值获取旋转角度。

图4-8 计算偏离给定方向的旋转角

atan方法返回的值在+90度和–90度之间(当两个负数相除时，负号会丢失)。

幸运的是，.NET Framework还提供了Atan2方法，这个方法可以让你分别提供X和Z值，返回在–180至180度范围内的值，让旋转角定义在完整的一个圆周内。

下面的简单方法计算对应模型当前位置和前一个位置的旋转角：

private float CalculateRotation(Vector3 oldPos, Vector3 newPos, float lastRot)
{
    Vector3 direction = newPos - oldPos;
    if ((direction.X == 0) &&(direction.Z == 0))
        return lastRot;
    else
        return (float)Math.Atan2(direction.X,direction.Z);
}

首先你找到旧位置指向新位置的方向用于计算对应旋转角。如果模型停止移动或垂直移动，这个方向的X和Z都是0，Atan2方法会返回一个无用的值，这时要返回上次的旋转值，这样不动的模型会保持它的旋转值。

用法

你应该使用这个计算出的旋转角创建一个绕向上的Y轴的旋转，这样模型的前方就会朝向运动的方向。当创建世界矩阵时，你应该首先平移模型然后进行旋转。这意味着你应该在矩阵乘法中将旋转放置在平移的左边(见教材4-2)。看一下下面的例子，模型最后的位置存储在position变量中，而angle变量是由CalculateRotation方法计算得到的：

worldMatrix = Matrix.CreateTranslation(position);
worldMatrix = Matrix.CreateRotationY(angle) * worldMatrix;
worldMatrix = Matrix.CreateScale(0.005f) * worldMatrix;

在本例中，首先模型坐标被移动到模型中心的最终位置，然后施加绕Y轴的旋转，之后使模型变小。

代码

你可以在前面看到CalculateRotation方法的代码。


4.4 使用加速度控制速度

问题

你想让模型能够漂亮的加速而不是从静止直接提高到最大速度。图4-9显示了你想实现的这种加速过程。

图4-9 模型的速度时间图像

解决方案

通过添加加速度，你可以定义你的模型的速度变化有多快。加速度就是每帧速度的增加量。

工作原理

你需要保存模型的位置和旋转，这是因为你需要知道哪个方向是向前的。在本例中，你只是让模型绕着它的向上的y轴旋转并沿着X和Z轴移动。如果你想让模型只基于X和Z轴沿着一个表面移动可参加教程4-17。

因为模型以后的速度取决于当前速度，所以你需要保存当前速度。你将存储速度矢量而不是标量。这个速度矢量包含模型当前前进的方向，它的长度表示速度的大小。所以在类中添加三个变量：

Vector3 modelPosition = new Vector3();
float modelYRot = 0;
Vector3 modelVelocity= new Vector3();

你也可以定义模型的最大加速度和转弯速度：

const float modelMaxAcceleration = 30.0f;
const float modelMaxTurnSpeed = 0.002f;

在Update方法中接受自上一帧以来经过的时间为参数，检测模型移动的距离：

float elapsedSeconds = (float)gameTime.ElapsedGameTime.Milliseconds / 1000.0f;
float forwardReq= 0;
float angleReq = 0;
if (keyState.IsKeyDown(Keys.Up)) forwardReq += 1.0f;
if(keyState.IsKeyDown(Keys.Down)) forwardReq -= 1.0f;
if (keyState.IsKeyDown(Keys.Left))angleReq += 1.0f;
if (keyState.IsKeyDown(Keys.Right)) angleReq -= 1.0f;

当模型向前加速时变量forwardReq为正，减速或向后加速时为负。变量angleReq表示模型左转还是右转。

在光滑表面上加速

下面的代码添加基本加速行为：

Matrix rotMatrix = Matrix.CreateRotationY(angle);
Vector3 forwardDir = Vector3.Transform(new Vector3(0, 0, -1), rotMatrix);
velocity = velocity + elapsedTime * forwardReq *maxAccel *forwardDir;
modelPosition += velocity;
modelYRot += rotationReq * maxRotSpeed* velocity.Length();

前两行代码计算模型当前的Forward矢量，这个矢量用来获得模型加速的方向。这个Forward向量是在向上y－轴上附加在默认的(0,0,-1) Forward方向上的旋转获得的。

注意：本教程中的模型只能绕y轴旋转，所以Forward向量只是基于绕向上y轴的旋转。可参加教程2-3和2-4学习计算整个3D空间或四元数的旋转。

接下来的代码计算速度。在前一个速度的基础上基于用户输入添加一个新的矢量。自上一帧经过的时间越长，越需要调整这个速度矢量。另外，加速度越大，越需要调整这个速度矢量。最后你还要考虑模型的最大加速度。将这三个因素相乘获得这一帧需要调整多少速度。因为你需要将一个Vector3添加到速度矢量上，要乘以带有forwardDir的这个值获取想要添加的矢量。

注意：如果没有旋转，速度和moveDirection会指向相同的方向，只是简单使速度矢量变大让模型移动地更快。

最后，这个Vector3添加到模型的位置，模型的旋转被调整。模型运动得越快，转向也越快。

当使用这个代码时，你注意到有两个缺点。首先，模型将一直加速，它没有一个最大速度。你想如图4-9所示增加速度并终止于一个确定的最大速度。使用这个代码，模型将一直以相同的步进加速。

第二，如果模型在一个方向上的速度很大，在旋转模型后，它可能仍在相同的方向。如果模型是在一个冰面上当然不错，但通常这并不是你想要的结果。

添加摩擦力

在真实情况中，模型的速度会因为模型与空气、表面之间的摩擦等慢慢减小。当你停止加速，摩擦力会让速度减小直至停止。当持续加速，摩擦力会导致速度增加到一个特定值不在增加。

你可以通过减去前面的速度获取摩擦力。下面的代码添加了摩擦力：

velocity = velocity * (1 - friction * elapsedTime) + elapsedTime * forwardReq *maxAccel *forwardDir;

两帧之间的时间越长，摩擦力的效果越明显，所以你要根据流逝时间乘以friction变量。

让模型保持在向前方向运动

虽然模型现在已经以一个比较自然的方式加速了，但在它旋转时仍有一点瑕疵（除非是在空间游戏或冰面滑行游戏中）。通常，你只想让模型沿着前进方向移动。

你需要知道沿着Forward 方向上的速度矢量是多少。这可以通过点乘得到：它将Velocity (V) 矢量投影到Forward (F)上，如图4-10所示，并返回投影速度的大小。

图4-10 将速度矢量投影到Forward矢量上

所以在更新速度后使用下列代码：

float forwardSpeed = Vector3.Dot(velocity, forwardDir);
velocity = forwardSpeed * forwardDir;
modelPosition += velocity * elapsedTime;
modelYRot+= rotationReq * maxRotSpeed * forwardSpeed;

forwardSpeed变量表示Velocity矢量在Forward方向上分量的大小，然后乘以Forward方向并将结果存储在一个新的Velocity矢量中。通过这个方法，你可以保证模型将沿着向前方向移动。

注意：使用forwardSpeed变量的一个额外好处是当模型向前运动是它为正，向后运动时它为负，而velocity. Length ()总是正的。

代码 Accelerate方法根据加速度调整模型的位置，速度和旋转，将它们和最大加速度和旋转速度相加。你还要获取用户输入和摩擦力变量。

private float Accelerate(ref Vector3 position, ref float angle, ref Vector3 velocity,float forwardReq,
        float rotationReq, float elapsedTime, float maxAccel, float maxRotSpeed,float friction)
{
    Matrix rotMatrix = Matrix.CreateRotationY(angle);
    Vector3 forwardDir= Vector3.Transform(new Vector3(0, 0, -1), rotMatrix);

    velocity = velocity * (1- friction * elapsedTime) + elapsedTime * forwardReq * maxAccel *forwardDir;
    loat forwardSpeed = Vector3.Dot(velocity, forwardDir);
    velocity = forwardSpeed * forwardDir;

    modelPosition += velocity * elapsedTime;
    modelYRot += rotationReq * maxRotSpeed* forwardSpeed;

    return forwardSpeed;
}

这个方法返回forwardSpeed变量，若模型向后运动则为负值。

扩展阅读

你可以通过允许乘以加速度施加在模型上扩展这个方法，例如，重力加速度，你可以加上这个加速度将总和作为forwardDir。


4.5 构建模型的全局包围球

问题

模型的包围球是完整包围模型的最小球体。在很多情况中，例如碰撞检测，判断大小等，使用包围球是非常有用的。

解决方案

你可以访问模型中的每个ModelMesh的包围球。使用CreateMerged方法，你可以将这个包围球组合起来，获取包围整个模型的包围球。

但是，因为每个ModelMesh的包围球是相对于Bone矩阵定义的，所以你需要进行转换。

工作原理

你将创建一个方法将一个素材加载到一个模型变量中，初始化它的Bone矩阵数组，并将全局包围球保存到模型的Tag属性中。

技巧：你可以使用模型的Tag属性存储任何对象。本例中，你使用Tag属性存储包围球。Tag属性在本章中会经常用到。

private Model LoadModelWithBoundingSphere(ref Matrix[] modelTransforms, string asset, ContentManager content)
{
    Model newModel = content.Load<Model>(asset);
    modelTransforms = new Matrix[newModel.Bones.Count];
    newModel.CopyAbsoluteBoneTransformsTo(modelTransforms);

    return newModel;
}

这个方法加载一个模型，已在教程4-1中解释过了。在返回模型前，你需要遍历模型的查看它们的包围球(这些包围球是由默认模型处理器在编译时创建的，可见教程4-13中的解释)。

foreach (ModelMesh mesh in newModel.Meshes)
{
    BoundingSphere origMeshSphere = mesh.BoundingSphere;
}

你想将这些包围球组合在一起获取整个模型的全局包围球。你可以通过保存一个新的包围球并把这些小的包围球组合在一起实现这个功能。

BoundingSphere completeBoundingSphere = new BoundingSphere();

foreach (ModelMesh mesh in newModel.Meshes)
{
    BoundingSphere origMeshSphere = mesh.BoundingSphere;
    completeBoundingSphere = BoundingSphere.CreateMerged(completeBoundingSphere, origMeshSphere);
}

这样completeBoundingSphere就是一个包含所有小球的大球。

但是，当你获取ModelMesh的包围球时，这个包围球是定义在ModelMesh的本地空间中的。例如，一个人的躯干的包围球是80cm，而头的包围球只有30cm，如果你只是简单将两者组合在一起，你获得的是80cm的球，这是因为小球是完整包含在大球中的。

要正确的组合两者，你首先要将头的包围球移动到躯干的顶部然后在进行组合，这样才会获得一个包含两者的大的包围球。

在XNA中，这意味着你必须首先使用ModelMesh包含的Bone矩阵转换这个ModelMesh的包围球，就像以下代码所示：

BoundingSphere completeBoundingSphere = new BoundingSphere();

foreach (ModelMesh mesh in newModel.Meshes)
{
    BoundingSphere origMeshSphere = mesh.BoundingSphere;
    BoundingSphere transMeshSphere = XNAUtils.TransformBoundingSphere(origMeshSphere, modelTransforms[mesh.ParentBone.Index]);

    completeBoundingSphere = BoundingSphere.CreateMerged(completeBoundingSphere, transMeshSphere);
}
newModel.Tag = completeBoundingSphere;

正确的全局包围球会被存储到模型的Tag属性中。

注意：BoundingSphere.Transform方法是XNA Framework自带的，但它不能处理包含旋转的矩阵，所以我在XNAUtils文件中包含了一个扩展版本，你可以在示例中找到这个方法。

用法

当你想从Tag属性中获取全局包围盒时，你需要首先将它转换为BoundingSphere对象，这是因为Tag属性中可以存储任何类型的对象：

BoundingSphere bSphere = (BoundingSphere)myModel.Tag;

代码

下面是完整代码，包括初始化一个模型并将它的包围盒存储到Tag属性中：

private Model LoadModelWithBoundingSphere(ref Matrix[] modelTransforms, string asset, ContentManager content)
{
    Model newModel = content.Load<Model>(asset);
    modelTransforms = new Matrix[newModel.Bones.Count];
    newModel.CopyAbsoluteBoneTransformsTo(modelTransforms);

    BoundingSphere completeBoundingSphere = new BoundingSphere();

    foreach (ModelMesh mesh in newModel.Meshes)
    {
        BoundingSphere origMeshSphere = mesh.BoundingSphere;
        BoundingSphere transMeshSphere = XNAUtils.TransformBoundingSphere(origMeshSphere, modelTransforms[mesh.ParentBone.Index]);
        completeBoundingSphere = BoundingSphere.CreateMerged(completeBoundingSphere, transMeshSphere);
    }

    newModel.Tag = completeBoundingSphere;

    return newModel;
}


4.6 缩放模型

问题

当从磁盘载入模型时，往往会太大或太小，你想将模型缩放到定义的大小。

解决方案

首先你需要定义一个模型的全局包围球，前面一个教程已经解释了。知道了这个包围球，你就可以知道模型的当前尺寸了。从这个尺寸，你可以知道需要将模型放大或所小多少。你也可以将这个缩放操作储存在root Bone矩阵中，这样缩放会施加到模型中所有Bone的所有矩阵上(可见教程4-9)。

工作原理

通常，你使用的模型是由不同的工具制作的，或从网上下载的，你无法指定模型的大小。所以，如果你能将模型缩放到你想要的大小那会很棒。

下面的代码计算模型需要缩放到多少，这需要存储在模型Tag属性中的全局包围球（见前一个教程）。

private Matrix[] AutoScale(Model model, float requestedSize)
{
    BoundingSphere bSphere = (BoundingSphere)model.Tag;

    float originalSize = bSphere.Radius * 2;
    float scalingFactor = requestedSize / originalSize;
    model.Root.Transform = model.Root.Transform * Matrix.CreateScale(scalingFactor);
    Matrix[] modelTransforms = new Matrix[model.Bones.Count];
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);

    return modelTransforms;
}

你需要将模型传递给这个方法指定最终的模型有多大。这个方法一开始获取一个模型的全局包围球，你需要将求的半径乘2获取球的大小，对应于模型的初始大小。接下来，你将requestedSize除以这个值获取这个模型需要缩放到多大。你可以使用这个缩放因子创建一个矩阵，将这个矩阵作为绘制模型时的世界矩阵，这样你的模型就会缩放到想要的大小了。

在Root Bone上施加缩放

构建良好的模型将所有子网格链接到它们的root bone。这意味着当你缩放了root bone矩阵 (见教程4-9)，所有ModelMeshes也会自动缩放。你可以通过将Root矩阵乘以缩放矩阵实现，在前面计算的scalingFactor基础上，将这个结果矩阵存储在模型中。因为你改变了Bone矩阵的内容，所以你必须提取modelTransforms矩阵的新版本。

注意：更好的方法是在自定义模型处理器中使用一个可以进行配置的变量，见教程4-12学习如何扩展默认模型处理器。

代码

在LoadContent方法中，你需要使用两行代码加载并缩放模型：

myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tank", Content);
modelTransforms = AutoScale(myModel, 10.0f);

当绘制模型时，你可以使用普通的代码，因为缩放信息已经被储存在模型的Root bone中了。通过这种方式，每次当你调用Model. CopyAbsoluteBoneTransformsTo方法时，所有的结果矩阵都已经包含了缩放操作，这样在将模型的各个部分绘制到屏幕之前它们已经进行了缩放。

Matrix worldMatrix = Matrix.Identity;
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
        effect.View = fpsCam.ViewMatrix;
        effect.Projection = fpsCam.ProjectionMatrix;
    }
    mesh.Draw();
}

记住，你可以在教程4-1和4-9中找到关于Bone的更多信息，在教程4-2中找到世界矩阵的更多信息。


4.7 使用自定义Effects和纹理绘制模型(简单方法)

问题

当你使用教材4-1中的代码将一个模型加载到XNA项目时，你使用的是一个BasicEffect实例。在简单的情况下BasicEffect可以很好地绘制模型，当我们常常想使用一个不同的，自定义的effect绘制一个模型。

解决方案

通过将模型的effect包含在BasicEffect对象中，你可以获取effect的所有信息，诸如纹理和材质等信息。当你将这些属性复制到某个安全的地方后，就可以用你选择的effect覆盖这个effect。

注意：你还可以使用自定义内容处理器更加清晰地完成这个任务，可见教材4-12。

工作原理

首先你需要加载一个模型和自定义effect，两者都需要使用内容管道进行加载：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    myModel = Content.Load<Model>("tank");
    modelTransforms = new Matrix[myModel.Bones.Count];
    customEffect = Content.Load<Effect>("bwcolor");
}

本例中的自定义effect会使用灰度值绘制模型（译者注：本例中的灰度实现使用最简单的三个颜色通道求平均值的方法，而不是更好的给绿色更多的权重）。

因为effect是存储在模型的ModelMesh中的，所以你可以简单地使用自定义effect覆盖它们。但是，所有原始effect信息都会丢失！这些信息包括了纹理和材质颜色信息。

在覆盖模型的默认effect前，你想存储这些信息，因为自定义effect需要知道某些信息，例如使用哪个纹理。

你将创建一个方法处理模型和自定义effect，这个方法会使用自定义effect的副本替换模型中的所有effect。最后，存储初始数据，返回初始effect中所有纹理的数组，这样你就可以将它们传递到自定义effect。本例中，所有的effect都会被自定义effect复写，但a small adaptation suffices to override only the effect of a particular ModelMeshPart（译者：？）：

private Texture2D[] ChangeEffect(ref Model model, Effect newEffect)
{
}

因为你想永久地改变模型，所以model参数要使用引用(在第一个变量前加上ref)。否则会使用一个本地副本，而对于这个副本所有改变都不会被调用代码获知。

首先，你要创建一个被原始effect使用的所用纹理的副本：

List<Texture2D> textureList = new List<Texture2D>();
foreach (ModelMesh mesh in model.Meshes)
    foreach (ModelMeshPart modmeshpart in mesh.MeshParts)
    {
        BasicEffect oldEffect = (BasicEffect)modmeshpart.Effect;
        textureList.Add(oldEffect.Texture);
    }
    Texture2D[] textures = textureList.ToArray();
    return textures;

你创建了一个集合存储纹理，然后遍历每个ModelMesh的不同ModelMeshParts，每个ModelMeshPart包含指向effect的链接。将这个effect传递到一个BasicEffect对象，这样你就可以访问到它的属性，例如纹理。对于模型中的每个effect都附加上一个纹理集合中的纹理。

收集完所有的纹理后，你将集合转换为一个数组并将这个数组返回。

现在你所做的操作会使用自定义effect的副本覆盖原始effect，所以在第二个循环中添加以下代码：

 modmeshpart.Effect = newEffect.Clone(device);

注意：你使用了Clone方法创建了一个effect的副本，你想使用这个副本，否则所有的ModelMeshPart都会共享相同的effect。通过给每个ModelMeshPart施加各自effect的副本，每个ModelMeshPart都能使用不同的纹理。

在LoadContent方法中加载了模型和自定义effect之后调用这个方法：

modelTextures = ChangeEffect(ref myModel, customEffect);

当绘制模型时，你就可以单独定义自定义effect的每个参数了：

int i = 0;
Matrix worldMatrix = Matrix.CreateScale(0.01f);
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (Effect currentEffect in mesh.Effects)
    {
        currentEffect.Parameters["xWorld"].
        SetValue(modelTransforms[mesh.ParentBone.Index]*worldMatrix);
        currentEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
        currentEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
        currentEffect.Parameters["xTexture"].SetValue(modelTextures[i++]);
    }
    mesh.Draw();
}

请注意你是如何将原始effect信息传递到自定义的effect中的，例如本例中的纹理。

遍历ModelMeshPart而不是Effect

前面的循环中遍历了所有effect，但这样做当你想设置一个特定ModelMeshPart的effect时会出现问题，所以你遍历ModelMesh的ModelMeshPart而不是它的effect：

int i = 0;
Matrix worldMatrix = Matrix.CreateScale(0.01f);
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (ModelMeshPart part in mesh.MeshParts)
    {
        Effect currentEffect = part.Effect;
        currentEffect.Parameters["xWorld"]. SetValue(modelTransforms[mesh.ParentBone.Index] * worldMatrix);
        currentEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
        currentEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
        currentEffect.Parameters["xTexture"].SetValue(modelTextures[i++]);
    }
    mesh.Draw();
}

存储所有原始信息

如果你还想存储的纹理之外的信息，只需简单地将原始effect添加到集合替代纹理：

BasicEffect[] originalEffects;

在ChangeEffect方法中，将原始effect添加到集合中去：

BasicEffect oldEffect = (BasicEffect)modmeshpart.Effect;
effectList.Add(oldEffect);

当设置自定义effect的参数时，你可以很容易地访问到初始信息：

currentEffect.Parameters["xTexture"].SetValue(originalEffects[i++].Texture);

代码

在LoadContent方法中，加载模型和自定义effect：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);
    myModel = Content.Load<Model>("tank");
    modelTransforms = new Matrix[myModel.Bones.Count];
    customEffect = Content.Load<Effect>("bwcolor");
    originalEffects = ChangeEffect(ref myModel, customEffect);
}

最后一行代码使用自定义effect的副本替换了模型中的effect，是在下列方法中实现的：

private BasicEffect[] ChangeEffect(ref Model model, Effect newEffect)
{
    List<BasicEffect> effectList = new List<BasicEffect>();
    foreach (ModelMesh mesh in model.Meshes)
        foreach (ModelMeshPart modmeshpart in mesh.MeshParts)
        {
            BasicEffect oldEffect = (BasicEffect)modmeshpart.Effect;
            effectList.Add(oldEffect);
            modmeshpart.Effect = newEffect.Clone(device);
        }
    BasicEffect[] effects = effectList.ToArray();
    return effects;
}

当绘制模型时，你可以像这样访问到存储的信息：

int i = 0;
Matrix worldMatrix = Matrix.CreateScale(0.01f);
myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (ModelMeshPart part in mesh.MeshParts)
    {
        Effect currentEffect = part.Effect;
        currentEffect.Parameters["xWorld"]. SetValue(modelTransforms[mesh.ParentBone.Index] * worldMatrix);
        currentEffect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
        currentEffect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
        currentEffect.Parameters["xTexture"].SetValue(originalEffects[i++].Texture);
    }
    mesh.Draw();
}


4.8 可视化模型骨骼结构

问题

如在教程4-1中的最后所讨论的，一个模型通常包含许多成员，这些成员叫做ModelMeshes。这些ModelMeshes之间的位置联系是包含在Model对象的Bone结构中的。Bone结构定义了ModelMeshes是如何并在哪儿互相联系，每个ModelMesh 相对于parent ModelMesh旋转和/或缩放了多少。

在你可以让模型动起来前，你需要知道ModelMeshes是连接在哪个Bone上，需要可视化Bone的结构。

解决方案

Model是由ModelMeshe组成的。这些ModelMeshe包含了渲染模型确定部分所需的所有数据，包含所有顶点、索引、纹理、effect等，如教程4-1的第二部分所述。但是，ModelMesh 不包含在模型中的位置的信息。例如，一个car模型的右前门ModelMesh包含所有绘制需要的顶点和纹理信息，但它不包含相对于车身中心绘制在哪儿的信息。

这个信息存储在模型的Bones集合中。每个ModelMesh链接到一个Bone，这样XNA可以找到ModelMesh相对于Model中心的位置。一个Bone只是包含一个变换矩阵，保存这个ModelMesh相对于它的parent ModelMesh的位置。例如，车身的ModelMesh指向一个保存0平移的Bone，这是因为车身是车的主体。右前车门的ModelMesh指向一个保存右车门相对于车身中心位置的转换矩阵的Bone。

你还可以加以扩展。一辆高级的汽车模型还包含一个独立的右前车窗ModelMesh，这个ModelMesh也指向一个Bone，这个Bone包含车窗相对于右前车门的位置信息。图4-11显示了这个Bone结构。我在每个名称的后面加了一个B提醒你这些都是Bone对象。要产生这种结构，每个Bone对象都要链接到它的parent Bone和所有它的child Bone。

图4-11 一个汽车模型的Bone层次

在本教程中，你将遍历这个结构并用类似于图4-11的形式将Bone对象写入一个文件。你还要遍历模型的所有ModelMeshes并显示它们链接到哪个Bone。

工作原理

幸运的是，你可以很容易地通过在加载模型后的代码行中设置断点的方法看到模型中定义了哪些ModelMeshe，这一步可以通过点击代码左侧的侧边栏实现，也可以使用如下的代码：

myModel = Content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];
System.Diagnostics.Debugger.Break();

当运行代码时，程序会在最后一行停止，屏幕的左下角你可以浏览到模型并看到这个模型包含了哪些ModelMeshe，如图4-12所示。

图4-12 浏览实时变量

但你还需要知道每个ModelMesh链接到哪个Bone上，你还想遍历Bone集合获知Bone的层次结构。在本教程中，你将编写一些代码生成一个简单的文本文件，包含Bone结构和ModelMesh-to-Bone之间的联系。

下面的输出清单是DirectX SDK自带的dwarf模型的信息：

Model Bone Information
----------------------
- Name : DXCC_ROOT
    Index: 0
        - Name : null
            Index: 1
                - Name : Drawf
                    Index: 2
                        - Name : Sword_new
                            Index: 3
                                - Name : Sword_newShape
                                    Index: 4
                        - Name : backpack_new
                            Index: 5
                            - Name : backpack_newShape
                                Index: 6
                        - Name : Body1
                            Index: 7
                                - Name : Body1Shape
                                    Index: 8
                        - Name : Armor_new
                            Index: 9
                                - Name : Armor_newShape
                                    Index: 10
                        - Name : Face_gear_new
                            Index: 11
                                - Name : Face_gear_newShape
                                    Index: 12
                        - Name : Head_new
                            Index: 13
                                - Name : Head_newShape
                                    Index: 14
                 - Name : side
                     Index: 15
                 - Name : front
                     Index: 16
                 - Name : top
                     Index: 17
                 - Name : persp
                     Index: 18
Model Mesh Information
----------------------
- ID : 0
    Name: Sword_newShape
    Bone: Sword_newShape (4)
- ID : 1
    Name: backpack_newShape
    Bone: backpack_newShape (6)
- ID : 2
    Name: Body1Shape
    Bone: Body1Shape (8)
- ID : 3
    Name: Armor_newShape
    Bone: Armor_newShape (10)
- ID : 4
    Name: Face_gear_newShape
    Bone: Face_gear_newShape (12)
- ID : 5
    Name: Head_newShape
    Bone: Head_newShape (14)

在代码底部，你知道这个矮人（dwarf）模型包含六个ModelMeshe，每个ModelMeshe链接到各自的Bone。你可以独立地移动剑和背包，但无法移动手臂和腿，因为手臂和腿不是一个独立的ModelMeshes。

这个模型也不支持基本动画。让我们看一下另一个模型的结构，在XNA Creators Club网站上的一个坦克模型：

Model Bone Information
----------------------
- Name : tank_geo
    Index: 0
        - Name : r_engine_geo
        Index: 1
            - Name : r_back_wheel_geo
                Index: 2
            - Name : r_steer_geo
                Index: 3
                    - Name : r_front_wheel_geo
                        Index: 4
        - Name : l_engine_geo
            Index: 5
                - Name : l_back_wheel_geo
                    Index: 6
                - Name : l_steer_geo
                    Index: 7
                        - Name : l_front_wheel_geo
                            Index: 8
       - Name : turret_geo
           Index: 9
               - Name : canon_geo
                   Index: 10
               - Name : hatch_geo
                   Index: 11
Model Mesh Information
----------------------
- ID : 0
    Name: r_back_wheel_geo
    Bone: r_back_wheel_geo (2)
- ID : 1
    Name: r_front_wheel_geo
    Bone: r_front_wheel_geo (4)
- ID : 2
    Name: r_steer_geo
    Bone: r_steer_geo (3)
- ID : 3
    Name: r_engine_geo
    Bone: r_engine_geo (1)
- ID : 4
    Name: l_back_wheel_geo
    Bone: l_back_wheel_geo (6)
- ID : 5
    Name: l_front_wheel_geo
    Bone: l_front_wheel_geo (8)
- ID : 6
    Name: l_steer_geo
    Bone: l_steer_geo (7)
- ID : 7
    Name: l_engine_geo
    Bone: l_engine_geo (5)
- ID : 8
    Name: canon_geo Bone:
    canon_geo (10)
- ID : 9
    Name: hatch_geo
    Bone: hatch_geo (11)
- ID : 10
    Name: turret_geo
    Bone: turret_geo (9)
- ID : 11
    Name: tank_geo
    Bone: tank_geo (0)

在这个例子中。你会发现几乎每个独立的部分都有一个ModelMesh！轮子有四个ModelMeshes，车身有两个，炮塔一个，炮管一个，甚至连舱盖也有一个。因为所有的ModelMeshes都有各自的Bone，所以它们都可以独立运动！例如，炮管的 ModelMesh链接到Bone 10，改变Bone 10的矩阵就可以改变炮管的位置和旋转，下一个教程中你会学到更多的知识。

收集模型信息

下面的方法可以将模型信息写到一个文件中：

private void WriteModelStructure(Model model)
{
    StreamWriter writer = new StreamWriter("modelStructure.txt");
    writer.WriteLine("Model Bone Information");
    writer.WriteLine("----------------------");
    ModelBone root = model.Root;
    WriteBone(root, 0, writer);

    writer.WriteLine();
    writer.WriteLine();

    writer.WriteLine("Model Mesh Information");
    writer.WriteLine("----------------------");
    foreach (ModelMesh mesh in model.Meshes)
        WriteModelMesh(model.Meshes.IndexOf(mesh), mesh, writer);
    writer.Close();
}

因为你要将文字写到一个文件中，所以需要创建一个StreamWriter，这要求在代码顶部添加System.IO命名空间：

using System.IO;

下一步，在文件中先写一个文件头，然后调用WriteBone方法处理模型的root Bone。这个马上就要创建的方法会写下这个Bone的信息并将这个调用传递到所有child Bone对象，将所有子Bone对象的信息也写入文件。

在写入第二个文件头后，你遍历模型的所有ModelMeshe并将它们传递到WriteModelMesh方法，这个方法会将ModelMesh的信息写到文件中。

WriteBone方法

这个方法写入名称（如果没有指定名称则为null）和索引。最后这个方法对当前Bone的所有child Bone调用自己。通过这种方式，你可以只对模型的root Bone 调用此方法一次，所有链接到这个root Bone的Bone对象都会罗列出来。

private void WriteBone(ModelBone bone, int level, StreamWriter writer)
{
    for (int l = 0; l < level; l++)
        writer.Write("\t");
    writer.Write("- Name : ");
    if ((bone.Name == "") || (bone.Name == "null"))
        writer.WriteLine("null");
    else
        writer.WriteLine(bone.Name);
    for (int l = 0; l < level; l++)
        writer.Write("\t");
    writer.WriteLine(" Index: " + bone.Index);
    foreach (ModelBone childBone in bone.Children)
        WriteBone(childBone, level + 1, writer);
}

WriteModelMesh方法这个方法写入每个ModelMesh的ID、名称和链接到的Bone，下一个教程你需要这个信息绘制模型动画：

private void WriteModelMesh(int ID, ModelMesh mesh, StreamWriter writer)
{
    writer.WriteLine("- ID : " + ID);
    writer.WriteLine(" Name: " + mesh.Name);
    writer.Write(" Bone: " + mesh.ParentBone.Name);
    writer.WriteLine(" (" + mesh.ParentBone.Index + ")");
}

用法

只需在加载模型时调用WriteModelStructure方法即可：

myModel = Content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];
WriteModelStructure(myModel);

当运行这个代码时，模型结构会被写到modelStructure.txt文件中，这个文件可以在生成.exe 文件的映射位置找，默认是在\bin\x86\Debug位置中。

代码

WriteModelStructure，WriteBone和WriteModelMesh方法前面已经全部写过了。


4.9 使Bone独立运动：模型动画

问题

你想独立的移动模型的每一部分。例如，你想摇低一辆车的车窗或让车轮转动。

解决方案

如教程4－1中解释的那样，一个模型是由可单独绘制的ModelMesh组成的。每个ModelMesh都链接到一个Bone，这些Bone互相联系，之间的位置关系是由矩阵显示的。

每个模型都有一个root Bone，所有其他Bone对象直接或间接与它链接，图4－11显示了这种结构的一个例子。

如果你想对图中的root Bone进行变换－例如，对这个root Bone进行缩放，那么它的所有child Bone对象(包括child Bone对象的child Bone对象) 会自动以同样的方式缩放。第二个例子，如果你想对存储在右前门Bone中的矩阵进行旋转，只有门本身，它的车窗和门锁会跟着旋转，这正是你需要的。

工作原理

在你对模型应用一个动画之前，你需要对模型的Bone结构有个概览，前面的教程中已经解释了如何可视化Bone结构，看到哪个ModelMeshes链接到哪个Bone对象上。

让我们看一下在XNA Creators Club网站上找到的坦克模型的Bone结构，这个结构在前一个教程中已经写过了。你有一个root Bone，当你缩放这个Bone的矩阵时，整个坦克都会进行缩放。接下来看一下炮塔的Bone，它是root的子Bone，如果你旋转这个Bone，每个链接到这个Bone的ModelMesh和它的child Bone对象都会旋转。所以当你旋转炮塔Bone的矩阵时，炮塔，炮管，翻盖都会旋转，因为它们都是链接到炮塔上的。

下面是你必须要记住的重点：

  • 模型中所有的可独立绘制的，可变换的部分都存储在不同的ModelMesh对象中。每个ModelMesh对象都链接到一个Bone对象。
  • 每个Bone对象存储这个Bone相对于它的parent Bone的位置，旋转和缩放信息。
  • 当你设置一个Bone的变换时，这个变换还要影响到这个Bone的所有child Bone对象。

CopyAbsoluteBoneTransformsTo方法的必要性(额外的解释)

上面列表中的最后一点不是很容易。在绘制每个ModelMesh时，你需要设置它的世界矩阵，因为你想让ModelMesh放置在正确的3D空间中。问题是这个世界矩阵定义的是ModelMesh在3D空间中的位置，但是，Bone矩阵包含的矩阵存储的相对于它的parent ModelMesh的位置！

以坦克的火炮为例，火炮的Bone矩阵包含一个诸如(0,0,-2)的偏移量：相对于它的父：炮塔向前移动2个单位。

如果你只是简单地将火炮的世界矩阵设置为火炮ModelMesh的矩阵，这会导致火炮ModelMesh被绘制到相对于3D空间的初始位置(0,0,0)偏离(0,0,-2)的地方。

但这不是你想要的结果！你实际是想将火炮放置到相对于它的parent：炮塔偏离(0,0,-2)的位置。

所以在绘制火炮前，你需要将它的Bone和它的parent的Bone (将两者相乘)组合起来。而且还要回溯到根节点，因为这种情况中最终矩阵还要和炮塔的parent Bone（坦克的车身）组合。通过这种方式，你获取了火炮的最终世界矩阵。

因为这个矩阵是相对于坦克初始位置的，所以叫做火炮的绝对变换矩阵（absolute transformation matrix）。

幸运的是，XNA提供了组合这些矩阵的功能。在绘制模型前，你需要调用模型的CopyAbsoluteBoneTransformsTo方法，这个方法会计算所有的组合并将它存储在结果的绝对矩阵数组中。这些绝对矩阵不再包含相对于parent Bone的变换信息；而只包含相对与模型的root的信息。结果是，这些包含在modelTransforms数组中的矩阵包含了坦克模型中所有ModelMesh的绝对变换信息。

你可以使用这些矩阵作为模型中每个ModelMesh的绝对世界矩阵：

myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (BasicEffect effect in mesh.Effects)
    {
        effect.EnableDefaultLighting();
        effect.World = modelTransforms[mesh.ParentBone.Index];
        effect.View = fpsCam.GetViewMatrix();
        effect.Projection = fpsCam.GetProjectionMatrix();
    }
    mesh.Draw();
}

虽然看起来变得更难了，但这样做会带来巨大的好处 。在这个例子中，只要炮塔的Bone矩阵发生旋转，炮管的(0,0,-2)平移矩阵也会跟着一起旋转。这是因为当CopyAbsoluteBoneTransformsTo方法结束后，火炮的绝对转换矩阵也会包含它的parent Bone 矩阵的旋转信息。而且，所有链接到炮塔的child ModelMesh，诸如炮管和翻盖，也会自动随着炮塔一起旋转。

设置模型中指定的ModelMesh的动画

知道了模型的结构后，现在可以实现模型动画了。

在本例中，你想提升炮管。要做到这一点，使用前一个教程中的方法看一下炮管的Mesh part与哪个Bone相链接，而你将对这个Bone的矩阵施加一个旋转。

但是，这个Bone矩阵中存储了火炮相对于炮塔的原始位置，如果你用旋转矩阵覆盖了这个矩阵，原始位置就丢失(或很难找到)！这样就无法以后对炮管施加动画了，因为你总是想从初始矩阵开始进行旋转，而这个初始矩阵的值已经丢失。

所以在加载了模型后，你需要创建一个原始Bone矩阵的备份，存储相对于parent的位置，这要用到CopyBoneTransformsTo方法：

Matrix[] originalTransforms = new Matrix[myModel.Bones.Count];
myModel.CopyBoneTransformsTo(originalTransforms);

注意：对每个ModelMesh，你都需要存储相对于parent ModelMesh的位置信息，所以你使用CopyBoneTransformsTo method。CopyAbsoluteBoneTransformsTo给你相对于模型初始位置的位置信息，如前面在“CopyAbsoluteBoneTransformsTo方法的必要性”一节中解释的。

你需要将这个代码放置在LoadContent方法中。

存储好矩阵后，你可以安全地覆盖存储在Bone对象中的矩阵了，在项目中添加一个canonRot变量：

float canonRot = 0;

可以让玩家在Update方法中调整这个变量：

if (keyState.IsKeyDown(Keys.U))
    canonRot -= 0.05f;
if (keyState.IsKeyDown(Keys.D))
    canonRot += 0.05f;

现在你可以使用键盘控制这个变量，将以下代码放到Draw方法中，在这行代码之前还要计算模型的绝对世界矩阵：

Matrix newCanonMat = Matrix.CreateRotationX(canonRot) * originalTransforms[10];
myModel.Bones[10].Transform = newCanonMat;

在前面的教程中你可以在坦克模型的结构中看到，炮塔的ModelMesh链接到Bone 10。这个代码将炮管的相对于炮塔的初始位置存储在矩阵中，沿着向右向量旋转(使之上下旋转)，并在模型中存储组合矩阵。当运行代码后，炮管会根据键盘输入上下旋转。

如果你想旋转整个炮塔，你需要对炮塔的Bone矩阵做同样的事情，首先添加turretRot变量：

float turretRot = 0;

然后在Update方法中添加键盘控制代码：

if (keyState.IsKeyDown(Keys.L))
    turretRot += 0.05f;
if (keyState.IsKeyDown(Keys.R))
    turretRot -= 0.05f;

在Draw方法中调整对应的Bone矩阵：

Matrix newTurretMat = Matrix.CreateRotationY(turretRot) * originalTransforms[9];
myModel.Bones[9].Transform = newTurretMat;

如你在前一个教程中看到的，炮塔的Bone索引是9。首先获取初始矩阵，然后绕着Y轴旋转让炮塔左右旋转。

注意：改变炮管矩阵和改变炮塔矩阵的顺序先后不会影响结果。在绝对矩阵中的Bone对象间的关系只有在调用CopyAbsoluteBoneTransformsTo方法时才会存储。

如前所述，如果旋转炮塔，那么炮塔的children (本例中是炮管和翻盖)也会跟着一起旋转，这是因为炮管的Bone矩阵通过CopyAbsoluteBoneTransformsTo方法和它的child Bone矩阵组合在了一起。

代码

在加载模型后，请确保你存储了原始Bone矩阵：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);

    myModel = Content.Load<Model>("tank");
    modelTransforms = new Matrix[myModel.Bones.Count];
    originalTransforms = new Matrix[myModel.Bones.Count];
    myModel.CopyBoneTransformsTo(originalTransforms);
}

在update过程中，我们可以改变旋转角度：

KeyboardState keyState = Keyboard.GetState();
if (keyState.IsKeyDown(Keys.U))
    canonRot -= 0.05f;
if (keyState.IsKeyDown(Keys.D))
    canonRot += 0.05f;
if (keyState.IsKeyDown(Keys.L))
    turretRot += 0.05f;
if (keyState.IsKeyDown(Keys.R))
    turretRot -= 0.05f;

最后绘制模型，你需要用旋转矩阵覆盖原始Bone矩阵并构建绝对Bone矩阵，而这些绝对矩阵必须作为ModelMesh的当前矩阵：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
    cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);

    //draw model
    Matrix newCanonMat = Matrix.CreateRotationX(canonRot) * originalTransforms[10];
    MyModel.Bones[10].Transform = newCanonMat;
    Matrix newTurretMat = Matrix.CreateRotationY(turretRot) * originalTransforms[9];
    myModel.Bones[9].Transform = newTurretMat;

    Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f);
    myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
    foreach (ModelMesh mesh in myModel.Meshes)
    {
        foreach (BasicEffect effect in mesh.Effects)
        {
            effect.EnableDefaultLighting();
            effect.World = modelTransforms[mesh.ParentBone.Index] * worldMatrix;
            effect.View = fpsCam.ViewMatrix;
            effect.Projection = fpsCam.ProjectionMatrix;
        }
        mesh.Draw();
    }
    base.Draw(gameTime);
};


4.10 使用包围球进行基本模型碰撞检测

问题

你想检测两个模型是否发生碰撞。如果在场景中有许多模型，你将无法进行一个精确的逐三角形的碰撞检测。你想使用一个快速检测方法并在以后进行一个更好的碰撞检测。

解决方案

当进行碰撞检测时，你会发现常常需要在速度和精确度之间进行衡量。在大多数情况中，你会进行组合检测。在第一轮检测中，使用快速检测遍历所有对象，之后对快速检测中可能发生碰撞的物体进行精确检测。

本教程展示了两个方法处理两个模型间的碰撞检测。快速检测方法是找到模型的全局包围球，并通过调用一个包围球的Intersect 方法检测是否相交。

你可以改进这个方法以增加精确度。由几个ModelMeshes组成的模型存储了模型不同部分的几何信息。每个ModelMeshes都能生成各自的包围球，所以你可以检测第一个模型和第二个模型的每个包围球。显然，这样做提高了精度但计算量也随之加大。

工作原理

快速检测

这个方法使用整个模型的包围球。如果这两个模型的包围球相交，则它们可能发生了碰撞。

在某些情况下，这个方法会导致糟糕的结果，例如在滑板游戏中，当你想检测两个玩家的滑板的碰撞时，滑板的包围球相对于滑板本身大得多，滑板的体积不到包围球体积的百分之一，这个方法将会对全局包围球中的所有物体（可能包含其他滑板！）进行检测，如图4-13所示。

但是，这个方法在进行第一次检测时用得还是很多的，因为它的速度足够快。

图4-13 每个滑板都在另外一个滑板的包围球中

要实现这个快速检测，首先从加载模型和生成它们的的全局包围球开始，这一步已在教程4-5中介绍过了。包围球将存储在模型的Tag属性中：

myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tank",Content);

这个包围球将包含模型的中心位置(中心位置没必要一定在模型的(0,0,0)点上)和半径。

当绘制两个模型时，你需要在每个模型上施加一个不同的世界矩阵让它可以移动（和/或缩放/旋转）至正确的位置。当你移动（和/或缩放/旋转）一个模型时，还需要移动包围球的中心位置（和/或缩放半径）。你可以使用XNAUtils 文件中的TransformBoundingSphere方法做到这一点，这个方法以包围盒和世界矩阵为参数，更加世界矩阵移动中心位置和缩放半径，并返回结果包围球。

要检测两个模型间的碰撞，你需要通过世界矩阵变换每个包围球并检查变换过的包围球是否相交。

private bool CoarseCheck(Model model1, Matrix world1, Model model2, Matrix world2)
{
    BoundingSphere origSphere1 = (BoundingSphere)model1.Tag;
    BoundingSphere sphere1= XNAUtils.TransformBoundingSphere(origSphere1, world1);

    BoundingSphere origSphere2= (BoundingSphere)model2.Tag;
    BoundingSphere sphere2 = XNAUtils.TransformBoundingSphere(origSphere2,world2);

    bool collision = sphere1.Intersects(sphere2);
    return collision;
}

因为模型的Tag属性可以包含任何东西(这是因为Object类在C#中是根类)，你需要通过(BoundingSphere)进行类型转换。如果变换过的包围球发生碰撞，这个方法会返回true。

更加精确，但有点慢

每个模型是由多个成员组成的，几何数据是存储在模型的Meshes集合中的。每个 ModelMesh都能生成自己的包围球。这些小的包围球的总体积比模型的全局包围球小得多，如图4-14所示。图的右边你可以看到模型每个部分的独立包围球。

图4-14 全局包围球和多个小的包围球

在图的左边，两个包围球相交，这样第一次检测会显示两个模型发生碰撞。而在图的右边，没有包围球发生碰撞，所以这次检测会正确地显示两者并没有发生碰撞。第二个方法通常会返回更好的结果；但是，第一种检测只需检查一次，而第二种需要检查(members inside Model1)*(members inside Model2)次。

下面的方法是根据第二种情况的碰撞检测：

private bool FinerCheck(Model model1, Matrix world1, Model model2, Matrix world2)
{
    if (CoarseCheck(model1, world1,model2, world2) == false)
        return false;
    bool collision = false;

    Matrix[] model1Transforms= new Matrix[model1.Bones.Count];
    Matrix[] model2Transforms = new Matrix[model2.Bones.Count];

    model1.CopyAbsoluteBoneTransformsTo(model1Transforms);
    model2.CopyAbsoluteBoneTransformsTo(model2Transforms);

    foreach (ModelMesh mesh1 in model1.Meshes)
    {
        BoundingSphere origSphere1 = mesh1.BoundingSphere;
        Matrix trans1 = model1Transforms[mesh1.ParentBone.Index] * world1;
        BoundingSpheretransSphere1 = XNAUtils.TransformBoundingSphere(origSphere1, trans1);

        foreach (ModelMesh mesh2 in model2.Meshes)
        {
            BoundingSphere origSphere2 = mesh2.BoundingSphere;
            Matrix trans2 = model2Transforms[mesh2.ParentBone.Index] * world2;
            BoundingSphere transSphere2 = XNAUtils.TransformBoundingSphere(origSphere2, trans2);
            if (transSphere1.Intersects(transSphere2))
                collision = true;
        }
    }
    return collision;
}

你首先进行快速检测，如果这次检测返回false，那就没必要进行精确检测。

如果快速检测显示可能发生碰撞，那么还有进行更加精确的检测。你首先将collision变量设置为false，除非检测到碰撞那么这个变量将保持为false。因为你需要将每个小包围球移动到正确的位置，所以需要绝对Bone矩阵。

要对第一个模型的每个ModelMesh进行这个检测，你需要将包围球移动到绝对位置。要实现这一步，你需要考虑模型包围球的位置和模型在3D世界中的位置。

然后你遍历第二个模型的所有部分并将这些包围球变换到绝对位置。对于第一个模型的每个包围球，你检查是否与第二个模型的任意一个包围球是否发生碰撞，如果是，则将collision变量设置为true。

最后，collision变量显示是否至少有一个模型1的包围球与模型2的包围球发生碰撞并返回这个变量。

优化

因为对模型1的每个部分来说，模型2的所有包围球都是以相同的方式进行变换的，显然这个方法可以进行优化。例如，你可以首先变换模型1和模型2的包围球一次，在方法的开头，将它们存储在一个数组中。之后，你只需简单地对这些变换过的包围球进行碰撞检测。

下面的代码是FinerCheck方法的优化版本：

private bool FinerCheck(Model model1, Matrix world1, Model model2, Matrix world2)
{
    if (CoarseCheck(model1, world1, model2, world2) == false)
        return false;
    Matrix[] model1Transforms = new Matrix[model1.Bones.Count];
    model1.CopyAbsoluteBoneTransformsTo(model1Transforms);
    BoundingSphere[] model1Spheres = new BoundingSphere[model1.Meshes.Count];

    for (int i=0; i<model1.Meshes.Count; i++)
    {
        ModelMesh mesh = model1.Meshes[i];
        BoundingSphere origSphere = mesh.BoundingSphere;
        Matrix trans = model1Transforms[mesh.ParentBone.Index]* world1;
        BoundingSphere transSphere = XNAUtils.TransformBoundingSphere(origSphere,trans);
        model1Spheres[i] = transSphere;
    }

    Matrix[] model2Transforms = new Matrix[model2.Bones.Count];
    model2.CopyAbsoluteBoneTransformsTo(model2Transforms);
    BoundingSphere[] model2Spheres= new BoundingSphere[model2.Meshes.Count];

    for (int i = 0; i < model1.Meshes.Count;i++)
    {
        ModelMesh mesh = model2.Meshes[i];
        BoundingSphere origSphere = mesh.BoundingSphere;
        Matrix trans = model2Transforms[mesh.ParentBone.Index] * world2;
        BoundingSphere transSphere = XNAUtils.TransformBoundingSphere(origSphere, trans);
        model2Spheres[i]= transSphere;
    }

    bool collision = false;
    for (int i=0; i<model1Spheres.Length;i++)
        for (int j = 0; j < model2Spheres.Length; j++)
            if (model1Spheres[i].Intersects(model2Spheres[j]))
                return true;
    return collision;
}

注意：CopyAbsoluteBoneTransformsTo方法只需在模型的Bone矩阵更新后被调用一次。

代码

CoarseCheck和FinerCheck方法的代码前面已经写过了。下面的Draw方法定义了两个世界矩阵将两个模型放置在不同位置。随着时间流逝，一个模型接近另一个，如果两者发生碰撞，背景会变成红色：

protected override void Draw(GameTime gameTime)
{
    //define World matrices
    float time = (float)gameTime.TotalGameTime.TotalMilliseconds / 1000.0f;
    Matrix worldM1= Matrix.CreateScale(0.01f) * Matrix.CreateRotationY(MathHelper.PiOver2) *Matrix.CreateTranslation(-10,0, 0);
    Matrix worldM2 = Matrix.CreateScale(0.01f) * Matrix.CreateRotationY(-MathHelper.PiOver2)* Matrix.CreateTranslation(10, 3, 0);
    worldM2 = worldM2 * Matrix.CreateTranslation(-time* 3.0f, 0, 0);

    //check for collision
    if (FinerCheck(myModel, worldM1, myModel, worldM2))
    {
        Window.Title = "Collision!!";
        device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer,Color.Red, 1, 0);
    }
    else
    {
        Window.Title = "No collision ...";
        device.Clear(ClearOptions.Target| ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
    }

    //render scene
    cCross.Draw(fpsCam.ViewMatrix,fpsCam.ProjectionMatrix);
    DrawModel(myModel, worldM1, modelTransforms); DrawModel(myModel,worldM2, modelTransforms);
    base.Draw(gameTime);
}


4.11 对小而快的对象使用Ray-Traced进行碰撞检测

问题

大部分的碰撞检测方法只在两个物体发生物理碰撞时才检测。但是，如果有一个小物体快速地穿过另一个物体，你的程序的更新速度就有可能跟不上而无法检测到碰撞。

举一个具体的例子，比如一枚子弹打穿一个瓶子。子弹以5000km/h 的速度射向一个瓶子，而瓶子的宽度只有15cm。XNA程序每秒更新60次，所以每次更新子弹会前进23米的距离。这样的话，在调用Update方法时几乎没有可能检测到子弹和瓶子的碰撞，即使上一帧子弹的确穿过了瓶子。

解决方案

你可以在子弹的上一个位置和当前位置之间创建一个Ray。然后通过调用Ray或包围球的Intersect方法检测Ray是否和包围球发生碰撞。如果发生碰撞，这个方法返回碰撞点与Ray的终点间的距离(你可以使用子弹的前一个位置)。

你可以使用这个距离检测在子弹的前一个位置和当前位置之间是否真的发生了碰撞。

工作原理

这个方法需要储存在模型Tag属性中的全局包围球，如教程4-5所示：

myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tank", Content);

你将创建一个方法以模型，模型的世界矩阵，快速物体的前一个位置和当前位置为参数。

private bool RayCollision(Model model, Matrix world, Vector3 lastPosition, Vector3 currentPosition)
{
    BoundingSphere modelSpere = (BoundingSphere)model.Tag;
    BoundingSphere transSphere = TransformBoundingSphere(modelSpere, world);
}

首先需要将模型的包围球移动到模型在3D空间中的当前位置，这一步可以通过使用模型的世界矩阵转换包围球做到(在前一个教程中已经解释过了)。 注意：如果你还想事先缩放模型，那么还要把缩放矩阵也组合到世界矩阵中去，这样可以让前面的代码正确地缩放包围球。

接下来找到快速物体的运动方向和自上一帧以来运动的距离：

Vector3 direction = currentPosition - lastPosition;
float distanceCovered = direction.Length();
direction.Normalize();

你可以将B减去A获得A至B的方向，调用这个方向的Length 方法获得两者的距离。对于方向来说，通常需要将它归一化使它的长度变为1。因此，请在存储了长度之后再将它归一化，否则距离将一直为1。

现在获得了Ray的一个点和它的方向就可以创建Ray了。你将使用这个Ray的Intersect方法检测与慢模型的包围球之间的碰撞。

Ray ray = new Ray(lastPosition, direction);

现在有了子弹的Ray，你就做好了完成该方法的准备：

bool collision = false;
float? intersection = ray.Intersects(transSphere);
if (intersection != null)
    if (intersection <= distanceCovered)
        collision = true;
return collision;

首先定义一个collision变量，除非发生碰撞这个变量将保持为false。在方法的最后将返回这个变量。

调用Ray的Intersect方法并传递到模型的变换过的包围球。

Intersection方法有点特别。如果发生碰撞，这个方法返回碰撞点和用来定义Ray的点之间的距离。但是，如果Ray和包围球没有发生碰撞，这个方法返回null。所以你需要一个 float?变量而不是float，因为它需要能够存储null值。

注意：如果碰撞点位于定义ray的点之后，这个方法也会返回null。因为你在创建ray时指定了方向，XNA知道ray的哪一边是否在这个点的前面或后面。

如果ray和包围球之间发生碰撞并且碰撞点在最终位置点之前，intersection就不是null。要验证两帧之间是否发生碰撞，你需要检查碰撞点和快速物体前一个点之间的距离是否小于这个物体在上一帧帧运动的距离。如果是，则发生了碰撞。

更精确的方法

前面的教程中已经解释过了，因为使用的是包围球，这个方法可能在实际上并没有发生碰撞时也会检测到碰撞。所以再次，你可以通过在模型的不同ModelMesh上的小包围球上进行ray和包围球的碰撞检测提高精度。

大而快的物体

前面介绍的碰撞检测的方法要求快速物体非常小，类似于3D空间中的一个点。但在快物体很大不能看成一个点的情况下，你需要将快速物体当作一个包围球。一个解决方法是基于快速物体上的不同点进行多次ray检测，但这样做比较复杂。

一个快速且更简单的方法是将快速物体的半径添加到慢物体的半径上，如图4-15所示。这个图显示了两个物体的碰撞边缘。如果你将小半径添加到大半径上，结果是相同的。使用这个方法，你可以用一个简单的点表示快物体，并使用前面相同的碰撞检测方法。

图4-15 增大包围球可以获得相同的结果。

代码

加载了慢模型后，你需要在Tag属性中存储它的包围球：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);
    myModel = XNAUtils.LoadModelWithBoundingSphere(ref modelTransforms, "tank", Content);
}

有了包围球就可以调用下面的方法，显示在上一帧的过程中是否发生碰撞：

private bool RayCollision(Model model, Matrix world, Vector3 lastPosition, Vector3 currentPosition)
{
    BoundingSphere modelSpere = (BoundingSphere)model.Tag;
    BoundingSphere transSphere = XNAUtils.TransformBoundingSphere(modelSpere, world);

    Vector3 direction = currentPosition - lastPosition;
    float distanceCovered = direction.Length();
    direction.Normalize();

    Ray ray = new Ray(lastPosition, direction);

    bool collision = false;
    float? intersection = ray.Intersects(transSphere);
    if (intersection != null)
        if (intersection <= distanceCovered)
            collision = true;
    return collision;
}


4.12 扩展模型内容导入器加载自定义Effects(清晰方法)

问题

你想使用一个自定义内容处理器改变模型的effect而不是实时处理。通过这种方式，模型可以正确地加载到XNA项目中，而无需在实时保存原始effect中的所有纹理和其他信息。

注意：如果你想学习如何扩展默认模型处理器，最好先看下一个教程。

解决方案

扩展默认模型处理器并重写ConvertMaterial方法，这个方法被模型中的每个MaterialContent调用，把这个方法重写将所有的MaterialContent信息传递到自定义材质处理器。

在这个自定义材质处理器中，你将创建一个空的材质对象，然后将自定义的effect链接到这个对象。你也可以将原始MaterialContent的纹理复制到这个新对象，这个就可以设置effect的所有HLSL变量了。

工作原理

因为这个教程需要扩展默认内容处理器，所以请确保已经读过了教程3－9中内容管道的知识。首先建立一个新的内容管道项目，具体解释可见教程3－9中的“扩展一个已存在的内容处理器”一节。

本教程中，你将扩展默认模型处理器，如图4-16所示。当默认模型导入器从磁盘读取一个模型并创建一个NodeContent对象时，它保存了MaterialContent对象中的effect。你的模型处理器只改变这些MaterialContent对象并存储在NodeContent中，NodeContent对象的其他部分保持不变。

图4-16 内容管道中的自定义模型处理器

注意：如果你比较一下图4-15和图3-6，你会发现你也是扩展了处理器。但是，因为这个处理器处理的是模型而不是纹理，所以它的输入和输出是不同的。

通过扩展Process方法，你可以在模型对象加载到XNA项目之前完全控制它的内容。在本教程中，因为effect是存储在材质信息中的，所以你想改变处理模型中材质的方式。在自定义模型处理器类中，你无需重写Process方法，但要重写ConvertMaterial方法，每次当模型加载材质时默认处理器都会调用这个方法。

所以用以下代码替换Process方法：

[ContentProcessor]
public class ModelCustomEffectProcessor : ModelProcessor
{
    protected override MaterialContent ConvertMaterial(MaterialContent material, ContentProcessorContext context)
    {
        return context.Convert<MaterialContent, MaterialContent>(material, "MaterialCustomEffectProcessor");
    }
}

以上代码创建一个ModelCustomEffectProcessor类。因为你只重写了ConvertMaterial方法，所以这个处理器会使用与默认模型处理器同样的方式处理几何数据。在ConvertMaterial 方法中，你指定材质需要被MaterialCustomEffectProcessor类处理。

在ModelCustomEffectProcessor类之后，添加这个MaterialCustomEffectProcessor类：

[ContentProcessor]
public class MaterialCustomEffectProcessor : MaterialProcessor
{
    public override MaterialContent Process(MaterialContent input, ContentProcessorContext context)
    {
        return base.Process(input, context);
    }
}

从参数可以看出这个处理器处理的是材质。通过从材质处理器继承，你以一个MaterialContent作为输入，然后进行处理，返回更新过的MaterialContent对象。这次，因为你想改变处理MaterialContent对象的方式，所以需要重写MaterialCustomEffectProcessor的Process方法。

注意：在ModelCustomEffectProcessor类中，你重写了NodeObject 中的所有MaterialContent对象的处理方式。与之类似，因为纹理和effect也是存储在MaterialContent 对象中的，你也可以通过重写MaterialCustomEffectProcessor 类中的BuildEffect或BuildTexture方法改变EffectContent和TextureContent对象的处理方式。

现在Process方法只是调用了它的基类MaterialProcessor。当然，你需要改变这个方法让你可以改变MaterialContent的处理过程。

现在，将Process方法的内容改为以下代码：

EffectMaterialContent myMaterial = new EffectMaterialContent();
string map 	= Path.GetDirectoryName(input.Identity.SourceFilename);
string effectFile = Path.Combine(map, "colorchannels.fx");
myMaterial.Effect = new ExternalReference<EffectContent>(effectFile);

上述代码会创建一个空的EffectMaterialContent对象并存储一个指向自定义effect文件的链接。首先找到模型存储的文件夹并附加上effect文件的名称，当编译器运行到这行代码时，会将这个effect添加到素材列表中，这样可以自动加载默认EffectProcessor使用的effect，但你也确保effect文件存储在与模型文件相同的目录中。

大多数模型还包含纹理，你需要将原始MaterialContent对象转换成新的。本例中使用的effect从一个叫做xTexture的纹理中采样颜色，让你可以单独设置红绿篮颜色通道的强度。大多数模型每个effect只有一张纹理，但有些有多张纹理(例如，一个凹凸映射，可见教程5-15)。因为没有和原始effect的纹理序号联系，所以这个代码只能获取xTexture 变量的最后一个纹理，但代码也展示了如何变量多个纹理的方法。

你可以访问原始effect的纹理集合并将这些纹理变量绑定到自定义effect的HLSL纹理变量上。

if (input.Textures != null)
    foreach (string key in input.Textures.Keys)
myMaterial.Textures.Add("xTexture", input.Textures[key]);

如果你想在加载模型时调整某些HLSL参数，下面是方法：

myMaterial.OpaqueData.Add("xRedIntensity", 1.1f);
myMaterial.OpaqueData.Add("xGreenIntensity", 0.8f);
myMaterial.OpaqueData.Add("xBlueIntensity", 0.8f);

注意：在HLSL effect文件中，每个像素的红、绿、篮颜色通道需要乘以这个变量。上面的代码让红色比其他颜色强度更大，模拟出太阳照射的效果。这在简单的post-processing effect也是很有用的！

最后需要返回新建的MaterialContent。你可以直接将它返回，但最好将这个对象传递到默认材质处理器，这样做可以保证错误（例如你没有指定需要的域）会被修正。你可以通过调用base. Process类或使用下列代码做到这点，两者效果一样。

return context.Convert<MaterialContent, MaterialContent>(myMaterial, "MaterialProcessor");

这行代码搜索叫做MaterialProcessor的处理器并将一个MaterialContent对象作为输入参数并生成另一个MaterialContent对象，这个新创建的Material对象被返回并存储在模型中。

注意：比较一下这行代码和ConvertMaterial方法中的最后一行代码（译者注：即return context.Convert<MaterialContent, MaterialContent>(material, "MaterialCustomEffectProcessor")）。

使用自定义Effect 将模型导入到XNA项目中，然后选择新的ModelCustomEffectProcessor，如图4-17所示 (不要选择MaterialCustomEffectProcessor，因为它也在列表中)。

图4-17 选择自定义模型处理器

在LoadContent method方法中加载这个模型：

myModel = content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];

现在你的模型已经包含了指定的effect！当绘制模型时，你可以使用清晰得多的代码设置参数：

myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
foreach (ModelMesh mesh in myModel.Meshes)
{
    foreach (Effect effect in mesh.Effects)
    {
        effect.Parameters["xWorld"]. SetValue(modelTransforms[mesh.ParentBone.Index]);
        effect.Parameters["xView"].SetValue(fpsCam.GetViewMatrix());
        effect.Parameters["xProjection"].SetValue(fpsCam.GetProjectionMatrix());
        effect.Parameters["xRedIntensity"].SetValue(1.2f);
    }
    mesh.Draw();
}

这个方法比教程4-7更加清晰。

代码

首先你要让MaterialCustomEffectProcessor处理模型中的每个MaterialContent对象，当然，你还要定义这个自定义材质处理器。

namespace ModelCustomEffectPipeline
{
    [ContentProcessor]
    public class ModelCustomEffectProcessor : ModelProcessor
    {
        protected override MaterialContent ConvertMaterial(MaterialContent material, ContentProcessorContext context)
        {
            return context.Convert<MaterialContent, MaterialContent>(material, "MaterialCustomEffectProcessor");
        }
    }

    [ContentProcessor]
    public class MaterialCustomEffectProcessor : MaterialProcessor
    {
        public override MaterialContent Process(MaterialContent input, ContentProcessorContext context)
        {
            EffectMaterialContent myMaterial = new EffectMaterialContent();
            string map = Path.GetDirectoryName(input.Identity.SourceFilename);
            string effectFile = Path.Combine(map, "colorchannels.fx");
            myMaterial.Effect = new ExternalReference<EffectContent>(effectFile);
            if (input.Textures != null)
                foreach (string key in input.Textures.Keys)
            myMaterial.Textures.Add("xTexture", input.Textures[key]);
            myMaterial.OpaqueData.Add("xRedIntensity", 1.1f);
            myMaterial.OpaqueData.Add("xGreenIntensity", 0.8f);
            myMaterial.OpaqueData.Add("xBlueIntensity", 0.8f);

            return context.Convert<MaterialContent, MaterialContent>(myMaterial,"MaterialProcessor");
        }
    }
}

接下来，将模型导入到XNA项目中，选择自定义模型处理器处理这个模型并加载：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);
    myModel = Content.Load<Model>("tank");
    modelTransforms = new Matrix[myModel.Bones.Count];
}

当执行这一步时，模型会加载自定义effect，让你可以在绘制前设置参数：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
    cCross.Draw(fpsCam.ViewMatrix,
    fpsCam.ProjectionMatrix);

    //draw model
    Matrix worldMatrix = Matrix.CreateScale(0.01f, 0.01f, 0.01f);
    myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
    foreach (ModelMesh mesh in myModel.Meshes)
    {
        foreach (Effect effect in mesh.Effects)
        {
            effect.Parameters["xWorld"]. SetValue(modelTransforms[mesh.ParentBone.Index] * worldMatrix);
            effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
            effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
            effect.Parameters["xRedIntensity"].SetValue(1.2f);
        }
        mesh.Draw();
    }
    base.Draw(gameTime);
}


4.13 通过扩展模型处理器直接访问顶点位置数据

问题

在XNA项目中，你想实时访问每个顶点的位置，如果你需要非常精确的碰撞检测或想找到模型上的特定点时，这一步非常有用。

虽然你可以使用常规代码访问模型的VertexBuffer，但因为顶点数据需要从显卡传递到系统内存，所以这种方法需要笨拙的代码并会拖慢程序。

解决方案

通过提取你想要的数据并把它们存储在模型的Tag属性中扩展默认模型处理器。本教程中，你将编写一个自定义模型处理器创建一个数组，这个数组包含模型中每个三角形的三个Vector3，并将这个数组存储在模型的Tag属性中。因为这个处理器只是默认模型处理器的扩展，所以你可以使用默认的模型导入器和TypeWriter，如图4-18所示。

图4-18 扩展内容管道中的模型处理器

工作原理

首先添加一个内容管道项目，具体步骤可见教程4－15，并起一个名称。

然后从默认ModelProcessor 继承定义你的处理器。因为你想改变模型对象(你需要在Tag属性中存储某些东西)，所以需要重写Process方法：

[ContentProcessor]
public class ModelVector3Processor : ModelProcessor
{
    public override ModelContent Process(NodeContent input, ContentProcessorContext context)
    {
        ModelContent usualModel = base.Process(input, context);
        List<Vector3> vertices = new List<Vector3>();
        vertices = AddVerticesToList(input, vertices);
        usualModel.Tag = vertices.ToArray();
        return usualModel;
    }
}

这个处理器首先将input传递到基类的Process方法中，这会返回一个默认的ModelContent对象，做好被串行化成一个二进制文件的准备。

但是，在你返回这个对象前，你需要添加一点自定义的东西。所以首先创建一个集合用来存储顶点的位置。在下一段落，你将创建AddVerticesTo List方法，这个方法会遍历整个模型结构并将所有顶点添加到集合中。返回这个集合，并转换为一个数组，然后将这个数组存储在ModelContent对象的Tag属性中。

你的自定义处理的“input”变量包含指向模型根节点（root node）的链接，接下来是几何和材质数据，这个根节点包含指向其子节点的链接等，直到遍历整个模型。如果你想创建一个方法将模型中的所有顶点都保存在一个集合中，那么首先需要保存根节点中的顶点，然后递归调用保存这个根节点的子节点，直至模型中所有顶点都被保存到集合中。

以上就是AddVerticesToList方法所做的工作，将这个方法添加到ModelVector3Processor 类中：

private List<Vector3> AddVerticesToList(NodeContent node, List<Vector3> vertList)
{
    MeshContent mesh = node as MeshContent;
    if (mesh != null)
    {
        //This node contains vertices, add them to the list
    }
    foreach (NodeContent child in node.Children)
        vertList = AddVerticesToList(child, vertList);
    return vertList;
}

一个节点不一定包含顶点，有时一个节点只是用来作为二个或二个以上子节点的父节点而不包含顶点数据。这意味着你需要首先判断一个节点是否是一个MeshContent对象，如果是说明它包含顶点。这里的“as”关键字进行了这种检查，如果不成功则说明对象（这里是mesh）为null。所以当程序运行到if代码块时，你可以判断节点一定包含几何数据，就需要把这些顶点存储在集合中。

注意：一个不包含顶点信息的节点只会作为模型对象的一个Bone，而不包含几何信息的节点看作是Bone和链接到Bone的一个ModelMesh(实际上是一个ModelMeshPart而不是ModelMesh)。

在当前节点的所有顶点都存储了之后，你需要调用同样的方法处理子节点，通过这种方法代码可以遍历整个模型的结构。下列代码获取节点中所有位置信息并将它添加到集合中，这个代码要放在if语句块中：

Matrix absTransform = mesh.AbsoluteTransform;
foreach (GeometryContent geo in mesh.Geometry)
{
    foreach (int index in geo.Indices)
    {
        Vector3 vertex = geo.Vertices.Positions[index];
        Vector3 transVertex = Vector3.Transform(vertex, absTransform);
        vertList.Add(transVertex);
    }
}

一个节点中的所有顶点的位置被它的父节点引用，要获取相对于根节点的位置(=下一个模型的初始节点)，你需要通过这个节点的绝对变换矩阵将位置进行转换(可见教程4-9的相关知识)。每个节点都在它的AbsoluteTransform属性中存储了绝对变换矩阵。

接下来你需要找到实际的位置数据。模型中的所有三角形是从IndexBuffer绘制的，而这个IndexBuffer链接到VertexBuffer (见教程5-3)。每个索引代表一个在VertexBuffer 中的顶点，因此对一个三角形你需要找到三个索引。

注意：模型中的所有三角形都是以索引化的三角形列表的形式绘制的(见教程5-3和 5-4)。

你遍历了节点的索引，从VertexBuffer中查询对应的顶点，将这个顶点的位置进行转换，这样这个位置就是相对于模型的初始位置而不是相对于ModelMesh的初始位置，然后将结果添加到集合中。

在将所有位置添加到集合中之后，这个集合返回到父节点，做好传递到下一个节点的准备，直到将所有节点都添加到集合中。之后，最终的集合返回到Process方法中，将这个集合转换为数组并将这个数组存储在模型的Tag属性中。

好了！当你使用自定义模型处理器将一个模型导入到XNA后，你会发现模型的Tag属性中有一个包含Vector3的数组：

myModel = content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];
Vector3[] modelVertices = (Vector3[])myModel.Tag;
System.Diagnostics.Debugger.Break();

因为Tag属性可以包含任何东西，你需要使用(Vector3[])指定类型。最后一行代码让程序暂停，这样你可以检查modelVertices的内容，如图4-19所示。

图4-19 模型的所有Vector3在实时都可以访问

代码

你的自定义管道项目包含了自定义模型处理器，这个处理器从默认的ModelProcessor继承但重写了Process方法，可以将所有顶点存储在一个Vector3数组中：

namespace Vector3Pipeline
{
    [ContentProcessor]
    public class ModelVector3Processor : ModelProcessor
    {
        public override ModelContent Process(NodeContent input, ContentProcessorContext context)
        {
            ModelContent usualModel = base.Process(input, context);
            List<Vector3> vertices = new List<Vector3>();
            vertices = AddVerticesToList(input, vertices);
            usualModel.Tag = vertices.ToArray();
            return usualModel;
        }

        private List<Vector3> AddVerticesToList(NodeContent node, List<Vector3> vertList)
        {
            MeshContent mesh = node as MeshContent;
            if (mesh != null)
            {
                Matrix absTransform = mesh.AbsoluteTransform;
                foreach (GeometryContent geo in mesh.Geometry)
                {
                    foreach (int index in geo.Indices)
                    {
                        Vector3 vertex = geo.Vertices.Positions[index];
                        Vector3 transVertex = Vector3.Transform(vertex, absTransform);
                        vertList.Add(transVertex);
                    }
                }
            }

            foreach (NodeContent child in node.Children)
                vertList = AddVerticesToList(child, vertList);
            return vertList;
        }
    }
}


4.14 通过扩展模型处理器直接处理每个ModelMesh的顶点位置数据

问题

前面的教程中让你可以访问模型的所有顶点的相对于模型初始位置的位置。但是，如果你想对模型的一部分施加动画，例如，旋转一个人的手臂，那么你还想变换手臂，手，手指的位置。使用上一个教程的结果是不可能的，因为Vector3集合不包含Vector3属于模型哪个部分的信息，只包含相对于模型初始位置的信息。

解决方案

模型的独立变换部分是存储在ModelMeshe中的。ModelMesh有一个Tag属性，你可以在Tag中存储有用的信息。在本教程中，你将在Tag属性中存储ModelMesh的所有顶点的位置信息。

你将存储相对于ModelMesh初始位置的顶点位置信息(而不是像前一个教程中的相对于模型的初始位置)。当XNA程序更新Bone矩阵并计算绝对矩阵时(见教程4-9)，你可以使用与渲染模型相同的绝对Bone矩阵变换位置，获得相对于模型初始位置的顶点位置。相对于前一个教程，这个方法最大的好处是，这次你将处理独立部分的当前变换。工作原理这个教程在前一个教程的基础上再加以扩展。你仍需要遍历模型的整个结构，但这次，无需将所有顶点存储在一个大数组中，你将为每个节点生成独立的数组。

在遍历了整个模型并存储了数组后，你使用默认模型处理器从节点产生一个ModelContent。最后，你遍历ModelContent中的所有ModelMeshContents，然后在对应的ModelMeshContent的Tag属性中存储每个数组。

注意：ModelContent对象是内容处理器的输出，然后它被写入一个二进制文件。当程序运行时，这个文件从磁盘被加载，这个对象被转换为一个模型。

以下是处理器的开始部分的代码：

public override ModelContent Process(NodeContent input,ContentProcessorContext context)
{
    List<Vector3[]> modelVertices = new List<Vector3[]>();
    modelVertices = AddModelMeshVertexArrayToList (input, modelVertices);
}

你创建了一个集合存储独立的Vector3数组，然后，将这个集合和根节点传递到AddModelMeshVertexArrayToList方法。

这个方法首先调用自己，传递它的子节点，这样可以将子节点的数组添加到集合中。

当所有子节点的数组添加到集合之后，AddModelMeshVertexArrayToList方法检查当前节点是否包含顶点。如果包含，则创建一个包含所有顶点位置的Vector3数组并将它添加到集合中。

private List<Vector3[]> AddModelMeshVertexArrayToList(NodeContent node, List<Vector3[]>modelVertices)
{
    foreach (NodeContent child in node.Children)
        modelVertices = AddModelMeshVertexArrayToList(child, modelVertices);
    MeshContent mesh = node as MeshContent;
    if (mesh != null)
    {
        List<Vector3> nodeVertices = new List<Vector3>();
        foreach (GeometryContent geo in mesh.Geometry)
        {
            foreach (int index in geo.Indices)
            {
                Vector3 vertex = geo.Vertices.Positions[index];
                nodeVertices.Add(vertex);
            }
        }
        modelVertices.Add(nodeVertices.ToArray());
    }
    return modelVertices;
}

注意：与上一个教程相反，这次位置不进行变换。这是因为你想存储相对于ModelMesh初始位置的顶点位置，而不是相对于模型初始位置。你可以在以后通过ModelMesh的绝对矩阵转换它们。

注意：AddModelMeshVertexArrayToList方法与上一个教程的AddVerticesToList方法最主要的不同之处在于：你在添加当前节点的数据之前首先将子节点的数据添加到集合中。在前一个教程中在添加子节点的数据之前首先添加当前节点的数据，这样看起来可能更加直观。但是，以ModelMesh保存到模型的相同顺序将ModelMesh的数组保存是很重要的，这样你可以很容易地在Process方法的最后加载数组。

最后，将下列代码添加到Process方法的最后：

ModelContent usualModel = base.Process(input, context);
int i = 0;
foreach(ModelMeshContent mesh in usualModel.Meshes)
    mesh.Tag = modelVertices[i++];
return usualModel;

有了数组后，你就可以使用默认模型处理器从节点创建一个默认的ModelContent对象。在每个ModelMeshes中你存储了正确的包含Vector3的数组。

但是，别忘了有些ModelMeshe是由超过一个ModelMeshPart构成的，每个ModelMeshPart 有自己的NodeContent，对于每个ModelMeshPart，AddModelMeshVertexArrayToList方法会将Vector3数组添加到集合。所以要让代码完整，你需要用以下代码替代前面的代码：

ModelContent usualModel = base.Process(input, context);
int i = 0;
foreach (ModelMeshContent mesh in usualModel.Meshes)
{
    List<Vector3> modelMeshVertices = new List<Vector3>();
    foreach (ModelMeshPartContent part in mesh.MeshParts)
    {
        modelMeshVertices.AddRange(modelVertices[i++]);
    }
    mesh.Tag = modelMeshVertices.ToArray();
}
return usualModel;

对于属于一个ModelMesh的所有ModelMeshPart，上面的代码将所有Vector3添加到一个集合，这个集合被转换到一个数组并存储在ModelMesh的Tag属性中。

注意：因为一个ModelMesh的所有ModelMeshPart使用相同的绝对变换矩阵，你可以一次性地处理所有Vector3仍能保证正确处理动画(见教程4-9)。你可能还想在ModelMeshPart 的Tag属性中存储每个ModelMeshPart的顶点，例如，创建一个更好的包围球处理快速的碰撞检测。

最后返回ModelContent，做好了串行化为一个二进制文件的准备。

当你使用自定义模型处理器导入一个模型时，你可以在模型的每个ModelMesh的Tag属性中找到一个数组，这个数组包含了ModelMesh的每个三角形的三个Vector3分量。在 LoadContent方法中加载模型：

myModel = Content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];

现在每个模型的ModelMesh的Tag属性中包含了Vector3的集合，你可以通过编译器访问储存在数组中的数据：

Vector3[] modelVertices = (Vector3[])myModel.Meshes[0].Tag;
System.Diagnostics.Debugger.Break();

最后一行代码放置了一个断点，你可以观察Vector3的内容。

代码

下面是自定义内容管道：

namespace Vector3Pipeline
{
    [ContentProcessor]
    public class ModelVector3Processor : ModelProcessor
    {
        public override ModelContent Process(NodeContent input, ContentProcessorContext context)
        {
            List<Vector3[]> modelVertices = new List<Vector3[]>();
            modelVertices = AddModelMeshVertexArrayToList(input, modelVertices);

            ModelContent usualModel = base.Process(input, context);
            int i = 0;
            foreach (ModelMeshContent mesh in usualModel.Meshes)
            {
                List<Vector3> modelMeshVertices = new List<Vector3>();
                foreach (ModelMeshPartContent part in mesh.MeshParts)
                {
                    modelMeshVertices.AddRange(modelVertices[i++]);
                }
                mesh.Tag = modelMeshVertices.ToArray();
            }
            return usualModel;
        }

        private List<Vector3[]> AddModelMeshVertexArrayToList(NodeContent node, List<Vector3[]> modelVertices)
        {
            foreach (NodeContent child in node.Children)
                modelVertices = AddModelMeshVertexArrayToList(child, modelVertices);
            MeshContent mesh = node as MeshContent;
            if (mesh != null)
            {
                List<Vector3> nodeVertices = new List<Vector3>();
                foreach (GeometryContent geo in mesh.Geometry)
                {
                    foreach (int index in geo.Indices)
                    {
                        Vector3 vertex = geo.Vertices.Positions[index];
                        nodeVertices.Add(vertex);
                    }
                }
                modelVertices.Add(nodeVertices.ToArray());
            }
            return modelVertices;
        }
    }
}


4.15 通过定义一个自定义的TypeWriter和TypeReader直接处理顶点位置数据

问题

你想访问模型中每个顶点的位置。但你想要一个三角形对象的数组，每个三角形对象包含三个Vector3向量，而不是想前面的教程中那样将所有Vector3放在一个大数组中。

或者，更普遍的情况，你想将一个自定义内容处理器类对象传递到XNA程序中，但内容管道无法知道应该如何串行化和反串行化这个类对象，所以你必须定义自己的TypeWriter和TypeReader。

解决方案

本教程的代码主要建立在前一个教程的基础上，但这次你将每三个顶点创建一个简单的三角形对象，然后将所有生成的三角形对象添加到一个集合中，并将这个集合存储在模型的Tag属性中。

最主要的区别是你将在自定义内容管道中定义一个Triangle类。默认的XNA内容管道无法将你的自定义类串行化为一个文件或从文件反串行化为一个对象。所以你将自己定义 TypeWriter和TypeReader。

工作原理

这个教程也可以作为编写自定义TypeWriter和TypeReader的教程。看一下图4-20找到 TypeWriter和TypeReader在内容管道中的位置。

图4-20 内容管道中的自定义处理器、TypeWriter和TypeReader

浏览一下教程3-9中的创建自定义内容管道的步骤，将这个类添加到你的新的内容管道项目中：

public class Triangle
{
    private Vector3[] points;

    public Triangle(Vector3 p0, Vector3 p1, Vector3 p2)
    {
        points = new Vector3[3];
        points[0] = p0;
        points[1] = p1;
        points[2] = p2;
    }

    public Vector3[] Points { get { return points; } }
    public Vector3 P0 { get { return points[0]; } }
    public Vector3 P1 { get { return points[1]; } }
    public Vector3 P2 { get { return points[2]; } }
}

这是个非常简单的类，用来存储三个Vector3。这个类还提供了getter方法，可以一次获取一个Vector3或包含三个Vector3的数组。

对模型的每个三角形来说，你想创建一个自定义类的对象，将这些对象储存在一个数组中，并将这个数组存储在模型的Tag属性中，这样就可以用于XNA的实时代码。

Process类的代码几乎和教程4-13中的一样，但这次你保存的是Triangle对象的集合而不是上一个教程的Vector3的集合。在Triangle类之后添加Processor类：

[ContentProcessor]
public class ModelTriangleProcessor : ModelProcessor
{
    public override ModelContent Process(NodeContent input, ContentProcessorContext	context)
    {
        ModelContent usualModel = base.Process(input, context);

        List<Triangle> triangles = new List<Triangle>();
        triangles = AddVerticesToList(input, triangles);

        usualModel.Tag = triangles.ToArray();
        return usualModel;
    }
}

AddVerticesToList方法会遍历整个模型结构，为每三个Vector3生成一个Triangle对象并将所有的Triangle对象添加到集合中：

private List<Triangle> AddVerticesToList(NodeContent node, List<Triangle>triangleList)
{
    MeshContent mesh = node as MeshContent;

    if (mesh != null)
    {
        Matrix absTransform = mesh.AbsoluteTransform;

        foreach (GeometryContent geo in mesh.Geometry)
        {
            //generate Triangle objects...
        }
    }

    foreach (NodeContent child in node.Children)
        triangleList = AddVerticesToList(child, triangleList);

    return triangleList;
}

上面的代码完全来自于教程4-13，下面你将编写生成Triangle对象的代码：

int triangles = geo.Indices.Count / 3;
for (int currentTriangle = 0; currentTriangle < triangles; currentTriangle++)
{
    int index0 = geo.Indices[currentTriangle * 3 + 0];
    int index1 = geo.Indices[currentTriangle * 3 + 1];
    int index2 = geo.Indices[currentTriangle * 3 + 2];

    Vector3 v0 = geo.Vertices.Positions[index0];
    Vector3 v1 = geo.Vertices.Positions[index1];
    Vector3 v2 = geo.Vertices.Positions[index2];

    Vector3 transv0 = Vector3.Transform(v0, absTransform);
    Vector3 transv1 = Vector3.Transform(v1, absTransform);
    Vector3 transv2 = Vector3.Transform(v2, absTransform);

    Triangle newTriangle = new Triangle(transv0, transv1, transv2);
    triangleList.Add(newTriangle);
}

上述代码很短，但简单的代码看起来更难。

模型是根据索引缓冲中的数据绘制的，每个索引对应顶点缓冲中的一个顶点（可见教程5-3学习更多索引的知识）。首先你需要知道模型中三角形的总数，它等于索引数除以3。

然后，对于每个三角形，你首先找到索引。每个三角形是由三个连续的索引定义的，所以首先你要将这些索引存储在变量中。一旦你有了这些索引值，就可以获取对应的顶点，并通过节点的绝对变换矩阵变换这些顶点(见教程4-9)，这样可以使顶点位置变成相对于模型初始位置而不是相对于ModelMesh的初始位置。最后，你基于三个顶点创建一个新的Triangle对象并将这个对象存储在集合中。

在Process方法的最后，这个集合被转换为一个Triangle对象的数组并将这个数组存储在模型的Tag属性中。

这就是上一个教程结束时所做的：你已经将额外的数据存储在了模型的Tag属性中了。但是当你导入模型，选则这个Processor处理这个模型并试图编译时会发生错误：

Unsupported type. Cannot find a ContentTypeWriter implementation for ModelTriaPipeline.Triangle

编写自定义内容TypeWriter

之所以会发生这个错误只因为内容管道不知道如何将Triangle对象串行化为一个二进制文件！所以，你必须编写一个简单的TypeWriter处理Triangle对象如何被串行化。当在处理器中生成的一个ModelContent对象处理到Triangle对象时，XNA就会调用这个TypeWriter将 Triangle保存为二进制文件。

在内容管道项目中添加这个新类，这个类需要放置在Processor类的外面：

[ContentTypeWriter]
public class TriangleTypeWriter : ContentTypeWriter<Triangle>
{
    protected override void Write(ContentWriter output, Triangle value)
    {
        output.WriteObject<Vector3>(value.P0);
        output.WriteObject<Vector3>(value.P1);
        output.WriteObject<Vector3>(value.P2);
    }

    public override string GetRuntimeReader(TargetPlatform targetPlatform)
    {
        return typeof(TriangleTypeReader).AssemblyQualifiedName;
    }
}

首先两行代码表示你将定义一个知道如何串行化Triangle 类对象的ContentTypeWriter。

首先你需要重写Write方法，它以你要保存的每个Triangle对象作为参数。这个数组以值参数的形式传递给Write方法。Output变量包含一个ContentWriter对象让你可以保存到二进制文件。

当对一个确定对象编写TypeWriter时，你首先需要考虑存储什么东西，让你可以在加载二进制文件时重建这些对象。然后，你需要将这个对象分解成更多的简单对象，直到内容管道知道如何串行化这些简单对象。

对Triangle来说，存储三个Vector3就可以让你可以重建Triangle了。你很幸运，，因为内容管道知道如何串行化Vector3，所以Write方法只需简单地将Triangle分解成三个Vector3并进行串行化。

技巧：你可以遍历output的不同重载方法，Write方法可以知道哪种数据类型可以被默认内容管道串行化。

注意：你也可以使用output. Write(value. P0); 这是因为Vector3类型是默认被支持的。但是，上述代码中的output. WriteObject方法更普遍，因为这个方法允许写入自定义TypeWriter中的自定义类对象。

当开始XNA程序时，二进制文件还需要被反串行化。默认内容管道也不知道如何反串行化Triangle对象，所以你还要自定义一个TypeReader。

要让XNA程序找到对应的自定义TypeReader，你的TypeWriter需要在GetRuntimeReader方法中指定TypeReader的位置。

这个GetRuntimeReader方法(在前面的代码中也定义了)只是简单地返回一个字符串，表示在哪可以找到用于Triangle对象的TypeReader。

如果运行代码，会产生一个错误：ModelTriaPipeline. TriangleReader class cannot be found，这可能是因为你还没有编写这个类。所以在ModelTriaPipeline命名空间中添加最后一个类：

public class TriangleTypeReader : ContentTypeReader<Triangle>
{
    protected override Triangle Read(ContentReader input, Triangle existingInstance)
    {
        Vector3 p0 = input.ReadObject<Vector3>();
        Vector3 p1 = input.ReadObject<Vector3>();
        Vector3 p2 = input.ReadObject<Vector3>();

        Triangle newTriangle = new Triangle(p0, p1, p2);
        return newTriangle;
    }
}

你从ContentTypeReader类继承，让你的自定义TypeReader可以反串行化Triangle类对象。

注意：确保你的自定义reader的名称与你在writer中GetRuntimeReader方法中指定的名称是一致的！否则，XNA仍然无法找到对应的TypeReader。

在XNA项目运行开始时，在二进制文件中每次碰到Triangle对象都会调用这个类的Read方法。你在每个三角形中存储了三个Vector3，所以在TypeReader中，你只需简单地从二进制文件中读出三个Vector3，然后基于这三个Vector3创建三角形对象，最后返回三角形。

在内容管道项目中添加引用

编写完TypeReader后，就做好了运行程序的准备。但是虽然项目可以正确生成，但在运行时仍会遇到一个错误：

Cannot find ContentTypeReader ModelTriaPipeline.Triangle, ModelTriaPipeline,
        Version=1.0.0.0, Culture=neutral

发生这个错误的原因是XNA项目无法访问TypeReader 中的ModelTriaPipeline命名空间。要解决这个问题，你需要在内容管道中添加一个引用。打开XNA主项目，选择Project→Add Reference，在弹出的对话框中选择Projects选项卡，可以看到列表中的内容管道，如图4-21所示，选择并点击OK。

图4-21 添加对自定义内容管道项目的引用

注意：如果你的内容管道不在列表中，请确保你已经生成了内容管道项目(可见教程4-9中步骤列表的第6步)。

这里你需要在XNA项目的Content 目录和XNA项目本身中添加内容管道的引用，如图4-22所示。第一个引用让你可以为模型选择自定义处理器，第二个引用让XNA项目可以实时调用TypeReader。

图4-22 需要两个自定义内容管道的引用

当你选择ModelTriangleProcessor处理模型并运行程序后，你的模型就在Tag属性中包含了Triangle对象的数组。

添加内容管道的命名空间

在XNA项目中，当你想访问存储在Tag属性中的Triangle对象时，你总有将Tag属性中的内容转换为你想要的类型，本例中是Triangle [] (三角形的数组)。但是，因为Triangle类是定义在另一个命名空间中的，你需要在Triangle名称之前加上它的命名空间：

ModelTriaPipeline.Triangle[] modelTriangles = (ModelTriaPipeline.Triangle[])myModel.Tag;

这看起来不好。如果你使用using将内容管道命名空间(本例中是ModelTriaPipeline)添加到XNA项目中：

using ModelTriaPipeline;

那么自定义内容管道的所有类都能被XNA项目知道，可以让代码变得更短：

Triangle[] modelTriangles = (Triangle[])myModel.Tag;

注意：存储在Triangle中的位置信息是相对于模型的初始位置的。当对模型的一部分施加动画时如何使用这些位置信息，可见教程4-14。

使用自定义类对象扩展处理器的步骤清单

这里你可以找到让包含自定义类的内容管道运行的步骤。与教程4-9做的一样，我会总结一个清单，你可以将它作为参考。教程4-9中的初始化列表扩展成两个部分，这在前面已经讨论过了。

1．在解决方案中添加一个内容管道项目

2．在新项目中，添加Microsoft. XNA. Framework. Content. Pipeline的引用。

3．在using代码块添加Pipeline命名空间。

4．表明你将扩展哪个部分(这部分的方法需要重写)。

5．编译新内容管道项目。

6．在主项目中添加新创建的引用

7．选择新创建的处理器处理一个素材。

8．设置项目依赖项。

9．在XNA主程序中，添加自定义内容管道的引用。

10．在XNA主程序中的using代码块中添加内容管道命名空间。

初始化后进行以下步骤：

1．定义自定义类

2．编写第4步中的代码

3．对每个处理器中使用的自定义类，创建一个TypeWriter。

4．对每个处理器中使用的自定义类，创建一个TypeReader。

注意：内容导入器，处理器和typewriters只在编译项目时才会被invoke。所以，这些类无法部署在Xbox 360平台上，因为TypeReader类和你的自定义类无法被Game类找到。要解决这个问题，可将这两个类移至主XNA项目中，并添加引用。可见本教程的示例代码（译者注：是指XboxPipeline文件夹中的示例）。

代码

下面是自定义内容管道命名空间下完整代码。这个命名空间包括一个带有辅助方法的自定义模型处理器，一个自定义类，一个TypeReader (将自定义类存储为一个二进制文件)，一个TypeReader (反串行化自定义类对象)。

namespace TrianglePipeline
{
    public class Triangle
    {
        private Vector3[] points;
        public Triangle(Vector3 p0, Vector3 p1, Vector3 p2)
        {
            points = new Vector3[3];

            points[0] = p0;
            points[1] = p1;
            points[2] = p2;
        }

        public Vector3[] Points { get { return points; } }
        public Vector3 P0 { get { return points[0]; } }
        public Vector3 P1 { get { return points[1]; } }
        public Vector3 P2 { get { return points[2]; } }
    }

    [ContentProcessor]
    public class ModelTriangleProcessor : ModelProcessor
    {
        public override ModelContent Process(NodeContent input, ContentProcessorContext context)
        {
            ModelContent usualModel = base.Process(input, context);

            List<Triangle> triangles = new List<Triangle>();
            triangles = AddVerticesToList(input, triangles);

            usualModel.Tag = triangles.ToArray();
            return usualModel;
        }

        private List<Triangle> AddVerticesToList(NodeContent node, List<Triangle> triangleList)
        {
            MeshContent mesh = node as MeshContent;

            if (mesh != null)
            {
                Matrix absTransform = mesh.AbsoluteTransform;
                foreach (GeometryContent geo in mesh.Geometry)
                {
                    int triangles = geo.Indices.Count / 3;
                    for (int currentTriangle = 0; currentTriangle < triangles; currentTriangle++)
                    {
                        int index0 = geo.Indices[currentTriangle *3+ 0];
                        int index1 = geo.Indices[currentTriangle *3+ 1];
                        int index2 = geo.Indices[currentTriangle *3+ 2];

                        Vector3 v0 = geo.Vertices.Positions[index0];
                        Vector3 v1 = geo.Vertices.Positions[index1];
                        Vector3 v2 = geo.Vertices.Positions[index2];

                        Vector3 transv0 = Vector3.Transform(v0, absTransform);
                        Vector3 transv1 = Vector3.Transform(v1, absTransform);
                        Vector3 transv2 = Vector3.Transform(v2, absTransform);

                        Triangle newTriangle = new Triangle(transv0, transv1, transv2);
                        triangleList.Add(newTriangle);
                    }
                }
            }

            foreach (NodeContent child in 	node.Children)
                triangleList = AddVerticesToList(child, triangleList);
            return triangleList;
        }
    }

    [ContentTypeWriter]
    public class TriangleTypeWriter : ContentTypeWriter<Triangle>
    {
        protected override void Write(ContentWriter output, Triangle value)
        {
            output.WriteObject<Vector3>(value.P0);
            output.WriteObject<Vector3>(value.P1);
            output.WriteObject<Vector3>(value.P2);
        }

        public override string GetRuntimeReader(TargetPlatform targetPlatform)
        {
            return typeof(TriangleTypeReader).AssemblyQualifiedName;
        }
    }

    public class TriangleTypeReader : ContentTypeReader<Triangle>
    {
        protected override Triangle Read(ContentReader input, Triangle existingInstance)
        {
            Vector3 p0 = input.ReadObject<Vector3>();
            Vector3 p1 = input.ReadObject<Vector3>();
            Vector3 p2 = input.ReadObject<Vector3>();

            Triangle newTriangle = new Triangle(p0, p1, p2);
            return newTriangle;
        }
    }
}

在主XNA项目中，加载模型，访问它的Triangles,，并放置一个断点让你可以检查modelTriangles数组的内容：

myModel = Content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];

Triangle[] modelTriangles = (Triangle[])myModel.Tag;
System.Diagnostics.Debugger.Break();


4.16 通过定义一个自定义的TypeWriter和TypeReader将多个对象存储在Tag属性中

问题

要让模型处理器可以将对象存储在模型中并传递到XNA项目，XNA提供了模型的Tag属性。从教程4-13，4-14和4-15中可以看到Tag属性对于存储一个相同类型的数组是很有用的，例如Vector3的数组或Triangle的数组。但很多情况中，你想传递多个对象，例如同时传递Vector3集合和模型的包围盒。

解决方案

定义一个自定义类，这个类存储所有你想传递的对象。在你的模型处理器中，创建这个类对象，将要传递到XNA项目中的对象存储在这个类对象中。最后在模型的Tag属性中存储这个类对象。

因为你定义了一个自定义类，所以你必须定义一个TypeWriter (见教程4-15)才能让XNA知道如何串行化这个对象，还要定义一个TypeReader，通过这个TypeReader从二进制文件读取这个对象。

工作原理

因为你要扩展内容处理器并传递自定义类对象，所以你要执行教程4－15中的“使用一个自定义类对象扩展内容处理器的步骤清单”中的初始化步骤。在第1步中，我调用了新内容管道项目TagPipeline。在第4步中我调用了处理器ExtendedModelProcessor。

namespace TagPipeline
{
    [ContentProcessor]
    public class ExtendedModelProcessor : ModelProcessor
    {
       public override ModelContent Process(NodeContent input, ContentProcessorContext context)
       {
           return base.Process(input, context);
       }
   }
}

完成初始化后，你就做好了定义一个可以存储你想传递到XNA项目的东西的新类的准备了。本例中，你将传递一个包含模型所有Vector3的数组和全局包围盒。

在内容管道命名空间顶部定义这个新类：

public class TagObject
{
    private Vector3[] positions;
    private BoundingBox boundingBox;

    public TagObject(Vector3[] positions, BoundingBox boundingBox)
    {
        this.positions = positions;
        this.boundingBox = boundingBox;
    }

    public Vector3[] Positions { get { return positions; } }
    public BoundingBox GlobalBoundingBox { get { return boundingBox; } }
}

这个简单的类可以存储一个Vector3数组和包围盒。它的构造函数将这些变量传递到内部变量中，你还定义了两个getter方法，让你可以获取变量的内容。

注意：因为这个变量不包含行为方法，你也可以使用结构数据类型替代类。

然后编写模型处理器代码。它可以获取你想传递的数据：Vector3数组和包围盒。

public override ModelContent Process(NodeContent input, ContentProcessorContext context)
{
    ModelContent usualModel = base.Process(input, context);

    List<Vector3> vertices = new List<Vector3>();

    vertices = AddVerticesToList(input, vertices);

    BoundingBox bBox = BoundingBox.CreateFromPoints(vertices);
    TagObject myTagObject = new TagObject(vertices.ToArray(), bBox);

    usualModel.Tag = myTagObject;
    return usualModel;
}

模型处理器开始的操作已经在前面的教程中做过很多次了。然后，你使用AddVerticesToList方法遍历整个模型结构将所有Vector3添加到一个集合中。

有了这个集合之后，可以使用BoundingBox. CreateFromPoints方法从这个集合生成包围盒。这个方法以Vector3集合为参数，而这个集合具有Ienumerable接口，就好像一个数组或 List。

通过将集合转换到数组，你就拥有了创建TagObject类对象所需的所有东西！最后，将这个类对象存储在模型的Tag属性中。

现在己经完成了这个教程的前半部分，在第二部分，你要编写TypeWriter和TypeReader。

编写TypeWriter和TypeReader

现在如果你运行代码，XNA会报错，这是因为它还不知道如何将TagObject类对象存储到二进制文件，所以需要在内容管道命名空间下添加自定义TypeWriter：

[ContentTypeWriter]
public class TagObjectTypeWriter : ContentTypeWriter<TagObject>
{
    protected override void Write(ContentWriter output, TagObject value)
    {
        output.WriteObject<Vector3[]>(value.Positions);
        output.WriteObject<BoundingBox>(value.GlobalBoundingBox);
    }

    public override string GetRuntimeReader(TargetPlatform targetPlatform)
    {
        return typeof(TagObjectTypeReader).AssemblyQualifiedName;
    }
}

如教程4-15中的解释，前两行代码指定这个类是一个可以串行化TagObject对象的ContentTypeWriter。同理，你也要重写两个方法：Write方法指定一个TagObject如何被写入到二进制文件中，GetRuntimeReader方法可以被XNA调用，让程序知道到哪找到对应的TypeReader，可在教程4-15见到更多信息。

默认内容处理器知道如何串行化Vector3数组和包围盒，所以你只需要简单地让XNA为你串行化就可以了。在GetRuntimeReader方法中，你声明在相同的命名空间中编写一个叫做TagObjectReader的对应TypeReader。

注意：如果默认您内容管道不知道如何串行化包围盒你可以自己定义。你可以调整处理TagObjects的TypeWriter，使它将包围盒中的两个Vector3保存到二进制文件中，这样ContentReader可以重新构造这个包围盒。但是，更好的方法是编写一个额外的TypeWriter和TypeReader用来串行化/反串行化一个包围盒对象，如果使用这个方法，后面的ContentWriters会知道如何串行化包围盒对象！

下面编写TypeReader，因为已经在GetRuntimeReader方法中定义了，所以TypeReader类必须被叫做TagObjectTypeReader：

public class TagObjectTypeReader : ContentTypeReader<TagObject>
{
    protected override TagObject Read(ContentReader input, TagObject existingInstance)
    {
        Vector3[] positions = input.ReadObject<Vector3[]>();
        BoundingBox bBox = input.ReadObject<BoundingBox>();

        TagObject restoredTagObject = new TagObject(positions, bBox);
        return restoredTagObject;
    }
}

在程序启动时，每个TagObject类对象都会被串行化为一个二进制文件，而TagObjectTypeReader方法可以重建这些对象。首先你从文件中读取Vector3数组并将它存储在一个变量中；然后对包围盒进行同样的处理。有了这两个对象后，就可以重建TagObject对象并把它传递到XNA程序中。

很简单，但有一点很重要，你必须以写入文件的同样顺序读取这些对象！如果你首先读入的是包围盒，你会基于第一个Vector3数组重建包围盒！幸运的是，XNA team对此进行了保护，如果你颠倒了读取顺序，程序会报错。

注意：如果你的解决方案无法编译，请再看一下教程4-15中的步骤清单。

在XNA项目中访问数据现在运行程序，所有使用ExtendedModelProcessor 的模型都会在Tag属性中包含一个TagObject对象、因为Tag属性可以包含任何东西，所以首先需要将它转换为TagObject对象。现在就可以访问它的属性了：

myModel = Content.Load<Model>("tank");
modelTransforms = new Matrix[myModel.Bones.Count];
TagObject modelTag = (TagObject)myModel.Tag;
BoundingBox modelBBox = modelTag.GlobalBoundingBox;
Vector3[] modelVertices = modelTag.Positions;

System.Diagnostics.Debugger.Break();

代码

这个教程中内容管道包含下列对象：

  • ExtendedModelProcessor模型处理器，包含AddVerticesToList辅助类
  • 自定义TagObject类定义 class definition
  • 自定义TypeWriter，可以串行化TagObject类对象
  • 自定义TypeReader，可以从二进制文件读取TagObject类对象

完整代码如下：

namespace TagPipeline
{
    public class TagObject
    {
        private Vector3[] positions;
        private BoundingBox boundingBox;

        public TagObject(Vector3[] positions, BoundingBox boundingBox)
        {
            this.positions = positions;
            this.boundingBox = boundingBox;
        }

        public Vector3[] Positions { get { return positions; } }
        public BoundingBox GlobalBoundingBox { get { return boundingBox; } }
    }

    [ContentProcessor]
    public class ExtendedModelProcessor : ModelProcessor
    {
        public override ModelContent Process(NodeContent input, ContentProcessorContext	context)
        {
            ModelContent usualModel = base.Process(input, context);

            List<Vector3> vertices = new List<Vector3>();
            vertices = AddVerticesToList(input, vertices);
            BoundingBox bBox = BoundingBox.CreateFromPoints(vertices);

            TagObject myTagObject = new TagObject(vertices.ToArray(), bBox);

            usualModel.Tag = myTagObject;
            return usualModel;
        }

        private List<Vector3> AddVerticesToList(NodeContent node, List<Vector3> vertList)
        {
            MeshContent mesh = node as MeshContent;

            if (mesh != null)
            {
                Matrix absTransform = mesh.AbsoluteTransform;
                foreach (GeometryContent geo in mesh.Geometry)
                {
                    foreach (int index in geo.Indices)
                    {
                        Vector3 vertex = geo.Vertices.Positions[index];
                        Vector3 transVertex = Vector3.Transform(vertex, absTransform);
                        vertList.Add(transVertex);
                    }
                }
            }

            foreach (NodeContent child in node.Children)
                vertList = AddVerticesToList(child, vertList);
            return vertList;
       }
   }

   [ContentTypeWriter]
   public class TagObjectTypeWriter : ContentTypeWriter<TagObject>
   {
       protected override void Write(ContentWriter output, TagObject value)
       {
           output.WriteObject<Vector3[]>(value.Positions);
           output.WriteObject<BoundingBox>(value.GlobalBoundingBox);
       }

       public override string GetRuntimeReader(TargetPlatform targetPlatform)
       {
           return typeof(TagObjectTypeReader).AssemblyQualifiedName;
       }
   }

   public class TagObjectTypeReader : ContentTypeReader<TagObject>
   {
       protected override TagObject Read(ContentReader input, TagObject existingInstance)
       {
           Vector3[] positions = input.ReadObject<Vector3[]>();
           BoundingBox bBox = input.ReadObject<BoundingBox>();
           TagObject restoredTagObject 	= new TagObject(positions, bBox);
           return restoredTagObject;
       }
   }
}


4.17 根据地形正确倾斜模型

问题

当在地形上移动一个汽车模型时，使用教程4-2你可以调整车的高度，使用教程5-9你可以找到位于汽车下面的地形的高度。但是，如果你没有根据车下面的坡度正确使车身发生倾斜，那么在起伏不平的地形上效果看起来不会很好。

你想正确地放置和倾斜汽车模型使之可以匹配地形的起伏。

解决方案

这个问题可以分成四个部分：

  1. 首先，你想找到模型四个轮胎的最低顶点的位置。
  2. 其次，你想获取这四个顶点之下的地形的高度。
  3. 下一步，你想获取模型沿Forward和Side向量的旋转以正确地倾斜模型。
  4. 最后，你需要找到模型和地形之间的高度差并补偿这个差异。

要做到第一步，你可以编写一个自定义模型处理器，这个处理器可以在每个ModelMesh的Tag属性中存储ModelMesh中最低顶点的位置。因为四个轮子的最低顶点位置在游戏运行时会发生移动，所以每次更新时你都需要基于这些向量的World位置变换这些位置。

要找到由三角形构成的表面上指定位置的高度，你可以使用教程5-9中的 GetExactHeightAt方法。

找到旋转角度的方法基于一个简单的数学原理(这个原理你应该在高中就学过!)。

最后一步需要在模型的世界矩阵上添加一个垂直平移。

工作原理

编写一个自定义模型处理器获取每个ModelMesh最低点的位置

第一步是获取模型轮子最低顶点的位置，这是因为这些顶点与地形接触。你将创建一个模型处理器，这个处理器是教程4-14的简化版本。

对于模型的每个ModelMesh，你将在Tag属性中存储最低点的位置。注意，你想基于ModelMesh的初始位置定义这些位置，这样做可以让本教程的方法也适用于模型的Bone动画(见教程4-9)。

开始的代码在教程4-14中已经解释过了，但在模型处理器的Process方法中有一点小变化：

public override ModelContent Process(NodeContent input, ContentProcessorContext
context) { List lowestVertices = new List(); lowestVertices =
FindLowestVectors(input, lowestVertices); ModelContent usualModel =
base.Process(input, context); int i = 0; foreach (ModelMeshContent mesh in
usualModel.Meshes) mesh.Tag = lowestVertices[i++]; return usualModel; }

FindLowestVertices方法遍历模型的所有节点并将每个ModelMesh的最低点位置存储在lowestVertices集合中。有了这个集合，你再将集合中的每个位置存储到对应ModelMesh的Tag属性中。

基于教程4-14介绍过的AddVertices方法，FindLowestVertices方法将顶点位置添加到集合并将这个集合传递到所有子节点：

private List<Vector3>FindLowestVectors(NodeContent node, List<Vector3>lowestVertices)
{
    Vector3? lowestPos = null;
    MeshContent mesh = node as MeshContent;

    foreach (NodeContent child in node.Children)
        lowestVertices = FindLowestVectors(child, lowestVertices);
    if (mesh != null)
        foreach (GeometryContent geo in mesh.Geometry)
            foreach (Vector3 vertexPos in geo.Vertices.Positions)
                if ((lowestPos == null) || (vertexPos.Y < lowestPos.Value.Y))
                    lowestPos = vertexPos;
    lowestVertices.Add(lowestPos.Value);
    return lowestVertices;
}

首先对子节点调用这个方法，这样也在集合中存储了它们最低点的位置。

对于每个节点，你检查节点是否包含几何信息。如果包含，你将遍历所有顶点。如果lowestPos为null (当第一次检查时lowestPos为null)或当前的位置低于存储在lowestPos中的前一个值，那么将当前位置存储在lowestPos中。

最后，有着最低Y坐标的顶点存储在lowestPos中，你将它添加到lowestVertices集合中并将这个集合返回到父节点中。

注意：如教程4-14中所讨论的，一个ModelMesh首先对自己的子节点调用这个方法，然后将最低点位置添加到集合中，更直观的方法是一个节点首先将自己的Vector存储在集合中然后在它的子节点上调用这个方法。你必须按照前面所示的顺序进行这个操作，因为这也是模型处理器中节点转换为ModelMesh的顺序。在Process方法中可以容易地将正确的Vector存储在正确的ModelMesh的Tag属性中。

请确保选择模型处理器去处理导入的模型。

获取轮子最低顶点的绝对3D坐标

在XNA项目中，你已经存储了四个轮子的位置。这些位置基于模型的结构和对应轮子的ModelMesh，你可以使用教程4-8中的代码可视化模型的结构。

对每个轮子来说，你需要知道对应ModelMesh的ID。知道了ID后，你可以访问到每个轮子的最低位置并将它存储在一个变量中。虽然你可以使用下列代码四次或者也可以以一个简单的循环代替，我总是给四个轮子使用四个有直观名称的变量。左前轮简写成fl，右后轮为br等。

int flID = 5;
int frID = 1;
int blID = 4;
int brID = 0;

Vector3 frontLeftOrig = (Vector3)myModel.Meshes[flID].Tag;
Vector3 frontRightOrig = (Vector3)myModel.Meshes[frID].Tag;
Vector3 backLeftOrig = (Vector3)myModel.Meshes[blID].Tag;
Vector3 backRightOrig = (Vector3)myModel.Meshes[brID].Tag;

记住，你需要对模型使用正确的ID，可参见教程4-8。

你存储在ModelMesh的Tag属性中的位置是相对于ModelMesh的相对初始位置的，你需要知道它们同地形相同的空间中的位置，即绝对3D空间中的位置。

首先需要知道最低顶点相对于模型初始位置的位置，这可以通过使用ModelMesh的绝对变换矩阵进行转换做到(见教程4-9)。接下来，你可能还要使用一个世界矩阵在3D世界的一个位置绘制模型，你可以将每个ModelMesh的Bone矩阵和模型的世界矩阵组合起来。

myModel.CopyAbsoluteBoneTransformsTo(modelTransforms);
Matrix frontLeftMatrix = modelTransforms[myModel.Meshes[flID].ParentBone.Index];
Matrix frontRightMatrix = modelTransforms[myModel.Meshes[frID].ParentBone.Index];
Matrix backLeftMatrix = modelTransforms[myModel.Meshes[blID].ParentBone.Index];
Matrix backRightMatrix = modelTransforms[myModel.Meshes[brID].ParentBone.Index];

Vector3 frontLeft = Vector3.Transform(frontLeftOrig, frontLeftMatrix * modelWorld);
Vector3 frontRight = Vector3.Transform(frontRightOrig, frontRightMatrix * modelWorld);
Vector3 backLeft = Vector3.Transform(backLeftOrig, backLeftMatrix * modelWorld);
Vector3 backRight = Vector3.Transform(backRightOrig, backRightMatrix * modelWorld);

首先，你计算模型的所有Bone的绝对变换矩阵(见教程4-9)。接下来，对每个轮子，你找到存储在对应轮子的ModelMesh的Bone中的绝对矩阵。

知道了每个轮子的绝对变换矩阵之后，将这个矩阵与模型的世界矩阵组合起来，并使用这个结果矩阵变换你的顶点，变换的结果Vector3包含了轮子最低向量的绝对3D坐标。

注意：根据教程4-2中的详细解释，矩阵乘法的顺序是重要的。因为这些顶点是模型的一部分，你首先需要考虑与存储在世界矩阵中的绝对初始位置的偏移，然后你要转换这些顶点使它们变成相对于模型的初始位置。通过这种方式，世界矩阵作用在模型的Bone上，这也是你想要的结果。如果不这样做，那么任何包含在Bone中的旋转将会作用在世界矩阵上，可参见教程4-2获取更对矩阵乘法顺序的知识。

最后，因为顶点的位置和地形坐标的位置都在绝对3D空间中，你就做好了检测四个轮子和地形之间碰撞的准备。

获取模型下面的地形的高度

现在你已经找到了四个轮子的绝对3D位置，就可以找到旋转角度了。首先你想知道应该绕Side向量旋转多少，即汽车的前部应该上升还是下降。你只需要基于两点计算而不是全部四点。第一个点，前面，位于两个前轮之间，第二个点，后面，位于后轮之间，如图4-23所示。你想找到旋转量，这样线段frontToBack (这条线段连接这两个点)会与地形对齐。

图4-23 当倾斜汽车时关注的点

你可以通过计算邻近轮子的平均值获取这两个点的位置，将这两个点相减获取两者之间的backToFront向量：

Vector3 front = (frontLeft + frontRight) / 2.0f;
Vector3 back = (backLeft + backRight) / 2.0f;
Vector3 backToFront = front - back;

记住，你想获取汽车绕Side向量旋转多少角度，所以它的front点会上下移动。理想情况中，你想让frontToBack向量与地形坡度有相同的倾斜角度，如图4-24所示。你想计算的角度在图4-24中表示为fbAngle。

首先需要找到地形上这两个点的高度差，这可以使用教程5－9中的GetExactHeightAt方法：

float frontTerHeight = terrain.GetExactHeightAt(front.X, -front.Z);
float backTerHeight = terrain.GetExactHeightAt(back.X, -back.Z);
float fbTerHeightDiff = frontTerHeight - backTerHeight;

图4-24 获取倾斜角度

计算旋转角度

现在知道了在地形上的高度差，可以使用三角函数计算倾斜角度了。在直角三角形中，如果你知道了一个锐角的对边（图4-24中的A）和邻边（图4-24中的B），皆可以使用反正切函数计算出这个角。对边的长度就是你刚才计算的高度差，第二个长度就是frontToBack向量的长度！

这个方法可以找到旋转角度和构建绕(1,0,0) Side向量的对应旋转。旋转角度存储在一个四元数中(四元数可以存储并组合没有万向节锁的旋转，可参加教程2-4)：

float fbAngle = (float)Math.Atan2(fbTerHeightDiff, backToFront.Length());
Quaternion bfRot = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), -fbAngle);

如果你使用这个旋转量旋转模型，模型的front和back点将会随着地形倾斜！

显然，现在只完成了50％的工作，因为你还要将模型绕着Forward向量旋转使它的left和right点也能随着地形发生偏转。幸运的是，你可以使用相同的方法和代码计算lrAngle。只需简单地让图4-23中的leftToFront线段对齐之下的地形：

Vector3 left = (frontLeft + backLeft) / 2.0f;
Vector3 right = (frontRight + backRight) / 2.0f;
Vector3 rightToLeft = left - right;

float leftTerHeight = terrain.GetExactHeightAt(left.X, -left.Z);
float rightTerHeight = terrain.GetExactHeightAt(right.X, -right.Z);
float lrTerHeightDiff = leftTerHeight - rightTerHeight;

float lrAngle = (float)Math.Atan2(lrTerHeightDiff, rightToLeft.Length());
Quaternion lrRot = Quaternion.CreateFromAxisAngle(new Vector3(0, 0, -1), -lrAngle);

有了两个旋转量，可以很容易地将它们相乘组合起来，并将这个变换与世界变换组合起来：

Quaternion combRot = fbRot * lrRot;
Matrix rotatedModelWorld = Matrix.CreateFromQuaternion(combRot) * modelWorld;

如果你使用这个rotatedModelWorld矩阵作为渲染模型的矩阵，那么模型将很好地匹配地形旋转！但是，你还需要将模型放置在正确的高度上。

将模型放置在正确地高度上

因为你旋转了模型，一些轮子会低于其他的。现在已经计算好了旋转，你可以很容易地找到轮子的旋转后的位置：

Vector3 rotFrontLeft = Vector3.Transform(frontLeftOrig, frontLeftMatrix * rotatedModelWorld);
Vector3 rotFrontRight=Vector3.Transform(frontRightOrig,frontRightMatrix * rotatedModelWorld);
Vector3 rotBackLeft = Vector3.Transform(backLeftOrig, backLeftMatrix * rotatedModelWorld);
Vector3 rotBackRight= Vector3.Transform(backRightOrig, backRightMatrix * rotatedModelWorld);

然后使用这些位置的X和Y分量找到它们应该放置的确切位置：

float flTerHeight = terrain.GetExactHeightAt(rotFrontLeft.X, -rotFrontLeft.Z);
float frTerHeight = terrain.GetExactHeightAt(rotFrontRight.X, -rotFrontRight.Z);
float blTerHeight = terrain.GetExactHeightAt(rotBackLeft.X, -rotBackLeft.Z);
float brTerHeight = terrain.GetExactHeightAt(rotBackRight.X, -rotBackRight.Z);

知道了轮子的Y高度坐标和应该放置的位置，就可以很简单地结算应该偏离多少：

float flHeightDiff = rotFrontLeft.Y - flTerHeight;
float frHeightDiff = rotFrontRight.Y - frTerHeight;
float blHeightDiff = rotBackLeft.Y - blTerHeight;
float brHeightDiff = rotBackRight.Y - brTerHeight;

你获得了四个不同的值用来将模型放置到正确的高度，但是你调整的是整个模型，所以只有一个值真正有用。

使用哪个值随你喜欢，如果你不想任意一个轮子陷进地面，那就取最大的一个。如果你不想让轮子与地面之间有空隙，则取最小的一个。本教程我取四者的平均值：

float finalHeightDiff = (blHeightDiff + brHeightDiff + flHeightDiff + frHeightDiff) / 4.0f;
modelWorld = rotatedModelWorld * Matrix.CreateTranslation(new Vector3(0, -finalHeightDiff, 0));

最后一行代码包含这个垂直变换的矩阵添加到世界矩阵中：

注意：你要将这个新矩阵放置在乘法的右边，这样才能使模型绕着绝对Up轴旋转。如果放在左边，模型将会沿着模型的Up轴旋转。

如果你使用这个矩阵绘制模型，那么模型会很好地匹配地形。代码量很大，但你把它放在一个for循环中，代码量已经除以4了。如果你觉得计算量很大，别忘了这些计算是只作用在那些必须被绘制到屏幕的模型上的！

为动画做准备

如果模型还有动画，你会遇到点麻烦。例如，如果你将一个轮子旋转180度，存储在Tag属性中的向量会变为轮子的最高的而不是最低点！这会让轮子沉到地面之下。要解决这个问题，你需要将轮子的Bone矩阵还原到计算前的初始位置。这不难，因为在进行模型动画时你总有存储这些位置(见教程4-9); 2, 4, 6和8是四个轮子的Bone索引。

myModel.Bones[2].Transform = originalTransforms[2];
myModel.Bones[4].Transform = originalTransforms[4];
myModel.Bones[6].Transform = originalTransforms[6];
myModel.Bones[8].Transform = originalTransforms[8];

float time = (float)gameTime.TotalGameTime.TotalMilliseconds / 1000.0f;
Matrix worldMatrix = Matrix.CreateTranslation(new Vector3(10, 0, -12)); // starting position
worldMatrix = Matrix.CreateRotationY(MathHelper.PiOver4*3)*worldMatrix;
worldMatrix = Matrix.CreateTranslation(0, 0, time)*worldMatrix; //move forward
worldMatrix = Matrix.CreateScale(0.001f)*worldMatrix; //scale down a bit
worldMatrix = TiltModelAccordingToTerrain(myModel, worldMatrix, 5, 1, 4, 0);//do tilting magic

代码

下面是content pipeline 命名空间下的代码，在每个ModelMesh的Tag属性中保存最低点顶点：

namespace ModelVector3Pipeline
{
    [ContentProcessor]
    public class ModelVector3Processor : ModelProcessor
    {
        Public override ModelContent Process(NodeContent input, ContentProcessorContext context)
        {
            List lowestVertices = new List();
            lowestVertices = FindLowestVectors(input, lowestVertices);
            ModelContent usualModel = base.Process(input, context);

            int i = 0;
            foreach (ModelMeshContent mesh in usualModel.Meshes)
                mesh.Tag = lowestVertices[i++];
            return usualModel;
        }

        private List FindLowestVectors(NodeContent node, List lowestVertices)
        {
            Vector3? lowestPos = null;
            MeshContent mesh = node as MeshContent;

            foreach (NodeContent child in node.Children)
                lowestVertices = FindLowestVectors(child, lowestVertices);
            if (mesh != null)
                foreach (GeometryContent geo in mesh.Geometry)
                    foreach (Vector3 vertexPos in geo.Vertices.Positions)
                        if ((lowestPos == null) || (vertexPos.Y < lowestPos.Value.Y))
                            lowestPos = vertexPos;
            lowestVertices.Add(lowestPos.Value);
            return lowestVertices;
        }
    }
}

下面的代码可以调整给定世界矩阵让模型很好地匹配地形。你需要传递模型的世界矩阵，模型和对应轮子的ModelMesh的四个索引。

private Matrix TiltModelAccordingToTerrain(Model model, Matrix worldMatrix, int flID,
        int frID, int blID, int brID)
{
    Vector3 frontLeftOrig = (Vector3)model.Meshes[flID].Tag;
    Vector3 frontRightOrig = (Vector3)model.Meshes[frID].Tag;
    Vector3 backLeftOrig = (Vector3)model.Meshes[blID].Tag;
    Vector3 backRightOrig = (Vector3)model.Meshes[brID].Tag;

    model.CopyAbsoluteBoneTransformsTo(modelTransforms);
    Matrix frontLeftMatrix = modelTransforms[model.Meshes[flID].ParentBone.Index];
    Matrix frontRightMatrix = modelTransforms[model.Meshes[frID].ParentBone.Index];
    Matrix backLeftMatrix = modelTransforms[model.Meshes[blID].ParentBone.Index];
    Matrix backRightMatrix = modelTransforms[model.Meshes[brID].ParentBone.Index];

    Vector3 frontLeft = Vector3.Transform(frontLeftOrig, frontLeftMatrix * worldMatrix);
    Vector3 frontRight = Vector3.Transform(frontRightOrig, frontRightMatrix * worldMatrix);
    Vector3 backLeft = Vector3.Transform(backLeftOrig, backLeftMatrix * worldMatrix);
    Vector3 backRight = Vector3.Transform(backRightOrig, backRightMatrix * worldMatrix);

    Vector3 front = (frontLeft + frontRight) / 2.0f;
    Vector3 back = (backLeft + backRight) / 2.0f;
    Vector3 backToFront = front - back;

    float frontTerHeight = terrain.GetExactHeightAt(front.X, -front.Z);
    float backTerHeight = terrain.GetExactHeightAt(back.X, -back.Z);
    float fbTerHeightDiff = frontTerHeight - backTerHeight;
    float fbAngle = (float)Math.Atan2(fbTerHeightDiff, backToFront.Length());
    Quaternion fbRot = Quaternion.CreateFromAxisAngle(new Vector3(1, 0, 0), -fbAngle);

    Vector3 left = (frontLeft + backLeft) / 2.0f;
    Vector3 right = (frontRight + backRight) / 2.0f;
    Vector3 rightToLeft = left - right;

    float leftTerHeight = terrain.GetExactHeightAt(left.X, -left.Z);
    float rightTerHeight = terrain.GetExactHeightAt(right.X, -right.Z);
    float lrTerHeightDiff = leftTerHeight - rightTerHeight;
    float lrAngle = (float)Math.Atan2(lrTerHeightDiff, rightToLeft.Length());
    Quaternion lrRot = Quaternion.CreateFromAxisAngle(new Vector3(0, 0, -1), -lrAngle);
    Quaternion combRot = fbRot * lrRot;

    Matrix rotatedModelWorld = Matrix.CreateFromQuaternion(combRot) * worldMatrix;
    Vector3 rotFrontLeft = Vector3.Transform(frontLeftOrig, frontLeftMatrix * rotatedModelWorld);
    Vector3 rotFrontRight = Vector3.Transform(frontRightOrig, frontRightMatrix * rotatedModelWorld);
    Vector3 rotBackLeft = Vector3.Transform(backLeftOrig, backLeftMatrix * rotatedModelWorld);
    Vector3 rotBackRight = Vector3.Transform(backRightOrig, backRightMatrix * rotatedModelWorld);

    float flTerHeight = terrain.GetExactHeightAt(rotFrontLeft.X, -rotFrontLeft.Z);
    float frTerHeight = terrain.GetExactHeightAt(rotFrontRight.X, -rotFrontRight.Z);
    float blTerHeight = terrain.GetExactHeightAt(rotBackLeft.X, -rotBackLeft.Z);
    float brTerHeight = terrain.GetExactHeightAt(rotBackRight.X, -rotBackRight.Z);
    float flHeightDiff = rotFrontLeft.Y - flTerHeight;
    float frHeightDiff = rotFrontRight.Y - frTerHeight;
    float blHeightDiff = rotBackLeft.Y - blTerHeight;
    float brHeightDiff = rotBackRight.Y - brTerHeight;
    float finalHeightDiff = (blHeightDiff + brHeightDiff + flHeightDiff + frHeightDiff) / 4.0f;

    worldMatrix = rotatedModelWorld * Matrix.CreateTranslation(new Vector3(0, -finalHeightDiff, 0));

    return worldMatrix;
}

扩展阅读

这个方法要返回正确结果取决于轮子的底部位置是正确的。如果轮子很窄它工作得很好，因为此时轮子底部位置正好在地形上。如果轮子比较宽，那么会有点问题。如果模型处理器获取的是靠内部的底部顶点并进行计算，那么有可能轮子会陷进地面中。如果发生这种情况，要调整Model processor使之在Tag属性中存储轮子的靠外边的底部，或在方法中手动指定一个。


4.18 使用逐三角形检查检测射线-模型碰撞

问题

你想检测一根3D射线是否与模型发生碰撞，如果你想进行一个最高细节的碰撞检测这是必须的，可以作为教程4-11的拓展进行子弹是否与物体发生碰撞的高细节碰撞。如下一个教程所示，这可以用来检测鼠标是否在模型上。

而且，这个方法也可以用来进行模型之间的最高精度的检测，这是因为对应一条射线的每一个三角形的边缘可以用来进行与另一个模型的碰撞检测。

这个教程会处理最复杂的情况，允许模型的不同部分独立地被转换，使这个教程可以适用于模型动画，这些变换作用在模型顶点的最终位置上。例如，如果一个人移动手臂，手臂顶点的最终位置会发生移动。这个教程会考虑到这个变换。

解决方案

这个教程使用教程4-13和4-14中的自定义素材管道的混合，因为在模型的每个ModelMesh的Tag属性中储存了三角形对象的数组，每个三角形对象存储了三个Vector3定义一个三角形。

让每个ModelMesh都存储各自Triangle对象数组的好处是让你可以使用ModelMesh 的Bone矩阵对它们进行转换。无论当前作用在ModelMesh 上的变换是什么，你总能通过使用ModelMesh的绝对变换矩阵找到Triangle对象在3D空间中的绝对位置（见教程3-9和4-17)。这是必须的，因为大多数情况下3D Ray是定义在绝对3D空间中的，所以你需要知道顶点的绝对3D位置。

知道了每个模型三角形的绝对3D位置后，剩下的问题就是对模型每个三角形进行 射线-三角形碰撞检测了。

工作原理

因为内容管道使用了教程4-15中的自定义类(Triangle)，最简单的方法是将这个内容管道项目添加到项目中。你可以右击解决方案并选择Add Existing Project，找到内容管道的. Csproj文件并选择它。

下一步是让XNA项目能够访问到这个内容管道项目，可参加教程3-9的步骤1–5。最后两步是让XNA反串行化Triangle对象，这在教程4-15中已经解释过了。

6. 将新创建的引用添加到项目中。

7. 选择新创建的处理器处理模型。

8. 设置项目依赖项。

9. 在XNA项目中，添加内容管道的引用。

10. 添加内容管道命名空间。

别忘了第7步，选择ModelMeshTriangleProcessor处理你导入的模型。

这个处理器将Triangle对象的数组储存在模型的ModelMesh的Tag属性中。

变换

现在你可以处理所有的模型顶点位置，你可以定义一个方法检测射线－模型间的碰撞：

private bool ModelRayCollision(Model model, Matrix modelWorld, Ray ray)
{
    Matrix[] modelTransforms = new Matrix[model.Bones.Count];
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);

    bool collision = false;
    foreach (ModelMesh mesh in model.Meshes)
    {
        Matrix absTransform = modelTransforms[mesh.ParentBone.Index] *modelWorld;
        Triangle[] meshTriangles = (Triangle[])mesh.Tag;

        foreach (Triangle tri in meshTriangles)
        {
            Vector3 transP0 = Vector3.Transform(tri.P0, absTransform);
            Vector3 transP1 = Vector3.Transform(tri.P1, absTransform);
            Vector3 transP2 = Vector3.Transform(tri.P2, absTransform);

            // if (Ray-Triangle collision)
                // return true;
        }
    }
    return collision;
}

思路很简单：你首先将collision设置为false。对模型的每个部分，你遍历所有三角形对象，包含三角形位置。对每个三角形，你检查三角形是否和ray碰撞，如果发生碰撞则返回true。

存储在Triangle对象中的位置是相对于ModelMesh的初始位置的，这意味你要首先通过ModelMesh的绝对变换矩阵进行变换获得相对于模型的位置，然后使用模型的世界矩阵获取在3D空间中的位置(换句话说，绝对位置)。

第一个变换看似多余，但事实上它有很大的好处：如果你想旋转模型的一个ModelMesh (例如，人的手臂)，这个旋转也会被考虑进去。通过绝对变换矩阵变换了手臂的顶点位置后，你就获得了相对应模型初始位置的位置并考虑了旋转！

在前面的代码中，你首先计算了模型所有Bone的绝对变换矩阵。接下来，将绝对矩阵和世界矩阵组合起来，使用最终的结果矩阵将ModelMesh中的每个三角形变换到绝对3D空间。

最后，你获得了ModelMesh中的每个三角形的确切3D空间位置，可以施加想要的任何动画了。

Ray-Triangle碰撞

有了每个三角形的绝对位置，你将检测三角形和射线之间的碰撞。

这个过程可以分为两部分。首先找到射线与三角形平面的碰撞点。因为三角形和这个相交点是在同一个平面上，所以问题就转化为了一个2D问题，如图4-25所示。接下来，你要检查碰撞点是否在三角形的内部。

图4-25 将3D问题（左图）简化为2D问题(右图)

你将编写两个方法处理问题。第一个方法，RayPlaneIntersection，接受一个Plane和一个Ray为参数并返回射线上的碰撞点的距离。由这个方法可以计算射线和三角形之间的相交点。

第二个方法，PointInsideTriangle，接受三角形的三个坐标和一个额外点（在本教程的情况中即相交点，返回点是否在三角形的内部。

如果在内部，你就检测到了射线和模型的碰撞了。下面的代码要替换掉前面的两行伪代码（译者注：即// if (Ray-Triangle collision) //return true;）：

Plane trianglePlane = new Plane(transP0, transP1, transP2);
float distanceOnRay = RayPlaneIntersection(ray, trianglePlane);
Vector3 intersectionPoint = ray.Position + distanceOnRay * ray.Direction;

if (PointInsideTriangle(transP0, transP1, transP2, intersectionPoint))
    return true;

首先基于三角形的三个顶点创建一个三角形plane。将这个plane和Ray传递到 RayPlaneIntersection方法，获取射线上相交点的位置，这可以用来计算相交点的位置。最后检查相交点是否在三角形内部。

注意：更多Ray对象的使用方法可参加教程4-11和4-19。

获取Ray和Plane之间的碰撞点

你很幸运，第一个方法主要是代数运算。我选择了一个基于矢量的方法让你可以形象地看到这个过程，如图4-26所示。

图4-26 找到射线上的碰撞点的位置

给定一条Ray和一个Plane，这个方法可以找到ray.Direction和collisionPoint两点间的距离，注意这个方法中的变量的所有长度在图中显示在左右两侧。

private float RayPlaneIntersection(Ray ray, Plane plane)
{
    float rayPointDist = -plane.DotNormal(ray.Position);
    float rayPointToPlaneDist = rayPointDist - plane.D;
    float directionProjectedLength = Vector3.Dot(plane.Normal, ray.Direction);
    float factor = rayPointToPlaneDist / directionProjectedLength;
    return factor;
}

首先找到rayPointDist，它是射线投影长度，位于平面法线上(垂直于屏幕的线段)。变量plane. D定义为初始点 (0,0,0)离开平面的最短距离，即沿着平面法线的距离。

有了这两个沿着法线的距离，你可以通过将两者相减找到ray. Position和平面间的距离(即rayPointToPlaneDist)。

这个离开射线的最短距离，从ray. Position指向平面并垂直于平面，即仍是沿着平面法线方向。现在决定射线方向沿平面法线的距离，换句话说，如果你从ray. Position出发发射一条射线，它离开平面有多远？

你可以通过将ray. Direction投影到平面法线上获取这个长度，这可以使用两者的点积。这个长度在图4-26右边显示。

知道了ray. Direction偏离平面的程度和ray. Position偏离平面的距离，你就可以计算需要在ray. Position上乘以ray. Direction的多少倍！这个因子由方法返回用来找到射线和平面相交点的确切位置。首先定义Ray的出发点(=ray. Position)，并通过这个因子添加Ray方向上，你就获得了碰撞点。这一步在ModelRayCollision方法中实现：

Vector3 intersectionPoint = ray.Position + distanceOnRay * ray.Direction;

检查碰撞点是否在三角形内部

知道了射线在哪儿与三角形平面相交后，你要检查相交点是否在三角形内部。否则，射线和三角形并没有真正相交。

这又一次可以使用一个基于向量的方法。图4-27展示了基本原理。如果你还记得碰撞点位于与三角形相同的平面的内部，这个图不是很复杂。

图4-27 检查碰撞点是否在三角形内部

在图4-27的左边，碰撞点在三角形的外部。首先考虑三角形上的T0,T1边界。 你想知道collisionPoint 在边界的哪一面上。看一下图4-27左边到矢量A = (collisionPoint – T0) 和B = (T1-T0)。如果你从A旋转到B，将是逆时针方向。

但是在图4-27的右边，collisionPoint在三角形内部，即在T0,T1边界的另一边，当从A旋转到B将是顺时针方向！

显然你可以通过这个方法检查collisionPoint在边界的那一边。你可以使用A和B的叉乘， A × B，返回一个向量，当逆时针旋转时这个向量指向平面的外部。如果顺时针旋转，如图4-27右边所示，这个向量指向下方。

下面的代码计算A和B向量和它们的叉乘：

Vector3 A = point - p0;
Vector3 B = p1 - p0;
Vector3 cross = Vector3.Cross(A, B);

让我们再进一步：如果collisionPoint在三角形边界的同一侧说明它在三角形的内部，换句话说，如果三个叉乘矢量同一方向则collisionPoint在三角形的内部。

所以为了检测点是否在三角形的内部，你要计算三角形每个边界的叉乘矢量。当这些矢量的分量的符号相同说明它们指向相同的方向。下面是代码：

private bool PointInsideTriangle(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 point)
{
    if (float.IsNaN(point.X))
        return false;

    Vector3 A0 = point - p0;
    Vector3 B0 = p1 - p0;
    Vector3 cross0 = Vector3.Cross(A0, B0);

    Vector3 A1 = point - p1;
    Vector3 B1 = p2 - p1;
    Vector3 cross1 = Vector3.Cross(A1, B1);

    Vector3 A2 = point - p2;
    Vector3 B2 = p0 - p2;
    Vector3 cross2 = Vector3.Cross(A2, B2);

    if (CompareSigns(cross0, cross1) && CompareSigns(cross0, cross2))
        return true;
    else
        return false;
}

上述方法以三角形的三个顶点和collisionPoint位置为参数。如果射线与平面平行，找不到collisionPoint，则point的每个分量都会是NaN(NaN即Not a Number)。如果发生这种情况，方法立即返回，对于一个平行的射线，三角形永远不会和它相交。

如果不平行，这个方法继续运行。对于三角形的每一边，你计算叉乘。如果所有的叉乘矢量都指向相同的方向(这意味着分量的符号是相同的),那么collisionPoint就在三角形的内部！

CompareSigns方法接受两个Vector3参数。它检测两个X分量，Y分量，Z分量是否符号相同。简而言之，这个方法检查这个方向的夹角是否小于90度。下面是代码：

private bool CompareSigns(Vector3 first, Vector3 second)
{
    if (Vector3.Dot(first, second) > 0)
        return true;
    else
        return false;
}

你只是简单地计算两个矢量的点积，将一个矢量投影到另一个矢量上，结果是一个 single类型，如果两个向量夹角大于90度结果为负，垂直时为0，小于90度为正。

性能优化

对于模型的每个三角形都有调用这个检测，所以，让它进行得尽可能快是很重要的。下面，你会学习几个性能优化的建议。

转换射线而不是位置

在ModelMesh中，你使用绝对变换矩阵转换了每个位置，将这些位置转换到绝对3D空间中，这样才可以和Ray比较，因为它同样定义在绝对3D空间中。

更好的做法是将射线转换到ModelMesh的坐标空间中，因为这样做只需对每个 ModelMesh操作一次。这样Ray和三角形位置都在相同的坐标空间中，它们也可以进行比较。

使用以下代码替换ModelRayCollision方法：

private bool ModelRayCollision(Model model, Matrix modelWorld, Ray ray)
{
    Matrix[] modelTransforms = new Matrix[model.Bones.Count];
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);

    bool collision = false;
    foreach (ModelMesh mesh in model.Meshes)
    {
        Matrix absTransform = modelTransforms[mesh.ParentBone.Index] * modelWorld;
        Triangle[] meshTriangles = (Triangle[])mesh.Tag;

        Matrix invMatrix = Matrix.Invert(absTransform);
        Vector3 transRayStartPoint = Vector3.Transform(ray.Position, invMatrix);
        Vector3 origRayEndPoint = ray.Position + ray.Direction;
        Vector3 transRayEndPoint = Vector3.Transform(origRayEndPoint, invMatrix);
        Ray invRay = new Ray(transRayStartPoint, transRayEndPoint - transRayStartPoint);

        foreach (Triangle tri in meshTriangles)
        {
            Plane trianglePlane = new Plane(tri.P0, tri.P1, tri.P2);
            float distanceOnRay = RayPlaneIntersection(invRay, trianglePlane);
            Vector3 intersectionPoint = invRay.Position + distanceOnRay * invRay.Direction;

            if (PointInsideTriangle(tri.P0, tri.P1, tri.P2, intersectionPoint))
                return true;
        }
    }
    return collision;
}

你将Ray从绝对3D空间转换到ModelMesh空间而不是将三角形位置转换到绝对3D空间，这是absTransform 变换的反变换，所以你要取逆矩阵。然后，你获取射线的两个点，将它们使用逆矩阵进行变换，并创建一个新的射线。有了在ModelMesh空间中的射线，就可以将这些与未经变换的顶点位置做比较了，因为此时它们在相同的坐标空间！

这样，对每个ModelMesh你只需进行两次变换，而不是对每个三角形进行三次变换！

首先进行一个包围球检测

相对于对射线和所有三角形进行碰撞检测，更好的做法是进行射线和ModelMesh的包围球之间的碰撞检测，如果这个简单快速的检查返回false，那就没必要检查每个三角形了！

如果这个检测返回true，在转到繁重的逐三角形检测之前，更好的方法是检测射线和更小的ModelMesh的包围球之间的碰撞。或者可以做得更好，检测所有ModelMeshPart包围球之间的碰撞（在模型的一个ModelMesh有多个ModelMeshPart的情况下）。

如你所见，一个模型可以分成几个ModelMesh，因为你拥有了更多（也更精确）的包围球，导致更少的三角形检测，所以让更精确的动画和加速碰撞检测成为可能。

代码

这是检测射线和模型间碰撞的最终方法：

private bool ModelRayCollision(Model model, Matrix modelWorld, Ray ray)
{
    Matrix[] modelTransforms = new Matrix[model.Bones.Count];
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);

    bool collision = false;
    foreach (ModelMesh mesh in model.Meshes)
    {
        Matrix absTransform = modelTransforms[mesh.ParentBone.Index] * modelWorld;
         Triangle[] meshTriangles = (Triangle[])mesh.Tag;

         foreach (Triangle tri in meshTriangles)
         {
             Vector3 transP0 = Vector3.Transform(tri.P0, absTransform);
             Vector3 transP1 = Vector3.Transform(tri.P1, absTransform);
             Vector3 transP2 = Vector3.Transform(tri.P2, absTransform);

             Plane trianglePlane = new Plane(transP0, transP1, transP2);
             float distanceOnRay = RayPlaneIntersection(ray, trianglePlane);
             Vector3 intersectionPoint = ray.Position + distanceOnRay * ray.Direction;

             if (PointInsideTriangle(transP0,transP1, transP2, intersectionPoint))
                 return true;
        }
    }
    return collision;
}

这个方法调用RayPlaneIntersection方法， 返回一个平面和一条射线之间的碰撞点的位置：

private float RayPlaneIntersection(Ray ray, Plane plane)
{
    float rayPointDist = -plane.DotNormal(ray.Position);
    float rayPointToPlaneDist = rayPointDist - plane.D;
    float directionProjectedLength = Vector3.Dot(plane.Normal, ray.Direction);
    float factor = rayPointToPlaneDist / directionProjectedLength;

    return factor;
}

PointInsideTriangle方法用来检测碰撞点是否在三角形的内部：

private bool PointInsideTriangle(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 point)
{
    if (float.IsNaN(point.X))
        return false;

    Vector3 A0 = point - p0;
    Vector3 B0 = p1 - p0;
    Vector3 cross0 = Vector3.Cross(A0, B0);

    Vector3 A1 = point - p1;
    Vector3 B1 = p2 - p1;
    Vector3 cross1 = Vector3.Cross(A1, B1);

    Vector3 A2 = point - p2;
    Vector3 B2 = p0 - p2;
    Vector3 cross2 = Vector3.Cross(A2, B2);

    if (CompareSigns(cross0, cross1) && CompareSigns(cross0, cross2))
        return true;
    else
        return false;
}

这个方法检测碰撞点是否在三角形的同一侧，这可以通过检测叉乘矢量是否同一方向实现，这个检测是在CompareSigns方法中实现的：

private bool CompareSigns(Vector3 first, Vector3 second)
{
    if (Vector3.Dot(first, second) > 0)
        return true;
    else
        return false;
}


4.19 检测光标是否在模型上

问题

你想检测光标是否在模型上。

解决方案

在XNA中，获取光标在屏幕上的2D位置是简单的。屏幕上的这个点对应3D空间中的一条射线Ray，如图4-28所示。

图4-28 2D光标对应3D空间的一条射线

因此，当你想检测光标在哪个模型上，需要检测射线与模型的碰撞，所以，这个教材会用到教程4-18的代码。

很有可能射线与多个模型相交，这个教程还会教你如何获取离屏幕最近的一个模型。

工作原理

你需要创建一个3D射线并将它与模型一起传递到教程4-18创建的ModelRayCollision 方法中。

只要知道了射线上的两个点就可以创建一条射线。你将使用的两个点如图4-28所示。第一个点是射线与近裁屏平面的交点；第二个点是与远裁平面的交点。

如果知道了这两个点的3D位置，你将使用ViewProjection矩阵进行转换获取屏幕上的2D位置。但是，你转换一个Vector3结果仍是一个Vector3，在结果Vector3中，通过使用ViewProjection矩阵进行变换，X和Y分量就是2D屏幕位置，第三个坐标Z也包含有用的信息，即相机与初始点的距离，为0时表示点在近裁平面，为1时表示在远裁平面。在深度缓冲中存储的正是这个距离。所以，每个在2D屏幕上绘制的像素实际上都有3个坐标值。

你要获取的两个点共享相同的像素，相同的2D位置，即它们的X和Y坐标是相同的。因为第一个点位于近裁平面，所以它的Z坐标为0。第二点位于远裁平面，所以Z坐标为1。这两个点在屏幕空间的三个坐标，在光标的情况中如下所示：

  • (mouseX, mouseY, 0)
  • (mouseX, mouseY, 1)

下面是代码：

MouseState mouseState = Mouse.GetState();
Vector3 nearScreenPoint = new Vector3(mouseState.X, mouseState.Y, 0);
Vector3 farScreenPoint = new Vector3(mouseState.X, mouseState.Y, 1);

如果从3D空间转换到屏幕空间，你要使用ViewProjection矩阵转换3D点。而这里你想讲这些点从屏幕空间转换到3D空间，所以使用的是ViewProjection矩阵的逆矩阵。你还需要将X和Y的光标坐标映射到[-1, 1]范围中，所以需要屏幕的像素大小的高和宽。

幸运的是，XNA提供了UnProject方法实现了这个映射和反向变换：

Vector3 near3DWorldPoint = device.Viewport.Unproject(nearScreenPoint, fpsCam.ProjectionMatrix, fpsCam.ViewMatrix, Matrix.Identity);
Vector3 far3DWorldPoint = device.Viewport.Unproject(farScreenPoint,fpsCam.ProjectionMatrix, fpsCam.ViewMatrix, Matrix.Identity);

你获得的这两个点如图4-28所示！

注意：你想知道相对于3D初始位置(0,0,0)的位置，所以你将Matrix. Identity作为世界矩阵。ViewProjection矩阵可以通过将View和Projection矩阵相乘获得，这也是你将这两个矩阵作为第二第三个参数的原因。

知道了射线的两个点，就可以创建一个Ray对象：

Vector3 pointerRayDirection = far3DWorldPoint - near3DWorldPoint;
pointerRayDirection.Normalize();
Ray pointerRay = new Ray(near3DWorldPoint,pointerRayDirection);

创建了Ray之后，你就做好了使用上一个教程中的ModelRayCollision方法检测Ray和模型间碰撞的准备：

selected = ModelRayCollision(myModel, modelWorld, pointerRay);

添加一个Crosshair

前面的代码看起来很好，但如果你不能测试，代码再好也看不出来。所以让我们添加一个图像可以显示光标的位置，可见教程3-1学习绘制一个图像的简短介绍。首先将光标的2D位置存储在一个Vector2中：

pointerPosition = new Vector2(mouseState.X, mouseState.Y);

在LoadContent方法中，添加一个SpriteBatch对象和一个Texture2D对象保存透明的crosshair图像：

spriteBatch = new SpriteBatch(device); crosshair = content.Load("cross");

然后在Draw方法中将这个图像绘制到屏幕：

spriteBatch.Begin(SpriteBlendMode.AlphaBlend,SpriteSortMode.Deferred, SaveStateMode.SaveState);
spriteBatch.Draw(cross, mouseCoords, null, Color.White, 0, new Vector2(7, 7), 1, SpriteEffects.None, 0);
spriteBatch.End();

这可以让你在屏幕上看到光标。图像的中心点(7,7)位于光标位置。

检测多个对象

如果在场景中有多个对象，那么可能有多个对象会与射线发生碰撞。在大多数情况中，你只关心离相机最近的那个对象，因为这个对象才占据屏幕的像素。

要做到这点，你可以稍微调整一下ModelRayCollision方法，让它返回碰撞的距离而不是简单的true或false。类似于Intersect方法，你使用一个可空类型float?变量，这样如果没有碰撞那么返回null：

private float? ModelRayCollision(Model model, Matrix modelWorld, Ray ray)
{
    Matrix []modelTransforms = new Matrix[model.Bones.Count];
    model.CopyAbsoluteBoneTransformsTo(modelTransforms);

    float? collisionDistance = null;
    foreach (ModelMesh mesh in model.Meshes)
    {
        Matrix absTransform = modelTransforms[mesh.ParentBone.Index]*modelWorld;
        Triangle[] meshTriangles = (Triangle[])mesh.Tag;

        foreach (Triangle tri in meshTriangles)
        {
            Vector3 transP0 = Vector3.Transform(tri.P0, absTransform);
            Vector3 transP1 = Vector3.Transform(tri.P1, absTransform);
            Vector3 transP2 = Vector3.Transform(tri.P2, absTransform);

            Plane trianglePlane = new Plane(transP0, transP1, transP2);
            float distanceOnRay = RayPlaneIntersection(ray, trianglePlane);
            Vector3 intersectionPoint = ray.Position + distanceOnRay * ray.Direction;

            if (PointInsideTriangle(transP0, transP1, transP2, intersectionPoint))
                if ((collisionDistance == null) || (distanceOnRay < collisionDistance))
                    collisionDistance = distanceOnRay;
        }
    }
    return collisionDistance;
}

每次发生碰撞时，你检查collisionDistance是否仍是null。这会显示是第一次检测到碰撞，所以你将这个距离存储到collisionDistance 中。从这时起，你检查这个距离是否小于已知的距离，如果是，则重写这个距离。

从变量collisionDistance返回的结果将包含离相机最近的碰撞点，你可以使用这个结果检测哪个模型离相机最近。

代码

这个代码创建一个3D射线，这个射线描述所有属于通过光标显示的像素的点。这个射线传递到ModelRayCollision方法：

Vector3 nearScreenPoint = new Vector3(mouseState.X, mouseState.Y, 0);
Vector3 farScreenPoint = new Vector3(mouseState.X, mouseState.Y, 1);
Vector3 near3DWorldPoint = device.Viewport.Unproject(nearScreenPoint, fpsCam.ProjectionMatrix, fpsCam.ViewMatrix, Matrix.Identity);
Vector3 far3DWorldPoint = device.Viewport.Unproject(farScreenPoint, fpsCam.ProjectionMatrix, fpsCam.ViewMatrix, Matrix.Identity);

Vector3 pointerRayDirection = far3DWorldPoint - near3DWorldPoint;
pointerRayDirection.Normalize();
Ray pointerRay = new Ray(near3DWorldPoint, pointerRayDirection);
selected = ModelRayCollision(myModel, worldMatrix, pointerRay);


5.0 第五章概述

无论创建一个3D游戏还是一些图形软件，在很多情况中你都想绘制诸如点、线或三角形的图元。所有图元都是由顶点定义的，它们是三角形的顶点或直线的端点。

顶点不光包含位置，它们也可以包含其他数据。它们可以指定三角形顶点的颜色，或使用一张纹理覆盖在三角形上，顶点需要指定纹理中的哪个点映射到三角形上。这只是顶点包含数据的简单例子，实际上包含数据的情况是非常多的。

本章首先介绍基础知识：如何定义顶点，如何从顶点绘制点、线或三角形。然后是纹理坐标和法线数据，因为大多数顶点通常都存储这个数据。

然后，介绍XNA提供的强大的功能，包括静态和动态VertexBuffers和IndexBuffers，它们让你可以在显存中存储顶点。实际应用是定义一个地形，因为一个地形(或通常意义上的网格)在游戏中是非常普遍的，本章会实现一些诸如判断线性高度和表面拾取的功能。使用Catmull-Rom插值，你还可以生成额外的点给地形添加细节。

因为顶点会发送到显卡，本章解释了为什么顶点格式连接了XNA项目、顶点和显卡上的vertex shader。

使用HLSL，你将学习如何给物体添加凹凸映射以增加细节。最后，你还要学习如何通过让显卡生成所有顶点和像素绘制海面。

另外，你还要学习如何定义自己的内容导入器，如何从XML文件加载一个对象。

本章包含以下内容：

  • 理解什么是顶点，如何定义它们。你还要学习如何从顶点绘制点、线或三角形，如何在三角形上覆盖一张纹理(教程5-1和5-2)。
  • 最优化地使用内存。你可以使用索引移除多余顶点，通过将将顶点和索引存储在VertexBuffer 和IndexBuffer中将它们放在显存中(教程5-3, 5-4和5-5)。
  • 理解为什么三角形为何不显示。这可能是因为显卡认为你的相机观察的是三角形的背面而不绘制它，或者说剔除它(教程5-6)。
  • 在每个顶点存储正确的法线产生正确的光照。你还要学习如何计算法线(教程5-7)。
  • 通过创建一个带纹理的地形将所学的知识整合起来。你需要计算地形上每个点的精确高度和光标所处的地形位置(教程5-8, 5-9和5-10)。
  • 学习如何从XML文件加载对象(教程5-11)。
  • 学习如何定义自定义格式的顶点，让你可以在顶点中存储任何数据，这样在vertex shader中也可以访问这些数据(教程5-12)。顶点格式是XNA项目、顶点和显卡上的vertex shader的纽带。
  • 通过凹凸映射给物体添加细节(教程5-13和5-14)。
  • 通过创建一个海面将所学的知识整合起来。vertex shader计算滚动的波，pixel shader添加细节(教程5-15)。
  • 给定一组点，学习如何计算通过给定点的曲线上的任意点。你可以使用这个技术创建一个赛道(教程5-16和5-17)。


5.1 绘制三角形，线和点

问题

你想定义一个3D几何结构并绘制到屏幕上，这个结构可以是由三角形，线或点组成。

解决方案

当绘制3D几何对象时，你首先需要使用primitives（图元）定义它的形状。Primitives （图元）是可以被XNA绘制的最基本的对象，最常被使用的图元是三角形。任何形状，包括圆，如果圆的数量足够多的话，都能用来表示三角形。XNA Framework可以将点，线、三角形作为图元绘制。

XNA允许你定义这些图元的所有3D坐标。当你调用DrawUserPrimitives方法时，只要你提供正确的观察矩阵和投影矩阵，XNA会自动将这些3D坐标转换为对应的屏幕坐标。

工作原理

要将3D坐标转换为屏幕上的像素位置，XNA需要知道相机的位置（存储在观察矩阵中）和关于相机镜头的某些细节（存储在投影矩阵中）。如果你的程序还没有提供这些矩阵或你未理解这些概念，可以看一下教程2－1和2－3，只需把QuakeCamera . cs文件复制并导入到项目中，通过添加如下变量在项目中添加相机系统：

private QuakeCamera fpsCam;

并在Initialize方法中进行初始化：

fpsCam = new QuakeCamera(GraphicsDevice.Viewport);

现在你的项目中已经有了一个相机系统！fpsCam对象提供了所需的观察矩阵和投影矩阵。如果你还想移动相机，可以在Update方法中传递用户的输入：

protected override void Update(GameTime gameTime)
{
    GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
    if (gamePadState.Buttons.Back == ButtonState.Pressed)
        this.Exit();
    MouseState mouseState = Mouse.GetState();
    KeyboardState keyState = Keyboard.GetState();
    fpsCam.Update(mouseState, keyState, gamePadState);
    base.Update(gameTime);
}

因为三角形使用得最多，所以我从绘制三角形开始。

定义并绘制三角形

使用XNA绘制图元是很简单的，绘制三角形的步骤如下：

  1. 定义三角形三个顶点的坐标和颜色，将它们存储在一个数组中。
  2. 设置绘制三角形要用到的effect。
  3. 声明要传递的数据类型。
  4. 在屏幕上绘制数组中的内容。

定义顶点

对应三角形的每个顶点，你不仅存储了3D位置还存储了顶点的颜色。XNA提供了一个叫做VertexPositionColor的结构，这个结构可以存储位置和颜色。所以你需要添加VertexPositionColor结构的数组，告诉显卡每个顶点所包含信息的类型。

VertexPositionColor[] vertices;
VertexDeclaration myVertexDeclaration;

定义了变量后，你将创建InitVertices 方法，此方法首先创建VertexDeclaration，然后将三个顶点添加到顶点数组中：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements);
    vertices = new VertexPositionColor[3];
    vertices[0] = new VertexPositionColor(new Vector3(-5, 1, 1), Color.Red);
    vertices[1] = new VertexPositionColor(new Vector3(-5, 5, 1), Color.Green);
    vertices[2] = new VertexPositionColor(new Vector3(-3, 1, 1), Color.Blue);
}

在显卡绘制三角形前要用到VertexDeclaration，它告知显卡在顶点中包含何种类型的信息，这个信息通过顶点格式VertexElements显示，本例中是位置和颜色。

注意大多数情况下只在Draw方法中使用myVertexDeclaration变量，所以在调用Draw方法时会创建myVertexDeclaration变量，但这样做会导致每帧都会重新创建myVertexDeclaration！会让程序拖慢一点。更重要的是，因为Xbox 360使用的是.NET Framework的精简版本，这会导致在Xbox 360平台上程序会不定时地发生冲突，而且很难找到原因。所以请确保你只初始化myVertexDeclaration一次。

接下来你指定顶点的位置和颜色，本例中所有顶点的Z坐标都相同，你的三角形是平行于XY平面的。确保在LoadContent方法中调用这个方法。加载BasicEffect 在绘制三角形之前，需要告知显卡如何处理它接收到的信息。本例中，你想让指定的颜色作为三角形顶点的颜色，在更复杂的场景中你可能还要将这些信息用于其他目的。这一步要求调用一个effect。幸运的是，XNA内置了一个effect可以让显卡处理大多数逻辑和基本操作，这个effect叫做BasicEffect，你需要首先创建一个用来绘制三角形。在项目中添加这个变量：

private BasicEffect basicEffect;

并在LoadContent方法中进行初始化：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;
    basicEffect = new BasicEffect(device, null);
    InitVertices();
}

将三角形绘制到屏幕

注意因为是演示，所以这里我使用了DrawUserPrimitive方法，虽然这个方法不是最快的。教程5－4会讨论一个更加快且复杂的方法。

定义了顶点和加载了BasicEffect后，你就做好了绘制三角形的准备。显然，绘制三角形应在Draw方法中完成，而Draw方法在XNA Framework中已经提供了，它每帧都会被调用。在清除场景后添加以下代码：

device.RenderState.CullMode = CullMode.None;
basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.VertexColorEnabled = true;
basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration =myVertexDeclaration; device.DrawUserPrimitives<VertexPositionColor>
    (PrimitiveType.TriangleList, vertices, 0, 1);
    pass.End();
}
basicEffect.End();

在绘制三角形前，你需要设置BasicEffect的一些参量。这些参量可能是通过设置世界矩阵（见教程4－2）移动，旋转或缩放三角形。本例中你只是简单地将三角形绘制到指定位置，所以世界矩阵设为单位矩阵。

BasicEffect还需要知道观察矩阵和投影矩阵让显卡可以根据相机的位置将顶点从3D坐标转换为2D屏幕坐标。最后你还需指定BasicEffect effect使用你指定的颜色信息。

注意：如果你没有指定BasicEffect从哪采样颜色，那么它会使用最近指定的那个。

接下来，开始effect。高级ffects可以有多个pass，虽然BasicEffect只有一个pass，但遍历所有可能的pass还是一个好习惯。

一旦开始pass，你首先需要传递VertexDeclaration是显卡知道顶点中包含何种数据。最后调用device.DrawUserPrimitives方法绘制三角形。DrawUserPrimitives方法需要四个参数。第一个参数指定是绘制三角形、线还是点，以及在数组中的存储方式。第二个参数是用来存储顶点的数组。第三个参数指定数组中开始绘制的顶点索引，因为你想从第一个顶点开始绘制，所以这里设置为0。第四个参数表示需要绘制多少个图元，因为在数组中你只存储了三个顶点，所以只需绘制1个三角形。

运行代码后就可以绘制一个三角形了！如你所见，三角形的每个顶点的颜色都是你指定的，顶点之间的颜色是通过线性插值求出的。

注意：代码device.RenderState.CullMode = CullMode.None会关闭剔除（见教程5－6）。剔除会使三角形不会被绘制，所以当开始3D编程前，你应关闭它。

使用TriangleList（三角带）绘制多个三角形TriangleList

会绘制一个三角形，那么绘制多个也不难。首先定义顶点：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements);
    vertices = new VertexPositionColor[12];
    vertices[0] = new VertexPositionColor(new Vector3(-5, 1, 1), Color.Red);
    vertices[1] = new VertexPositionColor(new Vector3(-5, 5, 1), Color.Green);
    vertices[2] = new VertexPositionColor(new Vector3(-3, 1, 1), Color.Blue);
    vertices[3] = new VertexPositionColor(new Vector3(-3, 5, 1), Color.Gray);
    vertices[4] = new VertexPositionColor(new Vector3(-1, 1, 1), Color.Purple);
    vertices[5] = new VertexPositionColor(new Vector3(-1, 5, 1), Color.Orange);
    vertices[6] = new VertexPositionColor(new Vector3(1, 1, 1), Color.BurlyWood);
    vertices[7] = new VertexPositionColor(new Vector3(1, 5, 1), Color.Gray);
    vertices[8] = new VertexPositionColor(new Vector3(3, 1, 1), Color.Green);
    vertices[9] = new VertexPositionColor(new Vector3(3, 5, 1), Color.Yellow);
    vertices[10] = new VertexPositionColor(new Vector3(5, 1, 1), Color.Blue);
    vertices[11] = new VertexPositionColor(new Vector3(5, 5, 1), Color.Red);
}

技巧：你也可以使用一个递增的整数节省时间，而不是手动设置所有索引。

定义完12个顶点后就可以绘制4个三角形了：

device.DrawUserPrimitives<VertexPositionColor> (PrimitiveType.TriangleList, vertices, 0, 4);

如果你只想绘制最后两个三角形，你可以从第7个顶点开始，因为数组的第一个索引是0，所以这个顶点的索引是6：

device.DrawUserPrimitives<VertexPositionColor> (PrimitiveType.TriangleList, vertices, 6, 2);

因为发送到显存的数据变少，这样做可以节省带宽。

使用TriangleStrip绘制多个三角形

如果你要绘制的三角形是相连的，就可以使用TriangleStrip 代替Triangle List，这样可以节省内存和带宽。看一下如图5－1所示的六个三角形。如果使用TriangleList需要6*3 = 18个顶点，当其中只有8个顶点是唯一的，其余10个顶点是重复存储并发送到显卡中的，这样做会浪费内存、带宽和GPU的处理能力！

图5－1 绘制一个TriangleStrip

将要绘制的三角形指定为一个TriangleStrip，XNA会基于前三个顶点绘制第一个三角形，然后为下一个顶点创建一个新三角形，而这个三角形使用了新的顶点和前两个顶点。这样，第一个三角形是由顶点0，1，2定义的；而第二个是由顶点1，2，3定义的；第三个是由顶点1，3，4定义的，以此类推。

要绘制x个三角形，你需要定义x+2个顶点。如果你在数组中存储了12个顶点，可以使用以下代码以TriangleStrip的形式绘制10个三角形：

device.DrawUserPrimitives<VertexPositionColor> (PrimitiveType.TriangleStrip, vertices, 0, 10);

注意：当使用TriangleStrip以逆时针方向定义三角形时，不可能遵循剔除规则。(见教程5－6)。要解决这个问题，对于TriangleStrip 来说剔除有些特别，第一个三角形必须以顺时针的顺序定义，而其他三角形的顺序相反。

使用PointList绘制多个点

绘制点和线段的码类似；只是指定的图元类型不同。在数组中只需为每个点存储一个顶点。如果你存储了12个顶点，下面的代码就可以在屏幕上绘制12个点：

device.DrawUserPrimitives<VertexPositionColor> (PrimitiveType.PointList, vertices, 0, 12);

注意：无论相机离开点的远近，每个点对应一个像素，你需要一双好眼睛才能看到这些像素。

译者注：要想绘制大于一个像素的点，需要使用PointSprite（点精灵），需要在绘制前添加如下代码：

graphics.GraphicsDevice.RenderState.DepthBufferEnable = false;
graphics.GraphicsDevice.RenderState.PointSpriteEnable = true;
graphics.GraphicsDevice.RenderState.PointSize = 30.0f;

程序截图如下：

使用LineList绘制多条线段

你需要两个点定义一条线段。12个顶点可以绘制12/2 = 6条线段，代码如下：

device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineList, vertices, 0, 6);

使用LineStrip绘制多条线段如果一组线是相连的，你也可以绘制一个线带，如图5－2所示。

图5－2 使用LineStrip绘制多条线段

绘制x条线段需要x－1个顶点，所以如果你在数组中存储了12个顶点，那么可以使用如下方法画出11条线段：

device.DrawUserPrimitives<VertexPositionColor> (PrimitiveType.LineStrip, vertices, 0, 11);

使用TriangleFan绘制多个三角形

如果三角形共享一个顶点，你也可以使用TriangleFan绘制三角形。图5－3显示了这种三角形。注意对应共享的中心点，每个三角形必须与邻近三角形共享一条边。

图5-3 使用TriangleFan绘制多个三角形

使用x–2个顶点绘制x个三角形。这意味着需要12个顶点绘制10个三角形。共享的点必须是数组中的第一个顶点。

注意：TriangleStrip在提供相同性能的前提下更加灵活：

device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.TriangleFan, vertices, 0, 10);

代码

因为这个本章的第一个教程，所以下面是使用TriangleStrip绘制三角形的全部代码：

using System; using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Audio;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.GamerServices;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Net;
using Microsoft.Xna.Framework.Storage;
namespace BookCode
{
    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GraphicsDeviceManager graphics;
        GraphicsDevice device;
        BasicEffect basicEffect;
        QuakeCamera fpsCam;
        CoordCross cCross;
        VertexPositionColor[] vertices;
        VertexDeclaration myVertexDeclaration;

        public Game1()
        {
            graphics = new GraphicsDeviceManager(this);
            Content.RootDirectory = "Content";
        }

        protected override void Initialize()
        {
            fpsCam = new QuakeCamera(GraphicsDevice.Viewport);
            base.Initialize();
        }

        protected override void LoadContent()
        {
            device = graphics.GraphicsDevice;
            basicEffect = new BasicEffect(device, null);
            InitVertices();
            cCross = new CoordCross(device);
        }

        private void InitVertices()
        {
            myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements);
            vertices = new VertexPositionColor[12];
            vertices[0] = new VertexPositionColor(new Vector3(-5, 1, 1), Color.Red);
            vertices[1] = new VertexPositionColor(new Vector3(-5, 5, 1), Color.Green);
            vertices[2] = new VertexPositionColor(new Vector3(-3, 1, 1), Color.Blue);
            vertices[3] = new VertexPositionColor(new Vector3(-3, 5, 1), Color.Gray);
            vertices[4] = new VertexPositionColor(new Vector3(-1, 1, 1), Color.Purple);
            vertices[5] = new VertexPositionColor(new Vector3(-1, 5, 1), Color.Orange);
            vertices[6] = new VertexPositionColor(new Vector3(1, 1, 1), Color.BurlyWood);
            vertices[7] = new VertexPositionColor(new Vector3(1, 5, 1), Color.Gray);
            vertices[8] = new VertexPositionColor(new Vector3(3, 1, 1), Color.Green);
            vertices[9] = new VertexPositionColor(new Vector3(3, 5, 1), Color.Yellow);
            vertices[10] = new VertexPositionColor(new Vector3(5, 1, 1),Color.Blue);
            vertices[11] = new VertexPositionColor(new Vector3(5, 5, 1), Color.Red);
        }

        protected override void UnLoadContent()
        {
        }

        protected override void Update(GameTime gameTime)
        {
            GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
                if (gamePadState.Buttons.Back == ButtonState.Pressed)
                    this.Exit();
            MouseState mouseState = Mouse.GetState();
            KeyboardState keyState = Keyboard.GetState();
            fpsCam.Update(mouseState, keyState, gamePadState);
            base.Update(gameTime);
        }

        protected override void Draw(GameTime gameTime)
        {
            device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
            cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);

            //draw triangles
            device.RenderState.CullMode = CullMode.None;
            basicEffect.World = Matrix.Identity;
            basicEffect.View = fpsCam.ViewMatrix;
            basicEffect.Projection = fpsCam.ProjectionMatrix;
            basicEffect.VertexColorEnabled = true;
            basicEffect.Begin();
            foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
            {
                pass.Begin();
                device.VertexDeclaration = myVertexDeclaration;
                device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.TriangleStrip, vertices, 0, 10);
                pass.End();
            }
            basicEffect.End();
            base.Draw(gameTime);
        }
    }
}


5.2 在三角形上添加纹理

问题

你想绘制一个颜色漂亮的三角形并可以完全控制三角形上的颜色。

解决方案

你的显卡允许你指定一张图像，从这张图像可以采样想要的颜色。

这意味着你需要将一张2D图像导入到XNA项目中，并在绘制三角形之前将它传递到显卡。对每个顶点，你要指定2D图像的哪个位置对应顶点。

工作原理

首先将一张2D图像导入到项目中，如教程3-1所示。在LoadContent方法中将它链接到一个变量：

myTexture = content.Load<Texture2D>("texture");

然后你要定义三角形的顶点。如前面的教程所述，你需要指定每个顶点的3D位置。这次你无需定义颜色而是定义在2D纹理中的对应位置，这一步需要在每个顶点中存储2D纹理坐标。如果三角形的每个顶点对应一个2D纹理坐标，显卡就会将你指定的2D图像的某一部分填充到三角形内部。

当指定纹理坐标时，要记住纹理的左上为坐标(0,0)，右上为(1,0)。这意味着第二个坐标值表示垂直位置，所以右下坐标为(1,1)。图5-4显示了对应不同纹理坐标的纹理区域。

图5-4 为顶点指定纹理坐标

你需要存储一个2D纹理坐标而不是每个顶点的颜色。不是存储一个包含VertexPositionColor元素的数组，而是创建一个包含VertexPositionTexture元素的数组，如这个名称所述，它将存储纹理坐标和一个3D位置：

private void InitVertices()
{
    vertices = new VertexPositionTexture[9];
    int i = 0;

    vertices[i++] = new VertexPositionTexture(new Vector3(-10, 1, -5), new Vector2(0,0));
    vertices[i++] = new VertexPositionTexture(new Vector3(-7, 5, -5), new Vector2(0.5f, 1));
    vertices[i++] = new VertexPositionTexture(new Vector3(-4, 1, -5), new Vector2(1,0));
    vertices[i++] = new VertexPositionTexture(new Vector3(-3, 1, -5), new Vector2(0, 0.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -5), new Vector2(1, 0));
    vertices[i++] = new VertexPositionTexture(new Vector3(3, 1, -5),new Vector2(1, 1));
    vertices[i++] = new VertexPositionTexture(new Vector3(4, 1, -5), new Vector2(0.25f, 0.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(7, 5, -5), new Vector2(0.5f, 0));
    vertices[i++] = new VertexPositionTexture(new Vector3(10, 1, -5), new Vector2(1, 1));

    myVertexDeclaration=new VertexDeclaration(device, VertexPositionTexture.VertexElements);
}

这里定义的九个顶点拥有对应图5-4中的三个三角形的纹理坐标。通过指定纹理坐标，你可以显示如图5-4所示的三角形。

注意： 三角形在3D空间中的实际位置定义为一个Vector3。例如，图5-4中的第一个三角形是倒置的，因为两个顶点的Y坐标是1，中间一个顶点的Y坐标为5，这使得三角形中间的字母XNA也是颠倒绘制的（译者注：图5-4中的第一个三角形中的XNA并没有颠倒，但在源代码中是颠倒的，怀疑作者截图有误）。

你还需要告知显卡顶点现在包含不同的信息。所以需要基于VertexPositionTexture的VertexElement创建一个VertexDeclaration，这样可以在绘制三角形前将它传递到显卡。

定义了顶点后，你就做好了绘制三个三角形的准备：

device.RenderState.CullMode = CullMode.None;
basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = myTexture;
basicEffect.TextureEnabled = true;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration=myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 3);
    pass.End();
}
basicEffect.End();

你需要将纹理传递到显卡，这一步需要将这个纹理设置为BasicEffect的当前纹理。然后，不像前一个教程那样使用定义在顶点中的颜色，本教程通过将 TextureEnabled设置为true指定BasicEffect从纹理采样颜色。因为你使用了一个不同的顶点格式，所以需要将新的VertexDeclaration传递到显卡。

最后绘制三个三角形。

纹理寻址模式

如前所述，纹理坐标(0,0)对应纹理的左上点的像素，(1,1)对应右下点。

但这并没有限定纹理坐标的值必须在[0,1]范围内，例如设定为(1.5f, –0.5f)也可以。在这种情况中，你需要通过设置U和V（U是第一个纹理坐标，V是第二个）的纹理模式让XNA知道如何处理这样的坐标。下面的代码定义了一个U和V坐标超过[0,1]范围的三角形：

private void InitVertices()
{
    vertices = new VertexPositionTexture[3];

    int i = 0;
    vertices[i++] = new VertexPositionTexture(new Vector3(-3, -3, -1), new Vector2(-0.5f,1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(3, -3, -1), new Vector2(1.5f,1.5f));
    myVertexDeclaration=new VertexDeclaration(device, VertexPositionTexture.VertexElements);
}

然后你要通过设置纹理采样器的寻址模式让XNA知道如何处理这些坐标，这用来从纹理采样颜色。

TextureAddressMode.Clamp

使用下列代码将UV模式设置为Clamp：

device.SamplerStates[0].AddressU = TextureAddressMode.Clamp;
device.SamplerStates[0].AddressV = TextureAddressMode.Clamp;

这个寻址模式让所有的纹理坐标截取到[0,1]范围内，所有小于0的坐标截取到0，大于1的截取到1。

结果是，超出[0,1] 范围的纹理坐标的所有像素的颜色为纹理边缘的颜色，如图5-5所示。图中显示的三角形是前面已经定义的。

下面是显卡在从图像采样颜色前首先映射的纹理坐标的例子：

  • (-0.5f, 1.5f) → (0, 1)
  • (0.5f, -1.5f) → (0.5f, 0)
  • (1.5f, 1.5f) → (1,1)

图5-5 Clamp (左)和Wrap (右)纹理寻址模式

TextureAddressMode.Wrap

这是默认的纹理寻址模式，在使用了一个不同的模式后你可以使用下列代码重新选择使用wrap模式：

device.SamplerStates[0].AddressU = TextureAddressMode.Wrap;
device.SamplerStates[0].AddressV = TextureAddressMode.Wrap;

使用这个模式，显卡会从坐标加上或减去1直到坐标仍回到[0,1]范围。

这会导致原始纹理被复制，如图5-5右边所示。

下面是一些纹理坐标映射的例子：

  • (-0.5f, 1.5f) → (0.5f, 0.5f)
  • (1.2f, -0.2f) → (0.2f,0.8f)
  • (-0.7f, -1.2f) → (0.3f,0.8f)

TextureAddressMode.Mirror

使用下列代码将纹理寻址模式设置为：

device.SamplerStates[0].AddressU = TextureAddressMode.Mirror;
device.SamplerStates[0].AddressV = TextureAddressMode.Mirror;

这个模式导致原始纹理被复制，但与Wrapping模式不同的是，复制的图像与相邻的原始图像是互为镜像的。复制的图像在原始图像的上方和下方是垂直镜像的，左方和右方是水平映射的。对角线上的复制沿着两边，翻转180度，如图5-6左图所示。

图5-6 Mirror (左)和MirrorOne (右)纹理寻址模式

这个模式很有用，因为可以在放大图像避免边缘锐化。

下面是一些坐标映射的例子：

  • (-0.5f, 1.5f) → (0.5f, 0.5f)
  • (0.5f, -1.5f) → (0.5f, 0.5f)
  • (1.2f, 1.7f) → (0.8f, 0.3f)

TextureAddressMode.MirrorOnce

你可以选择MirrorOnce模式：

device.SamplerStates[0].AddressU = TextureAddressMode.MirrorOnce;
device.SamplerStates[0].AddressV = TextureAddressMode.MirrorOnce;

这个模式将[-1,1] 区域内的纹理坐标镜像到[0,1]区间，而所有超出[-1,1]区间的坐标会截取到–1 (小于–1的值)或1 (大于1的值)，如图5-5右边所示。

下面是采样坐标的映射例子：

  • (-0.5f, 1.5f) → (0.5f, 1)
  • (0.5f, -1.5f) → (0.5f, -1)
  • (1.2f, 1.7f) →(1, 1)

TextureAddressMode.Border

你可以给纹理坐标超出[0,1]区间的像素设置一个指定的颜色，这个颜色叫做 BorderColor，可以使用下列代码存储在SamplerState中：

device.SamplerStates[0].BorderColor = Color.LightSeaGreen;
device.SamplerStates[0].AddressU = TextureAddressMode.Border;
device.SamplerStates[0].AddressV = TextureAddressMode.Border;

当调试时这个模式很有用，因为它清晰地显示了所有超出[0,1]区间的纹理坐标，所有超出此区间的像素都使用BorderColor绘制。

当心：在XBox360平台上只能使用白色作为边界颜色。

代码

首先需要定义顶点，因为需要保护3D位置和对应的纹理位置，你需要使用VertexPositionTexture elements。别忘了初始化对应的VertexDeclaration。

private void InitVertices()
{
    vertices = new VertexPositionTexture[3];

    int i = 0;
    vertices[i++] = new VertexPositionTexture(new Vector3(-3, -3, -1), new Vector2(-0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(3, -3, -1), new Vector2(1.5f, 1.5f));

    myVertexDeclaration= new VertexDeclaration(device,VertexPositionTexture.VertexElements);
}

有了存储在数组中的顶点和一个有效的VertexDeclaration，你可以设置一个纹理寻址模式并绘制三角形：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);

    //draw triangles
    device.RenderState.CullMode = CullMode.None;
    basicEffect.World = Matrix.Identity;
    basicEffect.View = fpsCam.ViewMatrix;
    basicEffect.Projection = fpsCam.ProjectionMatrix;
    basicEffect.Texture = myTexture;
    basicEffect.TextureEnabled = true;
    device.SamplerStates[0].AddressU=TextureAddressMode.Mirror;
    device.SamplerStates[0].AddressV = TextureAddressMode.Mirror;

    basicEffect.Begin();
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = myVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 1);
        pass.End();
    }
    basicEffect.End();

    base.Draw(gameTime);
}


5.3 使用索引移除冗余顶点

问题

你要绘制的三角形共享了很多顶点，如图5－7所示。

图5－7 可以从使用索引中受益的结构

如图5－7所示的八个三角形在使用TriangleList的情况下需要8*3 = 24个顶点，从这个图中可以看到实际上只有9个独立的顶点，所以其余15个顶点会浪费显卡的内存，带宽和处理能力。

解决方案

好的办法是将这9个独立顶点存储在数组中并将这个数组传递到显卡。然后创建一个包含24个数字的集合，作为这9个顶点的引用。图5－8左边是包含24个顶点的大集合，右边是包含9个顶点和24个索引的小集合，注意每个索引都对应一个顶点。

图5－8 由24个顶点绘制的8个三角形(左)或24个指向9个顶点的索引(右)

这样做可以带来很大的益处。因为索引只是数字，所以相对于包含一个Vector3，一个颜色，一个纹理坐标，可能还有其他信息的顶点来说，索引是很小的。这可以节省显存和带宽。而且显卡上的vertex shader只需处理9个顶点而不是24个，当处理由上千个三角形构成的复杂结构时这样做的好处是非常明显的。

每个索引指向9个顶点之一，所以三角形实际上是基于索引绘制的。使用TriangleList时，你需要24个索引用来绘制8个三角形。

工作原理

首先你需要定义独立顶点的数组。这个数组对应图5－7中的结构：

private void InitVertices()
{
    vertices = new VertexPositionColor[9];
    vertices[0]= new VertexPositionColor(new Vector3(0, 0, 0), Color.Red);
    vertices[1]= new VertexPositionColor(new Vector3(1, 0, 0), Color.Green);
    vertices[2]= new VertexPositionColor(new Vector3(2, 0, 1), Color.Blue);
    vertices[3]= new VertexPositionColor(new Vector3(0, 1, -1), Color.Orange);
    vertices[4]= new VertexPositionColor(new Vector3(1, 1, 0), Color.Olive);
    vertices[5]= new VertexPositionColor(new Vector3(2, 1, 0), Color.Magenta);
    vertices[6] = new VertexPositionColor(new Vector3(0, 2, 0), Color.Yellow);
    vertices[7] = new VertexPositionColor(new Vector3(1, 2, 1), Color.Tomato);
    vertices[8] = new VertexPositionColor(new Vector3(2, 2, -1), Color.Plum);
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements);
}

现在你需要定义使用哪个顶点创建三角形，每个三角形需要三个索引。

接下来，你将创建索引集合对应这些顶点。首先添加一个数组保存这些索引：

private int[] indices;

下面的方法将索引添加到新定义的数组中：

private void InitIndices()
{
    indices = new int[24];

    indices[0] = 0;
    indices[1] = 3;
    indices[2] = 1;

    indices[3] = 1;
    indices[4] = 3;
    indices[5] = 4;

    indices[6] = 1;
    indices[7] = 4;
    indices[8] = 5;

    indices[9] = 1;
    indices[10] = 5;
    indices[11] = 2;

    indices[12] = 3;
    indices[13] = 6;
    indices[14] = 7;

    indices[15] = 3;
    indices[16] = 7;
    indices[17] = 4;

    indices[18] = 4;
    indices[19] = 7;
    indices[20] = 5;

    indices[21] = 5;
    indices[22] = 7;
    indices[23] = 8;
}

我将代码按每三个索引一组分成代码块，每个块对应一个三角形，这个集合也对应图5－7中的灰色数字。例如，左上角的三角形由索引12至14定义，对应顶点3，6，7。

注意：三角形顶点是按顺时针方向定义的，要知道为什么可见教程5－6. 别忘了调用这个方法，例如在Initialize方法中： InitIndices();

定义了顶点和索引，你就可以将这些数据发送到显卡绘制三角形了：

basicEffect.World = Matrix.CreateScale(2.0f);
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.VertexColorEnabled 	= true;
basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserIndexedPrimitives<VertexPositionColor>(PrimitiveType.TriangleList, vertices, 0, 9, indices, 0, 8);
    pass.End();
}
basicEffect.End();

除了有两行代码，上面的代码段与教程5－1中的是相同的。第一行代码将世界矩阵缩放到2倍，表明所有顶点位置都乘以2。这样网格会从0扩展到4，关于世界矩阵更多的信息可见教程4－2。

第二第二行代码使用DrawUserIndexedPrimitives方法表示你使用索引数组绘制三角形。你需要声明顶点数组和需要绘制多少个顶点以及从哪个顶点开始绘制。接下来，需要指定包含索引的数组和从哪个索引开始绘制。最后一个参数指定绘制多少个图元，本例中图元是三角形。

注意：这个方法支持教程5－1中讨论过的所有图元类型。例如，可以使用索引绘制 TriangleStrip或LineList，但是，使用索引绘制点是毫无用处的。

何时使用索引？

使用索引不见得都能优化性能，所以在使用索引前，你应该首先不使用索引绘制三角形。

有些情况中使用索引反而会使性能降低。例如，有五个三角形并不共享一个顶点。不使用索引，你需要使用15个顶点，而使用索引，你仍要定义15个顶点，因为这些顶点是独立的！而且还要定义15个索引，每个索引对应一个顶点，这种情况下，传递到显卡的数据反而变多了！

作为一个规律，你可以将(独立顶点的数量) 除以(三角形的数量)。如果没有共享顶点，那么这个值是3，如果有共享，这个值会小于3。这个值越小，，使用索引提升的性能就越多。例如在图5－7中，这个值是9/8 = 1.125，这表示使用索引可以极大地提升性能。

代码

前面已经包含了所有的代码，这里我就不重复写了。


5.4 使用顶点缓冲和索引缓冲将顶点和索引保存在显存中

问题

每次当你调用device. DrawUserPrimitives方法时，顶点都会从系统内存传递到显卡中。通常，大部分数据没有变化，这意味着每帧重复传递了相同的数据。今天的显卡有容量很大而且很快的显存，所以你可以将顶点数据存储在显存中加速程序。从显存中将顶点数据传递到显卡速度要快得多，因为这些数据只在同一板卡的不同芯片间传输。同样索引数据也能获得加速。

解决方案

通过创建顶点数组的VertexBuffer，你可以将顶点数据复制到显存中。将顶点存储到显存后，就可以调用DrawPrimitives方法 (代替DrawUserPrimitives方法)，这个方法从更快的显存中获取顶点。

注意：如果顶点几乎无需更新，那么这个方法可以极大地提高性能。但当处理被称为动态顶点数据(dynamic vertex data)时，这意味着顶点数据更新频繁，你应使用DynamicVertexBuffer，这会在下一个教程介绍。

工作原理

你可以一次性地将数据传递到显卡并把它们存储在显存中，而不是在每次调用DrawUserPrimitives方法时将顶点数组传递到显卡。要实现这个方法，你可以在创建了顶点数组后将它加载到一个VertexBuffer（顶点缓冲）中。在项目中添加VertexBuffer变量：

VertexBuffer vertBuffer;

然后使用下面的方法生成六个顶点显示两个带纹理的三角形（可见教程5－1）。在这个方法最后，这六个顶点会加载到VertexBuffer并传递到显存中：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    VertexPositionTexture[] vertices = new VertexPositionTexture[6];
    int i = 0;

    vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, new Vector2(-0.5f,1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, new Vector2(1.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(-0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(1.5f, 1.5f));
    vertBuffer = new VertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly);
    vertBuffer.SetData(vertices, 0, vertices.Length);
}

当定义了所有顶点后，你需创建一个VertexBuffer。这个VertexBuffer将传递到显卡的保留内存中，由于这个原因，你需要指定对设备的链接和顶点占据的字节数量，这个数量等于 (一个顶点的字节大小)乘以(顶点数量)。

注意：你当然不想每帧都创建一个VertexBuffer，请确保只调用这行代码一次。如果你想覆盖VertexBuffer的数据，不要删除它并重建一个，而是应该使用SetData方法将新数据加载到当前已经存在的VertexBuffer中。只要当你需要增加顶点的数量时才有必要删除VertexBuffer 并重建一个。

创建了VertexBuffer后，你就可以使用SetData方法将顶点数据传递到内存中。显然，SetData方法需要从顶点数组中复制数据。它的一个重载方法可以只写入VertexBuffer的部分数据，在这种情况下，你需指定从哪个顶点开始以及复制多少个顶点。

注意：如果你要频繁地更新VertexBuffer的内容，你应该使用DynamicVertexBufferDynamicVertexBuffer的SetData方法更为强大。

在显卡中存储了顶点数据后就可以绘制三角形了。你可以使用教程5－1中的代码，但有两处变化。

你将使用DrawPrimitive方法表明你将从VertexBuffer中获取数据进行绘制。在这之前首先要激活VertexBuffer。

device.RenderState.CullMode = CullMode.None;
basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = myTexture;
basicEffect.TextureEnabled = true;
basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.Vertices[0].SetSource(vertBuffer, 0, VertexPositionTexture.SizeInBytes);
    device.DrawPrimitives(PrimitiveType.TriangleList, 0, 2); pass.End();
}
basicEffect.End();

在pass中，与以前一样需要将VertexDeclaration传递给设备表明顶点存储的数据类型、下一行代码设置VertexBuffer。对每个顶点来说，你可以在不同的VertexBuffers中存储不同的信息。本例中，你只使用了一个VertexBuffer，因此指定索引为0。使用SetSource方法，表明你将激活vertBuffer作为顶点的源。你还要指定第一个顶点的开始位置和一个顶点占据多少个字节(这样显卡才能将字节流裁切成独立的顶点)。激活VertexBuffer后，从第一个顶点开始使用TriangleList进行绘制，这个VertexBuffer 包含两个三角形的数据。

对性能的考虑：VertexBuffer构造函数

VertexBuffer的构造函数可以在最后一个参数中指定一些有用的可选择的标志，显卡的驱动会使用这个标志决定哪种内存是最快的。下面是可以使用的BufferUsages：

  • BufferUsage. None：允许从VertexBuffer中读写。
  • BufferUsage. Points：表示VertexBuffer中的顶点数据是用来绘制点和精灵的。这个与性能无关。
  • BufferUsage. WriteOnly：不从VertexBuffer读取数据。当使用VertexBuffer时，可以将顶点数据放在快得多的显存中。这样，当你想处理顶点数据时，比起将顶点数据在系统内存中存储一个副本，调用VertexBuffers的GetData方法往往更好。

你也可以组合这些标志，用 | 分隔表示OR操作(如果不矛盾，结果是指定选项的AND逻辑)。

IndexBuffer（索引缓冲）

如果你还想将索引存储在显卡中，你应在创建VertexBuffer之后再创建一个IndexBuffer ，所以在项目中添加一个IndexBuffer变量：

IndexBuffer indexBuffer;

作为一个简单的例子，你将定义两个三角形，因为它们共享一个顶点，所以只需定义5个独立顶点：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    VertexPositionTexture[] vertices = new VertexPositionTexture[5];
    int i = 0;

    vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, -3, -1), new Vector2(-0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(1.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(-0.5f, 1.5f));
    vertBuffer = new VertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly);
    vertBuffer.SetData(vertices, 0, vertices.Length);
}

接下来使用InitIndices方法创建索引数组并将它们复制到索引缓冲中：

private void InitIndices()
{
    int[] indices = new int[6];
    int i = 0;
    indices[i++] = 0;
    indices[i++] = 1;
    indices[i++] = 2;

    indices[i++] = 2;
    indices[i++] = 3;
    indices[i++] = 4;

    indexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly);
    indexBuffer.SetData<int>(indices);
}

创建IndexBuffer时，你需要指定索引的类型，类型可以是ints或shorts，还要指定索引的多少。

当心：一些低端显卡只支持16-bit的索引，如果你使用32-bit的整数型索引会出错。要解决这个问题你应将索引存储在一个short数组中，指定创建一个short类型的索引缓冲。

技巧：如果你的数组包含不超过32,768的索引，你应该使用shorts而不是ints。这样索引缓冲可以节省一半内存。

别忘了在项目开始调用这个方法。

当进行绘制时，你需要激活VertexBuffer和IndexBuffer，然后调用DrawIndexedPrimitives 方法绘制三角形：

device.RenderState.CullMode = CullMode.None;
basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = myTexture;
basicEffect.TextureEnabled = true;
basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.Vertices[0].SetSource(vertBuffer, 0, VertexPositionTexture.SizeInBytes);
    device.Indices = indexBuffer;
    device.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, 5, 0, 2);
    pass.End();
}
basicEffect.End();

代码

InitVertices，InitIndices和Draw方法的代码前面已经写过了。


5.5 将经常更新的顶点数据存储在DynamicVertexBuffer

问题

你需要频繁更新顶点数据，如果使用VertexBuffer的SetData方法会拖慢程序。

解决方案

如果你计划频繁更新顶点数据，应该使用DynamicVertexBuffer而不是VertexBuffer。

这会让数据不是存储在最快的显存中，而是更容易处理的某些内存中。所以，这样做会让性能有一点降低，但在VertexBuffer中频繁地改变数据会让性能得到极大地提高。

对于VertexBuffer，只要显卡被要求切换任务(例如，使用Alt+Tab切换程序)，DynamicVertexBuffer中的内容必须被重载，你可以通过订阅到DynamicVertexBuffer的ContentLost事件上实现这个功能。

工作原理

DynamicVertexBuffer的工作原理与VertexBuffer很像。首先需要一个顶点数组，如下所示：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionTexture.VertexElements);
    vertices = new VertexPositionTexture[6];
    int i = 0;

    vertices[i++] = new VertexPositionTexture(new Vector3(-5.0f, -3, -1), new Vector2(-0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(-2.5f, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(1.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(0, -3, -1), new Vector2(-0.5f, 1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(2.5f, 5, -1), new Vector2(0.5f, -1.5f));
    vertices[i++] = new VertexPositionTexture(new Vector3(5.0f, -3, -1), new Vector2(1.5f, 1.5f));

    dynVertBuffer = new DynamicVertexBuffer(device, VertexPositionTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly);
    dynVertBuffer.SetData(vertices, 0, vertices.Length, SetDataOptions.NoOverwrite);
    dynVertBuffer.ContentLost +=new EventHandler(dynVertBuffer_ContentLost);
}

创建DynamicVertexBuffer接受和创建VertexBuffer一样的参数，但SetData方法接受一个新的加强的参数，我会在下面讲到。最后一行代码是新的，如果显卡被要求处理另一个程序，设备会丢失，DynamicVertexBuffer的内容也会丢失。所以你要在DynamicVertexBuffer 的ContentLost事件上订阅一个方法。只要DynamicVertexBuffer丢失了内容就会引发ContentLost 事件，对应的方法就会被调用，这个方法会重载DynamicVertexBuffer中的内容。本例中，你将dynVertBuffer_ContentLost方法订阅到事件。

当然你还要定义这个方法，在这个方法中重载缓冲的内容：

private void dynVertBuffer_ContentLost(object sender, EventArgs e)
{
    dynVertBuffer.SetData(vertices, 0, vertices.Length, SetDataOptions.NoOverwrite);
}

注意：这需要本地处理顶点数据。因为只有当顶点数据变化地很频繁时才使用DynamixVertexBuffer，所以首先要保证数据没有问题，记住你永远不会从(Dynamic)VertexBuffer中读取数据。

当进行到绘制这步时，DynamicVertexBuffer的工作方式与VertexBuffer是相同的。

性能考虑：DynamicVertexBuffer.SetData方法

因为当你想频繁更新数据时才会使用DynamicVertexBuffer，所以你会经常使用SetData 方法。因此DynamicVertexBuffer的SetData方法还接受一个额外的参数SetDataOptions。

这个参数让你可以指定一些选项可以提高程序的速度。默认情况下，当你想覆盖显存中的内容时，因为不支持同时读写，显卡无法从显存中读取数据。当你把大量数据写至内存时会导致显卡的绘制过程中止，这是因为显卡要等待你的复制过程结束。但是，有两个方法可以让你确保显卡不会等待复制操作结束。你可以使用SetDataOptions 参数，下面是可选项：

  • SetDataOptions. None：这个选项可以完全控制覆盖VertexBuffer 哪一部分。但是，如前面解释的那样，这会导致性能降低。如果显卡绘制的数据是从VertexBuffer 之前内容提取的，那么显卡必须停止绘制直到较慢的复制过程结束。
  • SetDataOptions. Discard：使用这个选项表示你不再需要VertexBuffer的之前的内容。这时数据存储在显存的一个新的位置。在写入过程发生时，显卡还能继续使用老的数据。一旦写入过程完成，显卡可以使用新的数据绘制而抛弃老数据。简而言之，显卡无需等待，但你必须重写所有数据。(在Xbox平台上无法使用这个选项，你只能调用DrawUserIndexedPrimitive绘制频繁更新的顶点。)
  • SetDataOptions . NoOverwrite：这个选项很强大但比较危险。你必须保证正在覆盖的VertexBuffer部分没有被渲染过程使用。这样，你可以覆盖VertexBuffer 的特定部位，此时显卡无需等待复制过程结束，因为你正在覆盖的部分不参与绘制过程。这比使用Discard选项快，因为你无需保留在内存中保留一块新的部分。


5.6 开启Backface Culling（背面剔除）：它是什么和它能做什么

问题

在前面的教程中，你经常可以在Draw方法中看到以下代码：

device.RenderState.CullMode = CullMode.None;

如果你移除这行代码并将相机移动到三角形的后面，它们会消失！当你在没有理解backface culling时定义三角形顶点，会有50%可能三角形无法显示。

解决方案

你定义三角形顶点的方式告知XNA你想让哪一面朝向相机。

当绘制一个实心对象时，人们可以清楚地说出三角形的哪个面在物体的内部还是外部。要绘制必须的三角形，你可以要求XNA只绘制朝向相机的那个面，其他三角形在物体的内部被前面隐藏！

但是对计算机来说事情不是那么简单。对你定义的每个三角形，你需要说明三角形是在外面还是里面，这可以通过沿逆时针方向或顺时针方向定义三角形的顶点做到这点。

工作原理

当定义顶点时，你需要考虑相机的位置。要绘制三角形，顶点的顺序必须以相机看来按顺时针方向递增。

你可以想象一条从相机出发指向三角形中心的射线，如图5-9所示。如果顶点的顺序(从相机看过来!)是绕着这个中心点顺时针旋转的（左图），XNA就认为这个三角形是面向相机的，这样，三角形才会被绘制。

图5-9 位于三角形前方(左图)和后方(右图)的相机

如果你将相机放在同一个三角形的另一侧（如图5-9右图所示），顶点的顺序(同样是从相机看过来)是逆时针方向，XNA会认为这个三角形没有面向相机而不绘制它。

图5-10显示了相同的情况，但这次是从相机的角度观察，显然在右图中相机看到的是逆时针顺序。

图5-10 顺时针定义的三角形会被绘制(左图);而逆时针定义的会被剔除。

为什么需要背面剔除?

只有一个三角形的例子可以说明原理但无法说明使用背面剔除的好处。本教程的前面曾经提到，在绘制实心对象时背面剔除很有用，例如一个模型或地形。

让我们讨论一个立方体的例子。当你旋转一个立方体时，在任何时刻你只能看到三个面。这意味着绘制另外三个面对显卡的处理能力来说是一种浪费，没有朝向相机的三角形没必要绘制。

想象一下拿着一个立方体并只能看到一个面如图5-11的左边所示。然后看一下右图，前表面和后表面的各一个三角形加粗显示。

数字代表顶点的顺序。由12个三角形组成的六个面，需要定义36个顶点。图5-11中的数字表示哪个顶点定义了两个三角形。

注意：如果你使用索引，立方体只需要8个独立顶点，但仍需要36个索引才能绘制从8个顶点绘制12个三角形。那么图5-11中的数字代表哪个索引定义两个三角形。

图5-11 立方体：前表面和后表面的两个三角形

图5-12的左图显示了一根从相机出发的射线并与两个三角形相交，圆点表示射线和前面的三角形的交点。当你沿着前面的三角形的顶点增加的顺序(从0到1到2)前进时，会发现绕着圆点做顺时针旋转，这时XNA会绘制这个三角形。

现在看一下射线与后面的三角形的交点，当你沿着顶点增加的顺序(从6到7到8)前进时，会绕逆时针旋转，这时XNA会将这个三角形剔除，这样处理很好，因为这个三角形位于立方体的后方，被前表面隐藏了。

图5-12的右图显示了同样的立方体，但旋转了180度，这样前表面和后表面就互换了位置，相机仍位于页面的同一侧。现在如果顺着顶点6到7到8，将绕视线做顺时针旋转，这次，显卡会绘制这个三角形并剔除另一个！这正是我们想要的结果。

通过这种方式，显卡知道应该剔除哪些三角形，剔除那些不朝向相机的三角形可以极大地提高程序的性能，这也是默认激活的。

图5-12 从眼睛发出的射线与两个三角形的交点

技巧：教程5-7有一个圆柱体的例子，确保剔除是开启的，试着将相机移动到圆柱体上方，看一下结果（译者注：看不到圆柱体的上表面）！

如何关闭剔除

虽然当绘制由实心表面组成的对象时使用剔除可以带来极大地好处，但有时你也需要将提出关闭。例如，你想创建一个只由两个三角形组成的长方形墙，从外部看效果不错，但当你进入到建筑物内部，这面墙会被剔除，导致你可以看穿这堵墙！

要解决这个问题，你可以在墙的反面定义两个额外的三角形，这样无论从那一面观察这堵墙，总有两个三角形被绘制，另两个被剔除。更简单的方法是使用下列代码将剔除关闭：

device.RenderState.CullMode = CullMode.None;

别忘了在绘制墙后再将剔除打开。

技巧：当设计特别是调试程序时，你可以将剔除关闭，这样做可以排除物体没有被绘制的一个可能性。

代码

示例代码定义了一个沿顺时针和逆时针方向的立方体，当运行逆时针版本时，你将看到XNA只绘制了那些无需被绘制的三角形。

当运行顺时针版本时，试着将相机移动到立方体内部。


5.7 自动计算顶点缓冲中所有顶点的法线

问题

当绘制自定义的结构时，你会发现光照不正确。

这是因为你没有指定正确的法线向量，显卡要求每个顶点都有法线信息，这样它才可以决定每个三角形获得多少光照，详细信息可见第六章。

为每个顶点计算法线向量看起来很复杂，因为大多数顶点被多个三角形共享。

解决方案

如果每个顶点只被一个三角形使用，你只需找到三角形的法线向量(换句话说，这个向量垂直于三角形)并将这个向量作为三个顶点的法线向量。

但是在一个结构中，所有顶点被几个三角形共享。要获取平滑的效果，每个顶点需要存储周围三角形所有法线的平均值。

工作原理

使用下列伪代码，你可以找到每个顶点正确的法线：

  1. 对于结构中的每个顶点，找到使用顶点的三角形。
  2. 计算这些三角形的法线向量。
  3. 求所有法线向量的平均值。
  4. 将这个平均法线存储到顶点中。

求平均值的过程是必须的，因为你总是要归一化存储在顶点中的法线向量(换句话说，让长度变为1)。

注意：因为在vertex和pixel shader中要使用法线向量计算光线因子，所以要让法线向量长度变为1。当光线因子只由入射光和法线之间的夹角决定时，一个较大的法线向量会导致光线因子变大，具体解释可见第六章。

你可以将上述步骤变为具体代码，但如果你交换步骤1和2，会变得更容易：

  1. 对于结构中的每个三角形，计算法线向量。
  2. 将这个向量添加到三角形的三个顶点的法线中。对所有三角形进行这个操作后，执行以下操作：
  3. 归一化结构中的每个顶点的法线向量。

计算三角形的法线：叉乘的定义

在计算前，你需要知道什么是法线。简单的说，法线是垂直于三角形的方向，这意味着三角形上的任一点法线都是相同的。因为法线垂直于三角形平面，所以它也垂直于三角形的任何一个顶点。

那么如何计算一个三角形的法线向量呢？你可以使用叉乘，因为两个向量的叉乘返回垂直于两个向量决定的平面的向量。

你可以取三角形的两条边，通过叉乘获取垂直这个三角形的向量，如图5-13所示。这个法线的长度基于两条边的长度和夹角，所以需要将法线向量归一化。

图5-13 获取三角形的法线向量

注意：Vector3. Cross (Vec1, Vec2)和Vector3. Cross (Vec2,Vec1)计算结果是不同的。这两个结果长度相同但方向相反。这是因为一个平面有两个垂直方向：一个指向纸外，一个指向纸内。

GenerateNormalsForTriangleList方法

当定义一个大对象时，你通常希望使用索引定义结构，因为这是顶点被多个三角形共享的唯一方式，这让你可以平滑光照(见教程6-2)。所以这个教程基于结构中的顶点数组和索引数组进行计算：

Private VertexPositionNormalTexture[] GenerateNormalsForTriangleList(VertexPositionNormalTexture[] vertices, int[] indices)
{
}

这个方法接受一个不包含法线数据顶点数组，然后将正确的法线信息存储在每个顶点中并返回这个数组。根据索引信息，这个方法可以判断哪些顶点构成了三角形。但是，根据使用的是TriangleList还是TriangleStrip，索引数组的内容会不同，所以针对两者情况代码会有一些区别。

基于TriangleList计算法线

如果顶点已经包含法线数据，首先要将它们变为0：

for (int i = 0; i < vertices.Length; i++)
    vertices[i].Normal = new Vector3(0, 0, 0);

然后，如前面的伪代码所示，你要遍历所有三角形并计算它们的法线。在TriangleList中，每个三角形由三个连续的索引定义。这意味着三角形的数量为Length/3。下面是遍历定义在索引数组中的每个三角形的循环代码：

for (int i = 0; i < indices.Length/3; i++)
{
    Vector3 firstVec = vertices[indices[i*3 + 1]].Position - vertices[indices[i*3]].Position;
    Vector3 secondVec = vertices[indices[i*3 + 2]].Position- vertices[indices[i*3]].Position;
    Vector3 normal = Vector3.Cross(secondVec, firstVec);
    normal.Normalize();

    vertices[indices[i*3]].Normal += normal;
    vertices[indices[i*3 + 1]].Normal += normal;
    vertices[indices[i*3 + 2]].Normal += normal;
}

对每个三角形，你定义了两个如图5-13所示的向量。点P0和P1之间的向量可以通过将P1减P0获得，这是第一行代码。第二行代码计算从P0指向P2的向量。

然后，通过将两者叉乘获取垂直于这两个向量的向量，别忘了归一化结果让它的长度变为1。

注意：根据你定义索引的方式，你需要使用Vector3. Cross (secondVec, firstVec); 代替。前面已经提到过，这会获取一个相反方向的矢量。如果沿顺时针方向定义顶点(见教程5-6)， 代码会工作正常。

知道了三角形的法线，只需简单地将它添加到每个顶点中。当for循环完成后，每个顶点就存储了三角形的所有法线的和。然后要通过归一化将这些大向量的长度变为1。

for (int i = 0; i < vertices.Length; i++)
    vertices[i].Normal.Normalize();
return vertices;

把所有法线存储到顶点数组后，将这个数组返回到调用代码。

基于TriangleStrip计算法线

对TriangleStrip来说情况有点不同，因为创建索引数组中的每个索引都是基于它和前两个索引创建一个三角形的：

for (int i = 2; i < indices.Length; i++)
{
    Vector3 firstVec = vertices[indices[i - 1]].Position-vertices[indices[i]].Position;
    Vector3 secondVec = vertices[indices[i - 2]].Position-vertices[indices[i]].Position;
    Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize();
}

从第三个索引开始，你遇到的每个索引基于索引i, i-1和i-2创建了一个三角形。前面的代码遍历了由索引数组定义的所有三角形并创建了两个对应三角形两边的向量。

但是，你以TriangleStrip方式定义索引时，每个三角形后会自动反转旋转顺序(见教程5-1的注意事项)。结果是，firstVec和secondVec会改变位置，在Cross方法中改变firstVec和secondVec的位置会达到同样效果，每个三角形后都会反转法线的方向。

你无法改变这个反转，但可以解决这个问题。只需建立一个Boolean变量，每个三角形后就反转这个值。如果这个值为true，你就改变法线的方向：

bool swappedWinding = false;

for (int i = 2; i < indices.Length; i++)
{
    Vector3 firstVec = vertices[indices[i - 1]].Position - vertices[indices[i]].Position;
    Vector3 secondVec = vertices[indices[i - 2]].Position -vertices[indices[i]].Position;
    Vector3 normal = Vector3.Cross(firstVec, secondVec); normal.Normalize();

    if (swappedWinding)
        normal *= -1;

    vertices[indices[i]].Normal += normal;
    vertices[indices[i - 1]].Normal += normal;
    vertices[indices[i - 2]].Normal += normal;

    swappedWinding = !swappedWinding;
}

代码的其他部分与前面类似，别忘了代码最前面的将初始法线复位到0和最后的归一化法线的代码。

使方法Fail-Safe

如果firstVec 和secondVec向量方向相同，Vector3 . Cross方法会发生错误。这种情况下三角形会变成一条线，被称之为ghost三角形(见教程5-8的例子)。

这种情况下，Vector3 . Cross会返回包含的三个NaN值的Vector3。如果发生这种情况，那么就不要将这个向量添加到顶点，否则会报错：

if (!float.IsNaN(normal.X))
{
    vertices[indices[i]].Normal += normal;
    vertices[indices[i - 1]].Normal += normal;
    vertices[indices[i - 2]].Normal += normal;
}

从顶点缓冲和索引缓冲开始

前面的代码从包含顶点和索引的两个数组开始。如果你已经将它们存储在显存的缓冲中(见教程5-4)并保存了一个本地复制，你需要将这些数据取回来。下面是方法：

int numberOfVertices = myVertexBuffer.SizeInBytes / VertexPositionNormalTexture.SizeInBytes;
VertexPositionNormalTexture[] vertices = new VertexPositionNormalTexture[numberOfVertices];
myVertexBuffer.GetData(vertices);

int numberOfIndices = myIndexBuffer.SizeInBytes / 4;
int[] indices = new int[numberOfIndices];
myIndexBuffer.GetData(indices);

你通过查看顶点缓冲占据多少个字节获取缓冲中的顶点数量，因为你知道一个顶点占据多数字节，所以可以知道在顶点缓冲中有多少个顶点。

同样的方法可以找到索引缓冲中的索引数量，因为你知道一个索引时4个字节。

注意：如教程5-4中的解释，并不推荐在顶点缓冲或索引缓冲上使用GetData。而且如果你使用的是BufferUsage. WriteOnly标志创建缓冲，编译器会对GetData方法报错。

代码

下面的代码将法线数据存储到一个顶点数组中，而且索引是以TriangleStrip方式绘制三角形的：

private VertexPositionNormalTexture[] GenerateNormalsForTriangleStrip(VertexPositionNormalTexture[] vertices, int[] indices)
{
    for (int i = 0; i < vertices.Length; i++)
        vertices[i].Normal = new Vector3(0, 0, 0);

    bool swappedWinding = false;

    for (int i = 2; i < indices.Length; i++)
    {
        Vector3 firstVec = vertices[indices[i - 1]].Position- vertices[indices[i]].Position;
        Vector3 secondVec = vertices[indices[i - 2]].Position - vertices[indices[i]].Position;
        Vector3 normal = Vector3.Cross(firstVec, secondVec);
        normal.Normalize();

        if (swappedWinding)
            normal *= -1;

        if (!float.IsNaN(normal.X))
        {
            vertices[indices[i]].Normal += normal;
            vertices[indices[i - 1]].Normal += normal;
            vertices[indices[i - 2]].Normal += normal;
        }

        swappedWinding = !swappedWinding;
    }

    for (int i = 0; i < vertices.Length; i++)
        vertices[i].Normal.Normalize();

    return vertices;
}


5.8 基于一个顶点缓冲和一个索引缓冲创建一个地形

问题

基于一张2D高度图，你想创建一个地形并以一个有效率的方法绘制它。

解决方案

首先需要一张高度图，包含所有用来定义地形的高度数据。这张高度图有确定数量的二维数据点，我们称之为地形的宽和高。

显然，如果你想基于这些数据创建一个地形，这个地形将从这些width*height顶点中绘制，如图5-14右上角所示(注意数字是从0开始的)。

图5-14 地形网格

要使用三角形完全覆盖网格，你需要在每个网格的四个点中绘制两个三角形，如图5-14所示。一行需要(width-1)*2个三角形，整个地形需要(height-1)*(width-1)*2个三角形。

如果你想判断是否需要使用索引，可参见教程5-3的规则。本教程的情况中，(三角形数量) 除以(顶点数)小于1，所以应该使用索引。所有不在边界上的顶点会被不少于六个的三角形共享。

此外，因为所有三角形至少共享一条边，你应该使用TriangleStrip而不是TriangleList。

工作原理

定义顶点

首先定义顶点。下面的方法首先访问heightData变量，这个变量是一个包含地形所有顶点高度的2维数组。如果你还没有这样一个数组，本教程最后的LoadHeightData方法会基于一张2D图像创建一个。

private VertexPositionNormalTexture[] CreateTerrainVertices()
{
    int width = heightData.GetLength(0);
    int height = heightData.GetLength(1);
    VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height];

    int i = 0;
    for (int z = 0; z < height; z++)
    {
        for (int x = 0; x < width; x++)
        {
            Vector3 position = new Vector3(x, heightData[x, z], -z);
            Vector3 normal = new Vector3(0, 0, 1);
            Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f);
            terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord);
        }
    }

    return terrainVertices;
}

首先基于heightData数组的大小获取地形的高和宽。然后创建一个数组保存所有顶点。如前所述，地形需要width*height个顶点。

接着在两个循环中国创建所有顶点。里面的一个循环创建一行上的顶点，当一行完成后，第一个for循环切换到下一行，直到定义完所有行的顶点。

你使用X和Z坐标作为循环的计数器，z值是负的，因此地形是建立在向前(-Z)方向的。而高度信息取自heightData数组。

现在你给与所有顶点一个默认的法线方向，这个方向马上就会使用前一个教程的方法替换成正确的方向。因为你可能要在地形上加上纹理，所以需要指定正确的纹理坐标。根据纹理，你想控制它在地形上的大小。这个例子中将除以30，表示纹理每经过30个顶点重复一次。如果你想增大纹理匹配更大的地形，可以除以一个更大的数值。

有了这些数据，就做好了创建这些新顶点并存储到数组中的准备。

定义索引

定义了顶点后，你就做好了通过定义索引数组构建三角形的准备(见教程5-3)。你将以TriangleStrip定义三角形，基于一个索引及它前两个索引表示一个三角形。

图5-16显示了如何使用TriangleStrip绘制三角形。数组中的第一个索引指向顶点0和W。然后对行中的每个顶点，添加这个顶点和下一行对应的顶点，直到到达行一行的最后。。这时，你要定义2*width个索引，对应(2*width-2)个三角形，足够覆盖整个行。

但是你只定义了第一行，你没法使用这个方法绘制第二行，这是因为你是基于前三个索引定义的三角形添加的每个索引的。基于这点，你定义的最后一个索引指向顶点(2*W-1)。如果你再次从第二行开始，会从添加一个到顶点W的索引开始，如图5-15左图所示。但是，这回定义一个基于顶点W, (2*W-1)和(W-1)的三角形！这个三角形会横跨第一行的整个长度，这不是你想要的结果。

图5-15 使用TriangleStrip定义三角形的错误方式

你可以通过从右边开始定义第二行解决这个问题。但是，简单地从最后一个索引开始不是一个好主意，因为两行的三角形的长边有不同的方向，如教程5-9中的解释，你想让三角形有相同的朝向。

图5-16显示了如何解决这个问题。在指向顶点(2*W-1)的索引后，你将立即添加一个指向相同顶点的索引！这会添加一个基于顶点(W-1)和两个顶点(2*W-1)的三角形，只会形成一条位于顶点(W-1)和(2*W-1)之间的一条线，所以这个三角形不可见，叫做ghost三角形。接下来，添加一个指向顶点(3*W-1)的索引，因为这个三角形基于两个指向相同顶点(2*W-1)的索引，所以实际上是一条线。如果你从右边开始定义第二行，正常情况下你会从两个顶点开始，记住实际上你绘制了两个看不见的三角形。

图5-16 使用TriangleStrip定义三角形的正确方式

注意：你可能认为无需添加第二个指向(2*W-1)的索引，可以立即将一个索引添加到(3*W-1)中。但是，基于两个理由需要额外的指向顶点(2*W-1)的索引。首先，如果你没有添加这个索引，那么只有一个三角形被添加，你会被TriangleStrip方式所需的绕行方向的反转所干扰。第二，这会添加一个基于(3*W-1), (2*W-1)和 (W-1)的三角形，如果三个顶点高度不相同那么这个三角形会被显示。

下面是生成索引的方法：

private int[] CreateTerrainIndices()
{
    int width = heightData.GetLength(0);
    int height = heightData.GetLength(1);

    int[] terrainIndices = new int[(width)*2*(height-1)];
    int i = 0;
    int z = 0;

    while (z < height-1)
    {
        for (int x = 0; x < width; x++)
        {
            terrainIndices[i++] = x + z * width;
            terrainIndices[i++] = x + (z + 1) * width;
        }
        z++;

        if (z < height-1)
        {
            for (int x = width - 1; x >= 0; x--)
            {
                terrainIndices[i++] = x + (z + 1) * width;
                terrainIndices[i++] = x + z * width;
            }
        }
        z++;
    }

    return terrainIndices;
}

首先创建一个数组，存储地形所需的所有索引。如教程5-16所示，每行需要定义width*2个三角形。在本例中，你有三行顶点，但只绘制两行三角形，所以需要width*2*(height-1) 索引。

前面代码中的z值表示当前行。你从左向右创建第一行，然后，增加z，表示切换到下一行。第二行从右向左创建，如图5-16所示，z值仍然增加。这个程序放在while循环中，直到所有偶数行从左向右建立，奇数行从右向左建立。

当z变为height-1时while循环结束，返回结果数组。

法线，顶点缓冲和索引缓冲

你要创建法线数据，通过创建一个顶点缓冲和一个索引缓冲将这些数据发送到显卡，然后绘制三角形。

在LoadContents方法中添加以下代码：

myVertexDeclaration=new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices();

int[] terrainIndices = CreateTerrainIndices();
terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices);
CreateBuffers(terrainVertices, terrainIndices);

第一行代码用来告知显卡每个顶点包含位置，法线和纹理坐标的数据。我已近讨论过下面两个方法：它们生成所有顶点和索引。GenerateNormalsForTriangleStrip方法在教程5-7，它将法线数据添加到顶点中使地形光照正确。最后的方法将数据发送到显卡：

private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[] indices)
{
    terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length,BufferUsage.WriteOnly);
    terrainVertexBuffer.SetData(vertices);

    terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly);
    terrainIndexBuffer.SetData(indices);
}

你可以在教程5-3中找到所有方法和使用参数的解释。

把数据发送到显卡后，现在可以绘制地形了。代码的第一部分设置BasicEffect (包含光照，见教程6-1)的变量，所以在Draw方法中添加以下代码：

int width = heightData.GetLength(0);
int height = heightData.GetLength(1);

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = grassTexture;
basicEffect.TextureEnabled = true;

basicEffect.EnableDefaultLighting();
basicEffect.DirectionalLight0.Direction =new Vector3(1, -1, 1);
basicEffect.DirectionalLight0.Enabled = true;
basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f);
basicEffect.DirectionalLight1.Enabled = false;
basicEffect.DirectionalLight2.Enabled = false;
basicEffect.SpecularColor = new Vector3(0, 0, 0);

要将一个3D场景绘制到2D屏幕上，需要设置World，View和Projection矩阵(见教程2-1和4-2)。然后指定纹理。第二个代码块设置一个定向光（如教程6-1所示）。关闭镜面高光(见教程6-4)，这是因为草地地形没有闪亮的材质。

设置了effect后就可以绘制三角形了。这个代码从一个索引TriangleStrip绘制三角形，解释请见教程5-3：

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.Vertices[0].SetSource(terrainVertexBuffer, 0,VertexPositionNormalTexture.SizeInBytes);
    device.Indices = terrainIndexBuffer;
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height, 0, width * 2 * (height - 1) - 2);
    pass.End();
}
basicEffect.End();

首先将VertexBuffer和IndexBuffer作为显卡的当前缓冲。VertexDeclaration表明GPU需要何种数据，在数据流中的哪儿获取必要信息。DrawIndexedPrimitives绘制TriangleStrip，这需要处理所有width*height个顶点，绘制总共width*2*(height-1)-2个三角形。要获取最后一个值，需要查询索引数组中的索引总数。因为你是从一个TriangleStrip进行绘制的，所以顶点的总数为这个值减2。

代码

LoadContent方法中的最后四行代码生成所有索引和对应的顶点。法线数据被添加到顶点，最终的数据存储在顶点缓冲和索引缓冲中。注意LoadHeightMap方法会在后面讨论：

protected override void LoadContent()
{
    device = graphics.GraphicsDevice;

    basicEffect = new BasicEffect(device, null);
    cCross = new CoordCross(device);

    Texture2D heightMap = Content.Load<Texture2D>("heightmap");
    heightData = LoadHeightData(heightMap);

    grassTexture = Content.Load<Texture2D>("grass");
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
    VertexPositionNormalTexture[] terrainVertices = CreateTerrainVertices();

    int[] terrainIndices = CreateTerrainIndices();
    terrainVertices = GenerateNormalsForTriangleStrip(terrainVertices, terrainIndices);
    CreateBuffers(terrainVertices, terrainIndices);
}

在下列方法中创建顶点：

private VertexPositionNormalTexture[] CreateTerrainVertices()
{
    int width = heightData.GetLength(0);
    int height = heightData.GetLength(1);

    VertexPositionNormalTexture[] terrainVertices = new VertexPositionNormalTexture[width * height];

    int i = 0;
    for (int z = 0; z < height; z++)
    {
        for (int x = 0; x < width; x++)
        {
            Vector3 position = new Vector3(x, heightData[x, z], -z);
            Vector3 normal = new Vector3(0, 0, 1);
            Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f);
            terrainVertices[i++] = new VertexPositionNormalTexture(position, normal, texCoord);
        }
    }

    return terrainVertices;
}

在下列方法中创建索引：

private int[] CreateTerrainIndices()
{
    int width = heightData.GetLength(0);
    int height = heightData.GetLength(1);

    int[] terrainIndices = new int[(width) * 2 * (height - 1)];

    int i = 0;
    int z = 0;
    while (z < height - 1)
    {
        for (int x = 0; x < width; x++)
        {
            terrainIndices[i++] = x + z * width;
            terrainIndices[i++] = x + (z + 1) * width;
        }

        if (z < height - 1)
        {
            for (int x = width - 1; x >= 0; x--)
            {
                terrainIndices[i++] = x + (z + 1) * width;
                terrainIndices[i++] = x + z * width;
            }
        }
        z++;
    }
    return terrainIndices;
}

GenerateNormalsForTriangleStrip方法将法线数据添加到顶点中，而CreateBuffers方法 将数据储存到显卡中：

private void CreateBuffers(VertexPositionNormalTexture[] vertices, int[]indices)
{
    terrainVertexBuffer = new VertexBuffer(device, VertexPositionNormalTexture.SizeInBytes * vertices.Length, BufferUsage.WriteOnly);
    terrainVertexBuffer.SetData(vertices);

    terrainIndexBuffer = new IndexBuffer(device, typeof(int), indices.Length, BufferUsage.WriteOnly);
    terrainIndexBuffer.SetData(indices);
}

最后，在Draw方法中地形以TriangleStrip方式绘制：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.CornflowerBlue, 1, 0);
    cCross.Draw(fpsCam.ViewMatrix, fpsCam.ProjectionMatrix);

    //draw terrain
    int width = heightData.GetLength(0);
    int height = heightData.GetLength(1);

    basicEffect.World = Matrix.Identity;
    basicEffect.View = fpsCam.ViewMatrix;
    basicEffect.Projection = fpsCam.ProjectionMatrix;
    basicEffect.Texture = grassTexture;
    basicEffect.TextureEnabled = true;

    basicEffect.EnableDefaultLighting();
    basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 1);
    basicEffect.DirectionalLight0.Enabled = true;
    basicEffect.AmbientLightColor = new Vector3(0.3f, 0.3f, 0.3f);
    basicEffect.DirectionalLight1.Enabled = false;
    basicEffect.DirectionalLight2.Enabled = false;
    basicEffect.SpecularColor = new Vector3(0, 0, 0);

    basicEffect.Begin();
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.Vertices[0].SetSource(terrainVertexBuffer,0, VertexPositionNormalTexture.SizeInBytes);
        device.Indices = terrainIndexBuffer;
        device.VertexDeclaration = myVertexDeclaration;
        device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, width * height,0, width * 2 * (height - 1) - 2);
        pass.End();
    }
    basicEffect.End();

    base.Draw(gameTime);
}

从一张图像读取heightData数组

大多数情况中，你并不想手动地指定heightData数组，而是从一张图像加载。这个方法加载一张图像，将每个像素的颜色映射为高度值：

private void LoadHeightData(Texture2D heightMap)
{
    float minimumHeight = 255;
    float maximumHeight = 0;

    int width = heightMap.Width;
    int height = heightMap.Height;
    Color[] heightMapColors = new Color[width * height];
    heightMap.GetData<Color>(heightMapColors);
    heightData = new float[width, height];

    for (int x = 0; x < width; x++)
        for (int y = 0; y < height; y++)
        {
            heightData[x, y] = heightMapColors[x + y * width].R;
            if (heightData[x, y] < minimumHeight)
                minimumHeight = heightData[x, y];
            if (heightData[x, y] > maximumHeight)
                maximumHeight = heightData[x, y];
        }

   for (int x = 0; x < width; x++)
       for (int y = 0; y < height; y++)
           heightData[x, y] = (heightData[x, y] - minimumHeight) / (maximumHeight - minimumHeight) * 30.0f;
}

第一部分将每个像素的红色通道的强度存储在heightData数组中。后面的代码重新缩放每个值，这样可以让数组中的值介于0到30的范围中。


5.9 使用双线性插值计算地形的精确高度

问题

当你制作一个使用地形的游戏时，你需要知道地形确定点的精确高度。例如，在地形上移动一个模型时(见教程4-17)，当检测到光标和地形之间的碰撞时（下一个教程），或防止相机与地形碰撞时（见教程2-6）。

因为在前一个教程中你定义了地形每个顶点的3D位置，所以获取这些点的高度很简单。对位于这些顶点间的位置而言，你需要使用一种插值方法获取这个位置的精确高度。

解决方案

如果你想知道高度的点与地形的一个顶点发生碰撞，那么你已经知道了地形上该点的精确高度。如果这个点并没有与顶点发生碰撞，那么说明这个点在地形的一个三角形上。因为三角形是一个平面，所以你可以通过在三角形三个顶点间进行插值获取任何点的精确高度。

工作原理

首先从X和Z坐标开始，你想知道该点的对应Y坐标，这可以通过对三角形三个顶点的高度进行插值获取。

这意味着你首先要找到点究竟在哪个三角形中，这并不容易。但首先我想介绍插值。

线性插值

如果你只处理分离的数据、想知道分离点之间的某些值，需要用到某种类型的插值。这种情况如图5-17坐标所示。对某些分离的(整数) X值，你知道Y值。当X=2，你知道Y=10，X=3时Y=30。但你不知道X=2.7时的Y值。

图5-17 线性插值：简单常规的例子

使用线性插值，你通过连接两点的线段找到X=2.7对应的Y值，如图5-17所示。使用线性插值，通过连接两点的线段找到X=2.7对应的Y值。线性插值总是将X表达成0和1之间，0对应X的最小值（你知道对应的Y值，本例中为2），1对应X的最大值（本例中为3） 。本例中你想找到X=2.7时的Y值，结果是0.7，意思是“2和3至之间的70%。”

在图5-17的左图中，0%对应Y值10，100%对应Y值20，所以70%对应Y=17。这很容易看出，但右图的情况如何？14对应0.33，因为它是13和16之间的33%。但35和46之间的33%是多少？显然，你希望有代码可以为你计算结果。

首先要有代码找到0和1对应的值。从X值开始，首先减去最小的X值，这样最小值变为0。然后，将最大值缩放为1，你可以通过将它除以最大值和最小值之差实现。

下面是图5-17左图的做法： 2.7→(2.7－min)/(max－min)=(2.7－2)/(3－2)=0.7

然后，进行逆运算获取对应的Y值：首先缩放这个值(通过乘以最大值和最小值的差值)，并加到最小的Y值上：

0.7* (maxY－min Y)＋minY=0.7*(20－10)＋10=0.7*10＋10=17

这里你采取图5-17左图简单例子的规则，但你可以使用这个方法计算任何线性插值。看一下图5-17右图更难的例子，在这种情况中，你知道X=13对应Y=35，X=16对应Y=46，但你想知道X=14对应的Y值。所以，首先获取0和1之间对应的值：

14→(14－minX)/(maxX－minX) =(14－13)/(16－13)=0.33

知道了对应值，就做好了获取对应Y值的准备：

0.33* (maxY－minY)＋minY=0.33*(46－35)＋35=0.33*11＋35=3.67＋35=38.67

最后，需要进行浮点计算。图5-17的右图中找到X=14对应Y=38.67。事实上，几乎所有插值计算都返回一个浮点数。

技巧：XNA提供了一个功能可以为你计算Vector2, Vector3或Vector4的插值。例如，如果你想获取哪个Vector2位于(5,8)和(2,9)之间的70%，你可以使用Vector2. Lerp(new Vector2(5,8), new Vector2(2,9), 0.7f)。

双线性插值

在地形的例子中，对所有(X,Z)值，你已经定义了一个顶点并知道了它的精确高度。对所有在这些独立顶点之间的(X,Z)值，你不知道精确的Y值，所以需要进行插值。这次你需要获取0和1之间的值，包含X和Z。

有了这些值，就可以分两步计算出精确的Y值。

获取对应值

给定任意(X,Z)坐标，你需要找到地形上的精确Y高度。首先使用前面的公式找到对应的X和Z值，但这次因为在3为空间中，你需要用两次：

int xLower = (int)xCoord;
int xHigher = xLower + 1;
float xRelative = (xCoord - xLower) / ((float)xHigher - (float)xLower);
int zLower = (int)zCoord;
int zHigher = zLower + 1;
float zRelative = (zCoord - zLower) / ((float)zHigher- (float)zLower);

在地形中每个X和Z的整数值你定义了一个顶点，所以你知道精确的Y值。所以对每个X的浮点数，你要将它们转换为整数获取最小的X值(例如，2.7变为2)。将这个值加1获取最大X值(2.7对应3作为最大值)。知道了边界，很容易使用前面的公式找到对应值。Z值的求法类似。

获取minY和maxY的值

知道了0和1之间的对应值，下一步是找到精确Y值。但是，首先需要知道minY和maxY值。这些值表示顶点中的高度。你需要知道点在哪个三角形中才能知道使用哪个顶点的高度作为Y值。

你知道点P的X和Z坐标，所以你知道点周围的四个顶点，很容易获取它们的Y值：

float heightLxLz = heightData[xLower, zLower];
float heightLxHz = heightData[xLower, zHigher];
float heightHxLz = heightData[xHigher, zLower];
float heightHxHz = heightData[xHigher, zHigher];

LxHz表示“低X坐标，高Z坐标” 决定(X,Z)。

点在哪个三角形中用来绘制地形的两个三角形。有两个方式可以定义这两个三角形，如图5－18所示。绘制三角形的方式影响到P点的高度，如图所示。

图5-18 从四个顶点绘制两个三角形的两种方法

虽然四个顶点有相同的坐标，但两种情况中的点的高度并不相同，图中你可以可出明显的区别。

基于我即将讨论的理由，更好的方式是图5－18的右图。

使用这个旋转方式，很容易确定点在哪个三角形上方。两个三角形之间的边界由对角线给出。在右图中，如果xRelative + zRelative 为1的话，这条线对应具有X和Z坐标的点。

例如，如果这个点在四个点中央，如图5－18所示，xRelative和zRelative都是0.5f，所以和为1，说明在对角线上。如果这个点偏向左边一点，xRelative会小一些，和会小于1，对Z坐标也是类似的情况。所以如果和小于1，(X,Z)坐标位于左下角的三角形内；否则，该点在右上角的三角形内：

bool pointAboveLowerTriangle = (xRelative + zRelative < 1);

注意：图5－16中定义的所有三角形都是以图5－18右图中的形式绘制的。

获取精确高度

知道了对应高度，四个周围顶点的高度和点位于哪个三角形中，你就可以计算精确高度了。

如果点在左下方的三角形中，这时pointAboveLowerTriangle为true，下面是使用双线性插值获取三角形任意点高度的代码：

finalHeight = heightLxLz;
finalHeight += zRelative * (heightLxHz - heightLxLz);
finalHeight += xRelative * (heightHxLz - heightLxLz);

根据前面解释的单插值的方法，从lowestX的Y值开始。因为这是“双”插值，你要从lowestXlowestZ的Y值开始。

在单插值中，你maxY之间添加高度差，并乘以对应的X值。在双插值中，你乘的是 zRelative和xRelative。

换句话说，从左下顶点的高度开始，对这个高度，你添加了这个顶点和有着更高Z坐标的顶点间的高度差，并乘以距离第二个顶点的Z坐标的接近程度。最后一行代码类似：对这个高度，你添加了左下顶点和右下顶点的高度差，乘以距离右下顶点的X坐标的接近程度。

如果该点在右上三角形的内部，这时pointAboveLowerTriangle为false，情况有所不同，你需要以下代码：

finalHeight = heightHxHz;
finalHeight += (1.0f - zDifference) *(heightHxLz - heightHxHz);
finalHeight += (1.0f - xDifference) * (heightLxHz - heightHxHz);

从高度开始，从右上顶点开始，遵循同样的步骤：添加高度差，乘以对应距离。

代码

这个方法包含前面解释的所有代码。基于任意(X,Z)坐标，无论是整数还是浮点数，这个方法返回该点的精确高度。首先检查该点是否在地形上。如果不是，返回默认的高度10。

public float GetExactHeightAt(float xCoord, float zCoord)
{
    bool invalid = xCoord < 0;
    invalid |= zCoord < 0;
    invalid |= xCoord > heightData.GetLength(0) - 1;
    invalid |= zCoord > heightData.GetLength(1) - 1;

    if (invalid)
        return 10;

    int xLower = (int)xCoord;
    int xHigher = xLower + 1;
    float xRelative = (xCoord - xLower) / ((float)xHigher - (float)xLower);
    int zLower = (int)zCoord;
    int zHigher = zLower + 1;
    float zRelative = (zCoord - zLower) / ((float)zHigher - (float)zLower);
    float heightLxLz = heightData[xLower, zLower];
    float heightLxHz = heightData[xLower, zHigher];
    float heightHxLz = heightData[xHigher, zLower];
    float heightHxHz = heightData[xHigher, zHigher];

    bool pointAboveLowerTriangle = (xRelative + zRelative < 1);
    float finalHeight;
    if (pointAboveLowerTriangle )
    {
        finalHeight = heightLxLz;
        finalHeight += zRelative * (heightLxHz - heightLxLz);
        finalHeight += xRelative * (heightHxLz - heightLxLz);
    }
    else
    {
        finalHeight = heightHxHz;
        finalHeight += (1.0f - zRelative) * (heightHxLz - heightHxHz);
        finalHeight += (1.0f - xRelative) * (heightLxHz - heightHxHz);
    }
    return finalHeight; }


5.10 计算光标与地形的碰撞点：表面拾取

问题

你想获取地形上由光标指示的位置的精确3D坐标。

解决方案

如教程4-19的介绍中讨论的那样，通过光标指示的屏幕上的一个2D点对应3D场景中的一条射线。在本教程中，我们将沿着这条射线直到它与地形发生碰撞。

你可以使用一个二分法搜索（binary search algorithm）做到这点，这可以根据你选择的精度获取碰撞位置。

对高低起伏的地形来说，可能在射线和地形之间有多个碰撞点，如图5－20所示。所以，在二分法搜索之前需要进行线性搜索，以保证检测到的碰撞是最靠近相机的。

工作原理

下面的这个方法将光标的2D屏幕位置转换为一个3D射线，这已经在教程4－19的第一部分介绍过了。

private Ray GetPointerRay(Vector2 pointerPosition)
{
    Vector3 nearScreenPoint = new Vector3(pointerPosition.X, pointerPosition.Y, 0);
    Vector3 farScreenPoint = new Vector3(pointerPosition.X, pointerPosition.Y, 1);

    Vector3 near3DWorldPoint = device.Viewport.Unproject(nearScreenPoint, moveCam.ProjectionMatrix, moveCam.ViewMatrix, Matrix.Identity);
    Vector3 far3DWorldPoint = device.Viewport.Unproject(farScreenPoint, moveCam.ProjectionMatrix, moveCam.ViewMatrix, Matrix.Identity);

    Vector3 pointerRayDirection = far3DWorldPoint - near3DWorldPoint;
    Ray pointerRay = new Ray(near3DWorldPoint, pointerRayDirection);

    return pointerRay;
}

注意：本教程中你没有归一化射线的方向，我等会儿会解释这个问题。

二分法搜索

射线包含一个起点(本例中就是射线与近裁平面的交点)和方向。射线和地形如图5-19所示。起点用A表示。射线的方向为A和B之间的向量。二分法搜索的思路很直观、首先从A、B两点开始，你应确保碰撞点位于这两点之间。计算出A、B的中点，在图5-19中用1表示。你检查这个点在地形之上还是之下。如果这个点在地形的上方(比如本图中的情景)，那你就知道了碰撞点位于1和B之间。

图5-19 使用二分法搜索检测射线和地形的碰撞

然后继续找到1和B之间的点2，这个点位于地形之下，所以你知道碰撞点位于1和2之间。

继续缩小搜索范围。看一下位于1和2之间的点3，这个点在地形之上，所以碰撞点位于3和2之间。

接着，3和2之间的点4在地形之下，所以碰撞点在3和4之间。

最后，检查位于3和4之间的点5。你发现点5的高度非常接近地形上(X,Z)位置的高度，所以你找了碰撞点！

找到点A和点B

在开始二分法搜索之前，你需要从射线上的两个点A、B开始，必须确保碰撞点位于两点之间。

你可以安全地使用射线的起点作为A点，因为这是射线上最接近与相机的点(它位于近裁平面上，可见教程4-19)。

现在，可以取射线与远裁平面的交点作为B点，这个主意不错，因为这是相机内可见的射线上的最远点。

如教程4-19的GetPointerRay方法中解释的那样，pointerRay. Direction等于从A指向B的向量。

二分法搜索方法

现在知道了点A和指向B的方向，就可以检测与地形的碰撞了二分法搜索算法前面已经解释过了，翻译成伪代码如下：

只要当前点和在其之下的地形间的高度差过大，就执行以下操作：

  1. 将射线方向一分为二。
  2. 将结果方向添加到当前点获取下一个点。
  3. 如果下一个点仍在地形上方，将这个点作为当前点。

可以把这个步骤想象成在射线上行走，在放脚之前，你要检查脚是否位于地形下方。如果不是，则将步幅缩小一半继续。一但脚位于地形上方，你要及时撤回脚将它向下移动一半，直到脚位于地形之上与地形接触。

下面是代码：

private Vector3 BinarySearch(Ray ray)
{
    float accuracy = 0.01f;

    float heightAtStartingPoint = terrain.GetExactHeightAt(ray.Position.X, -ray.Position.Z);
    float currentError = ray.Position.Y - heightAtStartingPoint;

    while (currentError > accuracy)
    {
        ray.Direction /= 2.0f;
        Vector3 nextPoint = ray.Position + ray.Direction;
        float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);

        if (nextPoint.Y > heightAtNextPoint)
        {
            ray.Position = nextPoint;
            currentError = ray.Position.Y - heightAtNextPoint;
        }
    }
    return ray.Position;
}

首先计算射线上的开始点与地形的高度差。

当这个高度差小于你预设的0.01的accuracy才跳出while循环。如果高度差仍太大，你要将步幅减半并计算射线上的下一个点。如果下一个点在地形之上，你要计算这个点的高度差。如果下一个点位于地形之上，则不作任何操作，这样下一次循环步幅再次减半。

跳出while循环后，ray. Position将会包含射线上与地形的高度差小于0.01的点的位置。

这个方法需要首先有GetPointerRay 方法创建的AB射线：

pointerPos = BinarySearch(pointerRay);

注意：如果你的光标不在地形上，这个方法会永远停留在while循环中。 那么，如果计数器大于某个值就跳出while循环就很有用了，这会在后面的代码中讲到。

二分法搜索的问题

大多数情况中，二分法搜索做得很好，但在某些情况中会失败，如图5-20所示。

图5-20 二分法搜索会出问题的情况

因为二分法搜索不会检测点0和1之间的地形高度，导致射线与第一个山头的碰撞不会被检测到，会返回同样的结果（点5）作为射线和地形间的碰撞。

要解决这个问题，在二分法搜素之前应进行线性搜索，线性搜素相对来说比较简单。

线性搜索

在线性搜索中，你需要将射线分割成相同长度的几部分，例如，分成8段，如图5-21所示。

图5-21 线性搜索

你只是简单地以相同的步幅沿着射线前进，直到碰到一个位于地形下方的点。这不会给出一个精确的结果，但至少你检测到了射线与第一个山头的碰撞。

因为图5-21中的点1和点2都没有足够接近地形，所以需要使用二分法搜索找到点1和2之间的精确碰撞点。

LinearSearch方法以AB间的射线为参数，将这根射线分成相同的线段，返回对应哪段发生碰撞的一部分射线：

private Ray LinearSearch(Ray ray)
{
    ray.Direction /= 300.0f;

    Vector3 nextPoint = ray.Position + ray.Direction;
    float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);

    while (heightAtNextPoint < nextPoint.Y)
    {
        ray.Position = nextPoint;
        nextPoint = ray.Position + ray.Direction;
        heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);
    }
    return ray;
}

本例中，射线被分成不超过300段。增加这个值会增加检测到的可能性但需要更多处理能力。

对每个点，你计算了下一点并检测下一个点在地形之上还是之下。如果在地形之上，则继续。如果下一个点在地形之下，返回包含碰撞前的点和碰撞发生段信息的当前射线。

返回的这条射线用在了BinarySearch方法中：

Ray shorterRay = LinearSearch(pointerRay);
pointerPos = BinarySearch(shorterRay);

指定程序的优化

线性搜索让你可以检测到小的山峰，而接下来的二分法搜索精度更高。

在前面的代码中，你取射线与近裁平面和远裁平面的交点作为A点和B点。这工作良好，但是如果近裁平面和远裁平面间的距离很大，射线也会很大。这意味着你要进行很多无用的检查。

作为替代，你只需考虑可能发生碰撞的高度范围内的射线。在本书中用到的地形最大高度为30，最低为0。因此，更好的做法是在射线上找到Y坐标为30的点并将它作为起始点A。然后找到将射线上Y坐标为0的点作为终点B点。

这根射线由下面的方法构建：

private Ray ClipRay(Ray ray, float highest, float lowest)
{
    Vector3 oldStartPoint = ray.Position;
    float factorH = -(oldStartPoint.Y-highest) / ray.Direction.Y;
    Vector3 pointA = oldStartPoint + factorH * ray.Direction;
    float factorL = -(oldStartPoint.Y-lowest) / ray.Direction.Y;
    Vector3 pointB = oldStartPoint + factorL * ray.Direction;
    Vector3 newDirection = pointB - pointA;

    return new Ray(pointA, newDirection);
}

要找到射线上指定Y坐标的点，需要找到这个Y坐标和射线起始点的Y坐标之差。如果知道射线方向上的高度差，你就知道了需要将射线的哪一部分（存储在factor中）添加到起始点上以到达你要求的Y值的点。

注意：oldStartPoint的Y坐标是正的，当射线下降时direction的Y坐标是负的。你想让factor为一个正值，所以前面有个 – 号。

如果从输出的射线开始，在线性搜索中你只需进行少得多的检测就可以达到同样的检测率。

代码

你可以使用下面的代码检测光标射线与地形的碰撞点：

protected override void Update(GameTime gameTime)
{
    GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);

    if (gamePadState.Buttons.Back == ButtonState.Pressed)
        this.Exit();

    MouseState mouseState = Mouse.GetState();
    KeyboardState keyState = Keyboard.GetState();

    Vector2 pointerScreenPos = new Vector2(mouseState.X, mouseState.Y);
    Ray pointerRay = GetPointerRay(pointerScreenPos);
    Ray clippedRay = ClipRay(pointerRay, 30, 0);
    Ray shorterRay = LinearSearch(clippedRay);
    pointerPos = BinarySearch(shorterRay);

    base.Update(gameTime);
}

这个代码首先调用ClipRay方法返回Y坐标为0到30之间的一段射线：

private Ray ClipRay(Ray ray, float highest, float lowest)
{
    Vector3 oldStartPoint = ray.Position;
    float factorH = -(oldStartPoint.Y-highest) / ray.Direction.Y;
    Vector3 pointA = oldStartPoint + factorH * ray.Direction;
    float factorL = -(oldStartPoint.Y-lowest) / ray.Direction.Y;
    Vector3 pointB = oldStartPoint + factorL * ray.Direction;
    Vector3 newDirection = pointB - pointA;

    return new Ray(pointA, newDirection);
}

然后调用LinearSearch方法将一条很长的射线优化为一个短射线，这个射线包含最接近相机的碰撞点：

private Ray LinearSearch(Ray ray)
{
    ray.Direction /= 50.0f;
    Vector3 nextPoint = ray.Position + ray.Direction;
    float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);

    while (heightAtNextPoint < nextPoint.Y)
    {
        ray.Position = nextPoint;
        nextPoint = ray.Position + ray.Direction;
        heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);
    }

    return ray;
}

要精确找到碰撞的3D位置，要在这条短射线上调用BinarySearch方法：

private Vector3 BinarySearch(Ray ray)
{
    float accuracy = 0.01f;

    float heightAtStartingPoint = terrain.GetExactHeightAt(ray.Position.X,-ray.Position.Z);
    float currentError = ray.Position.Y - heightAtStartingPoint;

    int counter = 0;
    while (currentError > accuracy)
    {
        ray.Direction /= 2.0f;
        Vector3 nextPoint = ray.Position + ray.Direction;
        float heightAtNextPoint = terrain.GetExactHeightAt(nextPoint.X, -nextPoint.Z);

        if (nextPoint.Y > heightAtNextPoint)
        {
            ray.Position = nextPoint;
            currentError = ray.Position.Y - heightAtNextPoint;
        }

        if (counter++ == 1000)
            break;
    }
    return ray.Position;
}

注意：如果while循环进行了1,000次，代码会跳出这个循环。


5.11 从XML文件加载数据

问题

你想从一个XML文件中加载数据到XNA项目。你可以使用默认的.NET文件IO功能在XNA项目启动时读取文件做到这点，但这在Xbox360平台上无法工作。

你想使用内容管道将一个XML文件串行化为一个二进制文件，这样就可以在XNA项目中读取包含在这些文件中的内容了。

解决方案

在XML文件中，只需简单地将你想要加载的对象插入到<XNAContent>和<Asset>标签之间，下面的XML示例文件是一个自定义的MapData类对象:

<?xml version="1.0" encoding="utf-8"?>
<XnaContent?>
    <Asset Type="XMLDataPLine.MapData"?>
    <mapName>Battle In The Middle</mapName>
    <numberOfCastles>8</numberOfCastles>
    <allies>
        <Item>Humans</Item>
        <Item>Elves</Item>
        <Item>Dwarves</Item>
    </allies>
    </Asset>
</XnaContent>

技巧：如果你不知道如何从一个对象自动创建一个XML文件，你可以在本教程的最后学到方法。

XNA框架自带有内容导入器可以将XML文件转换为定义在XML文件中的对象。

这里因为对象已近建立了，所以你无需使用处理器，对象可以立即串行化为一个二进制文件。这里对象是自定义的MapData类，所以你需要定义一个自定义的TypeWriter和TypeReader。

注意：如果内容管道知道如何串行化/反串行化XML文件描述的对象，你就没必要编写一个新的TypeWriter或TypeReader。

图5-25显示了简图。

图5-25 当从XML文件导入对象时无需处理器。

工作原理

将一个. Xml文件导入到XNA项目中。在解决方案浏览器中，选择一个文件，它的属性显示在屏幕右下角。表面你想使用默认的XML内容导入器，选择No Processing Required表示导入器的输出不使用处理器就被串行化为一个文件。图5-26显示了最终的属性窗口。

图5-26 一个导入的XML文件的属性

如果XML文件包含一个内容管道知道如何串行化/反串行化的类对象，你可以将这个对象在LoadContent方法中加载到一个变量。但是本教程中是一个自定义类，所以你需要定义一个TypeWriter。

按照教程4-15中解释的步骤在解决方案中添加一个内容管道。这次，你无需定义处理器，只需定义MapData类 及对应的TypeWriter和TypeReader。

注意：确保添加内容管道项目的引用，解释请见教程4-15。这是必需的，这样你的主程序才可以访问到MapData类的定义，这个类定义是存储在内容管道项目中的。

定义自定义的MapData类

本例中的MapData类包含一个string，一个int和一个string的集合，将这个定义添加到内容管道项目中：

public class MapData
{
    public string mapName;
    public int numberOfCastles;
    public List<string>
    allies = new List<string>();
}

技巧：验证一下包含在前面的XML文件中的数据提供了这三个变量的数据。

定义一个可以串行化MapData类对象的TypeWriter

如教程4-15所述，TypeWriter需要从对象串行化足够的数据，这样以后对象才能被TypeReader重建。和以往一样，仍需要提供 TypeReader的位置：

[ContentTypeWriter] public class MapDataTypeWriter : ContentTypeWriter<MapData>
{
    protected override void Write(ContentWriter output, MapData value)
    {
        output.WriteObject<string>(value.mapName);
        output.WriteObject<int>(value.numberOfCastles);
        output.WriteObject<List<string>>(value.allies);
    }

    public override string GetRuntimeReader(TargetPlatform targetPlatform)
    {
        return typeof(MapDataReader).AssemblyQualifiedName;
    }

你表明了这个TypeWriter可以串行化MapData类对象。默认内容管道知道如何串行化一个string，一个int和一个List，所以你只需简单地将它们串行化为一个二进制文件。你传递了一个MapDataReader TypeReader的链接，下面就会定义这个链接。

定义一个可以串行化MapData类对象的TypeReader

TypeReader只是简单地创建了一个新MapData对象，读取string，int和List (按正确地顺序!)，并将它们存储在MapData对象中。这个对象被返回并发送到XNA游戏项目。

class MapDataReader : ContentTypeReader<MapData>
{
    protected override MapData Read(ContentReader input, MapData existingInstance)
    {
        MapData map = new MapData();
        map.mapName = input.ReadObject<string>();
        map.numberOfCastles = input.ReadObject<int>();
        map.allies = input.ReadObject<List<string>>();

        return map;
    }
}

使用方法

LoadContent方法的第一行代码就可以实时从MapData对象中读取数据，第二行代码只是在代码中添加一个断点，这样让你可以检查数据是否正确：

protected override void LoadContent()
{
    MapData loadedMap = Content.Load<MapData>("data");
    System.Diagnostics.Debugger.Break();
}

注意：只要编译项目，XML文件就会被转换为一个二进制文件。当项目开始时，只调用了TypeReader从二进制文件构建了MapData对象。这意味着如果你改变了XML的内容，你必须重新编译。

代码

自定义内容管道只包含类定义，TypeWriter和TypeReader，这在教程前面已经写过了。

扩展阅读

从一个已存在的对象创建一个XNA可用的XML文件

本节解释了如何以一个XML文件存储任意类对象，然后你就可以使用默认XML导入器加载它了。首先需要添加下面的命名空间：

using System.Xml;
using Microsoft.Xna.Framework.Content.Pipeline.Serialization.Intermediate;

你还需添加System. XML和Microsoft. XNA. Framework. Content. Pipeline的引用；你可以通过从Project菜单中选择Add Reference添加这些引用。

然后，要么链接到自定义内容管道，要么通过将下列代码放在项目命名空间的外面手动重定义MapData类。

namespace XMLDataPLine
{
    public class MapData
    {
        public string mapName;
        public int numberOfCastles;
        public List<string> allies = new List<string>();
    }
}

如果你手动重定义类，请确保将它放在与自定义内容管道相同的命名空间中（我教程中叫做XMLDataPLine），所有的变量也是相同的。

接下来，回到XNA项目的命名空间，确保已近有了一个MapData类对象：

XMLDataPLine.MapData myMap = new XMLDataPLine.MapData();
myMap.mapName = "Battle In The Middle";
myMap.numberOfCastles = 8;
myMap.allies.Add("Humans");
myMap.allies.Add("Elves");
myMap.allies.Add("Dwarves");

然后使用这个代码存储到一个XNA可用的XML文件中，叫做data . xml：

string fileName = "data.xml";
XmlWriter writer = XmlWriter.Create(fileName);
IntermediateSerializer.Serialize<XMLDataPLine.MapData>(writer, myMap, fileName);
writer.Close();

当你运行程序时，data . xml文件会被创建到与. exe相同的位置中。

注意：在示例中你也能找到这个代码。


5.12 创建自己的顶点格式

问题

顶点用来存储从XNA项目发送到显卡的数据。一个顶点格式包含存储在顶点中的数据的描述。XNA框架自带有默认的顶点格式，从简单的VertexPositionColor到 VertexPostionNormalTexture格式。

但是，如果你需要顶点带有额外的数据，例如切线或时间数据，就需要定义自己的顶点格式。如果要在顶点shader中使用这个数据，这一步是必须的。所以，只有编写自定义的顶点和像素shader时，你才需要定义一个自定义顶点格式。

解决方案

顶点格式定义了在顶点中存储何种类型的数据，何种数据可以被vertex shader访问。例如，当使用VertexPositionColor格式的顶点时，VertexPositionColor结构告知显卡顶点将包含位置和颜色数据，以及在数据流中的何处可以找到这些数据。

顶点格式是XNA代码和vertex shader之间的链接。首先，顶点格式表示数据在数据流中如何定位，如图5-24上面的灰色曲线所示，表明位置和颜色数据位于数据流的何处。

只要显卡需要从顶点中绘制三角形，它就需要将数据流还原为顶点。

然后，显卡需要将每个顶点分离成位置和颜色数据。所以顶点格式还告知显卡在哪切割数据流，如图5-24下面的灰色曲线所示。

最后，对每个重建的顶点，使用位置和颜色数据调用vertex shader。

图5-24 顶点格式表示在数据流中可以找到哪些数据。

工作原理

本教程中，你将创建自己的VertexPositionColor结构学习基础知识。在第二部分，你会将这个结构扩展为一个新的，自定义的顶点格式。

重新创建VertexPositionColor结构

这部分你将创建一个MyVertexPositionColor结构(和VertexPositionColor结构相同)学习基础知识。 VertexPositionColor结构能够储存所有必要的信息。它由三部分组成：

  • 每个顶点自身的数据，为一个Vector3存储位置和一个Color
  • 一个顶点的大小，这样显卡可以将数据流切割成单独的顶点
  • VertexElements，它可以告知显卡每个顶点包含何种数据，显卡应该如何切割顶点以获得对应的数据。
第1步

首先添加这个结构：

public struct MyVertexPositionColor
{
    public Vector3 Position;
    public Color Color;
    public MyVertexPositionColor(Vector3 position, Color color)
    {
        Position = position;
        Color = color;
    }
}

这个结构可以存储一个表示位置的Vector3和颜色。这足以创建一个顶点并将他们发送到显卡。

第2步

需要知道一个顶点要占据多少字节，这样显卡才能完美地将数据流切割成独立的顶点。在结构中添加如下代码行：

public static readonly int SizeInBytes = sizeof(float) * (3 + 1);

因为这个信息对所有顶点来说都是一样的，而且只需读取它，所以可以将它设置为static readonly。

每个顶点包含一个Vector3和一个Color。Vector3由三个浮点数组成，Color为一个浮点数。所以，一个顶点占据的字节大小为(一个浮点数占据的字节大小)*(3+1)。

注意：一个浮点数为4个字节，所以你可以将这个值表示成4*(3+1) = 16。看图5-24验证一下每个顶点都包含16的字节，每个新顶点开始的字节序号都是16的倍数。这就是显卡将字节流分割成独立的顶点的方式。

通过第二步，显卡就可以知道如何将字节流切割成独立的顶点。

第3步

最后，在结构中添加以下代码：

public static readonly VertexElement[] VertexElements =
{
    new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default,VertexElementUsage.Position, 0),
    new VertexElement(0, sizeof(float)*3, VertexElementFormat.Color, VertexElementMethod.Default, VertexElementUsage.Color, 0),
};

上面的信息显示包含在一个顶点中的数据类型以及数据位于顶点的哪个字节中。第一个VertexElement 表示每个顶点包含位置数据，第二个表示颜色数据。让我们依次讨论每个参数。

第一个参数表示到哪个数据流获取数据。只有高级程序会使用多个顶点数据流，所以通常为0。

技巧：多个顶点流是很有用的，如果Position数据保持不变而Color数据需要频繁更新时，可以将 数据分在两个顶点流中，Position数据保持不变，你只需将Color数据从CPU传递到GPU中即可。

第二个参数表示到哪找到数据。它是顶点中的数据的字节索引。因为Position数据是第一个数据，你指定在索引0位置找到它。Color数据在Position数据之后，所以你必须知道Position数据有几个字节。Position数据占据三个浮点数，所以你指定为sizeof(float)*3 (因为一个浮点数为4个字节，也可以用12表示)。看一下图5-27：Color数据在字节12处开始。

第三个参数表示保存到数据流中的数据格式，它是数据的基类型。对Position而言是Vector3，对Color而言是Color类型。

第四个参数只用于高级和特定的硬件扩展，例如在N-Patching中，一个几何体的三角形数量会基于法线进行调整，这样会提高几何体的阴影质量。

第五个参数非常重要，它表示这个数据链接到哪个vertex shader的输入。再看一次图5-24，注意vertex shader的输入参数。这两个参数后面都跟有一个语义POSITION0和COLOR0。在本例中，你将数据的第一部分，包含Position数据，和POSITION0语义链接在一起。数据的第二部分链接到COLOR0语义。

最后一个参数让你指定每个语义的多个版本。它实际上指向POSITION0和COLOR0最后的0。对应的例子会在本教程的后半部分展示。

通过第三步，显卡知道了到哪获取顶点中的什么数据。在本例中，它会将0到11的字节 (3个浮点数)传递到vertex shader的POSITION0输入，12到15字节(1个浮点数)传递到COLOR0输入。

完整的MyVertexPositionColor结构

下面是现阶段的成果：

public struct MyVertexPositionColor
{
    public Vector3 Position;
    public Color Color;
    public MyVertexPositionColor(Vector3 position, Color color)
    {
        Position = position;
        Color = color;
    }

    public static readonly VertexElement[] VertexElements =
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0),
        new VertexElement(0, sizeof(float)*3, VertexElementFormat.Color, VertexElementMethod.Default, ~ VertexElementUsage.Color, 0),
    };

    public static readonly int SizeInBytes = sizeof(float) * (3 + 1);
}

MyVertexPositionColor结构的用法

有了这个结构，你就可以定义顶点并将它们转换到一个VertexBuffer流中，可见教程5-4的解释。但这次，你用的是自定义的格式：

private void InitVertices()
{
    myVertexDeclaration = new VertexDeclaration(device, MyVertexPositionColor.VertexElements);
    MyVertexPositionColor[] vertices = new MyVertexPositionColor[3];

    int i = 0;
    vertices[i++] = new MyVertexPositionColor(new Vector3(1, 1, -1), Color.Red);
    vertices[i++] = new MyVertexPositionColor(new Vector3(3, 5, -1), Color.Green);
    vertices[i++] = new MyVertexPositionColor(new Vector3(5, 1, -1), Color.Blue);
    vertBuffer = new VertexBuffer(device, MyVertexPositionColor.SizeInBytes * vertices.Length, BufferUsage.WriteOnly);
    vertBuffer.SetData<MyVertexPositionColor>(vertices, 0, vertices.Length);
}

第一行代码创建VertexDeclaration，它会被传递到显卡中。

中间部分的代码创建一个包含三个MyVertexPositionColors的数组，定义了一个三角形。在每个顶点中国存储了位置和颜色。要基于这个数组创建一个VertexBuffer，你还需指定一个顶点占据的字节数，所以传递了MyVertexPositionColor.SizeInBytes为参数。

下面的代码是从VertexBuffer绘制三角形，解释可见教程5-4：

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.Vertices[0].SetSource(vertBuffer, 0, MyVertexPositionColor.SizeInBytes);
    device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1); pass.End();
}
basicEffect.End();

在绘制三角形前，你需要将VertexElements传递给显卡，这样显卡才知道如何正确地将字节流分割成有用的数据。

自定义顶点格式

第二个例子，你将创建一个新的顶点格式，它可以存储位置，纹理坐标和一个附加的 Vector4。这可以让你在每个顶点中存储四个额外的值，图5-25显示了字节流中的这样两个顶点。

图5-25 包含两个MyCustomVertexFormats的字节流

这是一个新结构，你仍要定义三个主要部分：

public struct MyCustomVertexFormat
{
    public Vector3 Position;
    public Vector2 TexCoords;
    public Vector4 Extra;

    public MyCustomVertexFormat(Vector3 Position, Vector2 TexCoords, Vector4 Extra)
    {
        this.Position = Position;
        this.TexCoords = TexCoords;
        this.Extra = Extra;
    }

    public static readonly VertexElement[] VertexElements =
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0),
        new VertexElement(0, sizeof(float)*3, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0),
        new VertexElement(0, sizeof(float)*(3+2), VertexElementFormat.Vector4, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 1),
    };

    public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 4);
}

第一部分代码允许每个顶点存储一个Position，一个Texture坐标和一个Vector4。

接下来，将它们连接到vertex shader输入。第一个Vector3链接到POSITION0 。因为这是第一个数据项目，所以可以字节0位置找到它(第二个参数)。

第二行代码表明包含纹理坐标的Vector2链接到TEXCOORD0。Position占据三个浮点数，所以纹理坐标可以在字节sizeof(float)*3 = 12处找到。

第三行将Vector4链接到另一个TEXTURE语义，因为这可以用来传递额外的数据。因为已经用过TEXTURE0，所以你将最后一个参数设为1表明将链接到TEXTURE1。

Vector4位于Position和Texture坐标数据之后，所以Vector4可以在字节sizeof(float)*(3+2) = 20处找到，可参见图5-25。

最后，一个顶点占据了总共sizeof(float)*(3+2+4) = 36个字节(其中Position三个浮点数，纹理坐标两个浮点数，Vector4四个浮点数)。

定义自定义格式的顶点

下面的代码创建了VertexDeclaration和包含三个自定义格式的顶点的VertexBuffer：

myVertexDeclaration = new VertexDeclaration(device, MyCustomVertexFormat.VertexElements);
MyCustomVertexFormat[] vertices = new MyCustomVertexFormat[3];

int i = 0;
vertices[i++] = new MyCustomVertexFormat(new Vector3(1, 1, -1), new Vector2(0,1), new Vector4(-1.0f,0.5f,0.3f,0.2f));
vertices[i++] = new MyCustomVertexFormat(new Vector3(3, 5, -1), new Vector2(0.5f, 0), new Vector4(0.8f, -0.2f, 0.5f, -0.2f));
vertices[i++] = new MyCustomVertexFormat(new Vector3(5, 1, -1), new Vector2(1, 1), new Vector4(2.0f, 0.6f, -1.0f, 0.4f));
vertBuffer = new VertexBuffer(device, MyCustomVertexFormat.SizeInBytes vertices.Length, ResourceUsage.WriteOnly);

vertBuffer.SetData<MyCustomVertexFormat>(vertices, 0, vertices.Length, SetDataOptions.None);

每个顶点需要一个Vector3，一个Vector2和一个Vector4。

定义一个可以使用自定义顶点格式的Vertex Shader

现在vertex shader可以从POSITION0， TEXCOORD0和TEXCOORD1接受数据，它们可以由以下代码访问到：

// Technique: CustomVertexShader
CVVertexToPixel CVVertexShader(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)
{
    CVVertexToPixel Output = (CVVertexToPixel)0;
    float4 origPos = float4(inPos, 1);
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(origPos, preWorldViewProjection);
    Output.Extra = sin(xTime*inExtra.xyz);

    Output.TexCoord = inTexCoord;
    Output.TexCoord.x += sin(xTime)*inExtra.w;
    Output.TexCoord.y -= inExtra.w;

    return Output;
}

如何使用额外的输入其实并不重要，重要的是让它们在vertex shader中可用。本例中首先将3D位置映射到2D屏幕坐标。然后使用Vector4的前三个浮点数作为正弦函数的频率调制因子，并将结构存储在output 结构中。最后纹理坐标被移动，而Vector4的最后一个浮点数用来调整移动的强度。

下面是pixel shader，它使用移动的纹理坐标采样纹理，并将Extra变量中的三个值添加到颜色通道内：

CVPixelToFrame CVPixelShader(CVVertexToPixel PSIn) : COLOR0
{
    CVPixelToFrame Output = (CVPixelToFrame)0;
    Output.Color = tex2D(TextureSampler, PSIn.TexCoord);
    Output.Color.rgb += PSIn.Extra.rgb; return Output;
}

从顶点绘制这个代码设置effect变量并从顶点绘制三角形：

effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xTexture"].SetValue(texture);
effect.Parameters["xTime"].SetValue(time);

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.Vertices[0].SetSource(vertBuffer, 0, MyCustomVertexFormat.SizeInBytes);
    device.DrawPrimitives(PrimitiveType.TriangleList, 0, 1);
    pass.End();
}
effect.End();

代码

XNA代码已经在前面写过了，下面的代码是HLSL文件的内容：

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float xTime;

Texture xTexture;
sampler TextureSampler = sampler_state
{
    texture = <xTexture> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

struct CVVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
    float3 Extra : TEXCOORD1;
};

struct CVPixelToFrame
{
    float4 Color : COLOR0;
};

// Technique: CustomVertexShader
CVVertexToPixel CVVertexShader(float3 inPos: POSITION0, float2 inTexCoord: TEXCOORD0, float4 inExtra: TEXCOORD1)
{
    CVVertexToPixel Output = (CVVertexToPixel)0;
    float4 origPos = float4(inPos, 1);
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);

    Output.Position = mul(origPos, preWorldViewProjection);
    Output.Extra = sin(xTime*inExtra.xyz);
    Output.TexCoord = inTexCoord;
    Output.TexCoord.x += sin(xTime)*inExtra.w;
    Output.TexCoord.y -= sin(xTime)*inExtra.w;

    return Output;

}

CVPixelToFrame CVPixelShader(CVVertexToPixel PSIn) : COLOR0
{
    CVPixelToFrame Output = (CVPixelToFrame)0;
    Output.Color = tex2D(TextureSampler, PSIn.TexCoord);
    Output.Color.rgb += PSIn.Extra.rgb;

     return Output;
 }

technique CustomVertexShader
{
    pass Pass0
    {
        VertexShader = compile vs_1_1 CVVertexShader();
        PixelShader = compile ps_2_0 CVPixelShader();
    }
}


5.13 凹凸映射：固定法线

问题

三角形的最主要问题是它是平的。如果你使用两个三角形绘制一堵巨大的墙并在墙上附上一个漂亮的纹理，结果是令人失望的平的。

你可以将三角形分割成更小的三角形以添加细节，这需要定义每个顶点的3D位置，但这样做会消耗太多的资源。

解决方案

你可以使用凹凸映射代替上述这个丑陋的方法。凹凸映射通过改变三角形每个像素的颜色给观察者留下三角形表面高低起伏的印象。

如果你看一张平整的红色的塑料板的图片，会发现所有像素几乎是一个颜色的。但是如果是一个粗糙表面的图片，比方说一块红砖，像素会有不同的红色，让观察者知道这块砖的表面是粗糙的。

这就是你想模拟的效果。在一个粗糙表面上，像素反光情况不同导致颜色是不同的。这是因为你可以把一块砖分成上千个小的表面，每个面都有不同的方向或者说不同的法线，如图5-29左边所示。对应每个不同的法线，你可以获取不同的光照情况 (见第6章)。

图5-29 一个有着上千个平面的砖块，每个面都有不同的法线

你可以不使用如图5-29左边所示的上百个三角形绘制砖块，而是如右图所示只用两个三角形绘制。在这两个三角形的每个像素上，你将稍微改变一下默认的法线，这会让所有像素有不同的光照并导致产生不同的颜色。通过这种方式，如果砖块位置或光线方向改变时，凹凸映射可以添加很多细节。

要获得最好的效果，你不能随机改变法线的方向，而是应该以正确的方式进行，如右图所示，注意右图的法线与左图是一样的。通常每个法线的X，Y和Z 坐标都存储在一张图像的R，G和B通道中，这叫做纹理的凹凸映射(或法线映射)。

所以，不要将砖块分成上千个三角形，而是使用两张纹理绘制有两个三角形的矩形：一张纹理包含砖块的颜色，另一张包含法线偏离量的信息。你需要使用pixel shader查询每个像素的颜色和法线偏移量，并使用这个法线计算光照进一步调整颜色的亮度。

本教程的凹凸映射是一个平面，平面是个简化的例子，因为它的法线向量是不变的。

工作原理

首先你需要定义一个新的三角形表示平面。下面的代码定义两个带纹理的三角形，这足以绘制一个大矩形了：

private VertexPositionTexture[] InitVertices()
{
    VertexPositionTexture[] vertices = new VertexPositionTexture[6];
    vertices[0] = new VertexPositionTexture(new Vector3(-5, 0, 10), new Vector2(0, 2));
    vertices[1] = new VertexPositionTexture(new Vector3(-5, 0, -10), new Vector2(0, 0));
    vertices[2] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2));
    vertices[3] = new VertexPositionTexture(new Vector3(-5, 0, -10),new Vector2(0, 0));
    vertices[4] = new VertexPositionTexture(new Vector3(5, 0, -10), new Vector2(1, 0));
    vertices[5] = new VertexPositionTexture(new Vector3(5, 0, 10), new Vector2(1, 2));
    return vertices;
}

因为你要从纹理进行采样，所以还要指定纹理坐标(见教程5－2)。因为Y坐标都是0，所以你定义的平面在XZ平面上，所以你知道默认法线向量是朝上的。

注意：这个假设只有在这种情况下才是对的，下一个教程会介绍一个更通用的方法。

定义好顶点后，就可以开始编写fx代码了。

XNA-to-HLSL变量

所有的3D effects都需要传递世界矩阵、观察矩阵和投影矩阵，这样vertex shader才能将顶点的3D位置转换到2D屏幕位置。要显示凹凸映射的效果，你还应该可以改变光线方向。

如前所述，你还要传入两张纹理：一张用来采样颜色，一张用来查询每个像素的法线。

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float3 xLightDirection;

Texture xTexture;
sampler TextureSampler = sampler_state
{
    texture = <xTexture> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = wrap;
    AddressV = wrap;
};

Texture xBumpMap;
sampler BumpMapSampler = sampler_state
{
    texture = <xBumpMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = wrap;
    AddressV = wrap;
};

struct SBMVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
};

struct SBMPixelToFrame
{
    float4 Color : COLOR0;
};

vertex shader会输出每个顶点的2D屏幕位置和纹理坐标，pixel shader计算最终颜色。

Vertex Shader

vertex shader非常简单，因为它只需将3D坐标转换到2D屏幕空间并将纹理坐标传递给pixel shader：

SBMVertexToPixel SBMVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
{
    SBMVertexToPixel Output = (SBMVertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    Output.TexCoord = inTexCoord;
    return Output;
}

3D位置通过世界矩阵、视矩阵、投影矩阵的组合转换到2D屏幕空间，纹理坐标只是直接传递到pixel shader。

Pixel Shader

对于三角形的每个像素，pixel shader都会从颜色纹理中查询颜色值。但这次pixel shader 还要采样凹凸贴图，查询指定像素偏离默认法线的方向。

法线向量是一个3D向量，由三个坐标轴定义。有关法线更多的信息可见教程5-7，要知道法线如何影响光线，可见教程6-1。

对凹凸映射的每个像素，法线坐标是保存在三个颜色通道中的。但是，这三个坐标变化范围应该在–1和1 (也就是说可以是±x，±y和±z方向)之间，但颜色的范围只是在[0,1]范围中。所以，你需要将[0,1]范围中的值映射到[–1,1]之间，这可以先将这个值减去0.5(映射到 [–0.5, 0.5]范围内)在乘以2 (映射到[–1,1]范围内)。这一步是在pixel shader中的第三行代码中进行的：

SBMPixelToFrame SBMPixelShader(SBMVertexToPixel PSIn) : COLOR0
{
    SBMPixelToFrame Output = (SBMPixelToFrame)0;
    float3 bumpMapColor = tex2D(BumpMapSampler, PSIn.TexCoord).rbg;
    float3 normalFromBumpMap = (bumpMapColor - 0.5f)*2.0f;
    float lightFactor = dot(-normalize(normalFromBumpMap), normalize(xLightDirection));
    float4 texColor = tex2D(TextureSampler, PSIn.TexCoord);
    Output.Color = lightFactor*texColor;
    return Output;
}

在凹凸映射中，三个颜色通道表示默认法线的偏移量。当默认法线不偏离时，对应的像素颜色是浅蓝(R=0.5, G=0.5, B=1，译者注：见上面的算法，以rbg顺序读取颜色，然后使用公式乘2减1，即R=0.5, G=0.5, B=1结果是(0,1,0))。在这个例子中的两个三角型是在XZ平面中，所以法线朝上，即(0,1,0)的方向。

凹凸映射中像素的颜色不是(R=0.5, G=0.5, B=1)表示法线需要偏离(0,1,0)方向。本例中，你想让法线朝X或Z方向偏离。可见下一个教程学习一个通用的方法。

这也是为什么这个例子中使用蓝色通道对应法线的Y坐标，红色和绿色通道对应 X和Z 坐标(这也是使用RBG swizzle的理由，译者注：swizzle是一种指定输入哪个通道的方法，例如float4 b(1, 2, 3, 4);则float4 a = b.xxxx(); // a is now 1, 1, 1, 1；a = b.wwwz(); // a is now 4, 4, 4, 3；a = b.yzzw(); // a is now 2, 3, 3, 4；a = b.wzyx(); // a is now 4, 3, 2, 1；你也可以使用诸如float4 a = b.rrrr();获得同样的效果，但混用颜色通道和坐标通道不可以，即不可以写成 float4 a = b.rryy();)。如果从凹凸映射采样的颜色等于(0.5,0.5,1)，则normalFromBumpMap为(0,1,0)，这意味着法线不偏离(都朝上)。如果bumpMapColor = (0.145,0.5,0.855)，那么normalFromBumpMap为(-0.71,0.71,0)这意味着法线会向左偏离45度。

一旦知道了每个像素的法线，就可以在点乘法线和光线方向之前归一化（使之长度为1）这两者法线和光线方向。如教程5－6解释的那样，点乘的结果表示当前像素的亮度，然后将这个亮度乘以颜色。

定义Technique

最后在fx文件中添加technique定义：

technique SimpleBumpMapping
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 SBMVertexShader();
        PixelShader = compile ps_2_0 SBMPixelShader();
    }
}

代码

把以上三部分添加到fx文件就是完整代码了。下面是设置参数的XNA代码，绘制了两个三角形：

protected override void Draw(GameTime gameTime)
{
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0);

    //set effect parameters
    effect.CurrentTechnique = effect.Techniques["SimpleBumpMapping"];
    effect.Parameters["xWorld"].SetValue(Matrix.Identity);
    effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
    effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
    effect.Parameters["xTexture"].SetValue(myTexture);
    effect.Parameters["xBumpMap"].SetValue(myBumpMap);
    effect.Parameters["xLightDirection"].SetValue(lightDirection);

    //render two triangles
    effect.Begin();
    foreach (EffectPass pass in effect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = myVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleList, vertices, 0, 2);
        pass.End();
    }
    effect.End();
    base.Draw(gameTime);
}


5.14 通过切线空间的凹凸映射添加逐像素细节

问题

虽然前一个教程中具有不变法线的平面物体工作良好，但如果对一个曲面或有转角的表面进行凹凸映射仍会遇到麻烦。

主要问题是包含在凹凸映射中的偏离法线是在切线空间中的，这意味着它与默认法线有联系。

为了形象化的说明这个问题，设想绘制一个圆柱体，如图5-30所示。左图表示圆柱体顶点的默认法线。

图5-30 圆柱体的默认法线和凹凸映射法线

想象一下你想对这个圆柱体使用凹凸映射。例如，你想对圆柱体的所有像素使用包含 (–1,0,1)方向法线的凹凸映射。这个法线相对于默认法线向左偏转45度。

获取这个法线正确的方式是位于默认法线的起点沿着这个默认法线向左旋转45度。看一下顶点法线0，4和6，对于圆柱体的每个像素这个方向是不同的，因为这个方向取决于像素的默认法线！如果对每个法线都进行这样的处理，你最终会获得如右图所示的结果。这些法线都相对于原始法线向左偏离45度。

但是，如果你想使用这个法线计算光照，需要找到法线(–1,0,1)对应于世界空间的方向。这是必须的，因为你需要使用两个向量进行光照计算，而这两个向量需定义在同一个空间中。你想在XNA程序中指定光线在世界空间中的方向。

解决方案

法线映射中的三个颜色通道包含了切线空间中的法线坐标，即相对于默认法线而言，这个坐标表示默认法线的偏离量。每个像素的局部坐标系是不同的，因为这取决于默认法线，而在一个曲面上默认法线的方向是不同的。这个局部坐标系叫做切线空间。

技巧：要理解切线空间，可以想象你站在如图5－30所示的圆柱体像素上，沿着法线方向。现在将你的上方（沿着默认法线方向）作为Z轴，你的右边就是X轴，前方就是Y轴。

这个切线坐标系统如图5－31中“a”图的三个灰色箭头表示。注意像素的默认法线在切线空间中是Z轴(见前面的技巧)。切线空间的x和y轴要与z轴互相垂直，z垂直与物体(因为这是默认法线方向)，x轴和y轴与物体相切，分别对应切线和副法线。

从凹凸贴图采样的三个颜色通道包含定义在切线空间的法线数据。由图中的黑色箭头表示，对应偏移量为X=0.3，Y=0和Z=0.8的法线。在“a”中，你看到了一个偏向x轴的新法线。

最后要计算像素的发光，这需要获取定义在切线空间中的法线和定义在世界空间中的光线方向的点乘。而要计算两个向量的点乘，这两个向量必须在同一个空间中。所以你要么将光线方向从世界空间转换到切线空间，要么将法线从切线空间转换到世界空间。在本教程中，我们先使用第二种方法。教程的最后一段会使用第一种方法。

图5-31 在切线空间(a)，模型空间(b)和世界空间(c)中的偏离法线

工作原理

对每个像素，你都要采样凹凸贴图获取相对于切线空间的偏离法线。最后要知道这个法线在世界空间中的坐标，然后才能与光线方法进行点乘。

切线空间到世界空间的转换

从凹凸贴图采样的法线是定义在切线空间中的，例如一个圆塔是圆柱形的，如图5－31所示。每个像素的偏离法线需要首先转换到模型空间，即塔的坐标空间。这会得到模型空间中的默认法线。

技巧：要理解塔的坐标空间，想象一下你站在塔的初始位置，例如塔内部的中心位置。现在，你的上方是y轴，右方是x轴，后方是z轴。你想得到的偏离法线是由塔的坐标系统指定的。

这个变换由图5－31中的从“a”指向“b”的箭头表示。在“a”中，切线空间中的法线坐标是(0.3,0,0.8)，而在“b”中模型空间中的法线坐标是(0.2,0,0.85)(由两个系统的坐标决定)。

你可以使用一个包含旋转的世界矩阵绘制一个塔(例如，绘制一个比萨斜塔)。在本例中，你获取的法线需要移除这个旋转才能获得法线的绝对（世界）方向。这个变换由图5－31中的从“b”指向“c”的箭头表示(注意这个图中的模型坐标实际上是世界坐标的旋转)。最后，你获得了世界坐标中的法线。

定义自定义顶点格式

要将法线从切线空间转换到模型空间，你需要乘以正确的变换矩阵。你需要对每个像素都做这样的操作。要创建这个矩阵，首先要知道每个顶点在切线空间的x，y和z轴(法线，切线和副法线) 。

因为默认法线和z轴是不同的，而且x轴和y轴必须垂直于z轴，所以通常每个顶点这三个轴是不同的。因为三个轴正交，所以你只需知道任意两个，就可以通过叉乘它们获取第三个轴。一个轴（z）是默认法线，可以使用教程5－7中的代码获取，切线向量由模型提供，或者在简单的情况中（例如一个圆柱体）由自己定义。

注意：本例中，vertex shader会通过叉乘法线和切线向量计算出副法线向量。这样的话，顶点只需存储法线和切线向量。你也可以编写一个自定义模型处理器计算每个顶点的副法线并将它存储在顶点中，只需使用MeshHelper. CalculateTangentFrames帮你完成这个繁重的工作。这样做的好处是显卡无需在每帧计算副法线向量了。

首先你需要定义一个自定义顶点格式存储每个顶点的3D位置，纹理坐标，法线和切线。可见教程5－14获取自定义顶点格式的细节。

public struct VertPosTexNormTan
{
    public Vector3 Position;
    public Vector2 TexCoords;
    public Vector3 Normal;
    public Vector3 Tangent;
    public VertPosTexNormTan(Vector3 Position, Vector2 TexCoords, Vector3 Normal, Vector3 Tangent)
    {
        this.Position = Position;
        this.TexCoords = TexCoords;
        this.Normal = Normal;
        this.Tangent = Tangent;
    }

    public static readonly VertexElement[] VertexElements =
    {
        new VertexElement(0, 0, VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Position, 0),
        new VertexElement(0, sizeof(float)*3, VertexElementFormat.Vector2, VertexElementMethod.Default, VertexElementUsage.TextureCoordinate, 0),
        new VertexElement(0, sizeof(float)*(3+2), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Normal, 0),
        new VertexElement(0, sizeof(float)*(3+2+3), VertexElementFormat.Vector3, VertexElementMethod.Default, VertexElementUsage.Tangent, 0),
    };

    public static readonly int SizeInBytes = sizeof(float) * (3 + 2 + 3 + 3);
}

每个顶点需要存储一个Vector3表示位置，一个Vector2表示纹理坐标，两个Vector3表示法线和切线，一共11个浮点数，而副法线在vertex shader中计算。

定义每个顶点的法线和切线

本例中，你定义了一些三角形创建一座塔（一个圆柱形的墙），使用教程5－7的代码生成法线数据。

如前所述，切线方向垂直于法线，与塔表明相切。本例中你定义一个垂直的塔，所以你知道向上方向不与塔相交，并垂直于塔的所有法线，这就是切线方向。

下面的代码定义一个圆柱形。对于每个生成的顶点计算出3D位置，并将(0,1,0)向上方向做为切线方向。

private void InitVertices()
{
    List<VertPosTexNormTan> verticesList = new List<VertPosTexNormTan>();

    int detail = 20;
    float radius = 2;
    float height = 8;
    for (int i = 0; i < detail + 1; i++)
    {
        float angle = MathHelper.Pi * 2.0f / (float)detail * (float)i;
        Vector3 baseVector = Vector3.Transform(Vector3.Forward, Matrix.CreateRotationY(angle));
        Vector3 posLow = baseVector * radius;
        posLow.Y = -height / 2.0f;
        Vector3 posHigh = posLow;
        posHigh.Y += height;
        Vector2 texCoordLow = new Vector2(angle / (MathHelper.Pi * 2.0f), 1);
        Vector2 texCoordHigh = new Vector2(angle / (MathHelper.Pi * 2.0f), 0);

        verticesList.Add(new VertPosTexNormTan(posLow, texCoordLow, Vector3.Zero, new Vector3(0, 1, 0)));
        verticesList.Add(new VertPosTexNormTan(posHigh, texCoordHigh, Vector3.Zero, new Vector3(0, 1, 0)));
    }
    vertices = verticesList.ToArray();
}

接下来，基于顶点生成索引，所有的代码已在教程5－7中解释了：

vertices = InitVertices();
indices = InitIndices(vertices);
vertices = GenerateNormalsForTriangleList(vertices, indices);

设置了顶点和索引，下面可以处理. fx文件了。

XNA-to-HLSL变量

所有的3Dhaders都需要传递World，View和Projection矩阵。因为没有光线凹凸映射不起作用，所以还要设置光线方向。最后，xTexStretch 变量让你可以定义砖块纹理的平铺次数。

你需要一张颜色纹理采样颜色，还需要包含定义在切线空间中的法线信息的凹凸贴图。

vertex shader总是要把每个顶点的3D位置转换到2D屏幕坐标，还要传递纹理坐标。要让pixel shader将法线从切线空间转换到世界空间，vertex shader还要计算Tangent-to-World矩阵传递到pixel shader。

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float3 xLightDirection;
float xTexStretch;
Texture xTexture;
sampler TextureSampler = sampler_state
{
    texture = <xTexture> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = wrap;
    AddressV = wrap;
};

Texture xBumpMap;
sampler BumpMapSampler = sampler_state
{
    texture = <xBumpMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = wrap;
    AddressV = wrap;
};

struct BMVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
    float3x3 TTW : TEXCOORD1;
};

struct BMPixelToFrame
{
    float4 Color : COLOR0;
}

注意：这个代码使用了语义TEXCOORD1传递了一个3 × 3矩阵。这会被编译，但背景使用了TEXCOOR2和TEXCOORD3，所以你不能再使用它们了。

Vertex Shader

vertex shader将3D位置转换到2D屏幕坐标并将纹理坐标传递到pixel shader：

BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0)
{
    BMVertexToPixel Output = (BMVertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);

    Output.Position = mul(inPos, preWorldViewProjection);
    Output.TexCoord = inTexCoord; return Output;
}

接下来你要在vertex shader中添加一些代码计算Tangent-to-World矩阵。如前所述，这个转换包含两个过程。首先将法线的坐标从切线空间转换到模型空间，然后转换到世界空间。

首先定义一个tangentToObject矩阵，要定义这个矩阵，你需要知道坐标轴的基本向量——切线空间的法线，切线和副法线。

vertex shader接受每个顶点的法线和切线。如前所述，你可以通过叉乘两者计算出副法线，因为副法线向量是与法线和切线正交的。

float3 Binormal = normalize(cross(inTangent,inNormal));

计算了副法线后就可以构建Tangent-to-Object矩阵了，因为这个矩阵的三行分别代表这三个向量，这些向量都需要被归一化：

float3x3 tangentToObject;
tangentToObject[0] = normalize(Binormal);
tangentToObject[1] = normalize(inTangent);
tangentToObject[2] = normalize(inNormal);

技巧：归一化矩阵的每行后，因为这三个向量是正交的，所以这个矩阵的逆矩阵等于它的转置矩阵，你可以通过将矩阵中的元素关于左上角至右下角的元素做镜像获得它的转置矩阵，这样可以更容易地计算逆矩阵。

你要做的就是通过将Tangent-to-Object矩阵和Object-to-World矩阵相乘把两者组合起来。Object-to-World矩阵通常就是世界矩阵（例如包含塔的旋转），所以最后的代码是：

float3x3 tangentToWorld = mul(tangentToObject, xWorld);
Output.TTW = tangentToWorld;

将这个矩阵传递到pixel shader。现在pixel shader可以很容易地通过把向量乘以这个矩阵把向量从切线空间转换到世界矩阵！

Pixel Shader

在vertex shader做完了全部准备工作，pixel shader看起来就很简单了：

BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0
{
    BMPixelToFrame Output = (BMPixelToFrame)0;
    float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch);
    float3 normalT = (bumpColor - 0.5f)*2.0f;
    float3 normalW = mul(normalT, PSIn.TTW);
    float lightFactor = dot(-normalize(normalW),normalize(xLightDirection));
    float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch);
    Output.Color = lightFactor*texColor;

    return Output;
}

首先采样凹凸映射，这个颜色中包含三个颜色通道对应在切线空间中的偏离法线的坐标。

在前一个教程中的“Pixel Shader”一节中提到，颜色通道的区间是0至1，而法线坐标的区间是–1到1。所以首先将这个值减0.5映射到[-0.5,0.5]区间再乘以2映射到[–1,1]区间。计算结果存储在normalT变量中。 normalT变量包含定义在切线空间中的偏离法线的数据。

normalT值为(0,0,1)表示偏离法线等于默认法线(即切线空间中的z轴方向)。normalT值为(0,0.7,0.7)表示法线在默认法线和切线（切线空间的y轴方向）间的45度角方向。

将normalT 乘以Tangent-to-World 矩阵就可以完成从切线空间到世界空间的转换，获取normalW的值，这个值就是世界空间中的法线。

注意：你可以立即将法线从切线空间转换到世界空间，因为 Tangent-to-World矩阵是Tangent-to-Object矩阵和Object-to-World矩阵的组合。

最后，当你知道了世界空间中的法线后，就可以将这个向量乘以光线向量了，如教程6－1中解释的，这个点乘给出了光线强度，把这个强度乘以颜色，因为每个像素的法线不同，所以每个像素的反光程度也不同。

代码

HLSL部分

shader要计算Tangent-to-World矩阵传递给pixel shader使用：

BMVertexToPixel BMVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoord: TEXCOORD0, float3 inTangent: TANGENT0)
{
    BMVertexToPixel Output = (BMVertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    Output.TexCoord = inTexCoord;
    float3 Binormal = cross(inTangent,inNormal);
    float3x3 tangentToObject;
    tangentToObject[0] = normalize(Binormal);
    tangentToObject[1] = normalize(inTangent);
    tangentToObject[2] = normalize(inNormal);
    float3x3 tangentToWorld = mul(tangentToObject, xWorld);
    Output.TTW = tangentToWorld;

    return Output;
}

pixel shader采样凹凸贴图并将颜色映射到[–1,1]区间获取切线空间的法线，再乘以Tangent-to-World矩阵获取世界空间中的法线，然后点乘光线方向：

BMPixelToFrame BMPixelShader(BMVertexToPixel PSIn) : COLOR0
{
    BMPixelToFrame Output = (BMPixelToFrame)0;
    float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch);
    float3 normalT = (bumpColor - 0.5f)*2.0f;
    float3 normalW = mul(normalT, PSIn.TTW);
    float lightFactor = dot(-normalize(normalW),normalize(xLightDirection));
    float4 texColor = tex2D(TextureSampler, PSIn.TexCoord*xTexStretch);
    Output.Color = lightFactor*texColor;

    return Output;
}

XNA部分

XNA项目要提供每个顶点的切线，切线向量垂直于法线，生成这些顶点的方法前面已经写过了。

最后需要设置effect参数并绘制三角形，代码如下：

effect.CurrentTechnique = effect.Techniques["BumpMapping"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xTexture"].SetValue(myTexture);
effect.Parameters["xBumpMap"].SetValue(myBumpMap);
effect.Parameters["xLightDirection"].SetValue(lightDirection);
effect.Parameters["xTexStretch"].SetValue(4.0f);

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserIndexedPrimitives<VertPosTexNormTan>(PrimitiveType.TriangleList, vertices, 0, vertices.Length, indices, 0, indices.Length/3);
    pass.End();
}
effect.End();

相反的方法

前面的代码显示了如何构建tangentToWorld矩阵，这样每个像素都可以将它的法线从切线空间转换到世界空间。这一步需要在与光线方向点乘前完成，因为光线方向是在世界空间中的。

你也可以换种方法：你可以将光线方向从世界空间转换到切线空间并点乘切线空间中的法线。这很有趣，因为每个像素的光线方向是一样的，所以可以在vertex shader中转换光线方向并将它传递给pixel shader。通过这种方法，pixel shader可以立即进行点乘计算而无需计算任何转换！

注意：使用这个方法，每个三角形只需要进行三次光线向量转换，而不是对三角形的每个像素的法线进行转换。

你将转换到切线空间中的的光线向量传递到pixel shader，而不是传递tangentToWorld矩阵：

struct BMVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
    float3 LightDirT: TEXCOORD1;
};

在vertex shader中，你已经计算了tangentToWorld矩阵。这次，你想将光线向量从世界空间转换到切线空间，所以需要求tangentToWorld矩阵的逆矩阵。

计算逆矩阵是一个复杂的运算。幸运的是，tangentToWorld矩阵是由三个正交、归一化（副法线，切线和法线）的向量构成的。通常，你在乘法操作中以一个向量作为第一个参数，一个矩阵作为第二个矩阵：

float3 vectorW = mul(vectorT, tangentToWorld);

在三个正交归一化向量的特殊情况下，你可以通过改变乘法的顺序获取逆矩阵转换一个向量：

float3 vectorT = mul(tangentToWorld, vectorW);

注意：这是因为由三个正交并归一化的向量构成的矩阵的逆矩阵等于它的转置矩阵。

这正是你想获得的结果：将光线向量从世界空间转换到切线空间：

Output.LightDirT = mul(tangentToWorld, xLightDirection);

计算好切线空间的光线方向后，把把它传递到pixel shader。因为从映射中采样的法线和光线方向都是在切线空间中，你可以直接点乘两者：

float3 bumpColor = tex2D(BumpMapSampler, PSIn.TexCoord*xTexStretch);
float3 normalT = (bumpColor - 0.5f)*2.0f;
float lightFactor = dot(-normalize(normalT), normalize(PSIn.LightDirT));


5.15 在3D世界添加水面

问题

你可以在CPU上计算所有波的3D位置，但这样做会消耗大量的资源，每帧向显卡发送大量的数据是不可接受的。

解决方案

在XNA程序中，你只需创建一个三角形组成的平面网格。这和创建地形类似，在教程5－8中已经解释过了，只不过这次网格是平的，你将需一次性地把这些数据传递到显卡。当绘制网格时，vertex shader会在网格上添加水波，pixel shader添加反射，这一切都在GPU中完成，CPU只处理Draw指令，让CPU可以处理更重要的工作。

vertex shader接受网格的顶点数据并改变它们的高度，这样平的网格会形成一个波涛起伏的海面。你可以使用一个正弦波产生这个高度。只有一个正弦波会导致海面看起来太理想化，这是因为每个波都是一样的。

幸运的是，GPU上的所以操作在一次并行处理中能执行四次，所以计算一个正弦波和计算四个正弦波所用的时间是相同的。vertex shader会计算四个正弦波并将它们求和，如图5－32所示。当你对每个波设置不同的波长、波速和振幅，最终会形成比较真实的海面。

图5－32 四个正弦波的叠加

只有海面反射周围环境时它才会看起来足够真实。在pixel shader中，你将从天空盒（见教程2－8）采样反射颜色。但是，这些反射太完美的话会形成像玻璃一样的水洋面，反而不真实。所以在每个像素中，你还要在水面上添加凹凸映射（见教程5－16），在大的海浪上再增加一些小的逐像素的漪涟。

最后使用菲涅耳项调整最后结果，这样pixel shader会根据视角在深蓝色和反射颜色之间进行插值。

工作原理

在XNA项目中，你需要导入并绘制一个天空盒（见教程2－8）.接着，你需要为平面网格生成顶点和索引，这个只是教程5－8中地形生成的简化版本。使用如下代码生成顶点：

private VertexPositionTexture[] CreateWaterVertices()
{
    VertexPositionTexture[] waterVertices = new VertexPositionTexture[waterWidth * waterHeight];

    int i = 0;
    for (int z = 0;z < waterHeight; z++)
    {
        for (int x = 0; x < waterWidth; x++)
        {
            Vector3 position = new Vector3(x, 0, -z);
            Vector2 texCoord = new Vector2((float)x / 30.0f, (float)z / 30.0f);
            waterVertices[i++] = new VertexPositionTexture(position, texCoord);
        }
    }
    return waterVertices;
}

这个网格是平的，因为所有Y值都是0。在顶点中只存储了3D位置和纹理坐标，生成索引的方法已在教程5－8中介绍过了。定义好顶点和索引后，你就做好了编写HLSL effect文件的准备了。

XNA-to-HLSL变量

你想让海面的生成尽可能的灵活，所以期望可以在XNA项目中设置很多变量。因为你处理的3D位置要被转换到2D屏幕坐标，所以需要World，View和Projection矩阵。接着你想完全控制四个正弦波，每个波都有四个可控变量：振幅、波长、波速和方向。

因为你想让vertex shader在每帧都更新波，所以需要知道当前时间；要添加水面反射，需要知道相机的3D位置。最后，要在水面通过凹凸贴图添加漪涟，你还要一个凹凸贴图，还要两个变量设置凹凸贴图的强度和漪涟的大小：

float4x4 xWorld; float4x4 xView;
float4x4 xProjection;
float4 xWaveSpeeds;
float4 xWaveHeights;
float4 xWaveLengths;
float2 xWaveDir0;
float2 xWaveDir1;
float2 xWaveDir2;
float2 xWaveDir3;
float3 xCameraPos;
float xBumpStrength;
float xTexStretch;
float xTime;

你需要一个天空盒采样反射的颜色，还有一个凹凸贴图：

Texture xCubeMap;
samplerCUBE CubeMapSampler =sampler_state
{
    texture = <xCubeMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU =mirror;
    AddressV = mirror;
};

Texture xBumpMap; sampler BumpMapSampler =sampler_state
{
    texture = <xBumpMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

Output结构

要查询反射的颜色，pixel shader需要知道相机位置。需要根据pixel shader 中对凹凸贴图的处理构建出Tangent-to-World矩阵。

pixel shader只需计算每个像素的颜色：

struct OWVertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
    float3 Pos3D: TEXCOORD1;
    float3x3 TTW: TEXCOORD2;
};

struct OWPixelToFrame
{
    float4 Color : COLOR0;
};

Vertex Shader：正弦波

vertex shader中最重要的任务是调整每个顶点的高度。前面已经解释了，你将四个正弦函数进行叠加生成水波。正弦函数需要一个参数，当这个参数增加时，会形成在–1到1之间的波形。你使用顶点位置作为正弦函数的一个参数，这个参数与波的传播方向相关。你可以将顶点的X，Z位置与传播方向点乘，对于垂直于波的传播方向上一直线上的所有顶点来说，这个点乘值是相同的。如果参数相同，那么正弦函数也相同，所以它们的高度也是相同的。 这让波在正确地方向上波形良好。

OWVertexToPixel OWVertexShader(float4 inPos: POSITION0, float2 inTexCoord: TEXCOORD0)
{
    OWVertexToPixel Output = (OWVertexToPixel)0;

    float4 dotProducts;
    dotProducts.x = dot(xWaveDir0, inPos.xz);
    dotProducts.y = dot(xWaveDir1, inPos.xz);
    dotProducts.z = dot(xWaveDir2, inPos.xz);
    dotProducts.w = dot(xWaveDir3, inPos.xz);
}

因为shader允许你将四个正弦函数求和，你需要对顶点的XZ坐标和波的方向进行四次点乘。在使用这些点乘作为正弦函数的参数之前，需要将它们除以xWaveLengths变量，这样可以调整波长。

最后要使水波移动，所以需要在参数中添加当前时间。将xWaveSpeeds 变量乘以当前时间，让你可以定义每列波的波速，让某些波可以比其他波运动得更快或是更慢。下面是代码：

float4 arguments = dotProducts/xWaveLengths+xTime*xWaveSpeeds;
float4 heights = xWaveHeights*sin(arguments);

正弦函数的结果乘以xWaveHeights变量，这样你可以独立地缩放四个波形。记住这个代码是并行地执行了四次，让你可以快速地获取当前时间当前顶点的正弦波的高度。

计算完正弦函数后，将它们相加作为顶点的Y坐标：

float4 final3DPos = inPos;
final3DPos.y += heights.x;
final3DPos.y += heights.y;
final3DPos.y += heights.z;
final3DPos.y += heights.w;
float4x4 preViewProjection = mul(xView, xProjection);
float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
Output.Position = mul(final3DPos, preWorldViewProjection);
float4 final3DPosW = mul(final3DPos, xWorld);
Output.Pos3D = final3DPosW;

这可以有效地将顶点高度提升。一旦你知道了顶点的最终位置，你就可以将它们转换为2D屏幕位置了。

pixel shader也需要知道3D位置，所以你要将3D位置信息传递到pixel shader (因为Output.Position不是在pixel shader中被处理的)。

Pixel Shader：最简单的形式

在vertex shader转换了顶点的3D位置后，这个简单的pixel shader会给每个像素施加同样的颜色并将它们绘制到屏幕：

OWPixelToFrame OWPixelShader(OWVertexToPixel PSIn) : COLOR0
{
    OWPixelToFrame Output = (OWPixelToFrame)0;
    float4 waterColor = float4(0,0.1,0.3,1);
    Output.Color = waterColor;
    return Output;
}

只能看到一片蓝色混在一起上下起伏

定义Technique

添加technique定义使effect可用。如果你每帧更新xTime变量，那么波也会在vertex shader中更新：

technique OceanWater
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 OWVertexShader();
        PixelShader = compile ps_2_0 OWPixelShader();
    }
}

Vertex Shader：法线计算

所有光线计算都要用到顶点的法线，在你的XNA项目中你无需添加顶点的法线信息。法线向量必须垂直于表面，因为vertex shader在不停地改变表面，所以在vertex shader中还要计算法线。

对于一个平面，法线的方向是(0,1,0)向上。当水波通过平面时，你需要知道(0,1,0)向量会偏离多少。你可以通过求导算出一个函数（例如正弦函数）上任意点的偏移量。听起来很难，但幸运的是正弦函数的导数是余弦函数。如果太抽象，可以看一下图5-33。实线表示正弦函数，你可以看到虚线非常好（事实上是非常完美）地显示了波形上任意点的法线的偏移程度。

图5-33 正弦函数(实线)和余弦函数(虚线)

例如，在波峰和波谷(这里水面是平的)，余弦值为0，这表示(0,1,0)向量无需改变，而在波的平衡位置余弦函数最大。

这意味着计算完正弦函数后，还要对高度函数求导。因为高度函数要乘以xWaveHeights，所以求导函数也要乘以xWaveHeights。正弦函数的导数是余弦函数，在将dotProducts (图5-33中的水平轴)除以xWaveLengths时也要将它除以xWaveLengths。

float4 derivatives = xWaveHeights*cos(arguments)/xWaveLengths;

上面公式的意义是：波越高，则波形越陡，法线的偏移量就越大，而波长越长，法线偏离(0,1,0) 方向就越少。

注意：正弦和余弦都乘以xWaveHeights这意味这如果你将高度设置为0，那么顶点就没有起伏或法线就没有偏离。

现在你已经计算出了导数，将它们相加获得当前顶点的法线偏移量。图5-33中的波是2D的，所以余弦值表示法线向前或向后的偏移量。而你的波是在3D空间中的，所以前面的话中你应该用“波的传播方向”代替“向前”：

float2 deviations = 0;
deviations += derivatives.x*xWaveDir0;
deviations += derivatives.y*xWaveDir1;
deviations += derivatives.z*xWaveDir2;
deviations += derivatives.w*xWaveDir3;
float3 Normal = float3(-deviations.x, 1, -deviations.y);

顶点法线为(0,1,0)表示几乎不偏离，对应为波峰波谷的顶点，如果波长很短那么法线几乎都是指向波的传播方向的。

Vertex Shader: 创建Tangent-to-World矩阵

当你使用前一节中生成的法线给水面添加光照时，明暗效果已经很好了，但是你还要在水面上添加一些小起伏。要添加这个细节，你需要使用凹凸映射。在pixel shader中进行凹凸映射前，你需要生成正确的Tangent-to-World矩阵。

如教程5-16中解释的那样，这个矩阵中的行对应法线、副法线和切线向量。现在你已经有了法线向量，现在继续定义切线和副法线向量：

float3 Binormal = float3(1, deviations.x, 0);
float3 Tangent = float3(0, deviations.y, 1);

这三个向量必须彼此垂直。因为你要让法线偏离(0,1,0)方向，所以你需要将副法线偏离(1,0,0)方向，切线偏离(0,0,1)方向。

知道了切线空间的三个向量后，就很容易定义Tangent-to-World矩阵，如教程5-16中解释的那样：

float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal);
tangentToObject[1] = normalize(Tangent);
tangentToObject[2] = normalize(Normal);
float3x3 tangentToWorld = mul(tangentToObject, xWorld);
Output.TTW = tangentToWorld;
Output.TexCoord = inTexCoord+xTime/50.0f*float2(-1,0);
return Output;

你将Tangent-to-World 矩阵和用来从凹凸映射中采样的纹理坐标传递到pixel shader。

Pixel Shader：凹凸映射

在每个像素中，你将改变法线方向用来添加水面的小起伏。如果使用凹凸贴图，你可以很容易地在最后结果中看到凹凸贴图上的图案。对每个像素，你都要采样凹凸贴图的三个颜色通道并对结果求平均。记住颜色总是定义在[0,1]区间，所以你将颜色的每个分量减去0.5使它们在[–0.5,0.5]区间。然后在下一步把它们映射到[–1,1]区间(法线的XYZ坐标区间)。

float3 bumpColor1 = tex2D(BumpMapSampler, xTexStretch*PSIn.TexCoord)-0.5f;
float3 bumpColor2 = tex2D(BumpMapSampler, xTexStretch*1.8*PSIn.TexCoord.yx)-0.5f;
float3 bumpColor3 = tex2D(BumpMapSampler, xTexStretch*3.1*PSIn.TexCoord)-0.5f;
float3 normalT = bumpColor1 + bumpColor2 + bumpColor3;

这三个纹理坐标是不同的，因为它们乘以不同的因子。第二个纹理的XY坐标已经被改变，最后，将三个偏移量相加。

这个方向必须要归一化，但在这之前，你还可以缩放凹凸贴图。在凹凸贴图中，蓝色向量对应默认法线向量，红色和绿色对应副法线和切线的偏移量，所以如果你增加/减少红色或绿色，就可以增加/减少偏移量，并由此改变了凹凸映射！

normalT.rg *= xBumpStrength;
normalT = normalize(normalT);
float3 normalW = mul(normalT, PSIn.TTW);

上面代码中获取的方向需要归一化并转换到世界空间。你最终得到世界空间中的法线，这样可以和其他世界空间中的向量进行计算。

Pixel Shader：反射

有了定义在世界空间中的法线向量，现在可以计算水面的光照了。但是为了使效果更真实，你需要首先添加水面的反射。因为反射颜色是从天空盒采样的，所以需要从天空盒采样的方向(见教程2-8)。你可以将eye向量取关于法线的镜像获取这个反射方向，如图5-34所示。

图5-34 将eye向量关于法线取镜像获得reflection向量

你可以将目标点减去初始点获取eye向量：

float3 eyeVector = normalize(PSIn.Pos3D - xCameraPos);

因为eye向量盒法线向量都是在定义在间中，你可以对他们进行操作。HLSL提供了reflect 方法可以计算如图5-34的反射向量：

float3 reflection = reflect(eyeVector, normalW);

如果你从这个方向采样天空盒纹理，就可以获得当前像素的反射颜色(可见教程2-8获取更多关于texCUBE的知识)：

float4 reflectiveColor = texCUBE(CubeMapSampler, reflection);

Pixel Shader：菲涅耳项

如果你只是简单地施加反射颜色，那么水面任何地方反射程度都一样，这会导致水面像一面起伏的镜子。要解决这个问题，你需要根据观察角度调整反射率。如果你平行于水面观察，反射率会很高，如果垂直观察，反射率低，水面会呈现深蓝色。

译者注：在《新概念物理－光学教程》第276页，引用了M.C.Escher的名画《三界（Three Worlds）》形象地表明了：同是在水与空气的界面上的光，来自于入射角大的远处，以反射为主，只能看到树的倒影；来自于入射角小的近处，以透射为主，即反射率很小，可以清楚地看到水下的鱼。

这可以使用eye向量盒法线向量的点乘表示。如果你垂直观察水面，那么这两个向量夹角为0，点乘积最大（如果两个向量都是单位向量则为1）。如果你平行于水面观察，这两个向量的夹角为90度，点乘积为0。Eye向量盒法线向量的点乘叫做菲涅耳项（Fresnel term）。

菲涅耳项大（接近1）表示最小的反射，而菲涅耳项小的像素表示它的行为像一面镜子。水面看起来永远不会像一面镜子，所以你要把菲涅耳项从[0,1]区间缩放到[0.5,1]区间来降低反射率：

float fresnelTerm = dot(-eyeVector, normalW);
fresnelTerm = fresnelTerm/2.0f+0.5f;

注意：代码第一行中的负号是必须的，这是因为这两个向量方向相反，法线指向上方而eye向量指向下方，如果没有负号会导致菲涅耳项为负数。

效果已经不错了，但还没加上对太阳的反射效果

Pixel Shader：镜面反射

现在你已经知道了反射的颜色，反射与深蓝色混合的程度，下面你可以通过添加一些镜面反射来改进最后的效果。

如教程6-4中解释的那样，镜面反射通常取决于入射光的方向。在本例中，通过找到反射立方贴图中光源的水面上的点的方式获取更好的效果，它们通常对应于天空盒的亮白色部分。

要找到明亮的反射位置，要将反射颜色的三个颜色通道相加求和，明亮的部位和会接近于3，将这个值除以3使之处于[0,1]区间。

float sunlight = reflectiveColor.r;
sunlight += reflectiveColor.g;
sunlight += reflectiveColor.b;
sunlight /= 3.0f;
float specular = pow(sunlight,30);

将这个值进行30的幂计算，这样可以只剩下最亮的颜色，只有大于0.975的sunlight值才会得到超过0.5的specular值！

Pixel Shader：整合在一起

最后，你将所有要素组合在一起获得最终的颜色，下面是代码：

float4 waterColor = float4(0,0.2,0.4,1);
Output.Color = waterColor*fresnelTerm + reflectiveColor*(1-fresnelTerm) + specular;

深蓝色的水面颜色和反射颜色的联系是由菲涅耳项决定的。对几乎所有像素，这这种颜色的组合构成了最终的颜色。对于一个有很亮反光颜色的像素，specular值会大于1，这会让最终颜色亮得多，有着非常亮的反射的水面位置对应的是天空盒中太阳的位置。

最终效果

代码

XNA部分

在XNA项目中，你可以完全控制波。你可以独立地设置四个波的波速，振幅和波长。如果你想移除一个波，只需将它的振幅设置为0即可。下面的代码设置effect的所有参数并绘制海面的三角形：

Vector4 waveSpeeds = new Vector4(1, 2, 0.5f, 1.5f);
Vector4 waveHeights = new Vector4(0.3f, 0.4f, 0.2f, 0.3f);
Vector4 waveLengths = new Vector4(10, 5, 15, 7);
Vector2[] waveDirs = new Vector2[4];

waveDirs[0] = new Vector2(-1, 0);
waveDirs[1] = new Vector2(-1, 0.5f);
waveDirs[2] = new Vector2(-1, 0.7f);
waveDirs[3] = new Vector2(-1, -0.5f);

for (int i = 0; i < 4; i++)
    waveDirs[i].Normalize();
effect.CurrentTechnique = effect.Techniques["OceanWater"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xBumpMap"].SetValue(waterBumps);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xBumpStrength"].SetValue(0.5f);
effect.Parameters["xCubeMap"].SetValue(skyboxTexture);
effect.Parameters["xTexStretch"].SetValue(4.0f);
effect.Parameters["xCameraPos"].SetValue(fpsCam.Position);
effect.Parameters["xTime"].SetValue(time);
effect.Parameters["xWaveSpeeds"].SetValue(waveFreqs);
effect.Parameters["xWaveHeights"].SetValue(waveHeights);
effect.Parameters["xWaveLengths"].SetValue(waveLengths);
effect.Parameters["xWaveDir0"].SetValue(waveDirs[0]);
effect.Parameters["xWaveDir1"].SetValue(waveDirs[1]);
effect.Parameters["xWaveDir2"].SetValue(waveDirs[2]);
effect.Parameters["xWaveDir3"].SetValue(waveDirs[3]);

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.Vertices[0].SetSource(waterVertexBuffer, 0, VertexPositionTexture.SizeInBytes);
    device.Indices = waterIndexBuffer;
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawIndexedPrimitives(PrimitiveType.TriangleStrip, 0, 0, waterWidth *waterHeight,
    0, waterWidth * 2 * (waterHeight - 1) - 2);
    pass.End();
}
effect.End();

注意：波的参数只有在波变化时才需要被设置，而其他参数，如time，view matrix和camera position需要每帧都更新或当相机位置变动时更新。

HLSL部分

你可以在本教程开始部分找到XNA-to-HLSL变量，纹理和输出结构。

下面是vertex shader的代码，vertex shader中不停地改变每个顶点的高并计算Tangent-to-World矩阵：

OWVertexToPixel OWVertexShader(float4 inPos: POSITION0, float2 inTexCoord:TEXCOORD0)
{
    OWVertexToPixel Output = (OWVertexToPixel)0;

    float4 dotProducts;
    dotProducts.x = dot(xWaveDir0, inPos.xz);
    dotProducts.y = dot(xWaveDir1, inPos.xz);
    dotProducts.z = dot(xWaveDir2, inPos.xz);
    dotProducts.w = dot(xWaveDir3, inPos.xz);
    float4 arguments = dotProducts/xWaveLengths+xTime*xWaveSpeeds;
    float4 heights = xWaveHeights*sin(arguments);
    float4 final3DPos = inPos;
    final3DPos.y += heights.x;
    final3DPos.y += heights.y;
    final3DPos.y += heights.z;
    final3DPos.y += heights.w;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(final3DPos, preWorldViewProjection);
    float4 final3DPosW = mul(final3DPos, xWorld);
    Output.Pos3D = final3DPosW;

    float4 derivatives = xWaveHeights*cos(arguments)/xWaveLengths;
    float2 deviations = 0;
    deviations += derivatives.x*xWaveDir0;
    deviations += derivatives.y*xWaveDir1;
    deviations += derivatives.z*xWaveDir2;
    deviations += derivatives.w*xWaveDir3;

    float3 Normal = float3(-deviations.x, 1, -deviations.y);
    float3 Binormal = float3(1, deviations.x, 0);
    float3 Tangent = float3(0, deviations.y, 1);
    float3x3 tangentToObject; tangentToObject[0] = normalize(Binormal);
    tangentToObject[1] = normalize(Tangent);
    tangentToObject[2] = normalize(Normal);
    float3x3 tangentToWorld = mul(tangentToObject, xWorld);
    Output.TTW = tangentToWorld;
    Output.TexCoord = inTexCoord+xTime/50.0f*float2(-1,0);
    return Output;
}

pixel shader将深蓝色的水面颜色与反射颜色混合，混合程度取决于视角，由菲涅耳项表示。镜面反光项会在对应环境中光源的水面上的位置添加高光。

OWPixelToFrame OWPixelShader(OWVertexToPixel PSIn) : COLOR0
{
    OWPixelToFrame Output = (OWPixelToFrame)0;

    float3 bumpColor1 = tex2D(BumpMapSampler, xTexStretch*PSIn.TexCoord)-0.5f;
    float3 bumpColor2 = tex2D(BumpMapSampler, xTexStretch*1.8*PSIn.TexCoord.yx)-0.5f;
    float3 bumpColor3 = tex2D(BumpMapSampler, xTexStretch*3.1*PSIn.TexCoord)-0.5f;
    float3 normalT = bumpColor1 + bumpColor2 + bumpColor3;
    normalT.rg *= xBumpStrength;
    normalT = normalize(normalT);
    float3 normalW = mul(normalT, PSIn.TTW);
    float3 eyeVector = normalize(PSIn.Pos3D - xCameraPos);
    float3 reflection = reflect(eyeVector, normalW);
    float4 reflectiveColor = texCUBE(CubeMapSampler, reflection);
    float fresnelTerm = dot(-eyeVector, normalW);
    fresnelTerm = fresnelTerm/2.0f+0.5f;

    float sunlight = reflectiveColor.r;
    sunlight += reflectiveColor.g;
    sunlight += reflectiveColor.b; sunlight /= 3.0f;
    float specular = pow(sunlight,30);
    float4 waterColor = float4(0,0.2,0.4,1);
    Output.Color = waterColor*fresnelTerm + reflectiveColor*(1-fresnelTerm) + specular;

    return Output;
}


5.16 在3D空间中使用Catmull-Rom插值生成额外的顶点

问题

给定一个3D空间中的点序列，你想构建一个漂亮的，光滑曲线可以通过所有这些点。图5-32中的黑色曲线显示了这样条曲线，灰色线段表示使用简单的线性插值的情况，可参见教程5-9。

图5-32 通过5点的Catmul-Rom样条（spline）

这在许多情况中是很用的。例如，你可以用它产生一个赛道，可参见教程5-17。当相机非常靠近模型或地形时，你可以使用Catmull-Rom插值产生额外的顶点，使模型或地形看起来更加光滑。

解决方案

如果你想在两个基点之间生成Catmull-Rom样条，你还需要知道两个邻近基点。在图5-32中，当你想在点1和点2之间生成曲线时，你需要知道基点0, 1, 2和3的坐标。

XNA已经带有一维的Catmull-Rom功能。你可以将任意四个基点传入MathHelper. CatmullRom方法，这个方法可以为你计算第二和第三个点之间的曲线。你还需要传递在0和1范围内的第五个参数，表示你想要第二和第三个点之间的哪个点。

在本教程中，你将这个功能扩展到3维并创建一个方法用来生成多个基点之间的一根样条。

工作原理

XNA提供了一维的单个值的Catmul-Rom插值算法。因为Vector3只是三个单个值的组合，所以你可以通过在Vector3的X, Y和Z分量上调用一维XNA方法实现Catmull-Rom插值 。下面的方法是XNA默认方法的3D扩展，它接受四个Vector3而不是四个单个值。

private Vector3 CR3D(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float amount)
{
    Vector3 result = new Vector3();

    result.X= MathHelper.CatmullRom(v1.X, v2.X, v3.X, v4.X, amount);
    result.Y = MathHelper.CatmullRom(v1.Y, v2.Y, v3.Y, v4.Y, amount);
    result.Z = MathHelper.CatmullRom(v1.Z, v2.Z, v3.Z, v4.Z, amount);

    return result;
}

这个方法会返回一个叫做result的Vector3，位于v2和v3之间的样条上。变量amount让你可以指定v2，result和v3之间的距离，当amount为0时返回v2，1返回v3。

使用CR3D方法计算样条

有了计算3维Catmull-Rom插值的方法，就可以生成样条的多个点了。给定3维空间中的四个基点v1，v2，v3和v4，下面的方法返回v2和v3之间的20个点的集合，第一个点位于v2。

private List<Vector3> InterpolateCR(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4)
{
    List<Vector3> list = new List<Vector3>();

    int detail = 20;
    for (int i = 0; i < detail; i++)
    {
        Vector3 newPoint = CR3D(v1, v2, v3, v4, (float)i / (float)detail);
        list.Add(newPoint);
    }
    return list;
}

如果你想添加/移除v2和v3之间的点，可以增加/减少detail值(或更好的做法，将detail值作为这个方法的参数)。

注意：点集合中的最后一个点不是v3。这是因为最后一个点调用CR3D方法时，传递的参数是19/20而不是1。

你需要手动添加v3，例如，添加这行代码：list.Add(v3);。

将样条的多个部分连接在一起

前面的代码生成了位于四个点中间两点之间的样条，下面的代码表示如何扩展样条：

points.Add(new Vector3(0,0,0));
points.Add(new Vector3(2,2,0));
points.Add(new Vector3(4,0,0));
points.Add(new Vector3(6,6,0));
points.Add(new Vector3(8,2,0));
points.Add(new Vector3(10, 0, 0));

List<Vector3> crList1 = InterpolateCR(points[0], points[1], points[2], points[3]);
List<Vector3> crList2 = InterpolateCR(points[1], points[2], points[3], points[4]);
List<Vector3> crList3 = InterpolateCR(points[2], points[3], points[4], points[5]);

straightVertices = XNAUtils.LinesFromVector3List(points, Color.Red);
crVertices1 = XNAUtils.LinesFromVector3List(crList1, Color.Green);
crVertices2 = XNAUtils.LinesFromVector3List(crList2, Color.Blue);
crVertices3 = XNAUtils.LinesFromVector3List(crList3, Color.Yellow);

首先在3D空间定义七个（译者注：原文如此，不过好像是六个）点的集合。然后，使用前四个点生成points [ 1]和points [ 2]之间的额外点。然后切换到下一个点获取points [2]和points [ 3]之间的额外点。最后获取了points[3]和points[4]之间的额外点。

这意味着你最终获得了许多额外点，所有这些点都在从points[1]出发，经过points[2]和point[3]，终止于points[4]的样条上(看一下图5-32加深理解)。

因为你想绘制这些点，所以要从样条的这三个部分生成顶点。首先要将七个基点转换为顶点，这样就可以绘制线段了。使用下面的方法进行此步：

public static VertexPositionColor[] LinesFromVector3List(List<Vector3> pointList, Color color)
{
    myVertexDeclaration = new VertexDeclaration(device, VertexPositionColor.VertexElements);
    VertexPositionColor[] vertices = new VertexPositionColor[pointList.Count];

    int i = 0;
    foreach (Vector3 p in pointList)
        vertices[i++] = new VertexPositionColor(p, color);

    return vertices;
}

上述方法只是简单地将集合中的每个Vector3转换为一个顶点，并将它和选择的颜色存储在一个数组中。

你可以绘制位于这些顶点间的线段，可参加教程6-1：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.VertexColorEnabled = true;
basicEffect.TextureEnabled = false;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, straightVertices, 0, straightVertices.Length - 1);
    device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices1, 0, crVertices1.Length - 1);
    device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices2, 0, crVertices2.Length- 1);
    device.DrawUserPrimitives<VertexPositionColor>(PrimitiveType.LineStrip, crVertices3, 0, crVertices3.Length - 1);
    pass.End();
}
basicEffect.End();

代码

这个教程介绍了两个方法。第一个是XNA自带的Catmull-Rom 插值算法在3维空间中的扩展：

private Vector3 CR3D(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4, float amount)
{
    Vector3 result = new Vector3();

    result.X = MathHelper.CatmullRom(v1.X,v2.X, v3.X, v4.X, amount);
    result.Y = MathHelper.CatmullRom(v1.Y, v2.Y, v3.Y, v4.Y, amount);
    result.Z = MathHelper.CatmullRom(v1.Z, v2.Z, v3.Z, v4.Z, amount);

    return result;
}

第二个方法可以计算通过四个基点的样条上额外点：

private List<Vector3> InterpolateCR(Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4)
{
    List<Vector3> list = new List<Vector3>();

    int detail = 20;
    for (int i = 0; i < detail; i++)
    {
        Vector3 newPoint = CR3D(v1, v2, v3, v4, (float)i / (float)detail);
        list.Add(newPoint);
    }
    list.Add(v3);
    return list;
}


5.17 为赛道创建顶点

问题

给定三维空间中的一个基点集合，你想创建一条通过所有点的赛道。你想创建顶点，计算法线，在赛道上贴上纹理。

解决方案

你可以经过几个步骤从3D点的集合创建一个赛道。首先使用教程5-16中讨论的3维Catmull-Rom插值生成位于你预定义的点之间的样条上的额外点。第一步可用图5－33中的从“a”指向“b的箭头表示”。

基于这些点无法定义三角形。对样条上的每个点，你要计算垂直于样条的方向，在样条两侧各添加一个点，如图5-33中的“b”所示。

图5-33 为赛道生成顶点

最后，通过将这些新计算的点转换为顶点创建一个TriangleList，如图5-36的“d”所示。

工作原理

首先需要定义一些3D点。例如下面的集合定义了赛道类似于8的形状：

List<Vector3> trackPoints = new List<Vector3>();
trackPoints.Add(new Vector3(2, 0, 4));
trackPoints.Add(new Vector3(0, 0, 0));
trackPoints.Add(new Vector3(-2, 0, -4));
trackPoints.Add(new Vector3(2, 0, -4));
trackPoints.Add(new Vector3(0, 1, 0));
trackPoints.Add(new Vector3(-2, 0, 4));

注意：最后一个点比其他点高一个单位，所以你没必要将红绿灯放置在赛道上。

计算基点之间的额外点

首先使用Catmull-Rom插值计算基点间的许多额外点，如图5-34的”b”所示。在教程5-16中创建的InterpolateCR方法很有用，因为它可以计算任意两个基点间的额外点。

但是，要创建两个基点间的额外点你还需要提供两个邻近的基点。例如，如果你想计算图5-34中点1和点2间的额外点，需要将点0, 1, 2,3传递到InterpolateCR方法中。

图5-34 使用Catmull-Rom的头尾连接

通常来说，如果你想计算基点i和i+1之间的额外点，你需要提供点i-1, i,i+1和i+2。这在赛道末端会导致一个问题，如图5-34所示。这个赛道由八个点组成，你可以毫无问题地构建位于[1,2], [2,3], [3,4], [4,5]和[5,6]之间的部分。但是，当计算[6,7]间的额外点时，你需要传递点5, 6, 7和8。但因为集合中只包含基点0到7，这里就会出现问题。更坏的是，要计算最后的7到0，0到1两段时，你还需要基点9和10，它们也不存在。

幸运的是，你知道赛道的最后一点连接到第一点，所以你知道基点8就是基点0。同样的道理，基点9就是基点1，基点10就是基点2。这意味着你可以将基点0, 1和2添加到集合尾部来解决这个问题。这一步需要在GenerateTrackPoints方法的一开始进行，这个方法会创建整个赛道的所有额外点的集合：

private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints)
{
    basePoints.Add(basePoints[0]);
    basePoints.Add(basePoints[1]);
    basePoints.Add(basePoints[2]);

    List<Vector3> allPoints = new List<Vector3>();
    for (int i = 1; i < basePoints.Count-2; i++)
    {
        List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]);
        allPoints.AddRange(part);
    }
    allPoints.Add(allPoints[0]);

    return allPoints; }

将前三个基点复制到点集合的尾部后，你创建了一个新的空集合，这个集合包含了赛道的所有中心点，对应图5-33中的“b”。

在for循环中会从一段跳到下一段，使用InterpolateCR方法创建一段的额外点，并将所有点都添加到allPoints集合中。

对每一段，for循环会调用InterpolateCR方法，传递基点i-1，i，i+1和i+2，i从1开始。这意味着第一段从[ 1,2]开始，如图5-34左图所示。InterpolateCR方法会返回基点1和2之间的基点1和19个额外点。这20个点会被添加到allPoints集合中。

最后一段会被添加到[8,9]之间，与[0,1]相同。

注意：你可以通过InterpolateCR 方法中的detail 变量调整额外点的数量。

for循环会继续直到所有部分的所有额外点都被添加到allPoints集合中。

现在你知道了赛道的所有中心点，如图5-33的“b”所示。

计算外部的side点

对每个点你想在赛道的两边各定义一个新的点，如图5-33中“c”，这样你可以将它们连接在一起定义三角形。

要做到这一点，首先需要计算每个点的side方向。Side方向垂直于汽车行驶的方向和垂直于赛道的法线方向。这里(0,1,0) Up向量作为赛道上每个点的法线方向。

如果知道两个方向，你可以叉乘这两个方向获取垂直于这两个方向的方向。本例中，这两个方向是(0,1,0) Up方向和汽车的行驶方向，行驶方向是从当前点指向下一点的方向，你可以通过将当前点减去下一个点获取这个方向：

Vector3 carDir = basePoints[i + 1] - basePoints[i];
Vector3 sideDir =Vector3.Cross(new Vector3(0, 1, 0), carDir);
sideDir.Normalize();
Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth;
Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth;

获取了side方向，你将它乘以赛道宽度并加/减当前点。这时，你就计算了图5-33 中“c”的side点。

创建顶点

在为这些点创建顶点前，你需要提供它们的法线和纹理坐标。这里你将(0,1,0) Up向量作为法线方向。

纹理坐标有点难。如果你在每个顶点的Y坐标上添加了一个常量，那么在基点位置互相接近的位置的纹理会缩在一起。这是因为InterpolateCR方法会在任何情况下都要添加20个额外点，无论是两个基点非常靠近还是远离。

要解决这个问题，需要保存将两点间的距离保存在一个distance变量中，比较长的部分要比比较短的部分增加更多的Y坐标：

VertexPositionNormalTexture vertex;
vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength));
verticesList.Add(vertex);

vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength));
verticesList.Add(vertex);

distance += carDir.Length();

注意：d值在处理好赛道上每个点之后才增加。纹理的Length变量让你可以缩放赛道纹理。你需要对赛道的每个中心点都进行这样的操作：

private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3> basePoints)
{
    float halfTrackWidth = 0.2f;
    float textureLength = 0.5f;
    float distance = 0;
    List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>();

    for (int i = 1; i < basePoints.Count-1; i++)
    {
        Vector3 carDir = basePoints[i + 1] - basePoints[i];
        Vector3 sideDir = Vector3.Cross(new Vector3(0, 1, 0), carDir);
        sideDir.Normalize();
        Vector3 outerPoint = basePoints[i] + sideDir * halfTrackWidth;
        Vector3 innerPoint = basePoints[i] - sideDir * halfTrackWidth;

        VertexPositionNormalTexture vertex;
        vertex = new VertexPositionNormalTexture(innerPoint, new Vector3(0, 1, 0), new Vector2(0, distance / textureLength));
        verticesList.Add(vertex);

        vertex = new VertexPositionNormalTexture(outerPoint, new Vector3(0, 1, 0), new Vector2(1, distance / textureLength));
        verticesList.Add(vertex); istance += carDir.Length();
    }

    VertexPositionNormalTexture extraVert = verticesList[0];
    extraVert.TextureCoordinate.Y = distance / textureLength;
    verticesList.Add(extraVert);

    extraVert = verticesList[1];
    extraVert.TextureCoordinate.Y = distance / textureLength;
    verticesList.Add(extraVert);

    return verticesList.ToArray();
}

for循环之后，verticesList包含了赛道每个中心点的两个顶点。但是，当你从顶点集合绘制三角形时，仍会在最后一个中心点和第一个中心点之间出现缝隙。要连接这个缝隙，你需要将前两个中心点的side点复制到集合中。但是，因为前两个顶点的Y坐标为0，你需要将它们调整到当前的纹理坐标值。否则，最后两个三角形将会将它们的Y坐标回到0，导致大量的纹理堆砌在两个小三角形上。

最后，将集合转换为数组并返回调用代码。

绘制赛道

有了基点和定义了方法后，添加以下代码将基点转换为一个顶点数组：

List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints);
trackVertices = GenerateTrackVertices(extendedTrackPoints);

定义了顶点后，就可以在屏幕上绘制一些三角形了：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = road;
basicEffect.TextureEnabled = true;
basicEffect.VertexColorEnabled = false;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip,trackVertices, 0, trackVertices.Length - 2);
    pass.End();
}
basicEffect.End();

可见教程6-1学习如何绘制一个TriangleList和教程6-2学习如何绘制一个带纹理的三角形。

围栏

前面的代码生成了一个完全平坦的赛道。这是因为赛道的每个点的法线都是(0,1,0) Up方向。虽然这样做已经可以产生一个可用的赛道，但你还想让汽车保持在赛道上而不致冲出赛道。

要让赛道看起来更加真实，你需要在赛道上加上护栏防止车飞出赛道。

前面的代码使用(0,1,0) Up向量作为法线计算side向量。这次，你使用一个更加合适的法线向量。基于护栏计算法线几乎是不可能的。解决这个问题的方法是记住最后一个法线向量并对赛道上的每个点调整这个法线。

使用这个方法，你需要给法线指定一个初始值。如果你确定赛道的起点是平的，可以简单地使用(0,1,0) Up向量作为初始法线，所以在for循环中定义以下代码：

Vector3 currentNormal = Vector3.Up;

对赛道上的每个点，你需要考虑赛道的弯曲程度调整这个向量。 这个弯曲程度可以用离心方向表示：这个方向就是你将车拉回的方向，使车不至于飞出赛道外，如图5-35所示。

图5-35 找到离心（centrifugal）方向

你可以通过两个叉乘获取这个方向。首先，需要叉乘指向当前点的汽车行驶的方向(lastCarDir)和指向下一个点的汽车应该行驶的方向(carDir)。这两个方向如图5-35的左图所示。计算的结果向量垂直于这两个方向，指向纸外，所以很难在纸上表示出来。然后，叉乘这个结果向量和carDir，获取centriDir向量，这个向量总是指向弯曲赛道的内侧。

图5-35的右图显示了一个复杂的3D情况。

下面是代码：

Vector3 carDir = trackPoints[i + 1] - trackPoints[i];
carDir.Normalize();
Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1];
lastCarDir.Normalize();
Vector3 perpDir = Vector3.Cross(carDir, lastCarDir);
Vector3 centriDir = Vector3.Cross(carDir, perpDir);

因为centriDir方向指向曲线的内侧，所以赛道围栏必须垂直于这个向量。

基于以上理由，需要在赛道的每个顶点的法线向量中添加这个向量。这会让side向量慢慢地垂直于centriDir向量。

但是在一个很长的过程中，这会变得太突出，所以你需要添加某个复位因子。对这个复位因子使用Up向量可以让法线在过程结束时恢复到Up向量：

currentNormal = currentNormal + centriDir * banking + Vector3.Up/banking;
currentNormal.Normalize();

banking变量的值越大， 赛道添加的围栏越多。这里起你可以使用前面的代码，但别忘了用currentNormal代替(0,1,0) Up向量：

Vector3 sideDir = Vector3.Cross(currentNormal, carDir);
sideDir.Normalize();
currentNormal = Vector3.Cross(carDir, sideDir);

当使用这个代码时，你的赛道就拥有了围栏。而且，这个代码还可以让赛道循环！

代码

GenerateTrackPoints方法接受基点的数组并进行了扩展，让这个方法可以添加赛道的细节：

private List<Vector3> GenerateTrackPoints(List<Vector3> basePoints)
{
    basePoints.Add(basePoints[0]);
    basePoints.Add(basePoints[1]);
    basePoints.Add(basePoints[2]);

    List<Vector3> allPoints = new List<Vector3>();
    for (int i = 1; i < basePoints.Count-2; i++)
    {
        List<Vector3> part = InterpolateCR(basePoints[i - 1], basePoints[i], basePoints[i + 1], basePoints[i + 2]);
        allPoints.AddRange(part);
    }
    return allPoints;
}

基于这个扩展过的集合，GenerateTrackVertices方法创建了一个可以绘制带有围栏的赛道顶点数组：

private VertexPositionNormalTexture[] GenerateTrackVertices(List<Vector3>trackPoints)
{
    float halfTrackWidth = 0.2f;
    float textureLength = 0.5f;
    float banking = 2.0f;
    float distance = 0;
    List<VertexPositionNormalTexture> verticesList = new List<VertexPositionNormalTexture>();
    Vector3 currentNormal = Vector3.Up;

    for (int i = 1; i < trackPoints.Count-1; i++)
    {
        Vector3 carDir = trackPoints[i + 1] - trackPoints[i];
        carDir.Normalize();
        Vector3 lastCarDir = trackPoints[i] - trackPoints[i - 1];
        lastCarDir.Normalize();
        Vector3 perpDir =Vector3.Cross(carDir, lastCarDir);
        Vector3 centriDir = Vector3.Cross(carDir,perpDir);

        currentNormal = currentNormal + Vector3.Up/banking + centriDir * banking;
        currentNormal.Normalize();
        Vector3 sideDir = Vector3.Cross(currentNormal, carDir);
        sideDir.Normalize();
        currentNormal = Vector3.Cross(carDir, sideDir);

        Vector3 outerPoint = trackPoints[i] + sideDir * halfTrackWidth;
        Vector3 innerPoint = trackPoints[i] - sideDir * halfTrackWidth;
        distance += carDir.Length();

        VertexPositionNormalTexture vertex;
        vertex = new VertexPositionNormalTexture(innerPoint, currentNormal, new Vector2(0, distance / textureLength));
        verticesList.Add(vertex);
        vertex = new VertexPositionNormalTexture(outerPoint, currentNormal, new Vector2(1, distance / textureLength));
        verticesList.Add(vertex);
    }

    VertexPositionNormalTexture extraVert = verticesList[0];
    extraVert.TextureCoordinate.Y = distance / textureLength;
    verticesList.Add(extraVert);
    extraVert = verticesList[1];
    extraVert.TextureCoordinate.Y = distance / textureLength;
    verticesList.Add(extraVert);

    return verticesList.ToArray();
}

有了这两个方法，可以很容易地从一个3D点的集合绘制一条赛道：

List<Vector3> basePoints = new List<Vector3>();
basePoints.Add(new Vector3(2, 0, 4));
basePoints.Add(new Vector3(0, 0, 0));
basePoints.Add(new Vector3(-2, 0, -4));
basePoints.Add(new Vector3(2, 0, -4));
basePoints.Add(new Vector3(0, 1, 0));
basePoints.Add(new Vector3(-2, 0, 4));

List<Vector3> extendedTrackPoints = GenerateTrackPoints(basePoints);
trackVertices = GenerateTrackVertices(extendedTrackPoints);


6.0 第六章概述

给场景添加光照听起来很简单：设置3D世界中物体的位置，定义光源的位置就可以了。但是，虽然看起来让显卡实现光照很简单，其实不是。

对物体的每个表面，显卡需要计算表面接受光照的数量，这个光照数量是基于光线方向和表面法线方向的夹角的。幸运的是，XNA框架拥有BasicEffect，它可以为你进行所有的计算。本章第一部分解释如何使用BasicEffect给场景添加光照。

但是，如它的名称所示，BasicEffect只能用来计算基本的光照。如果你想在场景中添加点光源该怎么办呢，例如一个蜡烛？或许你还想在场景中添加多个光源，给物体添加阴影。

要解决这些问题，你需要在HLSL中编写自己的effect。本章的第二部分首先介绍创建与BasicEffect相同功能的HLSL effects。

然后你要学习如何实现额外的功能，诸如支持点光源和聚光灯。最后一个教程介绍延迟绘制（deferred rendering），让你可以同时使用大量的光源照亮场景，你还将学习如何使用延迟绘制组合阴影映射技术让物体产生阴影。
本章的教程包含以下内容：

  • 学习XNA 3.0中光照的基本知识，包括正确的光照需要什么以及为什么需要(教程6-1和6-2).
  • 使用BasicEffect计算屏幕上所有像素的光照，在反光表面添加镜面高光(教程6-3和6-4)
  • 当BasicEffect功能不足时编写自己的HLSL effect。首先实现BasicEffect的基本功能(教程6-5, 6-7和6-9)
  • 实现额外的功能，诸如支持点光源和聚光灯(教程6-6和6-8)
  • 使用多个光源让场景更生动。因为基本方法不够用，你需要使用延迟绘制在屏幕中添加光照(教程6-10)
  • 使用阴影映射技术添加阴影。你使用延迟绘制方法实现这个效果，让你可以实现多个光源的阴影(教程6-11)


6.1 定义法线和使用BasicEffect

问题

没有正确地光照，场景会缺乏真实感。在某些情况中，如果光照不正确3D效果会完全消失。

例如，一个有着不透明颜色的球，如果没有光照，球的所有像素将会是相同的颜色，在屏幕上看起来像一个平面的盘子。当光照正确时，球上面对光照的部分比其他部分的颜色更亮，使球看起来是一个真实的3D对象。

解决方案

在计算机图形学中，所有的3D对象都是由三角形组成的。你想使三角形对应入射光可以正确地被照亮。图6-1显示了一条从左向右的单向光，它影响到一个矩形的六个不同位置，每个矩形都是由两个三角形组成的。

图6-1 根据入射光的三角形光照情况

简单地定义光源的位置和对象的位置不足以让显卡在对象上添加正确地光照。对3D对象的每个三角形来说，你需要添加一些信息，是显卡可以计算照射到表面的光照强度。

这可以通过指定每个顶点的法线向量做到，法线在图6－1中用三角形顶点上的线段表示。一旦为每个顶点定义了正确的法线，BasicEffect就能使用正确的光照绘制对象。

工作原理

在图6-1中，绘制了一些矩形（每个矩形都是由两个三角形组成的），它们的颜色表示入射光强度。矩形越垂直于入射光方向，接受到的光越多。最后一个矩形垂直于光线方向被完全照亮。而第一个矩形平行于光线，所以不接受光照。

定义法线

那么显卡是如何知道相关信息的呢？在三角形的每个顶点上，你将定义垂直于三角形的方向。这个方向叫做法线。法线方向在图6－1中表示为伸出三角形顶点的线段。因为垂直于平面的方向是唯一的，所以矩形上所有顶点具有相同的法线方向。

法线方向让显卡可以计算三角形上获得的光线。如教程6-5所述，你可以将法线投影到光线方向上，如图6-2所示。光线方向用图片底部的长箭头表示，从左指向右。 图6-2中的旋转的黑色条代表图6－1中的矩形。法线在光线方向上的投影用光线箭头上的粗黑色块表示，黑色块越大，三角形获得的光越多。

图6-2 将法线投影到光线方向上

图中左边的三角形的法线垂直于光线方向，所以投影为0，三角形不受光照。右边的三角形的法线平行于光线方向，投影最大，被完全照亮。

给定光线和法线，显卡可以很容易地计算出投影长度。这就是显卡正确计算光照的方法。

将光照施加到场景中

显卡会计算每个顶点上的光强，然后用这个值乘以像素的初始颜色。

在顶点中添加法线数据

前面的段落中解释了除了包含3D位置和颜色，每个顶点中还需存储法线方向。

XNA拥有一个预定义的顶点格式可以在每个顶点中保存法线： VertexPositionNormalTexture结构。这个格式让你可以为每个顶点保存3D位置，法线方向和纹理坐标。可参加教程5－2学习带纹理的三角形，参加教程5－12学习如何自定义顶点格式。

下面的方法创建了一个拥有6个顶点的数组，定义了两个三角形形成一个矩形。这个矩形平躺在地面上，所以所有Y坐标都是0。因此，法线的方向为(0,1,0) Up方向，因为这就是垂直于矩形的方向。顶点格式还要有纹理坐标，这样显卡才知道如何从图像采样颜色(见教程5-2)。

private void InitVertices()
{
    vertices = new VertexPositionNormalTexture[6];
    int i = 0;
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,1));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1));

    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
}

最后一行代码确保VertexDeclaration (见教程5-1)只被创建一次，因为它不需要改变。

技巧：如果三角形是平铺在地面上的，很容易计算法线。可参加教程5-7学习如何为一个复杂的对象自动计算法线。

正法线和负法线

一个三角形其实有两个垂直方向。如果如前面的代码三角形是在地面上的，你可以定义一个指向上方或下方的法线。那么应该使用哪一个？这很重要，因为选择了错误的方向会导致错误的光照，通常会不受光照。

原则是，你需要选择指向对象外部的那个法线。

通常，三角形的一个面可以看做一个3D物体的“里面”，而另一个面就是“外面”。在这种情况下，你想选择指向物体外部的法线。

设置BasicEffect参数

定义好顶点后就可以绘制三角形了。本章的第二部分解释了如何编写HLSL effects，但第一个教程只使用BasicEffect。BasicEffect是一个预定义的effect，可以用来绘制使用基本光照效果的物体。确保有一个BasicEffect变量，因为每帧都创建一个新BasicEffect对象太耗费资源。在类顶部添加这个变量：

BasicEffect BasicEffect basicEffect;

在LoadContent方法中实例化basicEffect ：

basicEffect = new BasicEffect(device, null);

下面的设置将从一个单向光绘制3D场景的光照，例如像太阳：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = myTexture;
basicEffect.TextureEnabled = true;

basicEffect.LightingEnabled = true;
basicEffect.AmbientLightColor = new Vector3(0.1f, 0.1f, 0.1f);
basicEffect.PreferPerPixelLighting = true;

basicEffect.DirectionalLight0.Direction = new Vector3(1, -1, 0);
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;
basicEffect.DirectionalLight1.Enabled = false;
basicEffect.DirectionalLight2.Enabled = false;

代码的第一部分设置了World，View和Projection矩阵，用来将3D场景转换到2D屏幕，具体知识可参见教程2-1和4-2。因为在每个顶点中存储了纹理坐标，你还要将一张纹理传递到显卡并开启它，纹理的知识详见教程5-2。

接着设置光照。首先开启光照并定义一个环境光颜色，这个颜色是对象一直都受到的，无论它相对于光线的朝向如何。这里你定义了暗灰色，这样即使没有光线，物体仍会隐约可见。

如果显卡可以处理逐像素光照，你可以开启它，可参加教程6-3。

最后定义光源。使用BasicEffect时，可以一次使用三个光源。你需要设置光源方向和颜色，并开启光源。本例中只使用一个光源。

设置了BasicEffect后，就可以使用BasicEffect绘制三角形了：

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2);
    pass.End();
}
basicEffect.End();

注意：如果你通过将basicEffect. LightingEnabled设置为false关闭了灯光，会以全部光强绘制场景。如前面“将光照施加到场景中”一段解释的那样，显卡会在每个像素的初始颜色上乘以光照强度。如果关闭灯光，显卡会简单地绘制每个像素的初始颜色。实际上对应lighting factor为1的情况，意思是全部光强。

使用世界矩阵

在绘制三角形前，你可以设置effect的世界矩阵。这样你可以将三角形移动到另一个位置或缩放旋转它们(见教程4-2)。构建良好的effects (例如BasicEffect)将这个变换施加到顶点中的法线数据上。例如，前面的代码中的矩形若发生旋转，法线也会跟着一起旋转。

下面的代码显示了这样一个例子。

归一化法线

注意：在阅读此节时，你需要知道normal (法线)和normalize（归一化）的区别。法线是垂直于三角形的方向。归一化表示使一个向量的长度为单位长度，当归一化一个向量时，你将它的长度减少（或增加）到1。

光照强度只应该由光线和法线间的夹角决定。但是，显卡的计算结果还取决于两者的长度。所以，你必须确保法线方向和光线方向的长度都为1，这可以通过归一化实现。

当两者都是单位长度时，光照强度只取决于两者间的夹角，这正是你想要的结果。

代码

首先要提供每个顶点的法线数据：

private void InitVertices()
{
    vertices = new VertexPositionNormalTexture[6];

    int i = 0;
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,1));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, -1), new Vector3(0, 1, 0), new Vector2(0,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,0));
    vertices[i++] = new VertexPositionNormalTexture(new Vector3(-1, 0, 1), new Vector3(0, 1, 0), new Vector2(1,1));

    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
}

在使用BasicEffect绘制三角形前，你需要设定它的参数定义光照环境：

basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = myTexture;
basicEffect.TextureEnabled = true;

basicEffect.LightingEnabled = true;
basicEffect.DirectionalLight0.Direction = new Vector3(1, 0, 0);
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;

最后绘制三角形。下面的代码绘制了两个相同的三角形九次，每次都使用一个不同的世界矩阵。世界矩阵首先选择三角形然后将它沿x轴移动四个单位(参见教程5-2学习矩阵乘法的顺序)。

for (int i = 0; i < 9; i++)
{
    basicEffect.World = Matrix.CreateTranslation(4, 0, 0) * Matrix.CreateRotationZ((float)i * MathHelper.PiOver2 / 8.0f);

    basicEffect.Begin();
    foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = myVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2);
        pass.End();
    }
    basicEffect.End();
}

可参见教程5-1学习如何绘制三角形。


6.2 在顶点间共享法线

问题

在前面的教程中，你学习了如何根据法线数据使三角形获取正确的光照。

但是，盲目地将这个方法施加到所有三角形中往往得不到最好的效果。

如果三角形的每个顶点具有相同的法线方向，那么光照是一样的，所有像素会获得同样的光照。如果两个相邻三角形(不在同一平面)也是如此施加光照，那么一个三角形的所有像素获得同样的光照，另一个三角形的所有像素获得另一个光照。这会导致很容易地可见看见两者的边界，因为两个三角形有不同的颜色。

如果三角形中的颜色是平滑过渡的，你想获取一个更好的效果。要做到这点，从一个三角形到另一个三角形的明暗应该平滑过渡。

解决方案

显卡是根据三角形的三个顶点计算明暗的。三角形中的所有像素的明暗会进行插值。如果三个顶点的法线是相同的，所有像素会获得相同的明暗。如果不同，像素的明暗在角中会平滑过渡。

看一下两个三角形，共享一条边，由六个顶点组成。这种情况如图6-3的左图所示。要保证从一个三角形到另一个三角形的颜色能够平滑过渡， 你需要确保两个边界的颜色是一样的。这可以通过让共享的顶点具有相同的法线实现。在图6-3的左图，顶点1和4，顶点2和3有相同的法线。

图6-3 两个共享一条边的三角形

工作原理

本教程中，你将使用两个方法定义两个三角形。首先，定义两个所有顶点都有相同法线的三角形，这会导致三角形中的所有像素都有相同的光照。然后，你要确保共享顶点中的法线是相同的，这会在三角形边界上获得光滑的明暗效果。

每个三角形拥有各自的法线

这个方法获取垂直于三角形的方向并将这个方向存储在顶点中。

下面的代码定义了如图6－3左图中所示的六个顶点。每个三角形的三个顶点具有相同的法线方向，垂直于三角形。左边的三角形垂直放置，所以它的法线向左。第二个三角形水平放置，法线向上。

private void InitVertices()
{
    vertices = new VertexPositionNormalTexture[6];
    vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), new Vector3(-1, 0, 0), new Vector2(0,1));
    vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 1), new Vector3(-1, 0, 0), new Vector2(0.5f, 0));
    vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(-1, 0, 0), new Vector2(0.5f, 1));
    vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0), new Vector3(0, 1, 0), new Vector2(0.5f,1));
    vertices[4] = new VertexPositionNormalTexture(new Vector3(0,- 1), new Vector3(0, 1, 0), new Vector2(0.5f,1));
    vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0), new Vector3(0, 1, 0), new Vector2(1,1));

    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
}

然后定义一个稍微偏右下方向的光线。确保归一化这个光线的方向：

Vector3 lightDirection = new Vector3(10, -2, 0);
lightDirection.Normalize();
basicEffect.DirectionalLight0.Direction = lightDirection;

然后绘制两个三角形：

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2);
    pass.End();
}
basicEffect.End();

可参见教程6－1中的“归一化法线”一节理解为何需要归一化光线方向。

这时应该看到两个三角形，都有一个不透明颜色，如图6－4左图所示。你可以轻易地看到两者间的边界，在绘制大物体时这并不是你想要的结果。

图6-4 三角形着色(左图)，共享的顶点着色(右图)

共享法线

这次，你将在顶点1和4、顶点2和3上施加同样的法线方向。带来的一个问题是你应该选择哪个方向。

要获取最光滑的效果，你只需简单地求法线的平均值，如下面的代码所示：

private void InitVertices() vertices = new VertexPositionNormalTexture[6];
Vector3 normal1 = new Vector3(-1, 0, 0);
Vector3 normal2 = new Vector3(0, 1, 0);
Vector3 sharedNormal = normal1 + normal2;
sharedNormal.Normalize();

vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), normal1, new Vector2(0,1));
vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 1), sharedNormal, new Vector2(0.5f, 0));
vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), sharedNormal, new Vector2(0.5f, 1));
vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0), sharedNormal, new Vector2(0.5f,1));
vertices[4] = new VertexPositionNormalTexture(new Vector3(0,- 1), sharedNormal, new Vector2(0.5f,1));
vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0), normal2, new Vector2(1,1));

myVertexDeclaration=new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);

首先求出两个三角形的法线之和，然后归一化结果(见教程6－1中的“归一化法线”一节)。计算的结果会指向左和上之间。

然后定义六个顶点。两侧的两个顶点不共享法线，所以仍保持原来的法线值。其他四个共享法线的顶点使用相同的法线。

现在，当绘制两个三角形时，明暗会光滑地从一个两侧的顶点过渡到共享的边，如图6-4右图所示。让你很难看到两个三角形的共同边，这样用户不会发现物体是由多个三角形组成的。

技巧：教程5－7介绍了如何给一个大物体自动计算共享法线。

当使用索引时也可以使用这个方法(参见教程5－3)。

代码

在教程的前面可以找到定义三角形的代码，下面的代码用来绘制三角形：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = blueTexture;
basicEffect.TextureEnabled = true;
basicEffect.LightingEnabled = true;
Vector3 lightDirection = new Vector3(10, -2, 0);
lightDirection.Normalize();
basicEffect.DirectionalLight0.Direction = lightDirection;
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2);
    pass.End();
}
basicEffect.End();

注意：这个教程使用的纹理只有一个不透明的颜色(蓝色)， 你可以确认最后的颜色渐变效果。


6.3 给光线添加更高的细节：逐像素光照

问题

在前两个教程中，是在每个顶点中计算明暗程度，对三角形的每个像素需要对这个值进行插值。所以这叫做逐顶点光照（per-vertex lighting，又叫做高洛德着色，高洛德浓淡Gouraud shading）。

在某些情况中，逐顶点光照没不能产生最好的结果。特别是使用大三角形或有锐利的边缘或两者都有时，往往得不到想要的结果。

举例说明，如图左边是一个有三个面的立方体。图的右边表示共享的法线应该如何定义。本例中，光线方向用四根箭头表示。

图6-5 Vertex shader对per-pixel lighting

关注立方体的顶部，对应右图中顶点2和4之间的线段。使用逐顶点光照，会计算顶点2和4的明暗。在顶点4光照不多，因为顶点4的法线几乎是垂直于光线方向的，这里我们认为是20%的光照。顶点2光照得多，因为它的法线方向几乎和光线方向一致，我们设为80%光照。在逐顶点光照中，三角形中的像素的明暗要进行插值，所有介于这两个顶点之间的像素接受的光照是20%至80%之间的插值。这样，没有一个像素可以获得100%的光照。

但是，顶点2和4之间的某一个像素的顶点方向与光线方向完全一致！这个法线显示在图6－5的右图中。很明显，这个像素应该获得100%的光照，但是使用逐顶点光照，这个像素只能获得介于20%和80%之间的某个光照值。

解决方案

逐顶点光照只计算顶点的精确明暗，而在顶点间的像素的明暗是通过插值获取的。

使用逐像素光照，你对所有像素的法线进行插值，让你可以计算每个像素的精确明暗。

工作原理

使用BasicEffect，很容易使用逐像素光照。在设置BasicEffect参数时，只需添加以下代码行：

basicEffect.PreferPerPixelLighting = true;

注意：要使逐像素shader可以工作，你必须要拥有支持Shader 2.0以上的显卡。你可以使用以下代码检查显卡的支持：

GraphicsDevice. GraphicsDeviceCapabilities.MaxPixelShaderProfile>=ShaderProfile.PS_2_0

代码

下面的代码创建如图6-5左图所示的顶点。因为某些法线可能不再是单位长度，所有确保在最后要对它们进行归一化：

private void InitVertices()
{
    vertices = new VertexPositionNormalTexture[8];
    vertices[0] = new VertexPositionNormalTexture(new Vector3(0, -1, 0), new Vector3(-1, 0, 0), new Vector2(0, 1));
    vertices[1] = new VertexPositionNormalTexture(new Vector3(0, -1,- 1), new Vector3(-1, 0, 0), new Vector2(0, 0));
    vertices[2] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(-1, 1, 0), new Vector2(0.33f, 1));
    vertices[3] = new VertexPositionNormalTexture(new Vector3(0, 0,- 1), new Vector3(-1, 1, 0), new Vector2(0.33f, 0));
    vertices[4] = new VertexPositionNormalTexture(new Vector3(1, 0, 0), new Vector3(1, 1, 0), new Vector2(0.66f, 1));
    vertices[5] = new VertexPositionNormalTexture(new Vector3(1, 0,- 1), new Vector3(1, 1, 0), new Vector2(0.66f, 0));
    vertices[6] = new VertexPositionNormalTexture(new Vector3(1, -1, 0), new Vector3(1, 0, 0), new Vector2(1, 1));
    vertices[7] = new VertexPositionNormalTexture(new Vector3(1, -1,- 1), new Vector3(1, 0, 0), new Vector2(1, 0));

    for (int i = 0; i < vertices.Length; i++)
        vertices[i].Normal.Normalize();

    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
}

阅读教程6－1中的“归一化法线”理解为何需要最后的for循环。

注意：因为XNA没有提供一个包含3D位置、颜色、法线的顶点结构，这个教程使用一个蓝色的纹理让每个像素的颜色都是一样的。通过这种方式，你看到的颜色中的所有变化都会被光照影响。

然后就可以使用逐像素光照绘制三角形了：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = blueTexture;
basicEffect.TextureEnabled = true;
basicEffect.LightingEnabled = true;
Vector3 lightDirection = new Vector3(3, -10, 0);
lightDirection.Normalize();
basicEffect.DirectionalLight0.Direction = lightDirection;
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;
basicEffect.PreferPerPixelLighting = true;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 6);
    pass.End();
}
basicEffect.End();


6.4 在反光表面添加镜面高光

问题

就算开启了逐像素明暗，有些金属或闪光表面仍显得有点暗淡。在现实生活中，当观察诸如金属、玻璃或一些塑料时，你会发现某些区域的反光非常强烈。这样的区域如图6－6的虚线圆圈所示。这些高亮的区域叫做镜面高光（specular highlights）。

图6-6 镜面高光

解决方案

和逐像素光照一样，你只需简单地告知BasicEffect创建镜面高光就可以开启它。

注意：镜面高光只应添加到发光材质上。不要将它们添加到诸如衣服或草地的柔软表面，或至少让反光效果非常微弱。

工作原理

使用BasicEffect开启镜面高光非常简单。对每个光源，你指定高光颜色。然后，设置高光强度。这个强度可以指定高光的宽度。强度越大，镜面高光越窄。可见教程6-8学习相关细节。

basicEffect.LightingEnabled = true;
basicEffect.DirectionalLight0.Direction = lightDirection;
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;
basicEffect.PreferPerPixelLighting = true;
basicEffect.DirectionalLight0.SpecularColor = Color.White.ToVector3();
basicEffect.SpecularPower = 32;

注意：当使用镜面高光时，总要使用逐像素光照，这是因为镜面高光通常是一个小的，非线性的点，所以它们不应该被插值，而是应该逐像素地计算。

代码

下面的代码定义顶点并绘制一个矩形：

private void InitVertices()
{
    vertices = new VertexPositionNormalTexture[4];

    vertices[0] = new VertexPositionNormalTexture(new Vector3(0, 0, 0), new Vector3(0, 1, 0), new Vector2(0, 1));
    vertices[1] = new VertexPositionNormalTexture(new Vector3(0, 0, - 10), new Vector3(0, 1, 0), new Vector2(0, 0));
    vertices[2] = new VertexPositionNormalTexture(new Vector3(10, 0, 0), new Vector3(0, 1, 0), new Vector2(1, 1));
    vertices[3] = new VertexPositionNormalTexture(new Vector3(10, 0,- 10), new Vector3(0, 1, 0), new Vector2(1, 0));

    myVertexDeclaration = new VertexDeclaration(device, VertexPositionNormalTexture.VertexElements);
}

在Draw方法中，添加如下代码创建BasicEffect，在矩形上添加镜面高光：

basicEffect.World = Matrix.Identity;
basicEffect.View = fpsCam.ViewMatrix;
basicEffect.Projection = fpsCam.ProjectionMatrix;
basicEffect.Texture = blueTexture;
basicEffect.TextureEnabled = true;
Vector3 lightDirection = new Vector3(0, -3, -10);
lightDirection.Normalize();
basicEffect.LightingEnabled = true;
basicEffect.DirectionalLight0.Direction = lightDirection;
basicEffect.DirectionalLight0.DiffuseColor = Color.White.ToVector3();
basicEffect.DirectionalLight0.Enabled = true;
basicEffect.PreferPerPixelLighting = true;
basicEffect.DirectionalLight0.SpecularColor = Color.White.ToVector3();
basicEffect.SpecularPower = 32;

basicEffect.Begin();
foreach (EffectPass pass in basicEffect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 2);
    pass.End();
}
basicEffect.End();


6.5 添加HLSL Vertex Shading

问题

使用你配置好的光照，BasicEffect可以很好地绘制场景。但是，如果你想定义一些更酷的效果，首先要实现的就是正确的光照。

本教程中，你将学习如何编写一个基本的HLSL effect实现逐顶点光照。

解决方案

传递每个顶点的3D位置和法线到effect中。显卡上的vertex shader需要对每个顶点做两件事。

首先，当绘制3D世界时，总是要使用世界矩阵，视矩阵和投影矩阵将3D位置转换为对应的2D屏幕坐标。

第二，通过叉乘光线方向和法线方向计算顶点的光照强度。

工作原理

首先需要在XNA项目中定义顶点。显然你需要将3D位置存储在每个顶点中。要在vertex shader 中计算正确的光照，你还需要为每个顶点提供法线，可参见教程6－1理解法线的概念。

你可以使用教程6-1中的相同代码，这个代码创建了包含一个3D位置和一个法线（还包含纹理坐标，只是这里你不使用它们）的六个顶点。

在XNA项目中创建一个新的. fx文件，添加以下代码。它包含了可以从XNA应用程序中改变的HLSL变量。

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float xAmbient; float3 xLightDirection;

当将一个3D坐标转换到2D屏幕坐标时，总是需要视矩阵和投影矩阵（见教程2－1）。因为你还想在场景中移动物体，所以还需一个世界矩阵(见教程4－2)。因为这个教材处理的是光照，你需要定义光线的方向。Ambient变量让你可以设置光照的最小级别，这样，即使一个对象没有被光源直接照射，它仍是隐约可见的。

在进入vertex shader和pixel shader前，首先需要定义output结构。首先，vertex shader的output 就是pixel shader的input，必须保存每个顶点的2D屏幕坐标。第二，vertex shader 还计算了每个顶点的光照强度。

在vertex shader和pixel shader之间，这些值进行了插值，让每个像素获取了它们各自的插值。

pixel shader仅计算每个像素的最终颜色。

struct VSVertexToPixel
{
    float4 Position : POSITION;
    float LightingFactor : TEXCOORD0;
};

struct VSPixelToFrame
{
    float4 Color : COLOR0;
};

Vertex Shader

vertex shader将World，View和Projection矩阵组合成一个矩阵，用来将3D坐标转换为2D屏幕坐标。

给定光线方向和法线方向，vertex shader可以根据图6-7计算光照强度。光线和法线间的夹角越小，光照越强烈，夹角越大，光照越少。

你可以通过点乘这两个方向获得这个值。点乘返回一个0到1之间的值（如果两个向量的长度都是1）。

图6-7 光线方向和法线方向的点乘

但是，在计算两者的点乘时首先要将其中一个方向反向，否则这两个方向是相反的。例如，图6－7右图中你发现法线和光线方向是相反的，这会导致点乘的结果为负。

VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    VSVertexToPixel Output = (VSVertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);

    Output.Position = mul(inPos, preWorldViewProjection);
    float3 normal = normalize(inNormal);
    Output.LightFactor = dot(rotNormal, -xLightDirection);

    return Output;
}

点乘的结果是一个single值，基于两个法线的夹角和长度。在大多数情况中，你只需要光线基于两者的夹角。这意味着你需要确保3D空间中的所有法线和光线方向长度是一样的；否则，具有更长法线的顶点会获取更多的光照。

这可以通过让所有法线的长度为1做到，即需要归一化法线。

注意：归一化（normalizing）的意思不是对法线不做操作，而是让一个向量的长度变为1，可参见教程6-1。

使用世界矩阵时确保正确的光照

前面的代码在世界矩阵为单位矩阵时工作良好，即物体需要放置在(0,0,0)3D空间的初始位置(见教程5-2)。

但在大多数情况中，你想使用另外的世界矩阵，让你可以移动/旋转/缩放对象。

如图6-1所示，如果你旋转了物体，法线也会跟着一起旋转。这意味着法线需要通过世界矩阵中的旋转量进行变换。

世界矩阵中的缩放操作不会影响光照的计算。你总要在vertex shader中归一化法线，让向量的长度变为1。

但是，如果世界矩阵中包含平移，你就会遇到麻烦。这是因为法线是最大长度为1的向量。例如，当你使用一个包含超过两个单位的矩阵变换法线时，所有的法线都会指向那个方向。

如图6-8所示，一个物体使用一个包含平移一段距离的世界矩阵向右平移时，顶点的位置会移向右方。法线也会根据这个世界矩阵移向右方，但它们的方向应该是不变的。所以，在使用世界矩阵变换法线时，你需要将世界矩阵中的平移部分剥离出来。

图6-8 被世界矩阵中的平移影响的法线

矩阵是一个包含4 × 4个数字的表格。你应该只使用世界矩阵中的旋转部分变换法线，而不要用平移部分。你可以提取出矩阵的旋转部分，它位于左上的3 × 3的数字中。只需简单地将4 × 4世界矩阵变换为一个3 × 3矩阵，就可以只获取旋转信息，这正是你所需要的！使用这个矩阵旋转法线，代码如下所示：

float3 normal = normalize(inNormal);
float3x3 rotMatrix = (float3x3)xWorld;
float3 rotNormal = mul(normal, rotMatrix);
Output.LightFactor = dot(rotNormal, -xLightDirection);

Pixel Shader

首先，三角形的三个顶点由vertex shader进行处理，计算光照值。然后，对三角形中的每个像素，这个光照值会在三个顶点间进行插值。这个插值过的光照值传递到pixel shader。

在这个简单地例子中，取蓝色为物体的基本颜色。要在三角形上添加明暗效果，要将这个基本颜色乘以LightFactor (在前面的vertex shader中计算)和环境光照(由XNA程序通过xAmbient变量设置)。环境光（ambient）因子确保所有物体不会是完全黑暗的，而LightFactor 根据光线方向施加对应的光照：

VSPixelToFrame VSPixelShader(VSVertexToPixel PSIn) : COLOR0
{
    VSPixelToFrame Output = (VSPixelToFrame)0;

    float4 baseColor = float4(0,0,1,1);
    Output.Color = baseColor*(PSIn.LightFactor+xAmbient);

    return Output;
}

定义technique

最后，定义technique：

technique VertexShading
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 VSVertexShader();
        PixelShader = compile ps_2_0 VSPixelShader();
    }
}

XNA代码

在XNA项目中，导入HLSL文件并将它存储在一个Effect变量中，这和教程3－1中对纹理的操作是类似的。在本例中，HLSL文件名为vertexshading. fx：

effect = content.Load<Effect>("vertexshading");

当绘制物体时，首先需要设置effect的参数，这需要用到BasicEffect：

effect.CurrentTechnique = effect.Techniques["VertexShading"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xLightDirection"].SetValue(new Vector3(1, 0, 0));

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleList, vertices, 0, 2);
    pass.End();
}
effect.End();

代码

XNA代码绘制对象的多个实例。因为使用了不同的世界矩阵，这些对象会绘制在不同位置。

最终结果和教程6-1是一样的，只是这次你使用了自己的HLSL effect：

effect.CurrentTechnique = effect.Techniques["VertexShading"];

effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xLightDirection"].SetValue(new Vector3(1, 0, 0));
effect.Parameters["xAmbient"].SetValue(0.0f);

for (int i = 0; i < 9; i++)
{
    Matrix world = Matrix.CreateTranslation(4, 0, 0) * Matrix.CreateRotationZ((float)i * MathHelper.PiOver2 / 8.0f);

    effect.Parameters["xWorld"].SetValue(world);

    effect.Begin();
    foreach (EffectPass pass in effect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = myVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleList, vertices, 0, 2);
        pass.End();
    }
    effect.End();
}

下面是.fx文件的完整内容：

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float xAmbient;
float3 xLightDirection;

struct VSVertexToPixel
{
    float4 Position : POSITION;
    float LightFactor : TEXCOORD0;
};

struct VSPixelToFrame
{
    float4 Color : COLOR0;
}

// Technique: VertexShading
VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    VSVertexToPixel Output = (VSVertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    float3 normal = normalize(inNormal);
    float3x3 rotMatrix = (float3x3)xWorld;
    float3 rotNormal = mul(normal, rotMatrix);
    Output.LightFactor = dot(rotNormal, -xLightDirection);

    return Output;
}

VSPixelToFrame VSPixelShader(VSVertexToPixel PSIn) : COLOR0
{
    VSPixelToFrame Output = (VSPixelToFrame)0;

    float4 baseColor = float4(0,0,1,1);
    Output.Color = baseColor*(PSIn.LightFactor+xAmbient);

    return Output;
}

technique VertexShading
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 VSVertexShader();
        PixelShader = compile ps_2_0 VSPixelShader();
    }
}


6.6 使用HLSL定义点光源

问题

直到现在，你都是用单向光照亮场景，这对在3D世界中添加阳光是很有用的。但很多情况中，你还需要一个从点发出的光线，例如一个探照灯或爆炸。这种光源叫做点光源。

解决方案

将点光源的3D位置从XNA项目传送到XNA effect中。对每个顶点，计算光源指向顶点的方向，并将这个方向作为光线方向。知道了光线方向就可以像以前一样继续了。

工作原理

在. fx文件中，使用下列代码替换xLightDirection参数，让你可以将光源的3D位置从XNA项目传递到HLSL effect中：

float3 xLightPosition;

然后，对每个顶点，你要计算从光源指向顶点的方向。从A指向B的方向可以通过B减去A获得。记住你需要顶点的最终3D位置，这意味着初始3D位置需要通过是世界矩阵进行变换：

float3 final3DPosition = mul(inPos, xWorld);
float3 lightDirection = final3DPosition - xLightPosition;
lightDirection = normalize(lightDirection);
Output.LightFactor = dot(rotNormal, -lightDirection);

因为光源指向顶点的方向可能比1大得多，所以要确保将这个方向归一化。有了光线方向，你可以像以前的教程那样继续处理了。

当运行代码时，对每个顶点都要计算光源到这个顶点的方向，所以这个方向往往是不同的。

距离衰减

要让效果变得更加真实，你想在光源与顶点的距离变大时让点光源的光照变弱。要做到这点，可以在归一化lightDirection之前对它使用length函数获得distance，然后将LightFactor除以这个distance：

float3 final3DPosition = mul(inPos, xWorld);
float3 lightDirection = final3DPosition - xLightPosition;
float distance = length(lightDirection);
lightDirection = normalize(lightDirection);
Output.LightFactor = dot(rotNormal, -lightDirection);
Output.LightFactor /= distance;

代码

在XNA项目中，你可以在任何你想的位置放置点光源：

effect.CurrentTechnique = effect.Techniques["VertexShading"];
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xLightPosition"].SetValue(new Vector3(1, 5, 0));
effect.Parameters["xAmbient"].SetValue(0.0f);

下面是完整的vertex shader：

VSVertexToPixel VSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    VSVertexToPixel Output = (VSVertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);

    float3 normal = normalize(inNormal);
    float3x3 rotMatrix = (float3x3)xWorld;
    float3 rotNormal = mul(normal, rotMatrix);

    float3 final3DPosition = mul(inPos, xWorld);
    float3 lightDirection = final3DPosition - xLightPosition;
    lightDirection = normalize(lightDirection);
    Output.LightFactor = dot(rotNormal, -lightDirection);

    return Output;
}


6.7 添加HLSL逐像素光照

问题

如教程6-3所示，要获得最好的光照效果应该使用逐像素光照，特别是对那些由大三角形构成的曲线的情况中。你想使用自己的effect添加逐像素光照。

解决方案

前两个教程中，你在每个顶点中计算明暗值（shading value，也可以翻译成着色值）。三角形三个顶点的明暗值会进行线性以获取每个像素的明暗值。

在逐像素光照中，你想对三个顶点的法线进行插值以获取每个像素的法线，这样就可以基于每个像素的法线计算光照因子。但是，当从一个顶点到另一个顶点进行法线插值时结果是有缺陷的。如图6－9中的左图所示，图中的水平直线表示一个顶点包含法线的三角形。当你在像素上对这左右两个顶点的法线进行插值时，插值的法线总会沿着虚线。导致在三角形中间的法线法线是正确的，其他位置的法线会小于实际值，如图所示。

图6-9 从顶点到像素的线性插值是错误的

解决方法是在pixel shader中处理这个插过值的法线。因为它的方向是正确的，你可以归一化这个向量。这是因为每个像素的缩放因子是不同的，所以这一步需要在pixel shader中进行。

注意：要理解为什么将法线长度变为1，可参见教程6－1中的“归一化法线”一节。

当你想让光照强度只取决于法线和入射光夹角时，更小的法线导致更小的光照强度。

光照方向也会遇到同样的问题，如图6－9右图所示。插过值的光线方向的长度沿着虚线曲线，这会导致向量比实际的小。你仍需要归一化这个插过值的光线方向。

工作原理

和以往一样，你的项目必须从至少包含3D位置和法线的顶点中与显卡进行交互。你想让XNA代码可以设置World，View，Projection矩阵，光源的3D位置和环境光：

float4x4 xWorld; float4x4 xView;
float4x4 xProjection;
float3 xLightPosition;
float xAmbient;

struct PPSVertexToPixel
{
    float4 Position: POSITION;
    float3 Normal: TEXCOORD0;
    float3 LightDirection: TEXCOORD1;
};

struct PPSPixelToFrame
{
    float4 Color: COLOR0;
};

如前所述，vertex shader会输出法线，这个法线已经进行了插值，光线方向也进行了插值。pixel shader只需计算每个像素最后的颜色。

注意：在单向光的简单例子中，光源的方向是XNA-to-HLSL变量，对顶点和像素来说都是不变的。所以vertex shader无需计算这个值。

Vertex Shader

vertex shader从顶点中接受法线，根据世界矩阵中的旋转值旋转这个法线（见教程6-5），并将它传递到pixel shader。 在vertex shader中还通过将顶点位置减去点光源的位置计算了光线方向(见教程6-5)。根据当前世界矩阵获取顶点的最终3D位置。

PPSVertexToPixel PPSVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    PPSVertexToPixel Output = (PPSVertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);

    Output.Position = mul(inPos, preWorldViewProjection);
    float3 final3DPos = mul(inPos, xWorld);
    Output.LightDirection = final3DPos - xLightPosition;
    float3x3 rotMatrix = (float3x3)xWorld;
    float3 rotNormal = mul(inNormal, rotMatrix);
    Output.Normal = rotNormal;

    return Output;
}

Pixel Shader

法线和光线方向在三个顶点间进行插值，作用在三角形的所有像素上。如前所述，因为被插值的向量的长度会比实际的小，所以会发生错误。你可以通过归一化操作解决这个问题。将两个方向归一化之后，就可以点乘两者获取光照因子：

PPSPixelToFrame PPSPixelShader(PPSVertexToPixel PSIn) : COLOR0
{
    PPSPixelToFrame Output = (PPSPixelToFrame)0;
    float4 baseColor = float4(0,0,1,1);
    float3 normal = normalize(PSIn.Normal);
    float3 lightDirection = normalize(PSIn.LightDirection);
    float lightFactor = dot(normal, -lightDirection);

    Output.Color = baseColor*(lightFactor+xAmbient);

    return Output;
}

定义Technique

这个technique需要Shader 2.0–compatible的显卡：

technique PerPixelShading
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 PPSVertexShader();
        PixelShader = compile ps_2_0 PPSPixelShader();
    }
}

代码

前面已经写过. fx文件中的HLSL代码了，所以下面只是绘制三角形的XNA代码：

effect.CurrentTechnique = effect.Techniques["PerPixelShading"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xAmbient"].SetValue(0.0f);
effect.Parameters["xLightPosition"].SetValue(new Vector3(6.0f, 1.0f, -5.0f));

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip, vertices, 0, 6);
    pass.End();
}
effect.End();

你可以试着改变光源的位置查看效果。本例中的光源前后移动。


6.8 使用HLSL定义聚光灯

问题

前面教程中定义的点光源从一个点发出四面八方的光。你想定义一个聚光灯，它与点光源很很像，但光线只照亮一个圆锥区域，如图6-10。

图6-10 定义一个聚光灯的变量

解决方案

在pixel shader中，判断当前像素是否在光照圆锥中，这可以通过将光线方向和圆锥方向进行点乘做到。

工作原理

开始的代码与前面的教程中的是一样的。因为聚光灯比点光源需要设置的东西更多，你需要将下列XNA-to-HLSL变量添加到. fx文件中：

float xLightStrength;
float3 xConeDirection;
float xConeAngle;
float xConeDecay;

第一个变量让你可以增加/减少光照强度。这个变量对其他类型的光也是很有用的，当场景中有多个光源时它也是必须的(见教程6-10)。然后定义光锥的中心线方向、光锥宽度。最后定义光照的衰减。

除此之外，你还要扩展pixel shader。基本上与逐像素点光源中（见教程6－7）做的一样，只是多了一个检查像素是否在光锥中的步骤：

SLPixelToFrame SLPixelShader(SLVertexToPixel PSIn) : COLOR0
{
    SLPixelToFrame Output = (SLPixelToFrame)0;

    float4 baseColor = float4(0,0,1,1);
    float3 normal = normalize(PSIn.Normal);
    float3 lightDirection = normalize(PSIn.LightDirection);
    float coneDot = dot(lightDirection, normalize(xConeDirection));

    float shading = 0;
    if (coneDot > xConeAngle)
    {
        float coneAttenuation = pow(coneDot, xConeDecay);
        shading = dot(normal, -lightDirection);
        shading *= xLightStrength;
        shading *= coneAttenuation;
    }
    Output.Color = baseColor*(shading+xAmbient);

    return Output;
}

归一化法线和光线方向之后，你需要检测当前像素是否在光锥之内。这可以检测两个方向间的夹角做到：

  • 当前像素到光源的方向
  • 光锥的中心线的方向

第一个方向就是lightDirection，第二个方向由xConeDirection变量定义。只有这两个方向的夹角小于某个临界值，像素才会被照亮。

检测的一个快速方法是计算这两个方向的点乘。结果接近于1表示两者的夹角很小，结果越小表示夹角越大。

要判断角度是否太大，你要检测点乘结果是否小于某个临界值，这个临界值存储在ConeAngle变量中。如果像素在光锥中，就计算光照因子。要在接近光锥边缘的地方减弱光照，你要计算变量coneDot的xConeDecay次幂。结果是，对那些远离光锥中心线方向的像素来说，当coneDot变量小于等于1时，幂的结果会变得更小(见图6－11的右图)。

光锥之外的像素光照值为0，光线对这些像素没有影响。

代码

完整的pixel shader代码前面已经有了。

在XNA代码的Draw方法中，开启effect，设置参数并绘制场景：

effect.CurrentTechnique = effect.Techniques["SpotLight"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xAmbient"].SetValue(0.2f);
effect.Parameters["xLightPosition"].SetValue(new Vector3(5.0f, 2.0f, -15.0f+variation));
effect.Parameters["xConeDirection"].SetValue(new Vector3(0,-1,0));
effect.Parameters["xConeAngle"].SetValue(0.5f);
effect.Parameters["xConeDecay"].SetValue(2.0f);
effect.Parameters["xLightStrength"].SetValue(0.7f);

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture>(PrimitiveType.TriangleStrip,vertices, 0, 6);
    pass.End();
}
effect.End();


6.9 添加HLSL镜面高光

问题

你想使用自定义的HLSL effect在场景中添加镜面高光。镜面高光是位于反光位置的高亮度区域,如图6-11所示。

解决方案

下面的讨论将帮助你判断哪个像素具有高光分量。

图6-11的左图显示了一条光线L，从光源指向三角形中的一个像素。在左图中还显示了 eye向量，从相机指向像素。如果L的反射向量与E相同，那么这个像素就有一个高光分量。

图6-11 使用靠近eye向量的光线方向检测像素

你可以通过求L关于像素法线的镜像获取L的反射向量。如果镜像方向与eye向量夹角很小则这两个方向几乎是相同的。你可以通过点乘这两个向量检测两者的夹角（可参见教程 6-8）。

如果角度为0，则这两个方向是相同的，你需要添加一个高光分量，这时点乘的结果为1。如果两个方向不同，则点乘结果小于1。

注意：两个向量A和B的点乘结果等于(A的长度)*(B的长度)*(两者夹角的余弦)。如果A和B都已经进行了归一化，点乘结果会变为(两者夹角的余弦)。如果A和B的夹角为0，则余弦值为1。如果两者垂直，夹角为90度，余弦值为0，如图6-11的右图所示。如果两个向量方向相反，夹角为180度，余弦值为－1。 当反射的方向与eye向量的方向夹角小于90度时，点乘结果为正。

你还不能立即使用这个值判断高光，因为这样做会在所有反射向量与eye向量的夹角小于90度的像素上添加高光，而你想在夹角小于10度时才添加高光。

这可以通过对点乘结果进行一个高次幂实现。例如，将点乘结果进行12次方的操作，会使角度小于10度的情况下这个值才会大于0，如图6-11右下图所示。

每个像素的运算结果是一个single值，表示高光强度。

工作原理

和以往一样，你需要首先设置World，View和Projection矩阵将3D位置转换到2D屏幕位置。因为这个教程用的是一个点光源，你还需指定它的位置。要计算eye向量，你需要知道相机的位置。你还需能够设置光照强度控制高光大小。因为光照强度可能大于1，因此需要缩小光照强度避免饱和（saturation）。

注意：在大多数情况中，你需要缩小光源的强度。在多光源的情况中大多数像素的光照会饱和，浪费光照effect。

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
float3 xLightPosition;
float3 xCameraPos;
float xAmbient;
float xSpecularPower;
float xLightStrength;

struct SLVertexToPixel
{
    float4 Position : POSITION;
    float3 Normal : TEXCOORD0;
    float3 LightDirection : TEXCOORD1;
    float3 EyeDirection : TEXCOORD2;
};

struct SLPixelToFrame
{
    float4 Color : COLOR0;
};

vertex shader还计算了EyeDirection并进行插值。pixel shader仍然只输出每个像素的颜色。

Vertex Shader

vertex shader与前面的教程没有太大的不同。唯一一个新的东西就是eye向量在vertex shader中进行计算。从一个点指向另一个点的向量可以通过将终点减去起点实现。

SLVertexToPixel SLVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    SLVertexToPixel Output = (SLVertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    float3 final3DPos = mul(inPos, xWorld);

    Output.LightDirection = final3DPos - xLightPosition;
    Output.EyeDirection = final3DPos - xCameraPos;

    float3x3 rotMatrix = (float3x3)xWorld;
    float3 rotNormal = mul(inNormal, rotMatrix);
    Output.Normal = rotNormal;

    return Output;
}

Pixel Shader

pixel shader更加有趣。基本颜色是蓝色的，无需关注太多。在pixel shader中归一化每个方向，因为它的长度可能不是1 (见教程6-3)。

与以往一样，你计算了光照，将它乘以xLightStrength缩小一点(xLightStrength小于1)。

SLPixelToFrame SLPixelShader(SLVertexToPixel PSIn) : COLOR0
{
    SLPixelToFrame Output = (SLPixelToFrame)0;

    float4 baseColor = float4(0,0,1,1);
    float3 normal = normalize(PSIn.Normal);
    float3 lightDirection = normalize(PSIn.LightDirection);
    float shading = dot(normal, -lightDirection);
    shading *= xLightStrength;

    float3 reflection = -reflect(lightDirection, normal);
    float3 eyeDirection = normalize(PSIn.EyeDirection);
    float specular = dot(reflection, eyeDirection);
    specular = pow(specular, xSpecularPower);
    specular *= xLightStrength;

    Output.Color = baseColor*(shading+xAmbient)+specular;

    return Output;
}

然后，使用reflect 函数计算光线方向的镜像。因为光线方向是指向像素的，它的反射方向将指向眼睛，反射方向与eye向量相反，所以需要取负值。

Specular的值可以通过点乘eye向量和反射方向获取，将这个值进行高次幂计算，使这两个向量的夹角小于10度的像素高光值才会大于0。这个值需要通过乘以xLightStrength 变得小一点。

最后，ambient，shading和specular分量组合在一起获得像素最后的颜色。

注意：specular分量在最终颜色中添加白色。如果光线有不同的颜色，你需要将specular值乘以光线的颜色。

定义Technique

下面是technique定义：

technique SpecularLighting
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 SLVertexShader();
        PixelShader = compile ps_2_0 SLPixelShader();
    }
}

代码

因为所有HLSL代码前面已经写过了，下面只是XNA代码：

effect.CurrentTechnique = effect.Techniques["SpecularLighting"];
effect.Parameters["xWorld"].SetValue(Matrix.Identity);
effect.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
effect.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
effect.Parameters["xAmbient"].SetValue(0.0f);
effect.Parameters["xLightStrength"].SetValue(0.5f);
effect.Parameters["xLightPosition"].SetValue(new Vector3(5.0f, 2.0f, -15.0f));
effect.Parameters["xCameraPos"].SetValue(fpsCam.Position);
effect.Parameters["xSpecularPower"].SetValue(128.0f);

effect.Begin();
foreach (EffectPass pass in effect.CurrentTechnique.Passes)
{
    pass.Begin();
    device.VertexDeclaration = myVertexDeclaration;
    device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleStrip, vertices, 0, 6);
    pass.End();
}
effect.End();


6.10 使用Deferred Shading在场景中添加多光源

问题

你想在场景中同时使用多个光源。

一个方法是使用多个光源绘制场景并将每个光源的影响混合在一起。当添加一个新光源时场景需要整个被重新绘制，这个方法无法拓展，因为帧频率会随着光源的增加按比例下降。

解决方案

本教程中将使用一个完全不同的方法。你将3D场景绘制到一张2D纹理中。然后，为这张纹理中的所有像素计算所有光源的光照。这意味着你要在2D纹理上进行逐像素的处理，但只需绘制3D场景一次。

但在进行光线计算时需要每个像素的初始3D位置，对吗？对。继续看下去如何做，整个过程分为3步，如图6-12所示。

在第一步中，你将整个3D场景绘制到一张纹理中（见教程3-8）—不是一张纹理而是一次三张纹理。下面是你想要的三张纹理：

  • 你要将每个像素的基本颜色绘制到第一张纹理中(图6-12中的左上图)。
  • 你需要将每个像素的法线转换为颜色并将这个颜色存储在第二张纹理中(图6－12中的中上图)。
  • 你要将每个像素的深度(像素与相机间的距离)存储在第三张纹理中(图6－12中的右上图)。

图6-12 deferred渲染中的三个步骤

前面已经说过，整个操作过程只需使用一个effect的一个pass进行一次，所以，这个操作在使用没有光照计算的方法绘制场景时开销相同（或者更少，因为effect非常简单）。

现在花点时间理解下面的文字。你需要将场景中的所有像素的深度值存储在一张纹理中。对每个像素，你还需要知道它的2D屏幕坐标，因为这个坐标和它的纹理坐标是相同的。这意味着通过某种方法，从每个像素的2D屏幕坐标中你可以重建它的3D位置。而且，你还存储了每个像素的3D法线。如果你重建了3D位置和法线，就可以计算像素上的光照了。

所以，这就是你接下去要做的事。在生成了三张纹理后，在第二步你要激活一个新的，干净的渲染目标。对这个新目标的每个像素，你将重建它的3D位置和3D法线。这让你可以计算第一光的光照值。最后你会得到一个包含第一个光照的shading贴图。

对每个光源重复上面的步骤，将它们的光照值添加到shading贴图中。最后，你会得到一张包含所有光照的shading贴图。这个过程如图6－12中的step II所示，显示了六个光源。

在第三步中，你将颜色贴图（在第一步中创建）和这个shading贴图（在第二步中创建）组合起来。如图6－12的step III所示。

使用Deferred渲染的优点

如果只是简单地为每个光源绘制场景并将它们组合起来，你必须将3D世界转换到屏幕空间中去。

这样的操作需要使用vertex和pixel shaders。vertex shader必须将每个顶点的3D位置转换到2D屏幕位置。pixel shaders必须计算比屏幕中的像素多得多的像素。例如，如果背景中的物体A首先被绘制，显卡会使用pixel shader 计算像素的颜色。如果接着绘制在物体A之前的物体B，显卡需要再次计算这些像素的颜色。这样，显卡计算的像素会大大增多。

简而言之，vertex shaders需要做大量的工作，pixel shaders需要处理比屏幕上的像素更多的像素。

使用deferred渲染，你只需在第一步中进行这样的操作一次。然后你将为每个光源在纹理上进行一些逐像素的处理，这个处理过程中只需处理像素一次。最后一步将颜色贴图和shading贴图组合在一起包含在了另一个逐像素处理过程中。

简而言之，只需进行一次将3D场景转换为2D空间的操作。对每个光源，你的显卡只需处理屏幕上的像素一次。相对而言，vertex shader做的工作也少很多，当使用多个光源时，使用deferred渲染方法pixel shader也会处理少得多的像素。

工作原理

Deferred渲染需要进行三个步骤，如下所述。

准备工作

每个步骤都要创建一个单独的HLSL文件。创建这些文件(Deferred1Scene. fx, Deferred2L ights . fx, and Deferred3 Final. fx)并在XNA代码中添加变量：

Effect effect1Scene;
Effect effect2Lights;
Effect effect3Final;

别忘了在LoadContent 方法中加载它们：

effect1Scene = Content.Load<Effect>("Deferred1Scene");
effect2Lights = ontent.Load<Effect>("Deferred2Lights");
effect3Final = Content.Load<Effect>("Deferred3Final");

确保在LoadContents方法中加载所需的几何数据。本例中，我将从一个顶点数组绘制一个简单的屋子，在InitSceneVertices方法中进行初始化：

InitSceneVertices();
InitFullscreenVertices();

最后一行代码初始化第二个顶点数组，定义了两个大三角形覆盖了整个屏幕。它们被用在了第二步和第三步中，当你使用自己的pixel shaders绘制全屏纹理时，允许你逐像素地处理全屏纹理。InitFullScreenVertices方法来自于教程2-12。

然后为了保持代码清晰，定义了一个RenderScene方法，这个方法以一个effect为参数，使用这个effect绘制整个屏幕。这个简单例子只从顶点数组中绘制三面带纹理的墙和一面地板。如果场景中包含模型，确保也使用这个effect绘制这些模型：

private void RenderScene(Effect effect)
{
    //Render room
    effect.Parameters["xWorld"].SetValue(Matrix.Identity);
    effect.Parameters["xTexture"].SetValue(wallTexture);

    effect.Begin();
    foreach (EffectPass pass in effect.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = wallVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionNormalTexture> (PrimitiveType.TriangleStrip, wallVertices, 0, 14);
        pass.End();
    }
    effect.End();
}

第一步：将3D场景绘制到三张纹理中

在第一步中，你将场景绘制到三张纹理中。这些纹理需要包含基本颜色，3D法线和屏幕上每个像素的深度值。深度表示相机和物体上对应像素的距离。

这里只使用一个pixel shader。pixel shader将一次渲染到三张纹理而不是一个。

XNA Code

首先定义渲染目标：

RenderTarget2D colorTarget;
RenderTarget2D normalTarget;
RenderTarget2D depthTarget;

在LoadContent方法中进行初始化：

PresentationParameters pp = device.PresentationParameters;
int width = pp.BackBufferWidth;
int height = pp.BackBufferHeight;
colorTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color);
normalTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color);
depthTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Single);

因为法线有三个分量，你将它存储为一个Color。深度值为一个single float值。当pixel shader写入多个渲染目标时，它们的格式必须是相同大小的。Color的每个分量使用8 bits (256 个可能值)，所以Color使用32 bits。一个float也使用32 bits，所以不会出错。

创建了渲染目标后就可以进行绘制了。下面的方法处理了整个第一步的过程，所以要在Draw方法的第一行中调用：

private void RenderSceneTo3RenderTargets()
{
    //bind render targets to outputs of pixel shaders
    device.SetRenderTarget(0, colorTarget); device.SetRenderTarget(1, normalTarget);
    device.SetRenderTarget(2, depthTarget);

    //clear all render targets
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0);

    //render the scene using custom effect writing to all targets simultaneously
    effect1Scene.CurrentTechnique = effect1Scene.Techniques["MultipleTargets"];
    effect1Scene.Parameters["xView"].SetValue(fpsCam.ViewMatrix);
    effect1Scene.Parameters["xProjection"].SetValue(fpsCam.ProjectionMatrix);
    RenderScene(effect1Scene);

    //deactivate render targets to resolve them
    device.SetRenderTarget(0, null);
    device.SetRenderTarget(1, null);
    device.SetRenderTarget(2, null);

    //copy contents of render targets into texture
    colorMap = colorTarget.GetTexture();
    normalMap = normalTarget.GetTexture();
    depthMap = depthTarget.GetTexture();
}

首先，你将三个渲染目标绑定到pixel shaders中的COLOR0, COLOR1和COLOR2。请确保将它们的内容清空为黑色，（更重要）z-buffer设置为1 (见教程2－1)。

初始化结束后，就可以绘制场景了。使用MultipleTargets technique，这个technique将在下面定义。设置World, View和Projection矩阵(World矩阵必须在RenderScene方法中设置，因为场景中每个对象的世界矩阵是不同的)。通过将MultipleTargets technique传递到RenderScene绘制场景。

RenderScene方法完成后，三个渲染目标就会包含屏幕上每个像素的颜色，法线和深度值。在将它们保存到纹理之前，需要关闭它们(见教程3-8)。

HLSL代码

你仍需定义MultipleTargets technique，这个technique一次将场景绘制到三张纹理中。首先定义XNA-to-HLSL变量：

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;

Texture xTexture;
sampler TextureSampler = sampler_state
{
    texture = <xTexture> ;
    agfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = wrap;
    AddressV = wrap;
};

像以往一样，你需要定义World，View和Projection矩阵。因为房间的墙和地板都带有纹理，还需要一个texture用来采样颜色。这些颜色会被保存到第一个渲染目标中。

下面是vertex 和pixel shaders的output结构：

struct VertexToPixel
{
    float4 Position : POSITION;
    float3 Normal : TEXCOORD0;
    float4 ScreenPos : TEXCOORD1;
    float2 TexCoords: TEXCOORD2;
};

struct PixelToFrame
{
    float4 Color: COLOR0;
    float4 Normal: COLOR1;
    float4 Depth: COLOR2;
};

在必须的Position之后，vertex shader还将法线传递到pixel shader以使它可以存储到第二个渲染目标。另外，因为pixel shader需要将深度值保存到第三个渲染目标中，你还需要将屏幕坐标传递到pixel shader。屏幕坐标的X和Y分量包含了当前像素的屏幕坐标，Z分量包含深度。

最后，pixel shader需要纹理坐标从纹理中对应的位置采样颜色。

非常重要的是pixel shader的output结构。不像本书的其他任何一个部分，本例中的pixel shader会生成多个output。你的pixel shader不仅会写入COLOR0，还会写入COLOR1和COLOR2 .显然这些output对应三个渲染目标。

先讨论简单的vertex shader：

VertexToPixel MyVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0, float2 inTexCoords: TEXCOORD0)
{
    VertexToPixel Output = (VertexToPixel)0;

    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    float3x3 rotMatrix = (float3x3)xWorld;
    float3 rotNormal = mul(inNormal, rotMatrix);
    Output.Normal = rotNormal;
    Output.ScreenPos = Output.Position;
    Output.TexCoords = inTexCoords;

    return Output;
}

3D位置转换为2D屏幕位置很简单。法线通过世界矩阵中的旋转部分进行旋转(见教程6-5)。纹理坐标直接输出到output，2D屏幕坐标复制到ScreenPos变量中。

下面是pixel shader：

PixelToFrame MyPixelShader(VertexToPixel PSIn)
{
    PixelToFrame Output = (PixelToFrame)0;
    Output.Color.rgb = tex2D(TextureSampler, PSIn.TexCoords);
    Output.Normal.xyz = PSIn.Normal/2.0f+0.5f;
    Output.Depth = PSIn.ScreenPos.z/PSIn.ScreenPos.w;

    return Output;
}

这很简单。颜色从纹理中采样(本例中是墙上的砖块纹理)并存储在第一个渲染目标中。然后是法线，因为3D法线的每个分量定义在[–1,1]区间,你需要将它们转换到[0,1]区间,这样它才可以存储为一个颜色分量。你可以将这个值除以2然后加0.5实现上述目的。

最后，需要在第三个渲染目标中存储深度值。深度值存储在ScreenPos变量的Z分量中。因为ScreenPos是4 × 4矩阵乘法额结果，所以它是一个4 × 1向量。在可以使用前三个分量前，你需要将它们除以第四个分量，这就是pixel shader中最后一行代码进行的操作。

下面是technique定义：

technique MultipleTargets
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 MyVertexShader();
        PixelShader = compile ps_2_0 MyPixelShader();
    }
}
第一步的总结

第一步结束后，你生成并存储了三个纹理：第一个纹理包含基本颜色，第二个包含法线，第三个包含深度。

第二步：生成Shading贴图

知道了每个像素的颜色，法线和深度后，就可以进行光照计算了。这需要让显卡绘制两个覆盖整个屏幕的三角形，让你可以创建一个pixel shader用来被屏幕上的每个像素调用。在这个pixel shader中，你将计算一个光源施加在一个像素上的光照值。

这个过程对场景中的每个光源进行重复，这些重复过程对应图6-12中step II 的六张图像，因为这个例子使用了六个光源。本例中展示的是如何计算聚光灯的光照。

注意：如果你想添加一个不同的光源，需要调整光照计算。这只是pixel shader中的一小部分代码，其他部分保持不变。

HLSL代码

简而言之，这个effect将从深度贴图中采样每个像素的深度以重建像素的3D位置。知道了3D位置，就可以进行光照计算了。

要重新创建3D位置，你需要反转ViewProjection矩阵和深度贴图。而且还需要法线贴图和一些变量设置聚光灯(见教程6-8)：

float4x4 xViewProjectionInv;
float xLightStrength;
float3 xLightPosition;
float3 xConeDirection;
float xConeAngle;
float xConeDecay;

Texture xNormalMap;
sampler NormalMapSampler = sampler_state
{
    texture = <xNormalMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

Texture xDepthMap;
sampler DepthMapSampler = sampler_state
{
    texture = <xDepthMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

然后是vertex和pixel shader的output结构。vertex shader生成2D屏幕坐标。还需要纹理坐标，这样每个像素才能从正确的位置采样法线贴图和深度贴图。

这次，pixel shader只需生成一个output值：当前光源对当前像素的光照值。

struct VertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
};

struct PixelToFrame
{
    float4 Color : COLOR0;
};

因为在InitFullscreenVertices方法中定义的六个顶点已经定义在屏幕坐标中（位于[(–1,–1),(1,1)]区间）了，vertex shader只需简单地将位置和纹理坐标传递到output:

VertexToPixel MyVertexShader(float4 inPos: POSITION0, float2 texCoord: TEXCOORD0)
{
    VertexToPixel Output = (VertexToPixel)0;
    Output.Position = inPos;
    Output.TexCoord = texCoord;

    return Output;
}

颜色处理都在pixel shader中。首先从法线贴图和深度贴图中采样法线和深度值：

PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0
{
    PixelToFrame Output = (PixelToFrame)0;

    float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb;
    normal = normal*2.0f-1.0f;
    normal = normalize(normal);
    float depth = tex2D(DepthMapSampler, PSIn.TexCoord);
}

深度值可以立即从深度贴图中采样。而法线必须首先将[0,1]区间重新映射到[–1,–1]区间，这是第一步中的逆操作。

下一步是重新构建像素的3D位置。要获取这个位置，首先需要当前像素的屏幕位置。 当前像素的纹理坐标适合做这件事，但它需要从[0,1]纹理坐标映射到[–1,1]屏幕坐标。屏幕坐标的Y需要取负值：

float4 screenPos;
screenPos.x = PSIn.TexCoord.x*2.0f-1.0f;
screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f);

但是，屏幕位置还有第三个分量：相机和像素的距离。这就是你为什么生成第二个渲染目标的原因。因为知道了深度，就知道了第三个分量：

screenPos.z = depth;
screenPos.w = 1.0f;

第四个分量是需要的，因为接下来你要将这个矢量与一个4 × 4矩阵相乘。你可以通过把第四个分量设置为1将一个Vector3变成一个Vector4。

现在有了像素的屏幕坐标，但你想获取3D位置。还记得你可以通过把3D位置乘以ViewProjection矩阵（教程2-1）将一个3D位置转换成2D屏幕位置吗？所以，如何进行相反的操作——将2D屏幕位置转换为3D位置？这很简单，只需乘以ViewProjection的逆矩阵：

float4 worldPos = mul(screenPos, xViewProjectionInv);
worldPos /= worldPos.w;

矩阵的逆矩阵由XNA代码设置并计算，这很容易做到。

向量与4 × 4矩阵的计算结果返回一个同源（homogenous）向量，在使用前你需要将它前三个分量除以第四个分量。

最后获得了像素的3D位置。你还知道了像素的3D法线。有了这两者，就可以进行任何光照计算了。pixel shader的其余部分计算了一个聚光灯的光照值（来自于教程6-8），下面是完整的pixel shader代码：

PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0
{
    PixelToFrame Output = (PixelToFrame)0;
    float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb;
    normal = normal*2.0f-1.0f; normal = normalize(normal);
    float depth = tex2D(DepthMapSampler, PSIn.TexCoord).r;
    float4 screenPos;
    screenPos.x = PSIn.TexCoord.x*2.0f-1.0f;
    screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f);
    screenPos.z = depth; screenPos.w = 1.0f;
    float4 worldPos = mul(screenPos, xViewProjectionInv);
    worldPos /= worldPos.w;
    float3 lightDirection = normalize(worldPos - xLightPosition);
    float coneDot = dot(lightDirection, normalize(xConeDirection));

    bool coneCondition = coneDot >= xConeAngle;
    float shading = 0;
    if (coneCondition)
    {
        float coneAttenuation = pow(coneDot, xConeDecay);
        shading = dot(normal, -lightDirection);
        shading *= xLightStrength;
        shading *= coneAttenuation;
    }
    Output.Color.rgb = shading;

    return Output;
}

下面是technique定义：

technique DeferredSpotLight
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 MyVertexShader();
        PixelShader = compile ps_2_0 MyPixelShader();
    }
}

你创建了一个effect，这个effect从一张深度贴图，一张法线贴图和一个聚光灯开始，创建了一个包含聚光灯光照可见范围的shading贴图。

XNA代码

在XNA代码中，你将对场景中的每个光源调用这个effect。要管理光源，应该创建一个结构，保存聚光灯的所有细节：

public struct SpotLight
{
    public Vector3 Position;
    public float Strength;
    public Vector3 Direction;
    public float ConeAngle;
    public float ConeDecay;
}

在项目中添加这些对象的数组：

SpotLight[] spotLights;

对它进行初始化以存储一些光源：

spotLights = new SpotLight[NumberOfLights];

现在你就可以定义每个聚光灯了。你可以在Update方法中改变它们的设置，让你可以让这些光源绕着场景旋转！

然后，你将创建一个可以以SpotLight对象为参数的方法，这个方法将这个聚光灯的光照值绘制到渲染目标中：

private void AddLight(SpotLight spotLight)
{
    effect2Lights.CurrentTechnique = effect2Lights.Techniques["DeferredSpotLight"];
    effect2Lights.Parameters["xNormalMap"].SetValue(normalMap);
    effect2Lights.Parameters["xDepthMap"].SetValue(depthMap);
    effect2Lights.Parameters["xLightPosition"].SetValue(spotLight.Position);
    effect2Lights.Parameters["xLightStrength"].SetValue(spotLight.Strength);
    effect2Lights.Parameters["xConeDirection"].SetValue(spotLight.Direction);
    effect2Lights.Parameters["xConeAngle"].SetValue(spotLight.ConeAngle);
    effect2Lights.Parameters["xConeDecay"].SetValue(spotLight.ConeDecay);
    Matrix viewProjInv = Matrix.Invert(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
    effect2Lights.Parameters["xViewProjectionInv"].SetValue(viewProjInv);

    effect2Lights.Begin();
    foreach (EffectPass pass in effect2Lights.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = fsVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleStrip, fsVertices, 0, 2);
        pass.End();
    }
    effect2Lights.End();
}

首先选择你刚才定义的HLSL technique。然后，传递法线贴图和深度贴图，这两个贴图都是在第一步中创建的。接下来的代码传递聚光灯的设置。最后一个变量设置ViewProjection矩阵的逆矩阵，这个逆矩阵可以简单地使用Matrix. Invert方法得到。

定义完所有变量后，显卡绘制两个覆盖整个屏幕的三角形。这样显卡就可以对屏幕上的每个像素计算当前聚光灯的光照值。

AddLight方法绘制一个光源的光照值。你可以为每个聚光灯调用这个方法并将它们的光照值加在一起！这可以通过使用additive alpha混合做到。使用additive alpha混合，每个光照值都会被添加到相同的渲染目标中。

private Texture2D GenerateShadingMap()
{
    device.SetRenderTarget(0, shadingTarget);
    device.Clear(ClearOptions.Target | ClearOptions.DepthBuffer, Color.Black, 1, 0);

    device.RenderState.AlphaBlendEnable = true;
    device.RenderState.SourceBlend = Blend.One;
    device.RenderState.DestinationBlend = Blend.One;

    for (int i = 0; i < NumberOfLights; i++)
        AddLight(spotLights[i]);
    device.RenderState.AlphaBlendEnable = false;
    device.SetRenderTarget(0, null);

    return shadingTarget.GetTexture();
}

GenerateShadingMap方法首先开启一个新的叫做shadingTarget的渲染目标。首先要清除前面的内容。然后，打开additive alpha混合并将所有的光照值添加到渲染目标。然后关闭alpha混合 blending防止与后面的渲染混在一起。最后，渲染目标的内容被保存到一张纹理，并返回这个纹理。

这个方法应该在Draw方法中的第二行中调用：

shadingMap = GenerateShadingMap();

还需要在项目中添加shadingTarget和shadingMap变量：

RenderTarget2D shadingTarget; Texture2D shadingMap;

在LoadContent方法中初始化渲染目标：

shadingTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Color);

因为这个渲染目标包含屏幕上每个像素的光照值，所以必须拥有与屏幕相同的大小。

第二步的总结

现在，你有了一张包含屏幕的每个像素光照值的shading贴图。

第三步：组合颜色贴图和Shading贴图

最后一步很简单。在第一步中每个像素的基本颜色存储在colorMap中。第二步中每个像素的光照值存储在shadingMap中。在第三步中，你只需简单地将两者相乘获取最终的颜色。

HLSL代码

effect接受colorMap和shadingMap纹理。要照亮场景中没有被聚光灯照到的部分，你需要添加一个小小的环境光：

float xAmbient;

Texture xColorMap;
sampler ColorMapSampler = sampler_state
{
    texture = <xColorMap> ;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

Texture xShadingMap;
sampler ShadingMapSampler = sampler_state
{
    texture = <ShadingMap>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

vertex shader和pixel shader的output结构，vertex shader本身与前面的教程中完全一样。这是因为vertex shader将接受六个顶点定义两个覆盖整个屏幕的三角形。

struct VertexToPixel
{
    float4 Position : POSITION;
    float2 TexCoord : TEXCOORD0;
};

struct PixelToFrame
{
    float4 Color : COLOR0;
};

// Technique:
CombineColorAndShading VertexToPixel MyVertexShader(float4 inPos: POSITION0, float2 texCoord: TEXCOORD0)
{
    VertexToPixel Output = (VertexToPixel)0;
    Output.Position = inPos;
    Output.TexCoord = texCoord;

    return Output;
}

pixel shader很简单：

PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0
{
    PixelToFrame Output = (PixelToFrame)0;
    float4 color = tex2D(ColorMapSampler, PSIn.TexCoord);
    float shading = tex2D(ShadingMapSampler, PSIn.TexCoord);
    Output.Color = color*(xAmbient + shading);

    return Output;
}

你采样color和shading值，添加环境光并将它们相乘。最终的颜色传递到渲染目标。

下面是technique定义：

technique CombineColorAndShading
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 MyVertexShader();
        PixelShader = compile ps_2_0 MyPixelShader();
    }
}
XNA代码

effect需要在Draw方法的最后被调用。CombineColorAndShading方法选择technique，传递color和shading贴图，设置环境光。最后，使用刚才定义的technique绘制两个三角形：

private void CombineColorAndShading()
{
    effect3Final.CurrentTechnique= effect3Final.Techniques["CombineColorAndShading"];
    effect3Final.Parameters["xColorMap"].SetValue(colorMap);
    effect3Final.Parameters["xShadingMap"].SetValue(shadingMap);
    effect3Final.Parameters["xAmbient"].SetValue(0.3f);

    effect3Final.Begin();
    foreach (EffectPass pass in effect3Final.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = fsVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionTexture> (PrimitiveType.TriangleStrip, fsVertices, 0, 2);
        pass.End();
    }
    effect3Final.End();
}

这两个三角形需要被绘制到屏幕而不是渲目标。

第三步的总结

对屏幕上的每个像素，你组合了基本颜色和光照强度。

代码

所有的effect文件、对应deferred shading的主要方法前面已经写过了。因为你将代码分解成几个方法，所以Draw方法非常清晰：

protected override void Draw(GameTime gameTime)
{
    //render color, normal and depth into 3 render targets
    RenderSceneTo3RenderTargets();

    //Add lighting contribution of each light onto shadingMap
    shadingMap = GenerateShadingMap();

    //Combine base color map and shading map
    CombineColorAndShading();

    base.Draw(gameTime);
}

性能技巧

对每个光源，你的pixel shader将计算屏幕上所有像素的光照值。要减少第二步中处理像素的数量，你可以只绘制屏幕中被光照影响的部分而不是整个屏幕。这可以通过调整两个三角形的坐标实现。


6.11在Deferred Shading引擎中添加阴影能力

问题

虽然你已经掌握了基本的计算机实时光照，但你应该注意到光源还没有投射出阴影。这是因为pixel shader是基于光线与法线的夹角计算光照的。直到现在，pixel shader还没有考虑到光线与像素间的物体。

阴影映射（shadow mapping）技术在我的网站(http://www. riemers. net)的第三个系列中加以介绍，这个技术可以对一个光源生成正确的阴影，但是你想用deferred渲染的方法实现。

解决方案

在场景中添加阴影的一个极好的方法是阴影映射技术，我已经在我的网站上详细介绍了 (http://www.riemers.net)。

简而言之，阴影映射技术将每个像素与光源之间的真实距离与从光源看起来的距离做比较。如果真实距离大于从光源看来的距离，那么必定有个对象处于相机与像素之间，因此这个像素不被照亮。

要允许这种比较，场景首先应该绘制成从光源看起来的情况，这样像素到光源的距离可以被存储在一张纹理中。

如果你想实现deferred版本的阴影映射技术，必须首先为每个光源生成一张深度贴图。完成这张贴图后，阴影映射的比较就可以施加在deferrded渲染的第二步中。

工作原理

本教程完全建立在前一个教程的基础上。前一个教程的第一步保持不变，因为你仍需要屏幕上所有像素的颜色，法线和深度值。

生成阴影贴图

XNA代码

在第二步中获取一个光源的光照值之前，你需要为这个光源生成阴影贴图。这个阴影贴图包含场景和光源间的距离。你还需要添加一个渲染目标和两个变量：一个存储距离，一个黑色图像用来重置另一个：

RenderTarget2D shadowTarget; Texture2D shadowMap; Texture2D blackImage;

在LoadContent方法中初始化渲染目标和黑色图像：

shadowTarget = new RenderTarget2D(device, width, height, 1, SurfaceFormat.Single);
blackImage = new Texture2D(device, width, height, 1, TextureUsage.None, SurfaceFormat.Color);

然后找到GenerateShadingMap方法。这个方法使用alpha混合将所有光源的光照值混合在一起添加到一张纹理中。这一步不需要，因为你需要在两个混合操作之间生成一张新的阴影贴图，这会导致阴影贴图混合到shading贴图中。

不用alpha混合，添加完每个光源的光照值后你将保存shading贴图。但首先要使用黑色图像删除shading贴图：

private Texture2D GenerateShadingMap()
{
    shadingMap = blackImage;
    for (int i = 0; i < NumberOfLights; i++)
    {
        RenderShadowMap(spotLights[i]);
        AddLight(spotLights[i]);
    }
    return shadingTarget.GetTexture();
}

对每个光源首先调用RenderShadowMap方法，这个方法会在shadowMap变量中存储光源的阴影贴图。基于这张阴影贴图，光源会将它的光照值添加到shading贴图中。

阴影贴图需要包含从光源看来的距离值。所以，每个光源需要定义View和Projection矩阵，这需要扩展SpotLight结构：

public struct SpotLight
{
    public Vector3 Position;
    public float Strength;
    public Vector3 Direction;
    public float ConeAngle;
    public float ConeDecay;
    public Matrix ViewMatrix;
    public Matrix ProjectionMatrix;
}

对一个聚光灯来说，定义这两个矩阵非常容易：

spotLights[i].ViewMatrix = Matrix.CreateLookAt(lightPosition, lightPosition + lightDirection,lightUp);
float viewAngle = (float)Math.Acos(spotLights[i].ConeAngle);
spotLights[i].ProjectionMatrix = Matrix.CreatePerspectiveFieldOfView(coneAngle * 2.0f, 1.0f, 0.5f, 1000.0f);

viewAngle基于聚光灯的光锥。通过这种方式，渲染目标的区域可以得到最优化的使用。

RenderShadowMap方法将这些矩阵传递到ShadowMap effect（下面就会定义）中。之后，场景从相机看来的方式被绘制，距离保存到shadowMap纹理中。

private void RenderShadowMap(SpotLight spotLight)
{
    device.SetRenderTarget(0, shadowTarget);
    effectShadowMap.CurrentTechnique= effectShadowMap.Techniques["ShadowMap"];
    ffectShadowMap.Parameters["xView"].SetValue(spotLight.ViewMatrix);
    effectShadowMap.Parameters["xProjection"].SetValue(spotLight.ProjectionMatrix);
    RenderScene(effectShadowMap); device.SetRenderTarget(0, null);
    shadowMap = shadowTarget.GetTexture();
}
HLSL代码

HLSL代码很简单。因为3D场景需要变换为2D屏幕坐标，effect需要World，View和 Projection矩阵。 要匹配RenderScene方法还需要设置一个纹理，所以effect也包含xTexture变量，虽然这里没有用到这个变量

float4x4 xWorld;
float4x4 xView;
float4x4 xProjection;
Texture xTexture;

struct VertexToPixel
{
    float4 Position : POSITION;
    float4 ScreenPos : TEXCOORD1;
};

struct PixelToFrame
{
    float4 Color : COLOR0;
}

vertex shader需要计算2D屏幕坐标。这个屏幕坐标还包含深度值让pixel shader输出。因为POSITION语义无法在pixel shader中访问，你要将它复制到ScreenPos变量中。

vertex shader非常简单，因为它做的就是将3D位置转换为2D屏幕位置：

VertexToPixel MyVertexShader(float4 inPos: POSITION0, float3 inNormal: NORMAL0)
{
    VertexToPixel Output = (VertexToPixel)0;
    float4x4 preViewProjection = mul(xView, xProjection);
    float4x4 preWorldViewProjection = mul(xWorld, preViewProjection);
    Output.Position = mul(inPos, preWorldViewProjection);
    Output.ScreenPos = Output.Position;

    return Output;
}

pixel shader接受屏幕坐标。因为这是一个齐次（homogeneous）向量，所以在使用前要将前三个分量除以第四个分量。pixel shader生成深度值输出。

PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0
{
    PixelToFrame Output = (PixelToFrame)0;
    Output.Color.r = PSIn.ScreenPos.z/PSIn.ScreenPos.w;

    return Output;
}

下面是technique定义：

technique ShadowMap
{
    pass Pass0
    {
        VertexShader = compile vs_2_0 MyVertexShader();
        PixelShader = compile ps_2_0 MyPixelShader();
    }
}

根据光照计算添加阴影映射

XNA代码

绘制了阴影贴图后，调用AddLight方法。这个方法首先开启shadingTarget和DeferredSpotLight technique。这个方法和technique主要部分与前一个教程相同，除了一些小变化。在每个光源的最后，shadingTarget的当前内容被保存到shadingMap纹理中。通过xPreviousShadingMapContents变量将这个内容传递到下一个光源中，你还要传递阴影贴图。

private void AddLight(SpotLight spotLight)
{
    device.SetRenderTarget(0, shadingTarget);
    effect2Lights.CurrentTechnique = effect2Lights.Techniques["DeferredSpotLight"];
    effect2Lights.Parameters["xPreviousShadingContents"].SetValue(shadingMap);
    effect2Lights.Parameters["xNormalMap"].SetValue(normalMap);
    effect2Lights.Parameters["xDepthMap"].SetValue(depthMap);
    effect2Lights.Parameters["xShadowMap"].SetValue(shadowMap);
    effect2Lights.Parameters["xLightPosition"].SetValue(spotLight.Position);
    effect2Lights.Parameters["xLightStrength"].SetValue(spotLight.Strength);
    effect2Lights.Parameters["xConeDirection"].SetValue(spotLight.Direction);
    effect2Lights.Parameters["xConeAngle"].SetValue(spotLight.ConeAngle);
    effect2Lights.Parameters["xConeDecay"].SetValue(spotLight.ConeDecay);

    Matrix viewProjInv = Matrix.Invert(fpsCam.ViewMatrix * fpsCam.ProjectionMatrix);
    effect2Lights.Parameters["xViewProjectionInv"].SetValue(viewProjInv);
    effect2Lights.Parameters["xLightViewProjection"].SetValue(spotLight.ViewMatrix * spotLight.ProjectionMatrix);

    effect2Lights.Begin();
    foreach (EffectPass pass in effect2Lights.CurrentTechnique.Passes)
    {
        pass.Begin();
        device.VertexDeclaration = fsVertexDeclaration;
        device.DrawUserPrimitives<VertexPositionTexture>(PrimitiveType.TriangleStrip, fsVertices, 0, 2);
        pass.End();
    }
    effect2Lights.End();
    device.SetRenderTarget(0, null);
    shadingMap = shadingTarget.GetTexture();
}

HLSL代码确保effect可以接受新的纹理：

Texture xShadowMap;
sampler ShadowMapSampler = sampler_state
{
    texture = <xShadowMap>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

Texture xPreviousShadingContents;
sampler PreviousSampler = sampler_state
{
    texture = <xPreviousShadingContents>;
    magfilter = LINEAR;
    minfilter = LINEAR;
    mipfilter=LINEAR;
    AddressU = mirror;
    AddressV = mirror;
};

唯一的变化在pixel shader中。你想获取点和光源之间的真实距离和存储在阴影贴图中的距离。如果存储在阴影贴图中的距离小于真实距离，说明有一个物体处于光源和像素之间，这个像素不会被当前光源照到。要找到真实距离，需要通过光照的ViewProjection矩阵转换3D位置。在除以它的齐次分量（译者注：即w分量，第四个分量）后，这个距离的Z分量就可以容易地使用了。

//find screen position as seen by the light
float4 lightScreenPos = mul(worldPos, xLightViewProjection);
lightScreenPos /= lightScreenPos.w;

然后，你想获取存储在阴影贴图中的距离。首先需要知道到哪采样阴影贴图，将lightScreenPos的分量从[–1,1]的屏幕位置区间映射到[0,1]纹理坐标区间，这和前一个教程一样：

//find sample position in shadow map
float2 lightSamplePos;
lightSamplePos.x = lightScreenPos.x/2.0f+0.5f;
lightSamplePos.y = (-lightScreenPos.y/2.0f+0.5f);

现在可以采样存储在阴影贴图中的深度值了。检查这个距离是否小于真实距离，表示像素是否被当前光源照亮：

//determine shadowing criteria
float realDistanceToLight = lightScreenPos.z;
float distanceStoredInDepthMap = tex2D(ShadowMapSampler, lightSamplePos);
bool shadowCondition = distanceStoredInDepthMap <= realDistanceToLight - 1.0f/100.0f;

最后，基于shadowCondition和coneCondition判断像素是否被照亮。这个shading值被添加到shadingMap中的原有值上，这是在pixel shader中的最后一行代码中执行的。

下面是完整的pixel shader代码：

PixelToFrame MyPixelShader(VertexToPixel PSIn) : COLOR0
{
    PixelToFrame Output = (PixelToFrame)0;

    //sample normal from normal map
    float3 normal = tex2D(NormalMapSampler, PSIn.TexCoord).rgb;
    normal = normal*2.0f-1.0f;
    normal = normalize(normal);

    //sample depth from depth map
    float depth = tex2D(DepthMapSampler, PSIn.TexCoord).r;

    //create screen position
    float4 screenPos;
    screenPos.x = PSIn.TexCoord.x*2.0f-1.0f;
    screenPos.y = -(PSIn.TexCoord.y*2.0f-1.0f);
    screenPos.z = depth;
    screenPos.w = 1.0f;

    //transform to 3D position
    float4 worldPos = mul(screenPos, xViewProjectionInv);
    worldPos /= worldPos.w;

    //find screen position as seen by the light
    float4 lightScreenPos = mul(worldPos, xLightViewProjection);
    lightScreenPos /= lightScreenPos.w;

    //find sample position in shadow map
    float2 lightSamplePos;
    lightSamplePos.x = lightScreenPos.x/2.0f+0.5f;
    lightSamplePos.y = (-lightScreenPos.y/2.0f+0.5f);

    //determine shadowing criteria
    float realDistanceToLight = lightScreenPos.z;
    float distanceStoredInDepthMap = tex2D(ShadowMapSampler, lightSamplePos);
    bool shadowCondition = distanceStoredInDepthMap <= realDistanceToLight - 1.0f/100.0f;

    //determine cone criteria
    float3 lightDirection = normalize(worldPos - xLightPosition);
    float coneDot = dot(lightDirection, normalize(xConeDirection));
    bool coneCondition = coneDot >= xConeAngle;

    //calculate shading
    float shading = 0;
    if (coneCondition && !shadowCondition)
    {
        float coneAttenuation = pow(coneDot, xConeDecay);
        shading = dot(normal, -lightDirection);
        shading *= xLightStrength;
        shading *= coneAttenuation;
    }

    float4 previous = tex2D(PreviousSampler, PSIn.TexCoord);
    Output.Color = previous + shading;

    return Output;
}

代码

这个教程使用与教程6-10相同的代码，但GenerateShadingMap，RenderShadowMap和 AddLight方法有所变化，而这几个方法前面已经写过了。


7.0 第七章概述

如果没有声音即使最好的游戏也会失去许多乐趣。如果在游戏中添加合适的声音，你的游戏将更加完美。

本章介绍了如何将声音添加到XNA项目中并管理它们。

本章包含以下内容：

  • 直接播放音效、MP3文件或WMV文件(教程7-1和7-2)
  • 创建一个Xact项目将文件加载到XNA项目中(教程7-3)
  • 循环播放声音给游戏添加一个背景音乐(教程7-4)
  • 通过添加3D声音让3D世界更加生动(教程7-5)


7.1 播放并控制简单的.wav声音文件

问题

你想有一个快速播放音效的方法。

解决方案

在XNA3.0中，你可以直接从XNA中加载和播放声音，而无需使用Xact加载它们。虽然这个方法移除了一些Xact的高级功能，但可以让播放音效变得非常容易。

工作原理

首先在项目中添加音效文件，方法与在项目中添加一张图像是一样的，可参见教程3－1。最简单的方法是将一个.wav文件拖动到解决方案的Content文件夹上。

将.wav文件添加到项目中之后，在代码顶部声明一个SoundEffect变量，它将链接到之后的音效文件：

SoundEffect myEffect;

在LoadContent方法中，添加以下代码加载一个.wav文件：

myEffect＝Content.Load<SoundEffect>(“effect”);

当你想播放声音时，可以使用以下代码：

myEffect.Play();

SoundEffect.Play()重载方法

SoundEffect.Play()方法有很多重载方法，可以提供更多的控制。最复杂的重载方法有四个参数：

  • 第一个参数volume让你可以控制音量，范围值在0.0f到1.0f之间，1.0f对应设置在SoundEffect.MasterVolumn，这个值也在0.0f和1.0f之间，1.0f对应系统音量（可在Windows系统工具栏的扬声器图标中看到）。
  • 第二个参数pitch是音高（速度），音高的数值范围是-1.0到1.0之间的浮点数，负值表示播放速度变慢，正值变快，0.0表示正常速度。注意这可以用来产生一个声音的不同版本：散弹枪和手枪使用相同的声音效果，但音高不同。
  • 第三个参数pan是声道，输入的数值范围是-1.0到1.0之间的浮点数，-1.0表示只通过左声道播放，1.0表示只通过右声道，正值表示右声道的音量比左声道大。
  • 最后一个参数loop让你可以设定是否循环，输入的是布尔值，1代表true，也就是声音播放完之后会自动再播放一次，而0代表false，即代表着声音播放完之后就不会再播放了，可见下面的章节学习如何防止一个声音循环播放。

控制音效

前面的代码已经足以播放音效了，但XNA还提供了SoundEffectInstance类，你可以使用这个类暂停或停止音效，改变音量等，首先要在代码顶部添加一个变量：

SoundEffectInstance myEffectInstance;

当调用SoundEffectInstance的Play方法时，SoundEffect对象会自动转换为一个SoundEffectInstance对象。

现在你可以访问SoundEffectInstance对象，你可以使用这个对象改变音高和音量或者检查它是否已经停止播放：

if (myEffectInstance != null)
{
    myEffectInstance.Volume *= 0.9f;

    if (myEffectInstance.State == SoundState.Stopped)
    {
        myEffectInstance.Dispose();
        myEffectInstance = null;
    }
}

这个代码会持续地降低音量，当停止播放时会移除对这个对象的引用。

注意：Zune可以同时播放16个SoundEffectInstance，但加载的SoundEffectInstance数量没有限制。Xbox360可以同时播放300个SoundEffectInstance，但一次只能加载300个SoundEffectInstance。在PC上没有限制。但提醒一点，在写本书的时候，SoundEffectInstance.Play方法有一个bug，它会在使用SoundEffectInstance.Resume方法时被忽略。


7.2 播放MP3或WMA声音文件

问题

你想在XNA游戏中播放一个MP3或WMA声音文件。

解决方案

XNA可以使用默认内容管道将MP3或WMA声音文件加载到一个Song对象中，加载之后，就可以使用静态的MediaPlayer类播放Song对象了。

工作原理

首先将MP3或WMA文件添加到XNA项目中。这和添加一个图像是一样的，可参见教程3－1。最简单的方法就是将MP3或WMA文件拖动到Content文件夹上。

你需要一个Song对象链接到MP3或WMA文件上，在代码顶部添加这个对象：

Song mySong;

在LoadContent方法中使用内容管道加载对象：

mySong=Content.Load<Song>(“Sample”);

使用以下简单命令就可以播放文件了：

MediaPlayer.Play(mySong);

MediaPlayer是一个静态类，一次只能播放一个声音，这对播放背景音乐是很理想的。但是，你需要像教程7－1中类似的方法在游戏中播放另一个声音。

Song对象和MediaPlayer类都有一些有用的属性，诸如歌的长度，当前歌的播放进度等：

TimeSpan toGo=mySong.Duration - MediaPlayer.PlayPositon;
String myText = “Time remaining for current song:” + toGo.ToString();


7.3 通过Xact播放简单的.wav文件

问题

你想在游戏中播放声音。

注意：Zune不支持Xact，所以参见教程7-1和7-2学习如何在Zune上播放声音。

解决方案

通过使用Xact，一个XNA Game Studio 3.0自带的免费工具，你可以创建包含游戏中使用的所有声音的Xact项目。你可以将这个Xact项目导入到XNA项目中，这样就可以使用一行简单的代码播放存储在XAct项目中的声音。

工作原理

首先，确保你已经打开了一个XNA项目并存储到了磁盘中的某处。要更好的组织素材，你应该创建一个新文件夹存储所有的声音素材。在XNA项目的Solution Explorer中，找到Content，右击它，选择Add→New Folder，起一个名称。

现在通过点击Start菜单并点击Programs→Microsoft XNA Game Studio 30→Tools→ Microsoft Cross-Platform Audio Creation Tool (XAct)打开Xact。

当开始Xact时，你要做的第一件事就是通过选择File→New Project开始一个新项目，给你的项目起一个名称，并将它保存到XNA项目的Content\Audio目录中。

现在有了一个新的空项目，在左边的树形目录中，找到Wave Banks，它包含了waves，即音频文件。右击Wave Banks选择New Wave Bank。然后，通过右击Sound Banks选择New Sound Bank创建一个新的sound bank，sound bank包含声音，它们是在XNA项目中实际播放的对象。一个sound指向一个wave，可以在wave上添加一些效果。

现在在窗口的右边应该有两个面板。点击Wave Bank面板，你应该可以在Xact窗口的左下角看到它的属性。找到Name属性，将它从Wave Bank改为myWaveBank。然后，点击Sound Bank面板，将Name属性改为mySoundBank。这些名称可以从XNA项目中访问到。

你可以使用wave bank存储所有. wav文件，所以右击面板的某处选择Insert Wave File(s)。浏览到磁盘上你想在游戏中播放的. wav文件，点击Open。你可以看到它被添加到了wave bank中，显示为红色，因为你还没有在sound bank中创建一个sound 使用这个wave。

技巧：文件的位置是相对于Xact项目存储的。如果你改变了声音文件的位置或Xact项目的位置就会遇到麻烦,所以在将声音文件导入到wave bank前先将它们复制到Content\Audio目录中。

一个wave是磁盘上一个声音文件的引用，一个sound使用一个wave让你可以添加一些效果，诸如改变音量和音高。从一个wave你可以生成多个sound。但是，程序员使用一个cue访问sound。所以对每个sound，在可以通过XNA代码访问前需要创建一个cue。一个cue可以包含多个声音，所以一行代码可以播放多个声音。

所以，在可以从wave bank播放一个wave前，你需要创建一个sound和使用这个wave的cue，这可以通过选择wave并拖动到Sound Bank面板的cue区域做到，如图7-1所示。

当心：确保将sound拖动到Sound Bank面板底部的cue列表中，而不是顶部的sound列表。

wave bank和sound bank是分离的，这样你可以从一个wave文件创建多个sound，通过这种方式，你可以在同一个wave上在不同的sound上添加不同的音效。

图7-1 将你的wave拖动到cue区域

这就是Xact工具的用法，别忘了通过选择File→Save Projec保存项目，并将文件导入到XNA项目中，方法和导入一张图像或对象文件是一样的。你可以将. xap 文件拖动到Content\Audio文件夹上，或在Solution Explorer中右击Audio文件夹并选择Add→Existing Item。

myXactProject. Xap文件会立刻显示在XNA Game Studio 3.0程序的Solution Explorer中。

当编译项目时，Xact项目会被读取并转换为二进制文件，这个二进制文件会被写入到项目的输出文件夹中。对添加到XNA项目的每个. xap Xact项目文件，XNA内容管道会创建一个. xgs二进制文件(与Xact项目的名称相同)，为每个wave bank创建一个. xwb二进制文件，为每个sound bank 创建一个. xsb二进制文件。

在XNA项目中，你需要将这三个文件连接到对应的变量中，首先在代码顶部进行声明：

AudioEngine audioEngine;
WaveBank waveBank;
SoundBank soundBank;

然后将它们连接到二进制文件(它们是在你编译项目时创建/更新的)，例如，在Initialize方法中：

  audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs");
  waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb");
  soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb");

变量soundBank包含所有可以播放的cue。但是，当你实例化一个新的SoundBank时，需要将它传递到一个AudioEngine，所以首先需要初始化AudioEngine。只要你从soundBank播放一个cue，它就会从AudioEngine对应的waveBank中找到wave，所以你需要第二行代码。

注意：myWaveBank和mySoundBank的名称是在Xact工具中命名的。

现在，当你想播放一个简单的声音时，你要做的就是调用soundBank变量的PlayCue方法，将要播放的cue名称传递到这个方法中！

soundBank.PlayCue("audio1");

当播放结束要确保所有cue从内存中移除，所以要在每一帧都调用 audioEngine的Update方法：

audioEngine.Update();

当心：当你改变了Xact项目并存储到文件后，你无法简单地运行XNA项目的. exe指望它将这个改变施加到Xact项目中。首先需要重新编译(按F5)XNA项目使内容管道从Xact项目中创建新的二进制文件。

代码

你需要通过全局变量播放包含在Xact项目中的声音cue：

AudioEngine audioEngine;
WaveBank waveBank;
SoundBank soundBank;

在项目的开始处进行初始化：

  protected override void Initialize()
    ...{
      audioEngine = new AudioEngine("myXactProject.xgs");
      waveBank = new WaveBank(audioEngine, "myWaveBank.xwb");
      soundBank = new SoundBank(audioEngine, "mySoundBank.xsb");

      base.Initialize();
  }

使用soundBank. PlayCue方法播放声音，例如下面代码中在按下一个键时播放声音。确保每一帧调用audioEngine. Update方法：

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      KeyboardState keybState = Keyboard.GetState();
      if (keybState.IsKeyDown(Keys.Space))
          soundBank.PlayCue("audio1");
      audioEngine.Update();
      base.Update(gameTime);
  }

现在当运行代码时，当你按下空格键时就会播放声音。


7.4 循环播放声音

问题

你想让声音循环播放，例如，播放背景音乐或播放连续的声音，例如一个汽车引擎的声音。

注意：因为Zune不支持Xact，你需要使用SoundEffect.Play()的重载方法循环播放声音，可见教程7-1中的对应解释。

解决方案

使用XAct audio tool，可以很容易地表示一个声音是否要循环播放。你将在XNA代码中创建一个Cue对象，因为你需要能够在播放过程中进行暂停或停止操作。

你还可以检测一个声音是否已经完成播放，这在你想切换背景音乐时很重要，这个操作可以通过检查Cue的IsStopped属性做到。

工作原理

循环播放一个声音你可以通过在Xact工具中将一个声音的LoopEvent属性设为Infinite让它循环播放。要做到这点，打开你的Xact项目，在sound bank中选择需要循环播放的sound。选择了sound后，在Sound Bank面板的右上方的Play Wave会变得可见，点击Play Wave节点，如图7-2所示。

在点击了Play Wave节点后，它的属性在XAct 窗口的左下方的属性窗口中可见，找到LoopEvent属性，设置为Infinite，别忘了保存Xact项目。

图7-2 当选择了sound后Play Wave节点会变得可见

现在当你重新编译XNA项目并使用前面的代码播放cue时，它就会无限循环播放。因为你想进行控制，可以在适当的时候停止播放这个cue，所以需要创建一个Cue对象储存对这个cue的引用：

Cue cue1;

通过调用soundBank变量的GetCue方法对这个变量赋值，你可以通过调用Play方法播放这个cue：

  cue1 = soundBank.GetCue("audio1");
  cue1.Play();

这会让cue循环播放，这是你在XAct audio tool中设置号好的。但这次，你有了指向cue的引用，所以可以暂停、继续或停止这个cue：

  cue1.Pause();
  cue1.Resume();
  cue1.Stop(AudioStopOptions.Immediate);

当心：当你从播放中停止一个cue后，你无法简单地在这个cue再次调用Play。你首先需要调用它的GetCue 方法才能从soundBank中再次调用它。

检查一个Sound是否已经停止播放/改变背景音乐

你可以通过sound的IsStopped属性检查一个sound cue是否已经完成播放：

  if (currentCue.IsStopped)
      //do something

如果你想让XNA程序循环播放一些背景音乐，你需要创建一个数组保存cue的名称而不是cue本身，当cue开始播放后cue本身会变得无用。你需要一些额外的变量创建一个背景循环系统：

  string[] bgCueNames;
  Cue currentCue;
  int currentCueNr = 0;

这个数组保存要播放的背景cue的名称，currentCue保存当前正在播放的cue，这样你可以检查它是否已经结束，currentCueNr变量用于激活下一个cue。

下面的方法初始化cun名称数组并开始第一个cue：

  private void InitSounds()
    ...{
      audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs");
      waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb");
      soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb");

      bgCueNames = new string[5];
      bgCueNames[0] = "bgAudio1";
      bgCueNames[1] = "bgAudio2";
      bgCueNames[2] = "bgAudio3";
      bgCueNames[3] = "bgAudio4";
      bgCueNames[4] = "bgAudio5";

      PlayBGCue(0);
  }

首先你需要创建一个教程7-1所示的Xact项目，包含5个cue。开始一个cue的PlayBGCue方法是简单的：

  private void PlayBGCue(int cueNr)
    ...{
      currentCue = soundBank.GetCue(bgCueNames[cueNr]);
      currentCue.Play();
  }

它在currentCue变量中存储了当前正在播放的cue的引用，所以你可以在UpdateSounds方法中检查它的IsPlayed属性，而UpdateSounds方法放在了主Update方法中：

  private void UpdateSounds()
    ...{
      if (currentCue.IsStopped)
        ...{
          if (++currentCueNr == bgCueNames.Length)
              currentCueNr = 0;

          PlayBGCue(currentCueNr);
      }
      audioEngine.Update();
  }

如果当前cue已经完成播放，则增加currentCueNr，如果它大于播放的cue数量（本例中为5）则设为0。最后调用PlayBGCue播放下一个sound。

注意：在currentCueNr之前的 ++ 表示你想在求值前增加currentCueNr的值。如果写成currentCueNr++，当currentCueNr为4时判断结果为false，之后当值为5时，会抛出 a GetCue方法的OutOfRange错误。通过写成++currentCueNr，如果currentCueNr为4，这个值首先被增加到5，这样判断结果为true，这个值会被设置为0。

通过在这个方法中放置audioEngine的Update方法，在Update调用的所有东西就是这个UpdateSounds方法。

代码

前面的章节中你可以找到循环播放背景音乐列表的所有代码，下面的代码通过按下空格键开始循环播放本教程第一部分中定义的sound，按Enter键停止：

  protected override void Update(GameTime gameTime)
    ...{
      GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);

      if (gamePadState.Buttons.Back == ButtonState.Pressed)
          this.Exit();

      KeyboardState keyState = Keyboard.GetState();

      if (keyState.IsKeyDown(Keys.Space) || (gamePadState.Buttons.B == ButtonState.Pressed))
    ...    {
          if ((cue1 == null) || (cue1.IsStopped))
    ...        {
              cue1 = soundBank.GetCue("audio1");
              cue1.Play();
          }
      }
      if (keyState.IsKeyDown(Keys.Enter) || (gamePadState.Buttons.A ButtonState.Pressed))
          if (cue1 != null)
              cue1.Stop(AudioStopOptions.Immediate);

      audioEngine.Update();
      base.Update(gameTime);
  }

当空格键第一次被按下时，cue1变量为null。只要用户按下了Enter键，cue就会停止。当用户再次按下空格键，cue会重新播放。

再次提醒，在cue停止后你需要重新创建cue1变量(通过调用GetCue方法)。


7.5 根据相机位置从一个3D位置播放声音：3D声音

问题

要让你的3D游戏更真实，你想让每个声音都位于3D空间的某个位置。通过这种方式，在相机右侧发生的爆炸会主要通过右声道播放，这样用户就会感到爆炸是真的发生在他的右侧。即使爆炸不在相机的视野中，玩家也可以知道在他的右侧发生了什么事。

注意：Zune不支持Xact和下面的Apply3D()方法，只支持Cue对象，3D音效只被PC和Xbox 360支持。

解决方案

XNA可以让这些事变得简单。对每个声音，你必须设置相机的3D位置和使用声音Cue对象的Apply3D方法设置声源的位置。注意当相机或声源移动时，你需要调用这个方法更新Cue。工作原理如果你想设置声源的3D位置，首先必须为这个音源设置一个AudioEmitter对象和为相机设置一个AudioListener对象。当将这两者传递到Cue的Apply3D方法中时，XNA会计算音响（或耳机）左右声道声音的分配。

下面的方法创建了两个对象：

  private void UpdateSoundPosition(Cue cue, Vector3 sourcePos, Vector3 camPos, Vector3 camForward, Vector3 camUp)
    ...{
      AudioEmitter emitter = new AudioEmitter();
      emitter.Position = sourcePos;

      AudioListener listener = new AudioListener();
      listener.Position = camPos;
      listener.Forward = camForward;
      listener.Up = camUp;

      cue.Apply3D(listener, emitter);
  }

对emitter（音源）来说，你只需设置位置。对listener（相机）来说，你还要设置Forward和Up向量，因为当相机旋转了180度后，左右声道会互换。

当创建了这两个变量后，你可以将它们传递到Cue的Apply3D方法中，当音源或相机发生移动时都要进行这个操作。

你需要在第一次调用Play方法前调用Apply3D方法。所以，确保在Cue中还没有调用 Play方法：

  protected override void Initialize()
    ...{
      fpsCam = new QuakeCamera(GraphicsDevice.Viewport, new Vector3(0,0,5) , 0, 0);

      audioEngine = new AudioEngine("Content/Audio/MyXACTproject.xgs");
      waveBank = new WaveBank(audioEngine, "Content/Audio/myWaveBank.xwb");
      soundBank = new SoundBank(audioEngine, "Content/Audio/mySoundBank.xsb");

      cue1 = soundBank.GetCue("audio1");

      base.Initialize();
  }

在Update方法中，如果你移动了相机位置或音源位置，就需要调用UpdateSoundPosition方法：

  protected override void Update(GameTime gameTime)
    ...{
      GamePadState gamePadState = GamePad.GetState(PlayerIndex.One);
      if (gamePadState.Buttons.Back == ButtonState.Pressed)
          this.Exit();

      MouseState mouseState = Mouse.GetState();
      KeyboardState keyState = Keyboard.GetState();

      fpsCam.Update(mouseState, keyState, gamePadState);

      float time = (float)gameTime.TotalGameTime.TotalMilliseconds/1000.0f;
      Vector3 startingPos = new Vector3(0, 0, -10);
      Matrix rotMatrix = Matrix.CreateRotationY(time);
      modelPos = Vector3.Transform(startingPos, rotMatrix);

      UpdateSoundPosition(cue1, modelPos, fpsCam.Position, fpsCam.Forward, fpsCam.UpVector);

      if (cue1.IsPrepared)
          cue1.Play();
      audioEngine.Update();

      base.Update(gameTime);
  }

这个方法的前半部分检查用户输入并将输入传递到相机，解释请见教程2-3。当中的代码块更新音源位置。当相机和音源更新后，你需要调用UpdateSoundPosition方法告知cue1对象最后的位置。如果cue1还没有播放，则开始播放。

当心：因为3D effect是通过控制左右声道的音量实现的，所以声音中的任何立体声信息会丢失：首先将声音转换为单声道，然后对应物体的3D位置设置左右声道的音量。

代码

所有的Initialize，UpdateSoundPosition和Update方法前面已经写过了。

扩展阅读

Xact音频工具让你可以在cue中添加一些很棒的3D音效，诸如距离衰减和多普勒效应。因为是图形界面，在视频中介绍要比在书中好得多。在http://creators.xna.com 网站上有一些很好的视频介绍如何将3D音效添加到cue中。

译者注：在http://creators.xna.com/en-US/sample/3daudio网页上也有一个3D音效的例子，实现了距离衰减，多普勒效应。


8.0 第八章概述

XNA 3.0框架包含网络功能，让你可以在多台PC、Zune和Xbox360上进行游戏。XNA小组确保相同的代码可以用来将多台Zune、PC和Xbox 360连接到一个网络上的会话中。

本章解释了如何创建一个网络会话和如何连接到一个已存在的会话。然后，你会学习如何发送数据和从网络的其他玩家处接收数据。你还会学习如何使用XNA的lobby功能。

本章包含以下内容：

  • 让用户在访问网络功能前选择或创建一个账号(教程8-1)
  • 创建一个网络会话或搜索一个已存在的会话(教程8-2和8-3)
  • 连接了会话后，学习如何发送和接收数据(教程8-4)
  • 实现异步网络操作，使你可以让用户得知过程的进度(教程8-5)
  • 给玩家添加富状态信息(rich presence information)，这些信息可以被其他玩家看到(教程8-6)
  • 使用XNA自带的基本lobby功能(教程8-7)


8.1 登录网络服务

问题

在你可以连接另一个玩家前，首先需要使用一个账号登录或创建一个新的账号。如果所有玩家都在相同的网络，这可以是一个离线账号，或者当你想通过Internet连接时也可以是一个在线Live账号。

在你可以访问XNA的网络功能前必须登录，它也允许其他玩家看到你的姓名和你可能提供其他信息。

注意：登录在Zune不需要。在Zune上的XNA游戏由一个SignedInPlayer开始，这个SignedInPlayer拥有你分配给Zune设备的名称。

解决方案

使用你的账号登录非常简单。你要做的就是调用Guide.ShowSignIn方法，这个方法允许用户选择一个已存在的账号或创建一个新的。

工作原理

在你可以使用XNA的网络功能前，你必须在游戏中添加GamerServicesComponent。

添加GamerServicesComponent

这个GameComponent (可参见教程1-7)保证整个网络引擎在背后以不变的时间间隔进行更新。对任何一个GameComponent，都要求在Game构造函数中加载：

  public Game1()
    ...{
      graphics = new GraphicsDeviceManager(this);
      Content.RootDirectory = "Content";
      Components.Add(new GamerServicesComponent(this));
  }

显示登录界面

简单的程序会在定义的两个状态中切换：SignIn和SignedIn。只要程序在SignIn状态中，登录界面就会显示给用户直到用户选择或创建一个账号。一旦选择了正确的账号，程序就会移动到SignedIn状态。

首先声明两个状态：

  public enum GameState
    ...{
      SignIn,
      SignedIn
  }

然后声明一个变量保存当前状态：

GameState currentGameState = GameState.SignIn;

这会让程序开始时处于SignIn状态。在更新循环中，你将检测程序位于哪个状态。如果处于SignIn状态，你想让用户选择一个账号。但是，因为用户可以选择一个账号自动登录，可能在开始GamerServicesComponent时一个账号已经登录。所以，你想检查当前登录是否还没有账号：

  if (Gamer.SignedInGamers.Count < 1)
    ...{
      Guide.ShowSignIn(1, false);
      log.Add("Opened User SignIn Interface");
  }
  else
    ...{
      currentGameState = GameState.SignedIn;
      log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SignedIn");
  }

如果没有账号被记录，你激活登录界面，让用户可以选择或创建一个账号。一旦选择了一个账号，Gamer.SignedInGamers.Count不为0。在下一个更新过程中，当前状态会被设置为SignedIn，包含玩家名称的信息会被添加到日志中。

注意：日志的内容会在Draw方法中输出到屏幕上，可参见教程3-5学习如何将文字显示在屏幕上。

Guide.ShowSignIn方法需要两个参数。第一个参数显示可以登录多少个玩家。在Xbox 360平台上，同时最多可有4个用户，在Windows平台上被限制为只有1个。第二个参数指定是否只允许在线Live账户登录，这在使用Live服务时很有用。

注意：记住用户可以取消Guide界面，这会导致没有账号被选择。

因为同一时间无法打开Guide界面两次，在调用Guide.ShowSignIn方法前你必须检查当前Guide是否已关闭，这可以通过检查Game的IsActive属性做到，当游戏窗口是active状态，没有被最小化，Guide没有显示时这个属性才为true。

代码

整个Update过程如下所示。先检查IsActive属性，之后如果还没有用账号登录就调用Guide.SignIn方法，一旦登录为正确的账号, 状态变为SignedIn。

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      if (this.IsActive)
    ...    {
          switch (currentGameState)
    ...        {
              case GameState.SignIn:
    ...            {
                  if (Gamer.SignedInGamers.Count < 1)
    ...                {
                      Guide.ShowSignIn(1, false);
                      log.Add("Opened User SignIn Interface");
                  }
                  else
    ...                {
                      currentGameState = GameState.SignedIn;
                      log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SignedIn");
                   }
              }
              break;
              case GameState.SignedIn:
            ...    {
              }
              break;
          }
      }
      base.Update(gameTime);
  }


8.2 创建一个网络会话

问题

你想创建一个网络会话(session)，这样其他Xbox 360平台或PC就可以找到并加入到你的会话。

解决方案

一台机器首先需要开始一个网络会话，这可以通过使用NetworkSession. Create方法很容易地做到。创建会话的机器就是会话主机。

在创建会话之后，所有连接到这个会话的机器，包括主机，都会监听这个会话产生的任何事件，诸如玩家加入或离开会话等。

工作原理

你需要包含Microsoft.XNA.Framework.Net命名空间，这可以通过在代码顶部添加using代码做到：

  using Microsoft.XNA.Framework.Net

本教程中，程序会在三个自定义状态中循环。如前一个教程所述，开始时你处于SignIn状态，要求玩家选择一个账号。前一个教程中的SignedIn状态由CreateSession状态替换， CreateSession状态中你将创建一个新的网络会话并监听它的事件。最后结束于InSession状态。

首先定义这些状态：

    public enum GameState ...{ SignIn, CreateSession, InSession}

SignIn状态前一个教程已经讨论过了。让我们开始编写CreateSession状态的代码。

创建一个网络会话

在这个状态中，你将创建一个新的网络会话。首先将这个变量添加到代码中：

NetworkSession networkSession;

你可以创建一个新的网络会话并使用NetworkSession . Create方法将它存储到刚才定义的变量中：

networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4,16);

这行代码会让机器创建一个其他玩家可以连接的新会话。如你所见，这个方法需要三个参数。

第一个参数指定会话类型。如果所有的玩家连接到同一台机器上应该创建NetworkSessionType. Local类型的会话，例如，四个玩家都有各自的手柄连接到同一台Xbox 360上。

如果一个或多个玩家在不同的机器上，这些机器连接到相同的网络，你应该创建一个NetworkSessionType. SystemLink类型的会话。NetworkSessionType. PlayerMatch类型的会话与SystemLink类型工作方式类似，但是它允许玩家通过Internet连接到会话，使用的是Live的服务，这种情况下，为了使用这些服务，所有参与的玩家都要需要购买Live Gold成员。

注意：因为Zune只能创建连接到另一台Zune的网络连接，在Zune上只能使用NetworkSessionType. Local和NetworkSessionType. SystemLink类型的会话。

第二个参数指定连接到同一个机器的多个玩家是否可以加入网络。如果不是，你需要指定每台机器只有一个玩家。例如，如果你想开始一个本地会话，你应该允许超过一个玩家可以连接到相同的机器。

注意：一个本地玩家指在代码运行的这台机器上的用户。XNA 3.0在每台Windows机器或Zune上只支持一个本地玩家，在Xbox 360允许最多四个玩家。只有另三个玩家都连接到同样的机器上时四个玩家中的每一个才被认为是本地的。

最后一个参数指定可以连接到这个会话的最大玩家数。因为你想处理多玩家游戏，这个数字至少为2，上限是31。

NetworkSession.Create另一个重载方法可以再接受两个参数。第一个参数指定保留给朋友的玩家插槽数量,通过这个方式,只有你标记为朋友的玩家才可以连接到会话。第二个参数让你可以用一个NetworkSession属性作为会话的标签，这样其他玩家可以很容易地找到它。可参见教程3-8看这样一个例子。

注意：如果机器没有连接到网络上，NetworkSession. Create方法会报错，所以你将它封装在一个try-catch结构中。

现在有了一个新会话，你可以设置一些属性了。一个重要的属性定义了如果主机退出的话应该发生什么事。你可以通过将AllowHostMigration属性设置为true指定XNA自动选择clients/peers中的一个变成新的主机：

  networkSession.AllowHostMigration = true;
  networkSession.AllowJoinInProgress = false;

如你所见，你也可以指定游戏开始后哪个客户端可被允许加入到会话中。

注意：要特别注意主机的迁移。如果当会话退出时游戏数据只保存在老的主机中，不可能将这个数据自动传送到新主机中。你需将重要的数据复制到至少两台机器上，这样的话，如果主机退出，你可以将这个数据复制到新主机上。

链接到会话事件

现在你的机器是一个网络会话的主机，然后需要知道其他玩家是否连接到这个会话。你可以通过链接一个自定义的方法到网络会话的GamerJoined 事件中做到这点。只要一个玩家加入会话，会话就会自动引发GamerJoined事件。结果是，链接到这个事件的所有方法都会被调用。

下面这行代码将自定义的GamerJoinedEventHandler方法连接到GamerJoined事件：

networkSession.GamerJoined += GamerJoinedEventHandler;

在GamerJoinedEventHandler方法中，你可以放置当一个新玩家加入到会话中时需要执行的代码。下面的例子中将一行包含玩家名称的文字显示在屏幕上：

  void GamerJoinedEventHandler(object sender, GamerJoinedEventArgs e)
    ...{
      log.Add(e.Gamer.Gamertag + " joined the current session");
  }

和所有事件处理方法一样，这个方法接受引发事件的对象(本例中是网络会话)，还有第二个参数包含事件类型的特定信息。本例中，GamerJoinedEventArgs包含对应加入这个会话的玩家的Gamer对象。

网络会话可以引发你想要监听的其他事件，包括GamerLeft事件、GameStarted事件、GameEnded事件（这个事件表示会话从大厅切换到游戏模式，可参见教程8-7）、SessionEnded事件和HostChanged事件。

在AllowHostMigration设置为false且主机退出会话时或主机在网络会话上调用Dispose方法时都会引发SessionEnded事件。在AllowHostMigration设置为true且主机退出会话时会引发HostChanged事件。

下面的代码通过网络会话监听GamerJoined，GamerLeft和HostChanged事件，并在屏幕上显示对应的信息：

  void HookSessionEvents()
    ...{
      log.Add("Listening for session events");
      networkSession.GamerJoined += GamerJoinedEventHandler;
      networkSession.GamerLeft += GamerLeftEventHandler;
      networkSession.HostChanged += HostChangedEventHandler;
  }

  void GamerJoinedEventHandler(object sender, GamerJoinedEventArgs e)
    ...{
      log.Add(e.Gamer.Gamertag + " joined the current session");
  }

  void GamerLeftEventHandler(object sender, GamerLeftEventArgs e)
    ...{
      log.Add(e.Gamer.Gamertag + " left the current session");
  }

  void HostChangedEventHandler(object sender, HostChangedEventArgs e)
    ...{
      log.Add("Host migration detected");
      NetworkSession eventRaisingSession = (NetworkSession)sender;
      if (eventRaisingSession.IsHost)
          log.Add("This machine has become the new Host!");
  }

在主机迁移的情况中，sender对象(这里是指一个网络会话)首先转换为一个NetworkSession对象，这样你可以获取它的属性。你可以使用其中的IsHost属性检测是否这个机器变成了会话的新主机。

确保在创建会话后立即调用HookSessionEvents方法，下面是Update方法中的CreateSession状态的代码：

  case GameState.CreateSession:
    ...{
      networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16);
      networkSession.AllowHostMigration = true;
      networkSession.AllowJoinInProgress = false;
      log.Add("New session created");

      HookSessionEvents();
      currentGameState = GameState.InSession;
  }

会话被创建，设置AllowHostMigration和AllowJoinInProgress的值，程序会监听任何由会话引发的事件。

更新网络会话

连接到一个会话后，你需要以一定的时间间隔更新它。显然应该在游戏的更新过程中放置这个代码。现在，在InSession 状态中只需进行一个操作：

  case GameState.InSession:
    ...{
      networkSession.Update();
  }
  break;

代码

这里是最终的Update方法。程序开始时处于SignIn状态，之后在CreateSession状态中创建一个会话。程序结束时处于InSession状态。

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      if (this.IsActive)
    ...    {
          switch (currentGameState)
    ...       {
              case GameState.SignIn:
    ...            {
                  if (Gamer.SignedInGamers.Count < 1)
    ...                {
                      Guide.ShowSignIn(1, false);
                      log.Add("Opened User SignIn Interface");
                  }
                  else
    ...                {
                      currentGameState = GameState.CreateSession;
                      log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to CreateSession");
                  }
              }
              break;

              case GameState.CreateSession:
    ...            {
                  networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16);
                  networkSession.AllowHostMigration = true;
                  networkSession.AllowJoinInProgress = false;
                  log.Add("New session created");

                  HookSessionEvents();
                  currentGameState = GameState.InSession;
              }
              break;

              case GameState.InSession:
    ...            {
                  networkSession.Update();
              }
              break;
          }
      }
      base.Update(gameTime);
  }


8.3 加入一个网络会话

问题

你想搜索你的机器、网络或Live服务找到活动会话，你想从检测到的会话列表中选择一个会话并加入其中。

解决方案

你可以使用NetworkSessions.Find方法搜索可用的会话，这会返回一个包含所有可用会话的AvailableNetworkSessionCollection对象。你可以使用 NetworkSession.Join方法加入其中的一个会话，将指定的会话作为一个参数。

当连接到一个会话时，如前一个教程所述，你应确保监听由这个会话引发的事件。

工作原理

在这个教程中，你将在前一个教程定义的GameState枚举中添加一个SearchSession状态：

    public enum GameState ...{ SignIn, SearchSession, CreateSession, InSession}

程序从SignIn状态开始，允许用户选择一个账号。然后，不是立即创建一个新的网络会话，而是首先找到并加入一个已存在的会话并进入InSession状态。如果没有找到可用的会话，你将进入CreateSession状态，这个已经在前一个教程中解释过了。

搜索可用会话

你可以使用NetworkSession.Find方法搜索所有可用的会话，这个方法需要三个参数，让你可以过滤结果。这个方法只返回与第一个参数同种类型并且与一台机器上允许玩家数量相同的会话。你可以在本教程最后找到最后一个参数的例子。

AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, null);

注意：和NetworkSession.Create方法一样，如果机器没有连接到网络，NetworkSession. Find方法会报错。所以，你需要将它封装在一个try-catch结构中。

所有检测到的会话存储在一个AvailableNetworkSessionsCollection中，如果没有找到会话，这个集合为空。

加入一个活动的会话

如果检测到至少一个会话，你可以使用NetworkSession. Join方法加入它：

AvailableNetworkSession networkToJoin = activeSessions[0];
networkSession = NetworkSession.Join(networkToJoin);

这会将程序连接到第一个参数指定的会话，本例中，你加入的是activeSessions中的第一个会话。在进行这个操作之前，你可能还想检查是否至少有一个玩家slot，这是由networkToJoin.OpenPublicPlayerSlots表示的。

注意：一个AvailableNetworkSession包含许多有用的信息，其中QualityOfService属性可以用来判断你的机器和会话主机间的连接速度。

最后的SearchSession 状态如下所示：

  case GameState.SearchSession:
    ...{
      AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, null);
      if (activeSessions.Count == 0)
        ...{
          currentGameState = GameState.CreateSession;
          log.Add("No active sessions found - proceed to CreateSession");
          AvailableNetworkSession networkToJoin = activeSessions[0];
          networkSession = NetworkSession.Join(networkToJoin);

          string myString = "Joined session hosted by " + networkToJoin.HostGamertag;
          myString += " with " + networkToJoin.CurrentGamerCount.ToString() +  " players";
          myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots.";
          log.Add(myString);
          HookSessionEvents();
          currentGameState = GameState.InSession;
      }
  }
  break;

首先找到指定三个过滤参数的所有活动会话，如果没有检测到会话，则进入CreateSession状态创建你自己的会话，可参见前一个教程。如果找到至少一个会话，加入找到的第一个会话。更高级的例子会将所有会话显示在屏幕上让用户可以加以选择。

然后，将连接的会话属性显示在屏幕上，诸如主机名称，加入这个会话的玩家数量，打开的玩家slots的数量。

最后在进入InSession状态前开始监听由会话引发的任何事件(可参见教程8-2) 。

使用NetworkSessionProperties过滤检测到的会话

当使用NetworkSession.Find方法搜索活动会话时，你可以指定三个参数过滤检测到的会话。

前面已经提及，前两个参数让你可以指定会话的种类和会话允许的最多玩家数量。

但是，当游戏变得非常流行时，Find方法会返回很大数量的活动会话。所以，当你创建一个会话时，指定一个NetworkSessionProperties对象是很有用的，这个对象最多可以包含八个整数值。这八个整数代表什么完全取决于你。例如，你可以让第一个整数表示游戏的难度设置，第二个代表bots的数量，第三个代表游戏正在处于的地图，本例中设置为null，因为你还没有设置这个对象。

当另外一个玩家搜索活动会话时，他们可以搜索对应的游戏，例如，在一张指定的地图上进行游戏。

根据NetworkSessionProperties创建一个会话

要指定一个NetworkSessionProperties，你需要使用NetworkSession.Create的第二个重载方法，它接受另外两个参数，让你可以将属性作为最后一个参数。创建了一个新NetworkSessionProperties对象后，你可以设定8个整数值。在下面的例子中，只设置两个值。之后，将属性作为最后一个参数传递到NetworkSession.Create方法中：

NetworkSessionProperties createProperties = new NetworkSessionProperties();
createProperties[0] = 3;
createProperties[1] = 4096;

networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16, 0, createProperties);

使用指定的NetworkSessionProperties搜索会话

你可以使用指定的属性搜索会话。只需简单地创建一个NetworkSessionProperties对象，指定你要查找的属性，将它传递到NetworkSession.Find方法中：

NetworkSessionProperties findProperties = new NetworkSessionProperties();
findProperties[1] = 4096;

AvailableNetworkSessionCollection activeSessions = NetworkSession.Find(NetworkSessionType.SystemLink, 4, findProperties);

本例中，只使用一个值而不是两个。Find方法会返回所有拥有第二个属性中4096的方法，而不管其他七个属性是什么。

代码

下面的Update方法显示了基本的会话管理。

当程序开始时，用户要求使用一个账号登录，然后，程序搜索活动会话，如果找到至少一个，则加入这个会话。

如果没有找到活动会话，程序会创建自己的会话。无论通过何种方式，你都会连接到一个会话，程序需要监听会话引发的事件。你结束于InSession状态，这个状态中会以一定的时间间隔更新会话。

同样的代码可以运行在多台机器上，如果这些机器连接到同一个网络，第一个机器会创建会话，其他机器会检测并自动加入这个会话。

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      if (this.IsActive)
    ...    {
          switch (currentGameState)
    ...        {
              case GameState.SignIn:
    ...            {
                  if (Gamer.SignedInGamers.Count < 1)
    ...                {
                      Guide.ShowSignIn(1, false);
                      log.Add("Opened User SignIn Interface");
                  }
                  else
    ...                {
                      currentGameState = GameState.SearchSession;
                      log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession");
                  }
              }
              break;
              case GameState.SearchSession:
                ...{
                  NetworkSessionProperties findProperties = new NetworkSessionProperties();
                  findProperties[0] = 3;
                  findProperties[1] = 4096;
                  AvailableNetworkSessionCollection activeSessions NetworkSession.Find(NetworkSessionType.SystemLink,4, findProperties);
                  if (activeSessions.Count == 0)
    ...                {
                      currentGameState = GameState.CreateSession;
                      log.Add("No active sessions found - proceed to CreateSession");
                  }
                  else
    ...                {
                      AvailableNetworkSession networkToJoin = activeSessions[0];
                      networkSession = NetworkSession.Join(networkToJoin);
                      string myString = "Joined session hosted by " + networkToJoin.HostGamertag;
                      myString += " with " + networkToJoin.CurrentGamerCount.ToString() + " players";
                      myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots.";
                      log.Add(myString);
                      HookSessionEvents();
                      currentGameState = GameState.InSession;
                  }
              }
              break;
              case GameState.CreateSession:
                ...{
                  NetworkSessionProperties createProperties = new NetworkSessionProperties();
                  createProperties[0] = 3;
                  createProperties[1] = 4096;

                  networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16, 0, createProperties);
                  networkSession.AllowHostMigration = true;
                  networkSession.AllowJoinInProgress = false;
                  log.Add("New session created");
                  HookSessionEvents();
              currentGameState = GameState.InSession;
              }
              break;
              case GameState.InSession:
    ...            {
                  networkSession.Update();
              }
              break;
          }
      }
      base.Update(gameTime);
  }


8.4 在网络上发送，接收数据

问题

创建并加入一个网络会话是一回事，但如果不能发送或接收任何数据那么网络会话有什么用呢？

解决方案

当玩家连接到会话时，你可以在一个PacketWriter流中存储所有想要发送的数据。完成这个操作后，你可以使用LocalNetworkPlayer.SendData方法将这个PacketWriter发送给会话中的所有玩家。

在玩家接收数据前，你应该检查他们的LocalNetworkGamer. IsDataAvailable是否被设为ture，这表示数据已经被接收并做好了处理的准备。

一旦IsDataAvailable为true，你就可以调用LocalNetworkGamer.ReceiveData方法，返回一个包含另一个玩家发送给本地玩家的所有数据的PacketReader。

工作原理

这个教程建立在前一个教程结果的基础上，前一个教程允许同一网络上的多个机器通过一个会话互联。程序结束于InSession状态，这个状态只是简单地调用会话的Update方法。

现在，你将在InSession状态中做点实际的操作，让你的玩家可以将一些数据发送到会话中的其他玩家那里。本例中，你将发送程序运行的分钟数和秒数。

然后，你监听可用的数据。如果有可用的数据，你会接收两个数字，将它们放在一个字符串中，显示在屏幕上。

要发送和接收数据，你需要一个PacketWriter对象和一个PacketReader对象，所以在代码中添加这两个变量：

PacketWriter writer = new PacketWriter();
PacketReader reader = new PacketReader();

在项目中使用超过一个的PacketWriter对象和PacketReader对象是毫无理由的。

将数据发送到会话中的另一个玩家

你需要在PacketWriter中存储所有要发送给其他玩家的数据，这可以通过将数据作为Write方法的参数做到：

writer.Write(gameTime.TotalGameTime.Minutes);
writer.Write(gameTime.TotalGameTime.Seconds);

在将所有数据存储到PacketWriter之后，你可以使用本地玩家的SendData方法将它发送到所有其他用户：

LocalNetworkGamer localGamer = networkSession.LocalGamers[0];
localGamer.SendData(writer, SendDataOptions.None);

SendDataOptions参数会在教程的最后解释。更重要的是，SendData有一个重载方法可以只将数据发送到指定玩家而不是会话中的所有玩家。

以上就是将数据发送到会话中的其他玩家需要的所用操作。

从会话中的另一个玩家处接收数据

从其他玩家接收数据大致就是反过程：调用本地玩家的ReceiveData方法，这会返回一个包含其他玩家发送数据的PacketReader。调用PacketReader. Read方法中的一个从PacketReader获取数据：

  NetworkGamer sender;
  localGamer.ReceiveData(reader, out sender);

  string playerName = sender.Gamertag;
  int minutes = reader.ReadInt32();
  int seconds = reader.ReadInt32();

ReceiveData方法存储PacketReader流中的数据，发送给你数据的玩家会存储在第二个参数中，这样你就可以知道数据来自于谁。

当从PacketReader读取数据时，你需要确保以与发送相同的顺序进行读取。而且，因为PacketReader只包含字节流，你需要告知你想从字节构建哪个对象。例如，一个整数需要的字节比矩阵少，所以需要在某些时候告知你想恢复为哪种类型的对象。

本例中分钟数和秒数为整数，所以你想从字节流中重新构建两个整数。看一下PacketReader的不同Read方法，注意支持哪个对象。如果你想重构矩阵，则应该使用ReadMatrix方法，使用ReadSingle方法重构float，ReadDouble方法重构double，ReadInt16 重构short。

LocalGamer.IsDataAvailable

如果多个玩家向你发送数据，可能会有多个字节流需要被读取，这种情况也会发生在其他玩家调用SendData的频率大于你调用ReceiveData的频率时。

在这种情况下，你可以查询localGamer.IsDataAvailable属性，因为只要有一个字节流正在等待本地游戏，这个属性就会为true。

只要数据对你的玩家可用，下面的代码就会接收一个新PacketReader并读取发送数据的玩家的GamerTag属性。然后，玩家程序运行的分钟数和秒数就会从PacketReader中读取。

  while (localGamer.IsDataAvailable)
    ...{
      NetworkGamer sender;
      localGamer.ReceiveData(reader, out sender);

      string gamerTime = "";
      gamerTime += sender.Gamertag + ": ";
      gamerTime += reader.ReadInt32() + "m ";
      gamerTime += reader.ReadInt32() + "s";
      gamerTimes[sender.Gamertag] = gamerTime;
  }

要让这个例子实际干点事情，数据被转换到一个叫做gamerTime的字符串中，它存储在一个Dictionary。Dictionary是默认的generic .NET查询表，可以使用以下代码创建：

  Dictionary<string, string> gamerTimes = new Dictionary<string, string>();

前面的代码会在Dictionary中创建一个数据项，对应发送给你数据的玩家。当从一个玩家接收新数据时，Dictionary中的对应数据项会被更新，你可以在Draw方法中将Dictionary中的字符串显示在屏幕上。

当玩家离开会话时，你需要将它们对应的数据项从Dictionary移除，这可以在GamerLeft 事件中加以处理：

  void GamerLeftEventHandler(object sender, GamerLeftEventArgs e)
    ...{
      log.Add(e.Gamer.Gamertag + " left the current session");
      gamerTimes.Remove(e.Gamer.Gamertag);
  }

SendDataOptions

当你将数据发送到会话中的其他玩家时，你期望到达接受者的信息的顺序与发送的顺序是相同的，但是基于Internet的原理，你的信息可能会以不同于发送的顺序到达，甚至更糟，有些数据可能根本就传不到！

幸运的是，你可以为发送的数据包指定两个重要的系数，在使用前你需要知道它们是什么，好处是什么，更重要的是，它们的缺点是什么：

  • 到达的顺序（Order of arrival）：数据包接收的顺序是否与发送的顺序相同？
  • 安全性（Reliability）：你发送的这个数据是否是至关紧要的，如果数据包丢失游戏还能进行吗？

以上两个问题不是是就是否，可以提供四种可能性。LocalNetworkGamer.SendData可以将SendDataOptions作为第二个参数，这个参数可以让你指定四种情况中的一个：

  • SendDataOptions.None：发送的数据不是关键的，你接收数据的顺序也无关紧要。
  • SendDataOptions.InOrder：接收数据的顺序必须和发送它们的顺序相同，但一些数据包丢失无大碍。
  • SendDataOptions.Reliable：与SendDatOptions.InOrder相反，你的数据是关键的，你发送的所有数据必须到达接收者。但是，接收数据的顺序是否和发送的顺序相同无关紧要。
  • SendDataOptions.ReliableInOrder：所有的数据必须以和发送顺序相同的顺序到达接收者。

不是很难，我选择最后一个！有些选项有点缺点，解释如下：

  • SendDataOptions.None：没有速度损失，只能指望数据能够成功发送。
  • SendDataOptions.InOrder：在数据发送前，所有的数据包被分配了一个序号。接收者检查这个序号，如果数据包A在一个更加新的数据包B之后被接收，数据包A会被抛弃。这是个简单的检查方法，几乎不花时间，但即使有些数据成功到达了目的地可能也会被抛弃。
  • SendDataOptions.Reliable：接收者会检查丢失了哪个数据包。当数据包C从数据包流ABDE中丢失时，接收者会要求发送者重新发送数据包C，同时，数据包D和E在XNA代码中可以被访问。
  • SendDataOptions.ReliableInOrder：只有在你的数据需要时才使用这个选项。当接收者法线数据包C从流ABDE中丢失时，它会让发送者重新发送数据包C。这次，其后的数据包D和E不会被接收者传递到XNA中，直到数据包C也被成功的传递。这会引起延迟，因为所有后继的数据包会保存在内存中直至数据包C被重新发送并收到才会被传递到XNA程序中。

普遍的原则是，对大多数数据来说SendDataOptions.InOrder是安全的，尽可能不要使用 SendDataOption.ReliableInOrder。

SendDataOption.Chat

在你开始发送数据前，你需要记住一件事情：在Internet上发送的聊天信息（chat message）不可以被加密，法律上是禁止的。

因为默认情况下使用localGamer.SendData方法发送数据都会进行加密，你必须使用SendDataOptions.Chat表示XNA不要加密聊天信息。你也可以使用SendDataOption的组合，如下所示：

localGamer.SendData(write,SengDataOptions.Chat|SendDataOptions.Reliable);

注意你可以发送加密和不加密混合的信息。如果你这样干，排序过的聊天信息只根据聊天数据排序而不是根据加密过的数据。例如，让我们看一下发送第一条信息时的情况，依次是数据信息、聊天信息、数据信息，如图8-1左图所示。

图8-1 顺序发送4个数据包（左图）和4种接收数据的方式

图8-1的右图显示了数据如何到达接收端的4中可能方式。在a情况中，数据到达的顺序与发送的顺序一样。在情况b和c中，数据包的顺序发生了改变，但是，这两种情况中第一个聊天数据包A在在第二个聊天数据包C之前被接收，第一个数据包B在第二个数据包D之前。

因为两个数据包和两个聊天数据包可以在一帧中被发送，你需要确保在接收端将它们混合起来，一个方法是在发送数据包前给它们添加一个小说明，表示它们是数据包还是聊天数据包，看一下下面的代码，其中D表示一个数据包，C表示一个聊天数据包：

  writer.Write("D");
  writer.Write(gameTime.TotalGameTime.Minutes);
  writer.Write(gameTime.TotalGameTime.Seconds);

  LocalNetworkGamer localGamer = networkSession.LocalGamers[0];
  localGamer.SendData(writer, SendDataOptions.ReliableInOrder);

  writer.Write("C");
  writer.Write("This is a chat message from " + localGamer.Gamertag);
  localGamer.SendData(writer, SendDataOptions.Chat|SendDataOptions.ReliableInOrder);

在接收端，只是简单地检查数据包是D还是C，并处理对应的数据包：

  while (localGamer.IsDataAvailable)
    ...{
      NetworkGamer sender;
      localGamer.ReceiveData(reader, out sender);

      string messageType = reader.ReadString();
      if (messageType == "D")
    ...    {
          string gamerTime = "";
          gamerTime += sender.Gamertag + ": ";
          gamerTime += reader.ReadInt32() + "m ";
          gamerTime += reader.ReadInt32() + "s";
          gamerTimes[sender.Gamertag] = gamerTime;
      }
      else if (messageType == "C")
    ...    {
          lastChatMessage[sender.Gamertag] = reader.ReadString();
      }
  }

多个本地玩家

如果多个玩家连接在同一个机器上，你需要通过迭代器发送和接受数据。

将数据发送到所有玩家很简单：

  //send data from all local players to all other players in session
  foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers)
    ...{
      writer.Write(gameTime.TotalGameTime.Minutes);
      writer.Write(gameTime.TotalGameTime.Seconds);
      localGamer.SendData(writer, SendDataOptions.ReliableInOrder);
  }

记住你可以使用SendData的一个重载方法将数据只发送到一个指定玩家。

接收数据也不难，只需循环代码直到所有本地玩家的IsDataAvailable为false：

  foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers)
    ...{
      while (localGamer.IsDataAvailable)
        ...{
          NetworkGamer sender;
          localGamer.ReceiveData(reader, out sender);

          string gamerTime = localGamer.Gamertag + " received from ";
          gamerTime += sender.Gamertag + ": ";
          gamerTime += reader.ReadInt32() + "m ";
          gamerTime += reader.ReadInt32() + "s";
          gamerTimes[sender.Gamertag] = gamerTime;
      }
  }

代码

下面是Update方法的代码，包括扩展过的InSession状态，你的机器上的所有玩家将数据发送到会话中的所有玩家。然后，他们会接收发送给他们的数据。

如果你在多个机器上运行这个代码，他们会自动连接到第一个机器创建的会话上。然后，开始发送时间信息并在Draw方法中将接受到的数据显示在屏幕上。

  protected override void Update(GameTime gameTime)
    ...{
      if (GamePad.GetState(PlayerIndex.One).Buttons.Back == ButtonState.Pressed)
          this.Exit();

      if (this.IsActive)
        ...{
          switch (currentGameState)
            ...{
              case GameState.SignIn:
    ...            {
                  if (Gamer.SignedInGamers.Count < 1)
    ...                {
                      Guide.ShowSignIn(1, false);
                      log.Add("Opened User SignIn Interface");
                  }
                  else
                    ...{
                      currentGameState = GameState.SearchSession;
                      log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession");
                  }
              }
              break;
              case GameState.SearchSession:
    ...            {
                  AvailableNetworkSessionCollection activeSessions =  NetworkSession.Find(NetworkSessionType.SystemLink, 4, null);
                  if (activeSessions.Count == 0)
    ...                {
                      currentGameState = GameState.CreateSession;
                      log.Add("No active sessions found - proceed to CreateSession");
                  }
                  else
    ...                {
                      AvailableNetworkSession networkToJoin = activeSessions[0];
                      networkSession = NetworkSession.Join(networkToJoin);

                      string myString = "Joined session hosted by " + networkToJoin.HostGamertag;
                      myString += " with " + networkToJoin.CurrentGamerCount.ToString() + " players";
                      myString += " and " + networkToJoin.OpenPublicGamerSlots.ToString() + " open player slots.";
                      log.Add(myString);

                      HookSessionEvents();
                      currentGameState = GameState.InSession;
                  }
              }
              break;
              case GameState.CreateSession:
    ...            {
                  networkSession = NetworkSession.Create(NetworkSessionType.SystemLink, 4, 16);
                  networkSession.AllowHostMigration = true;
                  networkSession.AllowJoinInProgress = false;
                  log.Add("New session created");

                  HookSessionEvents();
                  currentGameState = GameState.InSession;
              }
              break;
              case GameState.InSession:
    ...            {
                  //send data from all local players to all other players in session
                  foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers)
                    ...{
                      writer.Write(gameTime.TotalGameTime.Minutes);
                      writer.Write(gameTime.TotalGameTime.Seconds);
                      localGamer.SendData(writer, SendDataOptions.ReliableInOrder);
                  }
                  //receive data from all other players in session
                  foreach (LocalNetworkGamer localGamer in networkSession.LocalGamers)
                    ...{
                      while (localGamer.IsDataAvailable)
    ...                    {
                          NetworkGamer sender;
                          localGamer.ReceiveData(reader, out sender);

                          string gamerTime = localGamer.Gamertag + " received from ";
                          gamerTime += sender.Gamertag + ": ";
                          gamerTime += reader.ReadInt32() + "m ";
                          gamerTime += reader.ReadInt32() + "s";
                          gamerTimes[sender.Gamertag] = gamerTime;
                      }
                  }

                  networkSession.Update();
              }
              break;
          }
          base.Update(gameTime);
      }
  }


8.5 异步搜索网络会话

问题

一些网络操作，诸如下载玩家配置或搜索可用的会话，会花费大量的时间。前面的教程中使用的是最简单的设置，这些操作会使程序暂停直至操作完成，这段时间中你想让玩家知道操作的信息!

解决方案

XNA为几乎所有需要时间完成的网络操作提供了异步选择。例如，NetworkSession. Find 方法的异步操作对应NetworkSession. BeginFind方法。

在异步操作的开始，XNA会创建第二个线程用于这个操作，让这个操作可以与主程序并行处理。这样做的优点是，你的主程序只需初始化异步操作，然后可以继续自己的处理。这让你可以向玩家提供任何运行在第二个线程中的异步操作的信息。

一旦异步操作完成，结果会传递到你指定的方法中，让你可以处理异步操作产生的结果。

工作原理

这个教程会解释如何使用NetworkSession.BeginFind方法，XNA中的其他的异步操作工作原理是相同的。

这个教程是教程8-3的异步版本，在那个教程中，程序开始处于SignIn状态并依次进入SearchSession, CreateSession和InSession状态。在这个教程中，你将添加一个Searching状态，当操作在后台运行时可以向用户提供信息：

  public enum GameState
    ...{
      SignIn,
      SearchSession,
      Searching,
      CreateSession,
      InSession
  }

开始异步操作

SearchSession状态很容易，因为它要做的只是初始化异步操作：

  case GameState.SearchSession:
    ...{
      NetworkSession.BeginFind(NetworkSessionType.SystemLink, 2, null, EndAsynchSearch, null);
      log.Add("ASynch search started - proceed to Searching");
      log.Add("Searching");
      currentGameState = GameState.Searching;
  }
  break;

NetworkSession.BeginFind方法会导致在后台创建第二个线程，这个线程与主程序并行进行搜索操作，这样带来的好处是主程序在运行到NetworkSession.BeginFind时无需等待（而使用NetworkSession.Find方法时需要），可以立即进入到Searching状态。

NetworkSession.BeginFind方法需要五个参数。前三个和NetworkSession. Find方法是相同的，可见教程8-3的解释。

要理解第四个参数，我需要解释当后台的搜索操作结束后会发生什么。第二个线程会调用你的方法之一并将它传递到结果。这意味着你必须指定应该调用哪个方法，这可以通过使用第四个参数做到。在本例中，当第二个线程结束后你指定它调用EndAsynchSearch方法，这个方法后面就会讲到。

最后一个参数让你识别异步操作，当在同一时间有多个异步操作的情况中这是很有用的。可见教程1—9看一下这个参数的例子。

在此期间. . .

当搜索在后台进行时，你可以自由地在Searching状态中做任何其他操作。在这个简单的例子中，每次调用Update方法时， 会在显示在屏幕的文字后面加上一个”.”。

  case GameState.Searching:
    ...{
      log[log.Count - 1] +=”.”
  }
  break;

NetworkSessions.EndFind

当运行在后台的第二个线程结束异步搜索后，它会调用在NetworkSession. BeginFind方法中第四个参数指定的方法。在前面的代码中，我指定的是EndAsynchSearch方法，具体代码如下，看起来比较复杂，但主要部分来自于前一个教程的SearchSession状态中的代码：

  private void EndAsynchSearch(IAsyncResult result)
    ...{
      AvailableNetworkSessionCollection activeSessions = NetworkSession.EndFind(result);
      if (activeSessions.Count == 0)
        ...{
          currentGameState = GameState.CreateSession;
          log.Add("No active sessions found - proceed to CreateSession");
      }
      else
        ...{
          AvailableNetworkSession sessionToJoin = activeSessions[0];
          networkSession = NetworkSession.Join(sessionToJoin);

          string myString = "Joined session hosted by " + sessionToJoin.HostGamertag;
          myString += " with " + sessionToJoin.CurrentGamerCount.ToString() + " players";
          myString += " and " + sessionToJoin.OpenPublicGamerSlots.ToString() + " open player slots.";
          log.Add(myString);

          HookSessionEvents();
          currentGameState = GameState.InSession;
      }
  }

这个方法将异步操作的结果作为参数。

对每个开始一个异步操作的方法，你都会找到一个方法可以处理它的结果。本例中开始异步操作的是NetworkSession.BeginFind方法，你可以将结果传递到NetworkSession.EndFind方法，这个方法将结果放在一个AvailableNetworkSessionCollection对象中。接下去的操作就和前一个教程一模一样了。

代码

下面是Update方法中的switch代码块，这个代码让程序从SignIn状态移动到SearchSession和Searching状态。EndAsynchSearch方法决定是否移动到CreateSession或 InSession状态。

  switch (currentGameState)
    ...{
      case GameState.SignIn:
        ...{
          if (Gamer.SignedInGamers.Count < 1)
            ...{
              Guide.ShowSignIn(1, false);
              log.Add("Opened User SignIn Interface");
              currentGameState = GameState.SearchSession;
              log.Add(Gamer.SignedInGamers[0].Gamertag + " logged in - proceed to SearchSession");
          }
      }
      break;
      case GameState.SearchSession:
        ...{
          NetworkSession.BeginFind(NetworkSessionType.SystemLink, 2, null, EndAsynchSearch, null);
          log.Add("ASynch search started - proceed to Searching");
          log.Add("Searching");
          currentGameState = GameState.Searching;
      }
      break;
      case GameState.Searching:
    ...    {
          log[log.Count - 1] += ".";
      }
      break;
      case GameState.CreateSession:
    ...    {
          networkSession = NetworkSession.Create(NetworkSessionType.SystemLink,4, 16);
          networkSession.AllowHostMigration = true;
          networkSession.AllowJoinInProgress = false;
          log.Add("New session created");

          HookSessionEvents();
          currentGameState = GameState.InSession;
      }
      break;
      case GameState.InSession:
        ...{
          networkSession.Update();
      }
      break;
  }


8.6 添加富状态信息（Rich Presence Information）

问题

你想为玩家设置富状态信息，这些信息可以显示给其他Xbox Live玩家，这些玩家可以通过Xbox Guide或在http://www.xbox.com网站上看到这些信息。

解决方案

大多数游戏可以给玩家添加富状态信息，这样其他玩家可以看到玩家正在玩什么游戏，当前他们正在游戏中干什么。你可以在Gamer.SignedInGamers[0].Presence属性中设置这个信息。

工作原理

使用XNA的网络功能，在你设置玩家的富状态信息前，首先需要确保玩家已经登录到Xbox Live。你可以从60个PresenceModes中选择，对已经登录的玩家来说非常容易设置：

Gamer.SignedInGamers[0].Presence.PresenceMode=GamerPresenceMode.AtMenu;

所有的PresenceModes如表8-1所示。

表8-1 60个PresenceModes

对某些模式(Stage,Level,Score,CoOpStage,VersusScore)，你还可以设置一个值表示玩家的stage或关卡或当前得分。下面的例子表示玩家正处于第15关：

Gamer.SignedInGamers[0].Presence.PresenceMode = GamerPresenceMode.Level;
Gamer.SignedInGamers[0].Presence.PresenceValue = 15;


8.7 从Lobby移动到实际游戏

问题

创建了会话后，你想给所有玩家时间用来聚集、聊天、让他们可以示意已经准备好可以进行游戏了。

解决方案

XNA在会话状态和玩家的IsReady属性中带有基本的lobby功能。

会话开始于Lobby状态。只有主机可以调用NetworkSession. StartGame方法，这个方法将会话移动到Playing状态。主机可以基于所有玩家的IsReady状态做出决定。

工作原理

当会话创建后，会话的SessionState属性会拥有Lobby值。 当在这个状态时，你需要让所有在这个会话中的玩家可以示意他们已经做好了准备，这可以通过将他们的IsReady设为true做到。这个值可以被会话中的所有玩家读取：

  case GameState.InSession:
    ...{
      switch (networkSession.SessionState)
    ...    {
          case NetworkSessionState.Lobby:
            ...{
              if (keybState != lastKeybState)
    ...            {
                  if (keybState.IsKeyDown(Keys.R))
                  {
                      LocalNetworkGamer localGamer = networkSession.LocalGamers[0];
                      localGamer.IsReady = !localGamer.IsReady;
                  }
              }
          }
          break;
          case NetworkSessionState.Playing:
    ...        {
          }
          break;
      }

      networkSession.Update();
  }

主机需要检测是否所有玩家都将IsReady设为了true。最简单的方法是检查NetworkSess ion.IsEveryoneReady的值，如果所有人都准备好了，主机会调用NetworkSession.StartGame方法，这个方法将状态从Lobby移动到Playing：

  case NetworkSessionState.Lobby:
    ...{
      if (keybState != lastKeybState)
    ...    {
          if (keybState.IsKeyDown(Keys.R))
    ...        {
              LocalNetworkGamer localGamer = networkSession.LocalGamers[0];
              localGamer.IsReady = !localGamer.IsReady;
          }
      }
      if (networkSession.IsHost)
        ...{
          if (networkSession.AllGamers.Count > 1)
            ...{
              if (networkSession.IsEveryoneReady)
                ...{
                  networkSession.StartGame();
                  log.Add("All players ready -- start the game!");
              }
          }
      }
  }
  break;

当状态改变至Playing时，会话会引发GameStarted事件，这个事件所有玩家都会进行监听。这样可以让他们绘制游戏屏幕并开始发送和接收游戏数据。

当主机决定结束游戏时，需要调用NetworkSession.EndGame方法，这个方法将所有玩家的IsReady重置为false并返回Lobby状态。

  case NetworkSessionState.Playing:
    ...{
      if (networkSession.IsHost)
    ...    {
          if (keybState != lastKeybState)
    ...        {
              if (keybState.IsKeyDown(Keys.E))
                    ...{
                      networkSession.EndGame();
              }
          }
          }
  }
  break;

这会引发会话的GameEnded事件，所有玩家都可以监听这个事件，这样他们就可以，例如，绘制lobby图像。

代码

InSession状态包含大多数基本代码让多个玩家表示他们的IsReady，让主机从Lobby状态移动到Playing状态或返回：

  case GameState.InSession:
    ...{
      switch (networkSession.SessionState)
        ...{
          case NetworkSessionState.Lobby:
            ...{
              if (keybState != lastKeybState)
    ...            {
                  if (keybState.IsKeyDown(Keys.R))
    ...                {
                      LocalNetworkGamer localGamer = networkSession.LocalGamers[0]; localGamer.IsReady = !localGamer.IsReady;
                  }
              }
              if (networkSession.IsHost)
    ...            {
                  if (networkSession.AllGamers.Count > 1)
                    ...{
                      if (networkSession.IsEveryoneReady)
                        ...{
                          networkSession.StartGame();
                          log.Add("All players ready -- start the game!");
                      }
                  }
              }
          }
          break;
          case NetworkSessionState.Playing:
    ...        {
              if (networkSession.IsHost)
    ...            {
                  if (keybState != lastKeybState)
    ...                {
                      if (keybState.IsKeyDown(Keys.E))
    ...                    {
                          networkSession.EndGame();
                      }
                  }
              }
          }
          break;
      }
      networkSession.Update();
  }
  break;