安卓-C---游戏开发入门手册-全-

安卓 C++ 游戏开发入门手册(全)

原文:Beginning Android C++ Game Development

协议:CC BY-NC-SA 4.0

零、简介

在过去的几年里,游戏开发变得对卧室程序员更加开放。在 20 世纪 80 年代和 90 年代初,这是游戏开发的一个常见途径。在 20 世纪 90 年代末和 21 世纪初,游戏开发预算、进度和技术要求意味着很难找到游戏程序员自己创作游戏。

这一切随着手机的发布而改变,最**板电脑的 3D 图形功能超过了 Playstation 2 和 Sega Dreamcast 等游戏机。

这本书将向读者介绍 Android *台上的游戏开发世界。读者将学习如何从头到尾计划、开始和执行一个游戏开发项目。

我希望你喜欢它

一、游戏开发入门

在相对较短的时间内,电子游戏已经成为我们文化的重要组成部分。随着许多发达国家引入游戏开发税计划,该行业也正在发展成为许多现代经济的主要支柱。与此同时,在一段时间内,向商业市场发布游戏变得前所未有的容易。在过去的二十年里,游戏开发团队需要资金支持和一定水*的专业知识,才能通过*台持有者的严格测试,从而获准使用他们的开发硬件。今天,任何人只要有一部手机或一台*板电脑和一台电脑,甚至一台笔记本电脑,就可以制作一款游戏并出售,只需要最少的时间和资金支持。这并不意味着每个游戏都是成功的:对制作游戏所涉及的技术方面以及设计人们想要玩的游戏所涉及的考虑因素有很好的理解仍然是至关重要的。有时候,发展这种知识的最好方法是从头开始,所以我们会看一些视频游戏的历史。

电子游戏简史

最早的视频游戏之一被广泛认为是太空战!太空战!由麻省理工学院的斯蒂芬·拉塞尔创建,于 1962 年发布,作为最*发布的 PDP-1 计算机系统的功能演示。游戏如太空战!然而并没有达到大众批判的诉求。

商业上成功的电子游戏时代可以说始于 1972 年 Russell 在诺兰·布什内尔斯坦福大学的一名学生和他的合伙人 Ted Dabney 成立 Atari。雅达利负责发布了大量流行和商业上成功的游戏,如 Pong小行星Breakout 。在两个主要竞争对手进入之前,雅达利仍将是视频游戏行业最大的玩家之一。

任天堂和世嘉都在 1983 年凭借任天堂娱乐系统和世嘉 SG-1000(以及后来的 Master 系统)进入视频游戏业务。这些公司在 90 年代末成为视频游戏行业的主要玩家,并催生了大量游戏系列,如马里奥塞尔达传说刺猬索尼克世嘉拉力赛

几乎同样重要的是,任天堂和世嘉将推广手持游戏的概念。通过 Game Boy、Game Gear 到任天堂 3DS 等*台,以及目前来自索尼 Playstation Vita、任天堂和世嘉的竞争,证明人们有在移动中玩游戏的欲望。

自从手机开始拥有处理器和图形能力来运行我们可以识别为游戏的程序以来,游戏的这一分支就一直在与手机*台融合。九十年代末,诺基亚手机发布了一款非常流行的游戏贪吃蛇。高通在 2001 年发布了 BREW(无线二进制运行时环境)*台。诺基亚试图开发一个基于手机的游戏*台,名为 NGage,并于 2003 年发布。这两个*台都展示了移动电话*台最终的能力。

2008 年,苹果公司在手机游戏领域取得了第一次突破性的成功,当时他们在 iPhone 3GS 上发布了他们的应用商店。此后不久,谷歌于 2008 年 9 月推出了 Android Market(目前的 Google Play)。这些商店第一次允许任何公司或个人注册成为开发者,并直接向公众销售游戏,从而使游戏机游戏开发民主化。到目前为止,视频游戏控制台要求开发者注册并支付相当多的费用来获得他们所针对的硬件的开发版本。现在,任何人都可以用自己的家用电脑和手机制作应用和游戏。

随着手机硬件的快速发展,App Store 和 Google Play 越来越强大。在过去的四年里,移动*台已经从没有硬件浮点支持的单核处理器发展到多核设置,可以说多核设置的能力不亚于低端台式机 CPU。同样,可用的 GPU 已经从支持固定流水线 OpenGL ES 1.1 的部分发展到至少支持 OpenGL ES 2.0 的现代芯片,以及一些支持 3.0 版本的最新 GPU。

对于游戏开发领域的新手来说,其中一些术语听起来仍然令人生畏,这可能会成为进入的障碍。许多人在这一点上可能会被吓跑,所以消除这些感觉并看看谁可以和应该制作游戏是很重要的。

谁做游戏?

正如我在上一节中提到的,随着手机上现代应用*台的出现,传统的由知名公司与大型游戏发布社签署发行协议的模式不再是发布视频游戏的最常见方法。

目前在这些移动*台上有各种各样的开发者。一些最大的公司仍然是传统公司,如电子艺界,他们制作非常受欢迎和成功的游戏。然而,有越来越多的独立开发者正在创造有意义的游戏体验,这也带来了大量的下载量和可观的收入。一个很好的例子是寺庙经营神庙逃亡 由 Imangi 工作室开发,这是一个夫妻团队,他们增加了一名额外的成员来为他们的游戏创作艺术。

我认为 Jesse Schell 在他的书《游戏设计的艺术》中,在讨论谁可以成为游戏设计师时,说得最好。在他的第一章中,他提出了一个问题来说明如何成为一名游戏设计师:

“如何成为一名游戏设计师?”

他的回答是:

“设计游戏。现在就开始!别等了!甚至不要结束这次谈话!就开始设计吧!走吧。现在!”

当你完成这本书的时候,你已经从零开始做了一个游戏,并且准备好从你自己的设计开始开发你自己的游戏。

同样值得注意的是,游戏并不总是电子游戏。历史上许多最受欢迎的游戏都是棋盘游戏,比如国际象棋和大富翁马上就会出现在脑海中。那么是什么让电子游戏与众不同呢?

电脑游戏和桌游的区别

传统游戏已经存在了几千年,但现代视频游戏有一种吸引力,使它们有别于那些游戏。传统游戏有一个正式的结构。他们通常有一套规则,一个随机元素,一个玩家要实现的冲突目标,以及一个获胜条件。

垄断就是一个例子。游戏的目标是每个玩家都是最后一个有钱的人。你可以通过开发你自己的财产方块来减少别人的钱数,游戏规则规定了你如何以及何时可以进行这种开发。通过掷骰子来决定你的棋子落在哪个属性方格上,这个游戏增加了一点随机性。

尽管在玩像“大富翁”这样的游戏时会出现无穷无尽的变化,但规则和动作的范围仍然相当有限。这些游戏仍然依赖于玩家记住如何玩游戏才能成功。电子游戏的优势在于电脑可以模拟游戏,而不需要玩家记住游戏的状态。

因此,视频游戏可能是比传统游戏复杂得多的系统。今天的游戏机和 PC 游戏是这种复杂性的完美例子。像微软的《光晕 4》这样的游戏有一套庞大的规则,这些规则都是实时执行的。每种武器都有不同的特点;有车辆和敌人,每个都有一个独特的调整在他们的人工智能来代表不同的个性。对许多人来说,从表面上看,它似乎很像许多其他第一人称射击游戏,但不同游戏规则之间的相互作用是视频游戏与传统游戏的区别,也是好游戏与伟大游戏的区别。伟大的游戏几乎无缝地将复杂的规则、人工智能和玩家互动融合到一个可信的世界和故事中。

既然我们已经了解了桌游和主机游戏之间的区别,我们就来看看是什么让为移动设备设计的游戏不同于为家用主机设计的游戏。

将手机比作游戏机

这可能令人惊讶,但实际上目前的 Android 手机与传统游戏*台(如微软 Xbox 360、索尼 Playstation 3 和任天堂的 Wii U)之间几乎没有什么区别。

每个系统都有自己的权衡和潜在的独特控制器接口,但在表面下,每个系统都符合一些既定的标准。

  • 它们都有一个执行游戏代码的 CPU。
  • 每个都有一个渲染游戏几何图形的 GPU。
  • 每个都有不同分辨率和宽高比的显示屏。
  • 它们都输出声音。
  • 它们都接受用户输入。

从用户的角度来看,主要的区别因素是输入方面。传统上,PC 游戏使用键盘和鼠标,主机游戏使用控制器,现代手机游戏使用触摸屏。这就要求对游戏进行不同的设计,以最适合目标系统的输入设备。

从发展的角度来看,手机目前比游戏机弱,比 PC 弱很多。尽管支持顶点和片段着色器等现代功能,但与 PC 或控制台相比,手机上可以处理的顶点数量和可以绘制的像素数量有限。手机内存和 GPU 之间的内存带宽也有更严格的限制,这使得只发送 GPU 可以用来渲染当前帧的相关信息变得很重要。

这些限制会在游戏实现的最底层影响游戏,游戏程序员已经能够熟练地设计他们的技术来适应这些差异。许多挑战对所有手机游戏来说都是共同的,分享一个项目取得的进步只会有助于后续的游戏。为此,游戏引擎已经成为在主机上开发游戏的基础部分,在移动*台上也越来越多。

游戏引擎概述

在 20 世纪 80 年代,每一款游戏都是从头开始编写的,项目之间很少重用代码,这种情况并不罕见。随着 20 世纪 90 年代初至中期游戏引擎的出现,这种情况开始改变。随着 3D 加速器的出现,游戏代码的复杂性迅速增加。理解大量与游戏开发相关的主题变得很有必要,比如音频、物理、人工智能和图形编程。随着复杂性的增加,开发游戏所需的团队规模和资金也在增加。没过多久,游戏开发中就出现了双轨发展。有技术团队编写游戏运行的系统,也有游戏编程团队自己开发游戏。

由此诞生了游戏引擎的概念。底层系统是以抽象的方式编写的,因此游戏可以在顶层开发。当时引擎市场的一个关键参与者是 Id Software,它将其 Id Tech 引擎授权给其他开发者。诞生在 Id 游戏引擎上的一个值得注意的专营权是半衰期,它是使用雷神之锤引擎创建的。Id 自己的雷神之锤 3 ,发布于 1999 年,是他们当时最大的发布,是基于他们的 Id Tech 3 引擎开发的。这个引擎也获得了许可,最著名的例子是 Infinity Ward 使用这个引擎创作了使命召唤

从那时起,Unreal 已经成为一个非常成功的引擎,被来自美国、欧洲和日本的许多游戏团队授权来创建当代一些最大的主机游戏,Unity 引擎目前在 Android 和 iOS 上的许多游戏中使用。

从个人的角度来看,重要的是要认识到什么使游戏引擎成为一个有吸引力的前景的核心概念,无论是通过许可另一个开发者的技术,还是以类似引擎的方式编写自己的代码。使用这种技术允许您在项目之间重用大部分代码。这降低了开发游戏的财务成本,并通过允许您在游戏功能上花费越来越多的时间而在引擎上花费更少的时间来提高您的生产力。实际上,事情从来没有这么简单,但是尽可能多地将引擎代码和游戏逻辑代码分开是很重要的。这是我们在阅读本书的过程中要努力实现的目标:从开始到结束,我们一定会关注可重用引擎代码和游戏逻辑的分离,这是特定于单个应用的。

摘要

这就结束了对视频游戏开发的旋风式介绍,从其根源一直到现代开发的当前状态。这些主题中的每一个都可以在它们自己的书中深入讨论,但是我们在这里建立的基础应该对本书的其余部分有所帮助。

我们将介绍一个游戏的开发过程,从在 Eclipse 中建立一个游戏项目,设计一个小游戏,实现一个游戏引擎,一直到在 Google Play 中发布我们的第一个游戏。

我们开始吧。

二、Android 游戏开发生态系统简介

在我们简要介绍了电子游戏的历史之后,我们将看看如何迈出定义未来的第一步。Android *台为我们提供了前所未有的跨*台开发工具和 3D 图形硬件。这使得它成为游戏开发入门的理想候选*台。你所需要的只是一台电脑,所以让我们开始吧。

Java 和达尔维克虚拟机

Java 编程语言由 Sun Microsystems 于 1995 年发布,目前由 Oracle 维护。这种语言的语法是基于 C 的,因此对于许多已经熟练使用 C 和 C++ 的程序员来说是很熟悉的。C++ 和 Java 的主要区别在于,Java 是一种托管语言,代码在 Java 虚拟机上执行。

Android 推出时,Java 是应用开发者唯一可用的语言选项。Android 开发人员没有使用 Java 虚拟机,而是编写了自己的实现,他们将其命名为 Dalvik。Dalvik 最初没有许多与其他成熟 Java 虚拟机相关的特性。一个特别值得注意的遗漏是实时(JIT)编译。由于 Java 是一种在虚拟机中运行的托管语言,代码不是直接编译成本机 CPU 指令,而是编译成虚拟机可以使用的字节码。使用 JIT,虚拟机可以在程序需要之前将字节码块编译成机器码,因此可以提高运行程序的速度。这些编译后的单元也可以被缓存,以备将来提高速度。Android 直到 2.2 版本才有这个功能。

很多游戏编程相关的底层 API,在 Android *台上也仍然是用 C 实现的,比如 Open GL。Android 上的 Java 通过使用 Java 本地接口(JNI) 来支持这些 API。JNI 提供了一种机制,用于支持从 Java 虚拟机向本地库的函数调用传递参数,以及本地库向 Java 虚拟机返回值。

这为游戏开发者创造了次优的条件。Java 语言的托管性质意味着开发人员不负责游戏在其生命周期内的内存管理。虽然有许多理由说明这对普通应用来说可能是一件好事,但需要实时执行的游戏不能将内存分配和垃圾收集的控制权完全交给外部系统,这也增加了用 Java 调用某些函数的隐性成本。

在集合上使用迭代器时,可以发现一个隐藏成本的好例子。和许多其他 Java 对象一样,迭代器是不可变的。这意味着一旦你有了迭代器,它就不能被改变。当从当前迭代器移动到集合中的下一个位置时,Java 会分配一个新的迭代器,并在新的位置将其返回给调用者,同时将旧的迭代器标记为删除。最终,Dalvik 将调用垃圾收集器来释放所有孤立的迭代器,这将导致帧率明显下降,甚至导致游戏停止。这让我们想到了 C++ 和 NDK。

C++ 和 NDK

谷歌发布了 Android 原生开发套件(NDK),为开发者在 Android 上开发应用提供了另一种选择。第一个版本是为 Android 1.5 发布的,但不包含对 SDK(如 OpenGL ES)的必要支持。NDK 的修订版 5 是我认为第一个可行的游戏编程 NDK 版本。这个版本增加了支持NativeActivity和原生应用胶水库的能力,允许开发者完全用 C++ 编写 Android 应用,而不需要任何 Java。这是可能的,因为 NDK 的这一版本还通过 OpenGL ES 增加了对音频的支持,原生音频支持,对系统传感器(如加速度计和陀螺仪)的原生访问,以及对 app APK 包内文件存储的原生访问。

用 C++ 编写 Android 应用有很多好处。现有的开发人员可以将对该*台的支持添加到他们现有的 C++ 代码库中,而不需要为系统维护 Java 代码和 C++ 代码,新的开发人员可以开始为 Android 编写应用,然后可以移植到其他*台或同时为多个*台开发。

用 C++ 开发游戏不会没有挑战。由于 C++ 被编译成本机代码,Android 支持多 CPU 指令集,因此确保编写的代码编译和执行无误并符合预期变得非常重要。迄今为止,Android 支持以下功能:

  • 手臂ˌ武器ˌ袖子ˌ装备
  • ARM v7a
  • 每秒百万条指令
  • x86

市场上有支持这些指令集的设备。由于 Java 编译成字节码并在虚拟机上运行,这对 Java 开发人员来说是透明的。在撰写本文时,NDK 工具集还不像 Java 工具集那样成熟,与 Eclipse IDE 的集成也有点复杂和麻烦,尤其是在代码完成、构建和调试功能方面。

尽管有麻烦和缺点,用 C++ 在 Android 上开发的性能优势仍然超过了使用 NDK 工具集的缺点,希望这些工具的成熟度和功能性只会随着时间的推移而提高。既然你已经看到了在游戏开发方面 C++ 相对于 Java 的优势,那么看看 Android 生态系统中这两种语言共有的一些问题是很重要的。这些问题并不完全是新的,在 PC 开发的 OpenGL 和 DirectX 领域已经遇到、处理和解决了很多年;然而,这些考虑对于许多手机开发者来说是新的。这些问题被归为一类,而“碎片化”这个术语被创造出来以包含所有这些问题。

碎片化和 Android 生态系统

对于 Android *台上的碎片化对不同的人意味着什么,有很多观点和不同的定义。我会纯粹从游戏开发的角度来看问题。

安卓版本

从开发的角度来看,第一个问题是选择一个我们希望作为最低目标的 Android 版本。正如我在上一节中所讨论的,NDK 的许多基本特性都是在修订版 5 中添加的。NDK r5 支持 Android API level 9,在撰写本文时,Android 开发者仪表板显示,在过去 14 天内访问 Google Play 的 Android 设备中有 86.6%支持该版本;13.4%可能是一个相当大的市场,你可能不愿意放弃你的潜在客户群。为了便于开发,我决定不支持这个不断下降的 Android 版本比例是可以接受的。所以,明确一点,这本书会针对 Android API level 9。

屏幕分辨率和长宽比

下一个经常讨论的碎片问题是屏幕分辨率和纵横比。这是我从未完全理解的争论的一个方面。在过去的几十年里,游戏已经被编写为支持多种分辨率和宽高比。这是 PC、Xbox 360 和 PS3 以及之前开发过跨*台游戏的开发者的常见需求。早期版本的 iOS 设备支持相同的分辨率或多个分辨率,并保持相同的长宽比,这不太方便,但现在也不再是这样了。我们将在开发游戏时考虑多种屏幕分辨率和宽高比。

输入设备支持

另一个细分领域是对输入设备的支持。一些 Android 设备支持单点触摸,一些支持不同程度的多点触摸。有些有精确的传感器;有些根本没有这些传感器。最好的方法是设计你想制作的游戏,它支持可接受数量的设备。如果你的设计不需要多点触控支持,你将获得更广泛的受众,但如果有了这种支持,游戏会明显更好,那么就不值得降低你的工作质量,并通过支持不允许最佳体验的设备来损害销售。另一种选择是在可能的情况下提供多种控制方案,并在运行时选择使用哪一种。

绘图处理器

碎片化的最后一个主要领域是使用 GPU。Android GPU 领域有四个主要参与者,更高级的图形编程技术会遇到一些问题,其中一些对某些 GPU 不是最佳的,或者根本不支持。例如,它们对纹理压缩格式都有不同的支持,但是这些问题超出了本书的范围。

我们的第一款安卓游戏

在消化了所有关于游戏、开发和 Android *台的信息之后,现在是看一个小游戏例子的好时机。这个游戏是一个基本的突破克隆。您可以使用屏幕上的左右箭头来控制踏板。图 2-1 是在 Galaxy Nexus 上运行的游戏截图。

9781430258308_Fig02-01.jpg

图 2-1 。你好,克隆人越狱

部分代码相当复杂,将在后面的章节中讨论;其中一些代码涉及设置 Open GL、轮询 Android 系统事件和处理用户输入。《突围》是编写我们自己的游戏的伟大的第一次尝试,因为它融合了大型游戏的几个关键概念。

  • 首先,有一个由用户控制的玩家实体,即球拍。
  • 按钮中有一个基本的用户界面来控制踏板。
  • 球和块中存在基本的非玩家实体,这也导致我们必须考虑实时碰撞检测和响应。

尽管相对原始的图形和简单的游戏机制,这是一个很好的练习,创造一个完整的游戏体验,这真的不是很久以前,当游戏并不比我们在接下来的几个部分创造的更多。

为了实现我们的目标,你需要完成为 Android 组织、编写和构建游戏所需的步骤。您将使用 Eclipse 将游戏组织到一个项目中,使用 NDK 编写代码,并使用 NDK 构建过程构建游戏。

创建新的 Eclipse 项目

Eclipse 是 Android 开发的首选 IDE。Google 的 Android 团队提供了一个 Eclipse 版本,其中捆绑了适用于所有*台的大多数 Android 工具。关于如何获得该 IDE 的最新信息可以从http://developer.android.com/sdk/index.html获得。

NDK 是一个单独的下载,经常更新。有关最佳安装说明,请访问http://developer.android.com/tools/sdk/ndk/index.html

一旦你为你选择的*台下载、安装并配置了这些,就该开始你的第一个 Android 游戏了。这个过程的第一步是创建一个新项目。

  1. 通过设置首选项中的选项,确保 Eclipse IDE 知道 NDK 在您的计算机上的位置。您可以通过打开窗口➤偏好设置,然后导航到 Android ➤ NDK 并设置进入 NDK 位置的适当路径来找到该选项。

  2. Start the New Project wizard (see Figure 2-2) from the File ➤ New ➤ Project menu.

    9781430258308_Fig02-02.jpg

    图 2-2 。“新建项目”对话框

  3. From here, select the Android Application Project and click Next. The New Android Application box as shown in Figure 2-3 should be shown.

    9781430258308_Fig02-03.jpg

    图 2-3 。新的 Android 应用对话框

  4. 在新建 Android 应用对话框中,输入您的应用的名称;我选择了HelloDroid。当您输入应用名称时,项目名称将被自动填充,并且是 Eclipse 用来在项目浏览器中标识项目的名称。

  5. 包名是 Android 生态系统使用的唯一标识符。它通常被分成由句点分隔的独立部分。第一部分通常是com,它将应用的开发者标识为一家公司。下一个条目通常是公司名称、个人名称或项目名称的派生词。对于我的例子,我使用了beginndkgamecode。最后一项通常是项目的名称。我最后的包名是com.beginndkgamecode.hellodroid

  6. 最低要求 SDK 更改为 API 9: Android 2.3(姜饼)是对这些选项的另一个更改。

  7. 一旦设置好这些选项,点击下一个的

  8. 在下一个屏幕上,取消选中创建自定义启动器图标创建活动。如果你对项目的路径满意,点击完成

您的项目现在应该存在于项目浏览器中,我们可以继续设置项目以支持 Android NDK。

添加 NDK 支持

给这个项目增加 NDK 的支持是一项简单的任务。

  1. 右键单击项目浏览器窗口中的项目,并导航到 Android Tools 。选择添加原生支持。。。从弹出菜单中选择
  2. 现在将要求您为构建过程将生成的本机代码库提供一个名称。只要您的应用的名称合理地唯一,提供的名称就足够了。对名称满意后点击完成

现在,在我们准备开始向项目添加代码之前,我们还需要做一些更改。

首先我们需要设置NativeActivity支持,这将允许我们在不添加任何 Java 代码的情况下创建应用。我们通过将android.app.NativeActivity节点添加到清单中来实现这一点。

  1. Open the AndroidManifest.xmlfile, which can be found in the project folder (see Figure 2-4).

    9781430258308_Fig02-04.jpg

    图 2-4 。Eclipse Android 清单编辑器视图

  2. 我们需要访问的选项可以在应用选项卡上找到,所以现在点击它(见图 2-4 的底部)。

  3. 点击主题选择框旁边的浏览,选择主题。从提供的选项中选择。这个选项通知我们的应用全屏运行,也隐藏了 Android 状态栏。

  4. 的 HasCode 设置为真。这是确保我们的应用正确构建所必需的。

  5. 点击添加按钮,可以在应用节点窗口旁边找到。选择活动,点击确定

  6. 在活动部分的属性中,点击名称条目旁边的浏览。取消选中显示来自项目'<项目名称>'的类,只显示,并在过滤框中键入 NativeActivity 。选择NativeActivity类并点击确定

  7. 对于标签,输入@string/app_name

  8. 屏幕方向选择横向,以确保我们的游戏将始终在横向模式下运行。

  9. 应用节点窗口中点击本地活动节点,再次点击添加。输入名称android.app.lib_name,输入LOCAL_MODULE名称,可以在项目jni文件夹的Android.mk文件中找到。

  10. 应用节点窗口中选择 NativeActivity 节点(这是最后一次,唷!)和添加一个意图过滤器通过选择添加菜单,将动作类别添加到意图过滤器中。

  11. 动作的名称设置为android.intent.action.MAIN,将类别的名称设置为android.intent.category.LAUNCHER

您的项目设置现在已经完成。我们现在可以继续 NDK 构建流程了。

NDK 建筑系统一览

NDK 提供了一个称为 ndk-build 的构建过程。这个过程读取特定于 Android 的 makefiles,其中包含了构建一个本地库所需的所有信息。

注意Android NDK 包含一个基于 Make 的构建系统。Make 是一个流行的程序构建工具,尤其是在基于 Linux 的操作系统中,它可以在称为 makefiles 的文件中指定构建程序的参数。Android NDK 有这些文件的修改版本,我们将在本书的各个章节中看到。

默认的 Android.mk文件,可以在jni文件夹中找到,将包含以下文本:


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := hellodroid
LOCAL_SRC_FILES := hellodroid.cpp

include $(BUILD_SHARED_LIBRARY)

这个基本的 makefile 只执行几个步骤。

  1. 它为 makefile 设置本地构建路径,这允许它找到相对于自己路径的其他文件。
  2. 然后它调用一个外部命令来清除先前设置的构建变量。
  3. 它定义了要在LOCAL_MODULE变量中构建的库的名称和要在LOCAL_SRC_FILES变量中编译的源文件。
  4. 为了包装文件,它调用命令,使构建系统执行构建过程,编译并链接代码。

修改构建文件

我们需要修改这个文件来添加使用 NDK 构建游戏所必需的外部库,这需要这些特性。关于可用库的更多信息可以在 NDK 的docs文件夹中的STABLE-APIS.html文件中找到。

首先,我们使用LOCAL_LDLIBS定义我们的应用需要加载的外部库。


LOCAL_LDLIBS := -llog -landroid -lEGL -lGLESv2

这一行告诉构建系统,我们希望我们的应用能够使用 Android 现有的logandroidEGLGLESv2 (Open GL ES 2.0)库。由于这些是许多应用和 Android 操作系统本身共有的,所以它们是动态链接的。

我们还需要一个静态的 NDK 图书馆与我们的应用相联系。这个静态库叫做android_native_app_glue,它提供了我们需要的功能,使我们能够用 C++ 编写应用,而不需要使用任何 Java。我们通过使用以下代码行将它作为静态库包括在内:


LOCAL_STATIC_LIBRARIES := android_native_app_glue

我们还有最后一行要添加到 makefile 中。这一行告诉构建系统将静态库导入到我们的应用中。


$(call import-module, android/native_app_glue)

最终的Android.mk文件将如下所示:


LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := hellodroid
LOCAL_SRC_FILES := hellodroid.cpp
LOCAL_LDLIBS := -llog -landroid -lEGL -lGLESv2
LOCAL_STATIC_LIBRARIES := android_native_app_glue

include $(BUILD_SHARED_LIBRARY)

$(call import-module, android/native_app_glue)

添加应用级构建选项

还有我们需要设置的应用级构建选项。这些被添加到一个名为Application.mk的文件中。这个文件不是作为 Eclipse 中默认项目设置的一部分创建的,所以您必须自己创建这个文件。你可以右击jni文件夹,从菜单中选择新建文件。将新文件命名为Application.mk,并输入下面一行:


APP_PLATFORM := android-9

这一行通知 NDK,我们正在使用其库的 API level 9。这就是我们目前需要的全部内容,但是我们将在以后向这些文件中添加更多内容。

此时,您应该能够右键单击项目名称并选择构建项目。这应该在输出控制台中输出文本,希望没有错误。如果此时遇到任何错误,请尝试处理列表中的第一个错误,然后重试。许多错误会导致级联效应,通常修复第一个错误就会修复所有后续错误。如果错误被证明是顽固的,你应该从头开始重新检查,并尝试寻找代码、makefiles 和项目与本章提供的示例之间的差异。一旦您发现了修复问题中的错误的差异,请尝试对配置或代码进行试验,以熟悉错误、如何发现它们,以及更重要的是,如何修复它们。游戏开发人员并不是一贯正确的,学习如何破译由我们的工具(如编译器)产生的错误是一项需要培养的重要技能,也是你可能需要经常使用的技能。

启用调试

为调试支持设置构建是我们必须完成的下一个任务。

  1. 右键单击项目,将鼠标悬停在构建配置上,并选择管理

  2. 在出现的窗口中选择。将新配置命名为调试,并复制默认的设置;点击确定。在管理配置窗口中再次点击确定

  3. 再次右键单击项目并选择属性。导航到 C/C++ 构建菜单并切换到调试配置。取消勾选使用默认构建命令并将输入的行更改为:

    ndk-build NDK_DEBUG=1
    
    

注意如果您有一台多核机器,并且想要利用系统中的额外处理器,您还可以添加选项-jX,其中X是要创建的作业数量。我在支持超线程的四核系统上使用选项-j8

现在,您可以通过构建配置设置活动菜单在可调试构建和优化构建之间切换。

我们的项目设置已经完成,可以开始了;现在我们可以添加一些代码来制作一个游戏。

运行游戏

游戏的源代码可以在本书附带的 Chapter2.cpp 文件中找到,也可以从本书的网站http://www.apress.com/9781430258308获得。

您可以将该文件的内容直接复制到项目中的cpp文件中,并在您的设备上构建和运行游戏。

核心游戏功能存在于以下函数中:


static void

enigine_update_frame(struct engine* engine)

{
        if (engine->touchIsDown)
        {
                if (engine->touchX < 0.15f && engine->touchY < 0.2f)
                {
                        engine->playerX -= 0.015f;
                        if (engine->playerX < PADDLE_LEFT_BOUND)
                        {
                                engine->playerX = PADDLE_LEFT_BOUND;
                        }
                }
                else if (engine->touchX > 0.85f && engine->touchY < 0.2f)
                {
                        engine->playerX += 0.015f;
                        if (engine->playerX > PADDLE_RIGHT_BOUND)
                        {
                                engine->playerX = PADDLE_RIGHT_BOUND;
                        }
                }
        }

        engine->ballX += engine->ballVelocityX;
        if (engine->ballX < BALL_LEFT_BOUND || engine->ballX > BALL_RIGHT_BOUND)
        {
                engine->ballVelocityX = -engine->ballVelocityX;
        }

        engine->ballY += engine->ballVelocityY;
        if (engine->ballY > BALL_TOP_BOUND)
        {
                engine->ballVelocityY = -engine->ballVelocityY;
        }

        if (engine->ballY < BALL_BOTTOM_BOUND)
        {
                // reset the ball
                if (engine->ballVelocityY < 0.0f)
                {
                        engine->ballVelocityY = -engine->ballVelocityY;
                }

                engine->ballX = BALL_START_X;
                engine->ballY = BALL_START_Y;

                engine_init_blocks(engine);
        }

        float ballXPlusVelocity = engine->ballX + engine->ballVelocityX;
        float ballYPlusVelocity = engine->ballY + engine->ballVelocityY;

        const float ballLeft = ballXPlusVelocity - BALL_HALF_WIDTH;
        const float ballRight = ballXPlusVelocity + BALL_HALF_WIDTH;
        const float ballTop = ballYPlusVelocity + BALL_HALF_HEIGHT;
        const float ballBottom = ballYPlusVelocity - BALL_HALF_HEIGHT;
        const float paddleLeft = engine->playerX - PADDLE_HALF_WIDTH;
        const float paddleRight = engine->playerX + PADDLE_HALF_WIDTH;
        const float paddleTop = engine->playerY + PADDLE_HALF_HEIGHT;
        const float paddleBottom = engine->playerY - PADDLE_HALF_HEIGHT;
        if (!((ballRight < paddleLeft) ||
                        (ballLeft > paddleRight) ||
                        (ballBottom > paddleTop) ||
                        (ballTop < paddleBottom)))
        {
                if (engine->ballVelocityY < 0.0f)
                {
                        engine->ballVelocityY = -engine->ballVelocityY;
                }
        }
 )
        bool anyBlockActive = false;
        for (int32_t i=0; i<NUM_BLOCKS; ++i)
        {
                block& currentBlock = engine->blocks[i];
                if (currentBlock.isActive)
                {
                        const float blockLeft = currentBlock.x - BLOCK_HALF_WIDTH;
                        const float blockRight = currentBlock.x + BLOCK_HALF_WIDTH;
                        const float blockTop = currentBlock.y + BLOCK_HALF_HEIGHT;
                        const float blockBottom = currentBlock.y - BLOCK_HALF_HEIGHT;
                        if (!((ballRight < blockLeft) ||
                                        (ballLeft > blockRight) ||
                                        (ballTop < blockBottom) ||
                                        (ballBottom > blockTop)))
                        {
                                engine->ballVelocityY = -engine->ballVelocityY;

                                if (ballLeft < blockLeft ||
                                                ballRight > blockRight)
                                {
                                        engine->ballVelocityX = -engine->ballVelocityX;
                                }

                                currentBlock.isActive = false;
                        }
                        anyBlockActive = true;
                }
        }

        if (!anyBlockActive)
        {
                engine_init_blocks(engine);
        }
}
)

代码中缺少注释反映了这样一个事实,即代码在其简单性方面应该是相当自文档化的。这个函数的第一部分是当玩家按下屏幕的左上角或右上角时,更新拨片的位置。

当 Android 通知 app 用户已将手指放在屏幕上时,函数engine_handle_input 中的touchIsDown被设置为true;当 Android 通知我们手指已经抬起时,它再次设置为false


if (engine->touchIsDown)
{

触摸坐标从左上角的 0,0 开始,到右下角的 1,1。下面的if检查告诉应用玩家是否在触摸左上角;如果是这样,我们将玩家的位置向左移动。一旦玩家到了我们允许的最左边,我们就把他们的位置固定在那个点上。


                if (engine->touchX < 0.15f && engine->touchY < 0.2f)
                {
        engine->playerX -= 0.015f;
        if (engine->playerX < PADDLE_LEFT_BOUND)
        {
                engine->playerX = PADDLE_LEFT_BOUND;
        }
}

下一个测试做的完全一样,除了检查右上角的触摸,并把玩家移到右边。


        else if (engine->touchX > 0.85f && engine->touchY < 0.2f)
        {
                engine->playerX += 0.015f;
                if (engine->playerX > PADDLE_RIGHT_BOUND)
                {
                        engine->playerX = PADDLE_RIGHT_BOUND;
                }
        }
}

下一部分更新球的位置。

第一条线以水*速度水*移动球。


engine->ballX += engine->ballVelocityX;

如果球移出屏幕的左侧或右侧,该测试将反转球的行进方向。


if (engine->ballX < BALL_LEFT_BOUND || engine->ballX > BALL_RIGHT_BOUND)
{
        engine->ballVelocityX = -engine->ballVelocityX;
}

这段代码做了同样的事情,但是是针对垂直方向的,并且只针对屏幕的顶部进行测试。


engine->ballY += engine->ballVelocityY;
if (engine->ballY > BALL_TOP_BOUND)
{
        engine->ballVelocityY = -engine->ballVelocityY;
}

这个代码检查玩家是否允许球落在屏幕的底部。如果球离开了底部,我们将球重置到它的起始位置,确保它在屏幕上向上移动,并重新启用所有的块。


if (engine->ballY < BALL_BOTTOM_BOUND)
{
        // reset the ball
        if (engine->ballVelocityY < 0.0f)
        {
                engine->ballVelocityY = -engine->ballVelocityY;
        }

        engine->ballX = BALL_START_X;
        engine->ballY = BALL_START_Y;

        engine_init_blocks(engine);
}

下一段代码是通过对两个矩形进行重叠测试来检查玩家是否成功击球。

首先我们得到球的 x 和 y 坐标加上当前速度。这允许我们确定下一帧是否会有碰撞,并允许我们做出相应的反应。


float ballXPlusVelocity = engine->ballX + engine->ballVelocityX;
float ballYPlusVelocity = engine->ballY + engine->ballVelocityY;

然后我们计算球的边界矩形的边缘位置。


const float ballLeft = ballXPlusVelocity - BALL_HALF_WIDTH;
const float ballRight = ballXPlusVelocity + BALL_HALF_WIDTH;
const float ballTop = ballYPlusVelocity + BALL_HALF_HEIGHT;
const float ballBottom = ballYPlusVelocity - BALL_HALF_HEIGHT;

对桨做同样的操作:


const float paddleLeft = engine->playerX - PADDLE_HALF_WIDTH;
const float paddleRight = engine->playerX + PADDLE_HALF_WIDTH;
const float paddleTop = engine->playerY + PADDLE_HALF_HEIGHT;
const float paddleBottom = engine->playerY - PADDLE_HALF_HEIGHT;

然后,我们使用 if 测试来确定两者是否重叠。测试的简单英语示例如下:

  • 如果球的右边缘在球拍左边缘的左边,那么我们没有重叠。
  • 或者如果球的左边缘比球拍的右边缘更靠右,那么我们没有重叠。
  • 或者,如果球的底部边缘高于桨的顶部边缘,那么我们没有重叠。
  • 或者,如果球的顶部边缘低于桨的底部边缘,那么我们没有重叠。
  • 如果这些测试都不是真的,那么我们就重叠了。

if (!((ballRight < paddleLeft) ||
                (ballLeft > paddleRight) ||
                (ballBottom > paddleTop) ||
                (ballTop < paddleBottom)))
{
        if (engine->ballVelocityY < 0.0f)
        {
                engine->ballVelocityY = -engine->ballVelocityY;
        }
}

这种重叠矩形算法可能会非常混乱,虽然此时的插图可能有助于澄清这一点,但我建议坐下来用笔和纸或剪下两个矩形,并在不同的场景中工作,直到它有意义为止。你也可以编辑代码来降低球的速度,并尝试解决运行游戏的机制。在游戏开发生涯中,对碰撞和可视化几何的牢固掌握将会派上用场。

然后我们循环所有的方块,并分别在球和每个方块之间进行相同的测试。

第一个bool用于跟踪我们是否还有剩余的块。我们最初将其设置为false


bool anyBlockActive = false;

然后我们循环遍历这些块。


for (int32_t i=0; i<NUM_BLOCKS; ++i)
{
        block& currentBlock = engine->blocks[i];

我们检查该块是否仍处于活动状态:


if (currentBlock.isActive)
{

然后计算矩形的边界边缘


const float blockLeft = currentBlock.x - BLOCK_HALF_WIDTH;
const float blockRight = currentBlock.x + BLOCK_HALF_WIDTH;
const float blockTop = currentBlock.y + BLOCK_HALF_HEIGHT;
const float blockBottom = currentBlock.y - BLOCK_HALF_HEIGHT;

而如果球和块重叠。


if (!((ballRight < blockLeft) ||
                (ballLeft > blockRight) ||
                (ballTop < blockBottom) ||
                (ballBottom > blockTop)))
{

我们反转球的垂直方向。


engine->ballVelocityY = -engine->ballVelocityY;

这个测试决定了球是从左边还是右边击中了木块。如果球的左边缘比木块的左边缘更靠左,那么球一定是从左侧来的。我们可以算出,在类似的情况下,球是否是从右边击中的。


if (ballLeft < blockLeft ||
                ballRight > blockRight)
{

如果球从侧面击出,我们逆转它的水*速度。


        engine->ballVelocityX = -engine->ballVelocityX;
}

我们将该块设置为非活动状态。


        currentBlock.isActive = false;
}

如果该块在这一帧是活动的,我们将 anyBlockActive设置为true


                anyBlockActive = true;
        }
}

一旦所有的积木都被破坏了,我们就重置它们,继续游戏。


if (!anyBlockActive)
{
        engine_init_blocks(engine);
}

摘要

恭喜你:现在,你可以设置、构建和运行你的第一个 Android NDK 游戏应用了。它可能缺少许多专业头衔的精致特征,但它仍然涵盖了所有的基本要素。我们已经初始化了图形库,轮询了 Android 事件,处理了输入,并创建了一个游戏循环来逐帧更新和呈现游戏状态。

现在我们可以从头开始构建一个商业质量的游戏。

三、新手游戏设计:Droid Runner

开发一个视频游戏通常是一群人的合作努力。通常有艺术家、设计师、程序员和制作人员从游戏开发周期的开始一直参与到结束。也有可能你会把你的想法推销给第三方,可能是*台持有者或发布商,为你的作品获得资金或营销支持。

在所有这些情况下,在员工之间保持良好的沟通是至关重要的,以确保你的作品按计划制作。在开发的初始阶段,这种交流的中心焦点是游戏设计文档。

由于文档在游戏开发中是如此重要的一个支柱,所以在我们开始写代码之前,我们先来看看如何写我们自己的文档。

设计文档介绍

设计文档有几种不同的用途。首先,它们包含了游戏的功能规范。这个功能规范从用户的角度详细描述了游戏世界、机制和游戏系统。它有助于确定游戏将如何进行,以及游戏的不同部分如何结合起来创造用户体验。

设计文件的第二个目的是技术规格。技术设计部分将更详细地描述游戏的某些方面将如何实现。这在实现游戏时会很有用,因为它可以提供不同系统如何相互接口的高级概述。至少有一个粗略的规格来帮助安排开发也是很重要的。如果你在时间和预算有限的商业环境中开发游戏,尝试创建一个准确的时间表是至关重要的。

包含设计不同方面的多个文档并不少见,但是对于我们的小游戏来说,一个文档就足够了。第一个必需的部分是概述。

创造一个世界,讲述一个故事,并设置场景

每个游戏都需要讲一个故事。这个故事,无论多么详细,都有助于在玩家内部创造一种紧迫感和共鸣感,并可以将一系列机制转化为引人入胜的体验。即使是最早成功的游戏也设法讲述了一个故事:大金刚由任天堂于 1981 年发行,讲述了飞人试图从巨猿手中救出公主的故事。吃豆人的故事是关于玩家和人工智能之间的关系。四个鬼魂中的每一个都试图以自己的方式抓住吃豆人,直到玩家收集到一个能量球,桌子被翻转,然后鬼魂从吃豆人那里逃跑。讲故事的力量显而易见,开发者甚至给鬼魂起了独特的名字:Blinky、Pinky、inky 和 Clyde。

随着游戏技术的进步,现代游戏变得越来越受故事驱动。家用游戏机和电脑游戏现在通常是由参与好莱坞电影的作家编写的。大型游戏发行商已经以类似的方式编写和开发了面向 Android 等移动*台的游戏,虽然这超出了大多数小型开发者的能力,但故事感和旅程感仍然很重要。

我们的背景故事将在概览中介绍,我们真的不需要为我们的简单游戏添加任何内容。我们应该做的是牢记我们的故事,并确保我们添加到游戏中的一切都符合我们希望描绘的狭隘主题。对于我们的游戏来说,这是一个试图逃离一个我们被囚禁并且没有权力的地方的主题。

Droid Runner 设计概述

在接下来的章节中,我们将介绍游戏设计文档的不同部分。这个例子涵盖了我们向他人完整描述我们的游戏所需的最少部分。在设计游戏时,没有一套硬性的规则可以遵循。这有一定的意义,因为每个游戏都是不同的,没有两个文档可能包含相同的信息并描述完全不同的游戏设计。我们将从游戏概述开始。

第一部分-游戏概述

Droid Runner 是一款 侧滚游戏,玩家自动在屏幕上从左向右移动。游戏中的主角是 Droid,一个绿色的机器人,他正试图逃离一个被利用为工具的环境。在周围巡逻的红色安全机器人会阻止机器人离开,如果他们设法抓住他的话。环境包含不同的障碍,Droid 必须克服这些障碍才能到达出口。

上面的简短概述为机器人信使设定了基本场景。它有助于展示谁是玩家,以及谁是试图阻止玩家实现其目标的对手。由于我们正在使用一个新的*台创建我们的第一个游戏,这是一个我们将致力于创建的游戏的充分概述。接下来的部分将涵盖游戏的细节。

定义游戏和机制

设计文档的游戏性部分应该包括玩家在游戏中将要执行的动作的描述。这一部分分为游戏结构的高层次概述和将用于创建高层次体验的游戏机制的更详细分析。更复杂的游戏会有游戏发生的不同关卡的描述,对于带有角色扮演元素的游戏,技能系统的描述也会在这里找到。

第二部分-游戏性和机制

第 2.1 节-游戏性

一个关卡将从左向右前进,没有垂直移动。随着镜头的移动,玩家将会接触到敌人的角色以及他必须避开的障碍物。游戏的核心乐趣体验将通过设计关卡来创建,这些关卡以一种向玩家呈现挑战的方式来放置敌人和障碍,随着玩家在关卡中的前进,挑战的难度会增加。

玩家将通过到达关卡最右端的目标区域来完成关卡。

游戏结束场景 将由玩家接触到障碍物或敌方机器人而触发。

第 2.2 节-力学

第 2.2.1 节-运动

玩家会自动从左向右移动。玩家移动的速度将被调整到一个合适的速度,以确保挑战是通过障碍和敌人的定位来呈现的,这既不太容易也不太困难。

玩家可以在代表关卡高度 33%的高度跳跃。向上和向下的速度将是对称的,以便提供一致的和可预测的跳跃行为。落地跳跃和开始另一个跳跃之间不会有延迟,因为游戏依赖于反应灵敏的控制和时机来创造紧张感和乐趣。跳跃的速度应该在最高点附*减慢,以创造一个漂浮的区域,让玩家有能力先发制人地跳过障碍,并利用时间优势。

第 2.2.2 节-障碍

板条箱——这一关将包含堆叠的板条箱,玩家必须跳过去或跳到上面。板条箱将是方形的,障碍将通过并排放置板条箱或一个放在另一个上面来创建。所有板条箱将具有相等的尺寸,边长等于屏幕高度的 25%。这将使玩家能够轻松地跳过 33%高度的箱子。

敌人——这一关将包含敌人的机器人,它们将在两点之间沿着设定的路径前进。这些路径将是线性的,无论是垂直的还是水*的。水*路径不应覆盖 720p 屏幕可视宽度的 75%。垂直路径可以覆盖标高的整个高度。敌人将以比玩家水*速度稍慢的速度移动。这将允许玩家移动过去的敌人,只关心来自右边的新敌人。

第 2.2.3 节-皮卡

玩家将能够获得一个无敌皮卡,让他们能够 穿过障碍和敌方玩家,而不会触发游戏结束的场景。

关卡设计

许多游戏新手都在寻找一套关于如何构建关卡的完美规则。从我的经验来看,这样的一套规则是不存在的。如果这样的一套规则确实存在,我们可能会在不同游戏的水*变得非常相似的情况下结束,因为它们是根据相同的公式设计的,但那些水*可能不适合正在开发的游戏。这就是为什么我觉得关卡设计没有严格的规则的症结所在:每一个游戏都力求有稍微不同的机制,因此也有稍微不同的设计考虑。在为游戏设计关卡时,可以考虑一些关卡设计的原则。

我认为关卡设计的首要原则是速度。当构建一个跨越多个关卡的完整游戏时,重要的是要考虑你多久引入一次新的游戏机制,然后在这个世界中使用这些机制。

《塞尔达传说》游戏提供了一个经典的使用节奏提升游戏难度的蓝图。一种新的武器被引入,将会有一个小任务需要完成,由武器提供的新能力来指导玩家如何使用它。接下来将会有一场 boss 遭遇战,这需要新的能力来完成地下城并在故事中前进。玩家将能够进入外部世界的新地图区域,这些区域以前是无法进入的,而且通常比以前进入的区域更困难。

游戏还可以使用强度的节奏来吸引玩家。持续一段时间的高强度水*可能会导致球员变得紧张。如果这种压力被视为困难,那么许多玩家会感到不知所措,并决定停止玩游戏。你应该争取的是节奏的变化,以创造一种兴趣感。玩家在高强度、高参与度的阶段之后应该保持相对的*静。这将会给球员一个机会恢复和重新获得一些冷静。在开始时,游戏可以有较长时间的*静和短时间的高强度爆发,而一旦玩家有经验并需要更高水*的挑战,游戏就会接*尾声。

我们将尝试使用障碍的复杂性和 AI 角色在我们关卡中的位置,在玩家需要快速连续点击屏幕以清除区域和低水*输入的时间段之间交替,这将表示*静。接*关卡末尾时,相对*静的时段可能与关卡早期的高强度区域一样强烈,但是玩家应该更有经验,因此在接*关卡末尾时这些区域的关卡压力会更小。

美学

关卡的美感对于向玩家传达设定和主题是至关重要的。如果一个游戏的一个区域让玩家感觉强烈,这取决于游戏的类型,如果处理得当,美学将有助于传达这种强烈的感觉。

美学也可以用来带领玩家通过一个关卡。一般来说,设计师希望玩家通过关卡的路径会在关键点使用明亮的灯光照亮。如果你发现自己迷失在第一人称射击游戏中,比如光晕 4 ,花一点时间站着不动,环顾四周寻找任何路径开口或门,看起来它们可能被照亮得更亮或与其他颜色不同;很有可能这是你应该走的路。将秘密放置在较暗的区域也使得它们不太可能被下意识地沿着指引的路径前进的玩家发现,因此值得奖励给那些探索人迹罕至的区域的玩家。

规模

关卡的等级对于决定建造时间是很重要的。每个人都想要一个没有限制范围的游戏。像天际这样的 RPG 以其规模而闻名。规模不是免费的,你游戏的范围将与你关卡的规模紧密相连,反之亦然。

更大的级别通常需要大量的游戏机制来确保它们保持引人入胜。如果要求玩家一遍又一遍地重复完全相同的挑战,大型关卡会很快变得重复和乏味。对于可用机制的数量来说也很小的等级也可能意味着玩家可能无法使用他们被提供的一些最引人注目的功能,从而损害了游戏的感知质量。

关卡的规模也受到游戏目标硬件的影响。一个电脑游戏可以使用几千兆字节的内存,并且可以一次在内存中存储大量的数据。另一方面,Playstation 3 只有 256MB 的系统内存,因此只能存储更小级别的数据和其中包含的对象。诸如此类的问题将依赖于我们在下一节中看到的技术需求。

技术要求

技术需求文档详细说明了将用于创建游戏的底层系统的工程规格。该文档很少包含实现细节,而是给出了这样一个系统应该如何工作、要使用的算法以及允许不同系统通信的接口的概述。

写需求文档有几个重要的原因。这首先是为了团队沟通,这是相关的,即使你正在自己开发一个游戏,与你未来的自己沟通你所设想的系统如何与框架中的其他系统集成。

另一个是用于调度。有了经验,您将开始了解在编写需求文档时,实现一个给定的系统需要付出多少努力。这反过来将导致更好的预算和计划,这是构建商业上成功的游戏的一个重要方面。

给定系统的技术需求应该定义系统将向外界公开的接口。花时间设计接口将允许您理解数据将如何流入和流出系统,并将有助于识别任何高耦合和低内聚的区域。一旦开发已经开始,在设计时识别这些属性所在的区域有助于避免代价高昂的重构。

统一建模语言创建于 20 世纪 90 年代,旨在帮助技术作家可视化他们的面向对象设计。已经编写了一些软件来帮助构建你的系统模型,图 3-1 中的例子是用 ArgoUML 创建的。这个设计是为KernelTask系统设计的,我们将在本书稍后使用它们来创建我们的游戏循环。

9781430258308_Fig03-01.jpg

图 3-1 。核心 UML 类图

技术文档可以包含您认为必要的或多或少的文档;但是,需要注意的是,人们普遍认为,花在规划上的时间越多,花在实施上的时间就越少。实现系统通常是软件开发中花费最多时间和金钱的过程,所以任何有助于缩短这个周期的东西都是受欢迎的。在开始任何开发之前,没有必要编写所有的文档。使用像敏捷这样的开发方法意味着文档会随着你的进展而不断充实;然而,提前计划一些工作仍然是一个好主意,以确保系统接口对于手头的工作是足够的。

编写好的文档需要经验,在这一点上,我们对 Android 系统没有什么经验。对于你来说,为我们在接下来的章节中将要实现的系统写一些技术文档是一个很好的练习。撰写技术文档可能是一个令人生畏的前景和一项艰巨的任务。网站上提供了一些示例文档,以及本书中使用的示例的源代码。

摘要

本章介绍了通常称为生产前阶段的设计文档。这是开发阶段,原型被创建,头脑风暴会议发生,游戏的外观和感觉被研究出来。

我们已经看到将设计分成两个独立的部分是有用的;一个涵盖游戏性、逻辑和故事,另一个涵盖将用于创建远景的技术。这本书的其余部分将着眼于这两个部分同时向前发展。我们将从头开始构建游戏,首先创建一个游戏循环,与 Android 操作系统通信,然后初始化 OpenGL,然后从第二部分开始学习更多以游戏逻辑为中心的代码。

我们将在下一章开始着手创建一个基于任务的游戏循环,并将 Android 本地应用 glue 事件轮询封装到一个任务中。

四、构建游戏引擎

现代游戏公司的生产力主要是由从一个项目到下一个项目的代码和工具的重用驱动的。通过重用代码,公司和个人可以腾出更多的时间来开发实际的游戏,而不是重新实现技术,虽然这是必要的,但不会对成品产生明显的影响。

我们在第二章中看到的示例游戏没有任何可以以简单的方式从一个项目重用到下一个项目的代码。对于一个小例子来说,这可能是可以接受的,但是对于那些想制作不止一个游戏或者希望在游戏开发领域创业的人来说,这种方法并不是特别有益。

我们将在本章开始开发我们自己的游戏引擎。引擎本身将远离商业引擎(如 Unreal 或 Unity)的复杂性,但它将帮助我们理解为什么引擎是一个好主意,以及我们如何才能实现游戏代码和框架代码的分离。我们将从一个可重复使用的游戏循环开始,与 Android 操作系统通信,并学习如何为我们的帧计时。

让我们从查看我们的应用对象开始。

创建应用对象

面向对象设计的第一课是创建代表应用设计中名词的类。我们在为 Android 开发游戏时遇到的第一个名词是单词 app 。对我们来说,创建一个类来封装我们的应用是有意义的,这也是我们编写的第一个类,它是一个可重用的对象;这意味着类本身不应该包含任何特定于您正在构建的应用的代码。幸运的是,C++ 通过继承为我们提供了一种机制,这不是问题,但是开始时我们只看创建类本身,如清单 4-1 所示。

清单 4-1。 应用类:Application.h


namespace Framework
{
       class Application
       {
       private:

       public:
              Application(android_app* state);
              virtualApplication();

              bool       Initialize();
              void       Run();
       };
}

这是应用的类定义。目前,这段代码没有什么特别有趣的地方,但是随着我们的发展,我们会向应用添加对象。你可以认为Application是你的应用中的根对象。我们将从main中使用它,如清单 4-2 所示。

清单 4-2。 android_main,App 入口点:Chapter4.cpp


void android_main(struct android_app* state)
{
       app_dummy();

       Framework::Application app(state);

       if (app.Initialize())
       {
              app.Run();
       }
}

这里你可以看到main的内容与我们之前创建的基础游戏相比相对简单。这很好,因为我们已经设法创建了一个接口,它将允许我们从这个级别隐藏许多更复杂的操作,并且应该给我们更容易阅读和使用的代码。

InitializeRun方法的定义目前同样是基本的和空的,正如你在清单 4-3 中看到的。

清单 4-3。 应用的初始化和运行方法:Application.h


bool Application::Initialize()
{
       bool ret = true;
       return ret;
}

void Application::Run()
{
}

所有的实时游戏都在所谓的游戏循环 中运行。在下一节中,我们将看到一个对象,我们将使用它来创建这个循环。

使用内核和任务创建游戏循环

封装我们游戏循环的对象叫做内核。这个对象的基本设计是由 Richard Fine 在他的 Enginuity 系列中提出的,可以在www.gamedev.net找到。内核通过维护一个任务列表来工作。任务按优先级顺序添加到列表中,内核按顺序更新任务,每帧更新一次。

启动内核类

同样,Kernel类已经在Framework名称空间中声明,但是为了简洁起见,我将从文本中省略这一行;你可以在清单 4-4 中看到重要的代码。您可以看到这些类是如何编写的,以及它们相关的 includes 等。,在本章的示例代码中。

清单 4-4。 内核类:Kernel.h


class Kernel
{
       private:
              typedef std::list<Task*>                 TaskList;
              typedef std::list<Task*>::iterator       TaskListIterator;

              TaskList       m_tasks;
              TaskList       m_pausedTasks;

              void              PriorityAdd(Task* pTask);

       public:
              Kernel();
              virtualKernel();

              void       Execute();

              bool        AddTask(Task* pTask);
              void       SuspendTask(Task* task);
              void       ResumeTask(Task* task);
              void       RemoveTask(Task* task);
              void       KillAllTasks();

              bool       HasTasks()       { return m_tasks.size(); }
};

Kernel类的定义相当简单明了。正如我前面提到的,我们有一个包含指向Task对象的指针的列表。我们还声明了公共方法,允许我们添加和删除以及暂停和恢复单个任务。还有一个KillAllTasks方法,它允许我们杀死所有当前的任务,我们还可以使用HasTasks方法检查内核是否有任何当前正在运行的任务。

还有一个我们之前没有讨论过的成员,那就是暂停任务列表(m_pausedTasks)。当我们开始研究SuspendTaskResumeTask方法时,我们将会看到它的用途。

定义任务界面

首先我们来看看Task接口,如清单 4-5 所示。

清单 4-5。 任务界面:Task.h


class Task
{
private:
       unsigned int       m_priority;
       bool               m_canKill;

public:
       explicit Task(const unsigned int priority);
       virtualTask();

       virtual bool       Start()              = 0;
       virtual void       OnSuspend()          = 0;
       virtual void       Update()             = 0;
       virtual void       OnResume()           = 0;
       virtual void       Stop()               = 0;

       void               SetCanKill(const bool canKill);
       bool              CanKill() const;
       unsigned int       Priority() const;
};

这个接口是所有未来的Task类将继承的基类。我们可以看到每个Task都会有一个优先级和一个标志来告诉它是否可以被杀死(m_canKill)。

纯虚拟方法也是内核用来与Task交互的接口。Task的每个子方法都将覆盖这些方法,为给定的Task提供特定的功能。当我们实现实际的Task时,我们会更详细地看这些,但是现在我们可以看一下Kernel的方法,看看它是如何使用Task接口的。

检查内核方法

清单 4-6 显示了PriorityAddAddTask方法。在PriorityAdd中,我们得到一个任务列表的迭代器,循环遍历列表,直到当前任务的优先级大于新任务的优先级。这意味着零将是我们系统中的最高优先级,因为Task::m_priority字段是无符号的。然后,任务会在该点插入到列表中。如您所见,这意味着我们的优先级决定了任务更新的顺序。

我们可以看到,Kernel::AddTask在第一行调用了Task::Start。这很重要,因为这意味着只有当任务成功启动时,我们才会向内核添加任务。如果任务开始了,我们叫PriorityAdd

清单 4-6。 内核的优先级添加和添加任务 : Kernel.cpp


void Kernel::PriorityAdd(Task* pTask)
{
       TaskListIterator iter;
       for (iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
       {
              Task* pCurrentTask = (*iter);
              if (pCurrentTask->Priority() > pTask->Priority())
              {
                     break;
              }
       }
       m_tasks.insert(iter, pTask);
}

bool Kernel::AddTask(Task* pTask)
{
       bool started = pTask->Start();

       if (started)
       {
              PriorityAdd(pTask);
       }
       return started;
}

RemoveTask 直截了当;我们在列表中找到任务,并将其设置为可杀,如清单 4-7 所示。

清单 4-7。内核的 RemoveTask: Kernel.cpp


void Kernel::RemoveTask(Task* pTask)
{
       if (std::find(m_tasks.begin(), m_tasks.end(), pTask) != m_tasks.end())
       {
              pTask->SetCanKill(true);
       }
}

SuspendTask 找到当前正在运行的任务,并对该任务调用OnSuspend。然后,它会从“正在运行的任务”列表中删除该任务,并将其添加到“暂停的任务”列表中。清单 4-8 展示了SuspendTask方法。

清单 4-8。 内核的挂起任务:Kernel.cpp


void Kernel::SuspendTask(Task* pTask)
{
       if (std::find(m_tasks.begin(), m_tasks.end(), pTask) != m_tasks.end())
       {
              pTask->OnSuspend();
              m_tasks.remove(pTask);
              m_pausedTasks.push_back(pTask);
       }
}

ResumeTask 检查任务当前是否暂停(见清单 4-9 )。接下来,它调用Task::OnResume,将其从暂停列表中移除,然后以正确的优先级将任务添加回运行列表中。

清单 4-9。 内核的 ResumeTask: Kernel.cpp


void Kernel::ResumeTask(Task* pTask)
{
       if (std::find(m_pausedTasks.begin(), m_pausedTasks.end(), pTask) != m_pausedTasks.end())
       {
              pTask->OnResume();
              m_pausedTasks.remove(pTask);

              PriorityAdd(pTask);
       }
}

KillAllTasks 是另一种直截了当的方法(见清单 4-10 )。它简单地循环所有正在运行的任务,并将它们的 can-kill 标志设置为true

清单 4-10。 内核的 KillAllTasks: Kernel.cpp


void Kernel::KillAllTasks()
{
       for (TaskListIterator iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
       {
              (*iter)->SetCanKill(true);
       }
}

Execute 方法是我们游戏循环的所在,如清单 4-11 所示。这个方法循环遍历任务,并对每个任务调用Task::Update

清单 4-11。 内核的执行,游戏循环:Kernel.cpp


void Kernel::Execute()
{
       while (m_tasks.size())
           {
                  if (Android::IsClosing())
               {
                          KillAllTasks();
               }

              TaskListIterator iter;
                  for (iter = m_tasks.begin(); iter != m_tasks.end(); ++iter)
                  {
                         Task* pTask = (*iter);
                         if (!pTask->CanKill())
                         {
                                pTask->Update();
                         }
                  }

                  for (iter = m_tasks.begin(); iter != m_tasks.end();)
                  {
                         Task* pTask = (*iter);
                         ++iter;
                         if (pTask->CanKill())
                         {
                                pTask->Stop();
                              m_tasks.remove(pTask);
                              pTask = 0;
                         }
                  }
           }

       Android::ClearClosing();
}

这里我们可以看到,只要有任务要执行,Execute就会在while循环中运行。

如果系统正在关闭应用,我们会调用KillAllTasks来通知他们游戏即将关闭。Execute然后遍历任务列表,并对任何没有被标记为销毁的任务调用Task::Update。这很重要,因为我们不能保证任何预期要删除的任务仍然有有效的数据。运行第二个循环以从正在运行的循环中移除被标记为销毁的任何任务。

此时,您会注意到对名为Android的类的引用。这个类用于轮询 Android 事件系统;我们将在下一节讨论这个问题。

安卓的原生应用 Glue

Android NDK 提供了一个框架,该框架提供了一个到操作系统的接口,而不需要使用 Java 编程语言实现基本的应用结构。这个接口就是NativeActivity。尽管如此,程序员仍然需要实现大量的粘合代码来将来自NativeActivity的生命周期更新转换成他们自己的应用中可用的格式。幸运的是,Android NDK 开发者也在其原生应用 Glue 代码中提供了这一层。

首先,这个粘合代码为我们提供了一个访问 Android 应用生命周期的接口,这将是本节的重点。在后面的章节中,我们还会看到这个框架提供的其他接口,比如输入和传感器信息。

Android类将需要每帧更新一次,以从 Android 操作系统获取最新事件。这使得它成为我们首要任务的完美候选;关于Android类的详细信息,参见清单 4-12 。

清单 4-12。 一个安卓任务:Android.h


class Android
       :       public Task
{
private:
       static bool       m_bClosing;
       static bool       m_bPaused;
       android_app*       m_pState;

public:
       Android(android_app* pState, const unsigned int priority);
       virtualAndroid();

       android_app*       GetAppState() { return m_pState; }

       virtual bool       Start();
       virtual void        OnSuspend();
       virtual void        Update();
       virtual void        OnResume();
       virtual void        Stop();

       static void ClearClosing()                     { m_bClosing = false; }
       static bool IsClosing()                            { return m_bClosing; }
       static void SetPaused(const bool paused)       { m_bPaused = paused; }
       static bool IsPaused()                             { return m_bPaused; }
};

这里我们可以看到Android类继承自Task。为了方便起见,我们在结束和暂停标志中使用了静态变量。内核需要知道应用是否正在关闭,但是它不一定需要访问Android对象来这样做。我们还覆盖了Task中的方法,现在我们来看看这些方法。

OnSuspendOnResumeStop都是空方法。目前,我们不需要在其中加入任何东西。同样,Start除了返回true什么也不做。我们不需要运行任何初始化代码来允许 Android 系统执行,所以没有必要阻止我们的任务被添加到内核的运行列表中。剩下的是Update,如清单 4-13 中的所示。

清单 4-13。 安卓的更新:Android.cpp


void Android::Update()
{
       int events;
       struct android_poll_source* pSource;
       int ident = ALooper_pollAll(0, 0, &events, (void**)&pSource);
       if (ident >= 0)
       {
              if (pSource)
              {
                     pSource->process(m_pState, pSource);
              }

              if (m_pState->destroyRequested)
              {
                     m_bClosing = true;
              }
       }
}

Update方法相当简单。调用ALooper_pollAll方法,并从 Android 操作系统中检索我们的应用的任何当前事件。

  • 传递的第一个参数是超时值。因为我们是在实时循环中运行,所以我们不希望这个调用被阻塞的时间超过一定的时间。我们通过将零作为第一个参数来告诉该方法立即返回,而不等待事件。
  • 在某些情况下,第二个参数可以用来获取指向文件描述符的指针。我们不关心这个,过零。
  • 我们对第三个参数也不感兴趣,但是我们需要传递一个int的地址来检索它的值。
  • 第四个参数检索事件的源结构。我们在这个结构上调用process,并为我们的应用传递状态对象。

我们可以看看状态对象现在是在哪里初始化的,如清单 4-14 所示。

清单 4-14。 Android 的构造器和事件处理器:Android.cpp


static void android_handle_cmd(struct android_app* app, int32_t cmd)
{
       switch (cmd)
       {
       case APP_CMD_RESUME:
              {
                     Android::SetPaused(false);
              }
              break;

       case APP_CMD_PAUSE:
              {
                     Android::SetPaused(true);
              }
              break;
       }
}

Android::Android(android_app* pState, unsigned int priority)
       :       Task(priority)
{
       m_pState = pState;
       m_pState->onAppCmd = android_handle_cmd;
}

Android 提供了处理系统事件的回调机制。回调签名返回void,并被传递一个android_app结构指针和一个包含要处理的事件值的整数。对于 Win32 程序员来说,这种设置与 WndProc 并没有什么不同。

我们还不需要处理任何 Android OS 事件,但是为了举例,我添加了一个处理APP_CMD_RESUMEAPP_CMD_PAUSE事件的switch语句。

我们可以从 Android 构造函数中看到,我们已经存储了提供给我们的android_app指针,并将onAppCmd函数指针设置为静态命令处理程序方法的地址。这个方法将在ALooper_pollAll提供给我们的事件结构上的process调用期间被调用。

关于Android类剩下要做的唯一一件事就是实例化一个实例并将其添加到我们的内核中,如清单 4-15 所示。

清单 4-15。 实例化 Android 任务:Application.h、Task.h、Application.cpp


class Application
{
private:
       Kernel       m_kernel;
       Android       m_androidTask;

public:
       Application(android_app* state);
       virtualApplication();

       bool       Initialize();
       void       Run();
};

class Task
{
private:
       unsigned int       m_priority;
       bool               m_canKill;

public:
       explicit Task(const unsigned int priority);
       virtualTask();

       virtual bool       Start()              = 0;
       virtual void       OnSuspend()          = 0;
       virtual void       Update()             = 0;
       virtual void       OnResume()           = 0;
       virtual void       Stop()               = 0;

       void               SetCanKill(const bool canKill);
       bool              CanKill() const;
       unsigned int       Priority() const;

       static const unsigned int PLATFORM_PRIORITY = 1000;
};

Application::Application(android_app* state)
       :       m_androidTask(state, Task::PLATFORM_PRIORITY)
{
}

bool Application::Initialize()
{
       bool ret = true;

       m_kernel.AddTask(&m_androidTask);

       return ret;
}

void Application::Run()
{
       m_kernel.Execute();
}

这些是实例化Android对象并将其添加到内核所需的更改。您可以看到我们将KernelAndroid对象作为私有成员添加到了应用中。在Application构造函数初始化列表中调用Android构造函数,并向其传递android_app结构及其优先级。在Initialize方法中将Android对象添加到内核中,我们在Application::Run中调用Kernel::Execute

我们现在有了一个可以与 Android 操作系统正常连接的游戏循环。在我们能够开始编写游戏代码之前,我们还有许多底层的准备工作要做。接下来是帧时序。

计时

多年来,游戏中一个更重要的基准是 fps 或每秒帧数。对于目前这一代游戏主机来说,30fps 已经是大多数游戏达到的标准。id 等少数公司仍以 60fps 为目标,并通过减少延迟来提高响应速度。

无论你希望达到什么样的帧率,游戏中的计时都是很重要的。准确的帧时间对于在游戏中以一致的方式移动物体是必要的。20 世纪 90 年代初,在 SNES 和世嘉创世纪上,游戏在不同国家以不同速度运行是很常见的。这是因为游戏中的角色每帧以一致的速度移动。问题是电视在欧洲以每秒 50 次的速度更新,但在北美却是每秒 60 次。结果是欧洲玩家的游戏速度明显变慢了。

如果开发者根据时间而不是帧速率来更新他们的游戏,这种情况完全可以避免。我们通过存储处理最后一帧花了多长时间,并相对于该时间移动对象来实现这一点。我们将认为这已经在第六章中完成了,但是现在我们将看看如何存储前一帧的时间,如清单 4-16 所示。

清单 4-16。??【定时器任务:Timer.h


class Timer
       :       public Task
{
public:
       typedef long long       TimeUnits;

private:
       TimeUnits nanoTime();

       TimeUnits          m_timeLastFrame;
       float              m_frameDt;
       float              m_simDt;
       float              m_simMultiplier;

public:
       Timer(const unsigned int priority);
       ∼Timer();

       float              GetTimeFrame() const;
       float              GetTimeSim() const;
       void               SetSimMultiplier(const float simMultiplier);

       virtual bool        Start();
       virtual void        OnSuspend();
       virtual void        Update();
       virtual void        OnResume();
       virtual void        Stop();
};

不出所料,我们使用一个Task来更新每一帧的TimerTimer将被赋予零优先级,并且将是每帧中第一个被更新的任务。

在我们的Timer中,我们有两种时间概念。我们有帧时间,这是完成最后一帧的实际时间,我们有模拟时间。模拟时间是我们将在代码的游戏性部分使用的时间。sim 卡时间将被乘数修改。这个乘数将允许我们修改游戏更新的速度。我们也许可以将它用于游戏目的,但是它也可以用于调试目的。如果我们有一个 bug 要重现,它发生在一系列事件的末尾,我们可以通过增加时间乘数来加快重现过程,让游戏中的所有事情发生得更快。清单 4-17 显示了计算当前系统时间的方法。

清单 4-17。 定时器,nanoTime: Timer.cpp


Timer::TimeUnits Timer::nanoTime()
{
       timespec now;
       int err = clock_gettime(CLOCK_MONOTONIC, &now);
       return now.tv_sec*1000000000L + now.tv_nsec;
}

由于 Android 是基于 Linux 的操作系统,我们可以从 C++ 环境中访问许多 Linux 方法。一个例子就是clock_gettimeclock_gettime包含在time.h头文件中,它为我们提供了一个到我们正在运行的计算机中的系统时钟的接口。我们特别使用单调时钟,它给出了自过去事件以来的任意时间。我们不知道那个事件是什么时候,但是因为我们正在比较我们自己的帧时间,所以我们并不过度担心。

timespec结构包含两个成员:

  • 第一个是以秒为单位的时间;
  • 第二个是纳秒。

我们返回的值以纳秒为单位;我们将秒乘以 1,000,000,000,转换成纳秒,然后加上纳秒值。

Timer任务的初始值设置在Start中,如清单 4-18 所示。

清单 4-18。 启动和重启定时器:Timer.cpp


bool Timer::Start()
{
       m_timeLastFrame = nanoTime();
       return true;
}

void Timer::OnResume()
{
       m_timeLastFrame = nanoTime();
}

在这里设置初始值是必要的,因为我们总是需要一个先前的值来比较。如果我们在启动计时器时没有初始化这个值,我们的初始帧时间将完全不可靠。通过初始化前面的时间,我们将最坏的情况限制在初始帧时间为零,这并不是灾难性的。我们在OnResume中也做了同样的事情,尽管我无法想象在正常运行的情况下计时器会暂停。

计时器类的最后一个重要方法是Update,如清单 4-19 中的所示。

清单 4-19。 定时器的更新 : Timer.cpp


void Timer::Update()
{
       // Get the delta between the last frame and this
       TimeUnits currentTime = nanoTime();
       const float MULTIPLIER = 0.000000001f;
       m_frameDt = (currentTime-m_timeLastFrame) * MULTIPLIER;
       m_timeLastFrame = currentTime;
       m_simDt = m_frameDt * m_simMultiplier;
}

在这种方法中,您可以看到我们正在获取上一帧和当前帧之间的时间增量。我们从从系统时钟获取最新的nanoTime值开始。然后我们通过从当前的nanoTime中减去先前的nanoTime来计算帧时间。在这一步中,我们还将时间转换成一个浮点数,其中包含以秒为单位的帧时间。这种格式是在游戏代码中处理时间的最简单的方式,因为我们可以用每秒的数值来处理所有的移动速度,并将时间作为乘数。

然后将currentTime存储到最后一个帧时间成员中,用于计算下一个处理帧中的时间,最后,我们计算 sim 时间,作为帧时间乘以 sim 乘数的结果。

摘要

在这一章中,我们已经为我们的引擎做了很多基础工作。我们已经创建了一个可重用的任务和内核系统,并用一个与操作系统通信的Android任务和一个能够计算出我们的帧需要处理多长时间的Timer任务来填充它。现在这些任务已经写好了,我们希望再也不用写了。这是游戏引擎的开始,我们将在下一章通过查看我们的 OpenGL 渲染器来扩展这个框架。

与本书中的所有章节一样,完整的示例源代码可以从 Apress 网站获得。

五、编写渲染器

游戏引擎执行的关键任务之一是将几何数据馈送到图形处理单元(GPU) 。GPU 是一种高度专业化的硬件,可以并行处理数据流。这种处理的并行化使得 GPU 在现代实时图形应用中至关重要,也是它们被称为硬件加速器的原因。

渲染器的工作是尽可能高效地将几何图形提供给 GPU。在一个游戏引擎中,在处理游戏更新,也就是移动游戏对象,人工智能,物理等方面会有明显的不同。,并渲染场景。

编写一个在 CPU 上运行的软件渲染器是完全可能的,但是在 CPU 处理能力非常宝贵的手机上,这将是一个徒劳的任务。最好的解决方案是使用 GPU。在这一章,我们将看一个基本的渲染器。

使用 EGL 初始化窗口和 OpenGL

这个标题混合了一些新程序员可能不熟悉的首字母缩写词和概念。在过去的几十年里,操作系统一直使用基于窗口的系统,大多数人都知道窗口的概念。然而,您可能会惊讶地看到与移动操作系统相关的窗口概念,移动操作系统通常没有可重新定位的窗口。Android 仍然使用一个窗口系统来描述我们用来访问设备屏幕的抽象对象。

OpenGL 是一个图形库,自 1992 年就已经存在。OpenGL 最初是由 SGI 开发的,目前由 Khronos Group 维护。它是主要用于游戏开发的两个主要图形 API 之一。另一个是 DirectX,由微软开发,是基于 Windows 的操作系统的专属。因此,很长一段时间以来,OpenGL 一直是 Linux 和基于移动设备的操作系统的首选 API。

EGL (嵌入式系统图形库)是由 Khronos 提供的一个库,Khronos 是一个非营利性联盟,控制着几个行业标准 API,如 OpenGL、OpenCL 和许多其他 API。EGL 是一个接口 API,它为开发人员提供了一种在操作系统的窗口体系结构和 OpenGL API 之间进行通信的简单方法。这个库允许我们只用几行代码来初始化和使用 OpenGL,任何在十年或更久以前使用图形 API 开发应用的人都会欣赏它的简洁。

为了开始我们的渲染器,我们将创建一个名为Renderer的新类,它将继承我们在前一章创建的Task类。清单 5-1 中的类定义显示了Renderer接口。

清单 6-1。 渲染器类

class Renderer
       :      public Task
{
private:
       android_app*         m_pState;
       EGLDisplay           m_display;
       EGLContext           m_context;
       EGLSurface           m_surface;
       int                  m_width;
       int                  m_height;
       bool                 m_initialized;

public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtualRenderer();

       void Init();
       void Destroy();

       // From Task
       virtual bool         Start();
       virtual void         OnSuspend();
       virtual void         Update();
       virtual void         OnResume();
       virtual void         Stop();

       bool IsInitialized() { return m_initialized; }
};

通常的Task方法已经就位:我们现在来看看这些方法(参见清单 5-2 )。

清单 6-2。 渲染器的被覆盖任务方法

bool Renderer::Start()
{
       return true;
}

void Renderer::OnSuspend()
{

}

void Renderer::Update()
{

}

void Renderer::OnResume()
{

}

void Renderer::Stop()
{

}

目前,Renderer类没有做太多事情。我们将在阅读本章时填写它。下一个感兴趣的方法是Init(见清单 5-3 )。

清单 6-3。 使用 EGL 初始化 OpenGL

void Renderer::Init()
{
       // initialize OpenGL ES and EGL

       /* Here, specify the attributes of the desired configuration. In the following code, we select an EGLConfig with at least eight bits per color component, compatible with on-screen windows. */
       const EGLint attribs[] =
       {
              EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
              EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
              EGL_BLUE_SIZE, 8,
              EGL_GREEN_SIZE, 8,
              EGL_RED_SIZE, 8,
              EGL_NONE
       };

       EGLint        format;
       EGLint        numConfigs;
       EGLConfig     config;

       m_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);

       eglInitialize(m_display, NULL, NULL);

       /* Here, the application chooses the configuration it desires. In this sample, we have a very simplified selection process, where we pick the first EGLConfig that matches our criteria. */
       eglChooseConfig(m_display, attribs, &config, 1, &numConfigs);

       /* EGL_NATIVE_VISUAL_ID is an attribute of the EGLConfig that is guaranteed to be accepted by ANativeWindow_setBuffersGeometry(). As soon as we pick a EGLConfig, we can safely reconfigure the ANativeWindow buffers to match, using EGL_NATIVE_VISUAL_ID. */
       eglGetConfigAttrib(m_display, config, EGL_NATIVE_VISUAL_ID, &format);

       ANativeWindow_setBuffersGeometry(m_pState->window, 0, 0, format);

       m_surface = eglCreateWindowSurface(m_display, config, m_pState->window, NULL);

       EGLint contextAttribs[] =
       {
              EGL_CONTEXT_CLIENT_VERSION, 2,
              EGL_NONE
       };
       m_context = eglCreateContext(m_display, config, NULL, contextAttribs);

       eglMakeCurrent(m_display, m_surface, m_surface, m_context);

       eglQuerySurface(m_display, m_surface, EGL_WIDTH, &m_width);
       eglQuerySurface(m_display, m_surface, EGL_HEIGHT, &m_height);

       m_initialized = true;
}

这段代码实际上是示例应用中提供的代码的副本。有许多其他事情可以通过不同的配置和设置组合来实现,其中一些比我们现在想要的更高级,所以我们将坚持这个基本设置,直到我们启动并运行。

快速浏览一下清单 5-3 ,你可以看到我们正在使用 OpenGL ES 2.0 建立一个渲染表面,它可以存储红色、绿色和蓝色的 8 位值。

然后,我们通过对eglInitializeeglChooseConfigeglGetConfigAttrib的后续调用来设置 EGL(EGL 文档可以在www.khronos.org/registry/egl/找到)。通过这些方法获得的信息然后被用来告诉 Android 操作系统我们希望如何配置窗口来显示我们的游戏。最后但同样重要的是,我们用 EGL 将显示、表面和上下文设置为当前对象,并获得屏幕的宽度和高度。

我们在屏幕上绘制图形所需的一切都已经设置好了,并且正在工作。下一步是看看如何正确地清理我们自己(见清单 5-4 )。

清单 6-4。 破坏 OpenGL

void Renderer::Destroy()
{
       m_initialized = false;

       if (m_display != EGL_NO_DISPLAY)
       {
              eglMakeCurrent(m_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
              if (m_context != EGL_NO_CONTEXT)
              {
                     eglDestroyContext(m_display, m_context);
              }
              if (m_surface != EGL_NO_SURFACE)
              {
                     eglDestroySurface(m_display, m_surface);
              }
              eglTerminate(m_display);
       }
       m_display = EGL_NO_DISPLAY;
       m_context = EGL_NO_CONTEXT;
       m_surface = EGL_NO_SURFACE;
}

从清单 5-4 可以看出,拆掉 OpenGL 是一个很容易的过程。这对于及时将资源交还给操作系统是必要的,也是为了确保我们的游戏在用户重新开始游戏时有最好的机会恢复。通过为当前显示设置不存在的表面和上下文,我们可以确保没有其他资源可以在以后成功请求使用它们并导致问题。然后我们也破坏环境和表面来释放他们的资源。最后,我们终止 EGL 以完成关闭。简单、直接,是创建良好应用的良好开端。

随着我们的渲染器设置完毕并准备就绪,现在是时候来看看可编程 GPU 如何使用顶点和片段着色器了。

着色器简介

当消费类硬件 3D 加速器在 20 世纪 90 年代中期首次出现时,它们包含固定功能的管道。这意味着每个加速器都以完全相同的方式运行,因为它们执行的算法被内置在为这些特定目的而创建的芯片中。

所有厂商的第一代卡都进行了多边形的硬件加速光栅化;例如,获取纹理并将其应用于多边形的算法是这些卡执行的第一个特定任务。

此时,顶点仍在 CPU 上的软件中被转换和点亮。第一款实现硬件转换和照明的消费级显卡是 Nvidea GeForce 256。这种从软件到硬件加速顶点处理的转变采用率很低,因为驱动程序和 API 需要相当长的时间才能赶上硬件。最终,硬件 T & L 被更广泛地采用,这导致了除英伟达和 ATI 之外的几乎所有 GPU 制造商的灭亡,他们迅速转向生产支持 T & L 的廉价卡,其性能优于没有这种硬件的昂贵得多的卡。

随着 GeForce 3 的发布,消费 GPU 硬件的下一个重大转变再次来自 Nvidia。发布于 2001 年,它是第一个包含可编程像素和顶点着色器的 GPU。它恰逢 DirectX 8.0 API 的发布,该 API 包括对使用汇编语言编写着色器的支持。

OpenGL 中的汇编语言着色器支持通过 OpenGL 1.5 中的扩展添加。直到 2004 年 OpenGL 2.0 的发布,才出现完整的着色器支持。一个主要的范式转变也发生在这个时候,汇编语言着色器被 OpenGL 着色语言,GLSL 所取代。着色器编程语言的引入向更多人开放了该功能集,因为该语言比汇编编程更直观。

这段历史将我们带到了现代的 Android。移动图形处理器通常被设计为在小型电池供电设备上运行,因此放弃了桌面系统中的一些功能来延长电池寿命。这导致了 OpenGL 的移动专用版本的开发,称为嵌入式系统 OpenGL(OpenGL ES)。OpenGL ES 1.0 版不支持顶点和像素着色器;然而,这些是随着 OpenGL ES 2.0 的发布而引入的,OpenGL ES 2.0 被集成到 Android 操作系统中,并从 2.0 版本开始通过 SDK 提供。

对于这本书,我决定只看 OpenGL ES 2.0,因为以前的版本正在被淘汰,只有很少的新设备支持这个版本的 API。这意味着我们需要了解如何在游戏中编写和使用顶点和像素着色器。下一节将向我们介绍顶点着色器。

OpenGL ES 2.0 中的顶点着色器介绍

顶点着色器的目的是将顶点从它们的局部空间(它们在建模包中建模的空间)转换到规范视图体中。该体积是一个从 1,1,1 到–1,–1,–1 的立方体,有必要在管道的下一步将我们的顶点放入该立方体,以确保它们不会在片段着色阶段之前被 GPU 丢弃。在图 5-1 中可以看到管道。裁剪阶段移除将不被渲染的多边形部分,以防止这些片段被发送通过昂贵的光栅化阶段。

9781430258308_Fig05-01.jpg

图 5-1 。图形管道

顶点数据以流的形式提供给 GPU,有两种方法可以构建这些数据流。描述这些结构的常用术语有

  • 结构的数组;
  • 数组的结构。

我们将只看一系列结构的例子。原因是结构数组在流中交错顶点数据,这使得数据在内存中是连续的。当现代处理器需要的数据可以被预取到高速缓存中时,它们工作得最好,这是通过以块为单位从内存中抓取数据来实现的。如果处理器需要的下一组数据已经在块中,我们从内存中保存一个副本,这可能会使 GPU 停止工作。

清单 5-5 显示了一个四边形的结构数组,它将被顶点着色器转换。它由一个浮动数组组成,其中三个浮动描述顶点的位置,四个浮动描述顶点的颜色。然后,我们为渲染我们的对象所需的每个顶点重复相同的格式,在四边形的情况下是四个。

清单 6-5。 四边形顶点规格

float verts[] =
{
       –0.5f, 0.5f, 0.0f,          // Position 1 x, y, z

       1.0f, 0.0f, 0.0f, 1.0f,     // Color 1 r, g, b, a
       0.5f, 0.5f, 0.0f,           // Position 2 x, y, z
       0.0f, 1.0f, 0.0f, 1.0f,     // Color 2 r, g, b, a
       –0.5f, –0.5f, 0.0f,         // Position 3 x, y, z
       0.0f, 0.0f, 1.0f, 1.0f,     // Color 3 r, g, b, a
       0.5f, –0.5f, 0.0f,          // Position 4 x, y, z
       1.0f, 1.0f, 1.0f, 1.0f,     // Color 4 r, g, b, a
};

GPU 渲染三角形,因此为了能够渲染四边形,我们需要提供一些附加信息。我们可以按顺序提供六个顶点;然而,在总共 28 字节的 7 个浮点中,我们需要发送相同大小的重复顶点,当我们将这些顶点传输到 GPU 时,这会浪费一些内存和带宽。相反,我们发送一个索引流,描述 GPU 应该使用我们提供的顶点来渲染三角形的顺序。我们的索引可以在清单 5-6 中看到。

清单 6-6。 四联指数

unsigned short indices[] =
{
       0,     2,     1,     2,     3,     1
};

通过我们的索引,你可以看到我们每个顶点只上传了两个字节,因此即使在我们的简单例子中,我们也比指定副本节省了相当多的空间。现在我们来看看清单 5-7 中顶点着色器的代码。

清单 6-7。 一个基本顶点着色器

attribute vec4 a_vPosition;
attribute vec4 a_vColor;
varying vec4 v_vColor;
void main()
{
       gl_Position = a_vPosition;
       v_vColor = a_vColor;
}

前面的清单显示了一个用 GLSL 编写的非常基本的顶点着色器。前两行指定着色器的属性。我们的属性是来自我们提供的数据流的数据。尽管在清单 5-1 的中只指定了位置值的 x、y 和 z 元素,我们在这里使用了一个四元素向量(vec4)。当我们设置数据时,图形驱动程序可以填充这些附加信息。如您所见,我们为顶点的位置和颜色分别指定了一个属性。下一行指定了一个变化的。变量是一个输出变量,我们希望从这个顶点传递到我们的像素着色器。它有一个重要的特性,就是它从一个顶点到下一个顶点插值。

gl_Position变量是 GLSL 中的一个特殊变量,专门用于存储顶点着色器的输出顶点位置。有必要使用它,因为它表示需要通过后续管道阶段(如剪辑)传递的数据。我们可以从main()的第一行看到,我们只是将输入的顶点位置传递给这个变量。同样,我们也将输入的颜色传递给可变的颜色变量。

这就是我们现在拥有的简单顶点着色程序。下一个主要步骤是查看一个基本的片段着色器,并了解我们如何在片段着色器阶段访问顶点着色器的输出。

OpenGL ES 2.0 中片段着色器介绍

清单 5-8 显示了一个用 GLSL 写的基本片段着色器的代码。

清单 6-8。 基本片段着色器

varying vec4 v_vColor;
void main()
{
       gl_FragColor = v_vColor;
}

顶点着色器和片段着色器被捆绑在一起成为程序对象。为了使程序有效,来自顶点着色器的任何变化的对象都必须与片段着色器中相同类型和名称的变化相匹配。这里我们可以看到,我们有一个名为v_vColor的变量,它的类型是vec4。片段着色器的作用是为正在处理的像素提供颜色,GLSL 提供了gl_FragColor变量来存储这个输出结果;如您所见,我们将变量v_vColor的值存储到这个变量中。

这就是我们创建一个非常基本的片段着色器所需要的。结合前面的顶点着色器,我们有一个基本的着色器程序,可以渲染一个彩色图元。这种结构的好处是它的伸缩性非常好;与通用 CPU 相比,GPU 实现了高水*的性能,因为它们并行组合了多个顶点和着色器处理器,并同时执行多个顶点和片段的着色器。现代桌面 GPU 拥有统一的着色器处理器,可以执行顶点和片段着色器,并实现负载*衡器,以便在任何给定时间根据需求分配负载。我确信这是我们在不久的将来将在移动架构中看到的发展。

现在我们知道了顶点和像素着色器,我们将看看我们需要在代码中做些什么来将它们构建到着色器程序中。

创建着色器程序

由于我们的引擎将只支持 OpenGL ES 2.0,我们所有的渲染操作都将使用着色器。这给了我们一个清晰的设计目标,因为我们的渲染器在执行绘制调用之前必须设置一个着色器。我们还知道,我们可能想要为不同的对象指定不同的着色器操作。我们的一些着色器可能很复杂,并执行操作来为关键对象提供高质量的照明和材质属性。移动 GPU 无法在单帧中过于频繁地执行这些复杂的着色器,因此我们将不得不支持切换着色器,以支持更简单对象的更多基本操作,从而实现实时帧速率。为了实现这一点,我们将为着色器指定一个接口,,如清单 5-9 所示。

清单 6-9。 一个着色器界面

class Shader
{
private:
       void LoadShader(GLenum shaderType, std::string& shaderCode);

protected:
       GLuint               m_vertexShaderId;
       GLuint               m_fragmentShaderId;
       GLint                m_programId;

       std::string          m_vertexShaderCode;
       std::string          m_fragmentShaderCode;

       bool                 m_isLinked;

public:
       Shader();
       virtualShader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);

       bool IsLinked()      { return m_isLinked; }
};

清单 5-9 显示了Shader的类定义。它包含顶点和片段着色器以及程序对象的标识符。它还具有包含顶点和片段着色器的源代码的成员变量,以及一个用于跟踪着色器是否已链接的布尔值。

LoadShader方法用于将顶点和片段着色器的着色器代码加载、编译和附加到程序对象。在清单 5-10 中有规定。

清单 6-10。着色器的 LoadShader 方法

void Shader::LoadShader(GLuint id, std::string& shaderCode)
{
       static const uint32_t NUM_SHADERS = 1;

       const GLchar* pCode = shaderCode.c_str();
       GLint length = shaderCode.length();

       glShaderSource(id, NUM_SHADERS, &pCode, &length);

       glCompileShader(id);

       glAttachShader(m_programId, id);
}

LoadShader首先获取一个指向源代码的指针和源代码的长度。然后我们调用glShaderSource将源代码设置到指定着色器 ID 的 GL 上下文中。调用glCompileShader编译源代码,glAttachShader将编译好的着色器对象附加到程序上。清单 5-11 展示了LoadShader方法是如何适应整个程序的上下文的。

清单 6-11。 着色器的链接方法

void Shader::Link()
{
       m_programId = glCreateProgram();

       m_vertexShaderId = glCreateShader(GL_VERTEX_SHADER);
       LoadShader(m_vertexShaderId, m_vertexShaderCode);

       m_fragmentShaderId = glCreateShader(GL_FRAGMENT_SHADER);
       LoadShader(m_fragmentShaderId, m_fragmentShaderCode);

       glLinkProgram(m_programId);

       m_isLinked = true;

}

这里我们可以看到Link开始于调用glCreateProgram,它请求 GL 上下文创建一个新的着色器程序对象。我们无权访问该对象,而是返回一个标识符,我们在调用后续着色器方法时使用该标识符。然后我们要求 OpenGL 为我们创建一个VERTEX_SHADER对象,并用顶点着色器 id 和代码作为参数调用LoadShader。然后我们对一个FRAGMENT_SHADER对象重复这个过程。最后,我们调用glLinkProgram来完成着色器对象。

我们的Setup方法将用于告诉 OpenGL 上下文哪个着色器是下一个绘制调用的活动着色器。基类Shader在这一点上有一个非常基本的任务,并调用glUseProgram,如清单 5-12 中的所示。

清单 6-12。 Shader::Setup()

void Shader::Setup(Renderable& renderable)
{
       glUseProgram(m_programId);
}

用 OpenGL 渲染一个四边形

终于到了我们将第一个三角形渲染到屏幕上的时候了。这是创建游戏系统的重要一点,因为从这一点开始,我们渲染的所有图形都将是这个简单任务的扩展。游戏中所有复杂的模型和效果都源于渲染一系列三角形的能力,这些三角形是用一组顶点和一组索引创建的。这个简单的例子将向你展示如何使用由四个顶点和六个索引组成的两个三角形来渲染一个四边形,我们在本章前面的清单 5-5 和 5-6 中看到了。

表示几何图形

在我们的游戏中,表示顶点和索引可能是我们想要重复做的事情,因此将它们封装在一个类中是有意义的。我们将在我们的Geometry类中这样做,如清单 5-13 所示。

清单 6-13。 几何课

class Geometry
{

private:
       static const unsigned int NAME_MAX_LENGTH = 16;

       char          m_name[NAME_MAX_LENGTH];
       int           m_numVertices;
       int           m_numIndices;
       void*         m_pVertices;
       void*         m_pIndices;

       int           m_numVertexPositionElements;
       int           m_numColorElements;
       int           m_numTexCoordElements;
       int           m_vertexStride;

public:
       Geometry();
       virtualGeometry();

       void SetName(const char* name)                   { strcpy(m_name, name); }
       void SetNumVertices(const int numVertices)       { m_numVertices = numVertices; }
       void SetNumIndices(const int numIndices)         { m_numIndices = numIndices; }

       const char* GetName() const                      { return m_name; }

       const int GetNumVertices() const                 { return m_numVertices; }
       const int GetNumIndices() const                  { return m_numIndices; }

       void* GetVertexBuffer() const                    { return m_pVertices; }
       void* GetIndexBuffer() const                     { return m_pIndices; }

       void SetVertexBuffer(void* pVertices)            { m_pVertices = pVertices; }
       void SetIndexBuffer(void* pIndices)              { m_pIndices = pIndices; }

       void SetNumVertexPositionElements(const int numVertexPositionElements);
       int  GetNumVertexPositionElements() const        { return m_numVertexPositionElements; }

       void SetNumColorElements(const int numColorElements);
       int  GetNumColorElements() const                 { return m_numColorElements; }

       void SetNumTexCoordElements(const int numTexCoordElements);
       int  GetNumTexCoordElements() const              { return m_numTexCoordElements; }

       void SetVertexStride(const int vertexStride)     { m_vertexStride = vertexStride; }
       int  GetVertexStride() const                     { return m_vertexStride; }
               };

       inline void Geometry::SetNumVertexPositionElements(const int numVertexPositionElements)
       {
              m_numVertexPositionElements = numVertexPositionElements;
       }

       inline void Geometry::SetNumTexCoordElements(const int numTexCoordElements)
       {
              m_numTexCoordElements = numTexCoordElements;
       }

       inline void Geometry::SetNumColorElements(const int numColorElements)
       {
              m_numColorElements = numColorElements;
       }

清单 5-13 给出了Geometry类的定义。除了存储指向顶点和索引的指针,该类还包含用于描述顶点数据如何存储在数组中的字段。这些成员包括顶点和索引的数量,还包括位置数据中位置元素的数量、颜色元素的数量以及每个顶点的纹理坐标元素的数量。我们还有一个存储顶点步距的字段。步幅是我们从一个顶点跳到下一个顶点的字节数,当我们向 OpenGL 描述数据时,这是必需的,我们很快就会看到。

首先,我们来看看如何创建一个渲染器可以使用的对象。

创建可渲染的

我们知道,Renderer的工作是将Geometry提供给 OpenGL API,以便绘制到屏幕上。因此,我们能够以一致的方式描述Renderer应该考虑的对象是有意义的。清单 5-14 显示了我们将用来发送Renderable对象到渲染器进行绘制的类。

清单 6-14。 定义一可呈现

class Renderable
{
private:
       Geometry*            m_pGeometry;
       Shader*              m_pShader;

public:
       Renderable();
       ∼Renderable();

       void                 SetGeometry(Geometry* pGeometry);
       Geometry*            GetGeometry();

       void                 SetShader(Shader* pShader);
       Shader*              GetShader();
};

inline Renderable::Renderable()
       :      m_pGeometry(NULL)
       ,      m_pShader(NULL)
{
}

inline Renderable::∼Renderable()
{
}

inline void Renderable::SetGeometry(Geometry* pGeometry)
{
       m_pGeometry = pGeometry;
}

inline Geometry* Renderable::GetGeometry()
{
       return m_pGeometry;
}

inline void Renderable::SetShader(Shader* pShader)
{
       m_pShader = pShader;
}

inline Shader* Renderable::GetShader()
{
       return m_pShader;
}

目前这是一个简单的类,因为它只包含一个指向一个Geometry对象和一个Shader对象的指针。这是另一个将随着我们的前进而发展的类。

我们还需要扩充Renderer类来处理这些Renderable对象。清单 5-15 展示了Renderer如何处理我们添加的要绘制的对象。

清单 6-15。 更新渲染器

class Renderer
{
private:
       typedef std::vector<Renderable*>               RenderableVector;
       typedef RenderableVector::iterator               RenderableVectorIterator;

       RenderableVector               m_renderables;

       void Draw(Renderable* pRenderable);

public:
       void AddRenderable(Renderable* pRenderable);
       void RemoveRenderable(Renderable* pRenderable);
}

void Renderer::AddRenderable(Renderable* pRenderable)
{
       m_renderables.push_back(pRenderable);
}

void Renderer::RemoveRenderable(Renderable* pRenderable)
{
       for (RenderableVectorIterator iter = m_renderables.begin();
            iter != m_renderables.end();
            ++iter)
       {
              Renderable* pCurrent = *iter;
              if (pCurrent == pRenderable)
              {
                     m_renderables.erase(iter);
                     break;
              }
       }
}

void Renderer::Update()
{
       if (m_initialized)
       {
              glClearColor(0.95f, 0.95f, 0.95f, 1);
              glClear(GL_COLOR_BUFFER_BIT);

              for (RenderableVectorIterator iter = m_renderables.begin();
                   iter != m_renderables.end();
                   ++iter)
              {
                     Renderable* pRenderable = *iter;
                     if (pRenderable)
                     {
                            Draw(pRenderable);
                     }
              }

              eglSwapBuffers(m_display, m_surface);
       }
}

我们将Renderable对象存储在vector中,并在调用Update的过程中循环这些对象。每个Renderable然后被传递给私有的Draw方法,我们在清单 5-16 中描述了这个方法。

清单 6-16。 渲染器的绘制方法

void Renderer::Draw(Renderable* pRenderable)
{
       assert(pRenderable);
       if (pRenderable)
       {
              Geometry* pGeometry = pRenderable->GetGeometry();
              Shader* pShader = pRenderable->GetShader();
              assert(pShader && pGeometry);

              pShader->Setup(*pRenderable);

              glDrawElements(
                     GL_TRIANGLES,
                     pGeometry->GetNumIndices(),
                     GL_UNSIGNED_SHORT,
                     pGeometry->GetIndexBuffer());
       }
}

我们的Draw方法显示,我们对每个对象只执行两个任务。在验证了我们有一个有效的Renderable指针并且我们的 Renderable 包含有效的GeometryShader指针之后,我们调用Shader::Setup(),然后调用glDrawElementsglDrawElements 传递参数,让上下文知道我们想要渲染三角形,传递多少个索引,索引的格式,以及索引缓冲区本身。

您可能会注意到,我们没有向 draw 调用传递任何有关顶点的信息。这是因为此信息是着色器设置阶段的一部分,并作为数据流传递给着色器。现在,我们将看看如何处理向着色器传递数据。

创建基本着色器

前面,我们看了一个在我们的框架中表示着色器的基类;现在我们来看一个具体的实现。为了创建一个我们可以在 GPU 上使用的着色器,我们将从从Shader类派生一个新类开始。清单 5-17 显示了BasicShader类。

清单 6-17。 最基本的 Shader 类

class BasicShader
       :      public Shader
{
private:
       GLint         m_positionAttributeHandle;

public:
       BasicShader();
       virtualBasicShader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);
};

正如你从清单 5-17 中看到的,我们继承了Shader并重载了它的公共方法。我们还添加了一个字段来存储 GL 上下文中 position 属性的索引。为了简单起见,这个着色器将直接从片段着色器渲染颜色,并将放弃我们之前看到的流中的颜色值。清单 5-18 显示了包含着色器源代码的BasicShader类构造器。

清单 6-18。basic shader 构造函数

BasicShader::BasicShader()
{
       m_vertexShaderCode =
              "attribute vec4 a_vPosition; \n"
              "void main(){\n"
              "     gl_Position = a_vPosition; \n"
              "} \n";

       m_fragmentShaderCode =
              "precision highp float; \n"
              "void main(){\n"
              "    gl_FragColor = vec4(0.2, 0.2, 0.2, 1.0); \n"
              "} \n";
}

注意片段着色器源代码的第一行为着色器设置浮点变量的精度。可变精度是一个高级话题,我们在这里不讨论。开始时,您需要了解的最基本知识是,在 OpenGL ES 2.0 中,片段着色器必须声明浮点的默认精度有效。在本文中,我们将始终使用值highp

如你所见,我们的basic着色器简单地设置输出位置以匹配输入顶点位置,我们的片段着色器将颜色设置为深灰色。我们现在来看看需要覆盖的方法。第一个如清单 5-19 所示。

清单 6-19。 基础连接法

void BasicShader::Link()
{
       Shader::Link();

       m_positionAtributeHandle = glGetAttribLocation(m_programId, "a_vPosition");
}

这里你可以看到我们首先需要调用我们的父类的'Link方法。这确保了着色器已经被编译并链接到我们的程序对象中。我们接着叫glGetAttribLocation;这个方法返回给我们a_vPosition属性的索引,我们将在下一个方法Setup中使用,如清单 5-20 所示。

注意每次您希望为位置属性设置顶点流时,都可以使用属性名称,但是最好查询位置,因为这比每次调用时通过名称查找位置要快得多。

清单 6-20。 BasicShader::Setup()

void BasicShader::Setup(Renderable& renderable)
{
       Shader::Setup(renderable);

       Geometry* pGeometry = renderable.GetGeometry();
       assert(pGeometry);

       glVertexAttribPointer(
              m_positionAttributeHandle,
              pGeometry->GetNumVertexPositionElements(),
              GL_FLOAT,
              GL_FALSE,
              pGeometry->GetVertexStride(),
              pGeometry->GetVertexBuffer());
       glEnableVertexAttribArray(m_positionAttributeHandle);
}

在这个方法中,我们再次调用我们的父类,以确保基础上所需的任何操作都已完成,并且我们的着色器已准备好使用。

然后我们调用glVertexAttribPointer OpenGL 方法来指定顶点流。glVertexAttribPointer的论据如下:

  • 第一个参数是属性在我们描述的着色器中的位置。在这种情况下,我们只有顶点位置的数据。
  • 第二个参数告诉 OpenGL 每个顶点包含多少个元素。该值可以是 1、2、3 或 4。在我们的例子中,它是三,因为我们指定了顶点的 x,y 和 z 坐标。
  • 第三个参数指定该位置使用的数据类型。
  • 第四个决定我们是否希望值被规范化。
  • 然后我们传递一个参数,告诉 OpenGL 从这个顶点的数据开始跳到下一个顶点需要多少字节,这个参数称为步距。众所周知,当顶点之间没有数据,或者它们被紧密地压缩时,零是一个有效值。当我们查看需要非零值的顶点数据时,我们将更详细地查看步幅。
  • 最后但同样重要的是,我们传递一个指向内存地址的指针,在内存中可以找到对象的顶点数据。

在我们可以使用着色器之前,我们需要调用glEnableVertexAttribArray来确保 OpenGL 上下文知道数据已经准备好使用。

现在我们有了一个可以在程序中实例化和使用的着色器以及GeometryRenderable类,让我们创建一个可以使用它们在屏幕上绘制四边形的应用。

创建特定于应用的应用和任务

我们创建的每个应用都可能包含不同的功能。我们希望有不同的菜单、不同的关卡和不同的游戏方式。为了区分应用之间的功能,我们可以将我们的Framework Application类继承到一个特定于应用的实现中,并在其中包含一个Task,如清单 5-21 中的所示。

清单 6-21。 第五章任务

class Chapter5Task
       :      public Framework::Task
{
private:
       State                                     m_state;

       Framework::Renderer*                      m_pRenderer;
               Framework::Geometry               m_geometry;
               Framework::BasicShader            m_basicShader;
               Framework::Renderable             m_renderable;

public:
       Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority);
       virtualChapter5Task();

       // From Task
       virtual bool                Start();
       virtual void                OnSuspend();
       virtual void                Update();
       virtual void                OnResume();

       virtual void               Stop();
};

清单 5-21 显示了这个应用的Task。它包含一个指向Renderer的指针和代表Geometry、一个BasicShader和一个Renderable的成员。使用这些相当简单。

清单 5-22 显示了构造器所需的基本设置。

清单 6-22。 第五章任务构造器

Chapter5Task::Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority)
       :      m_pRenderer(pRenderer)
       ,      Framework::Task(priority)
{
       m_renderable.SetGeometry(&m_geometry);
       m_renderable.SetShader(&m_basicShader);
}

在这里,我们将m_pRenderer设置为传入的Renderer,并使用我们指定的优先级调用Task构造函数。

我们还用相应参数的成员变量的地址调用m_renderable上的SetGeometrySetShader

在清单 5-23 中,我们看看当Task被添加到内核时需要发生什么。

清单 6-23。 第五章任务开始

namespace
{
       float verts[] =
       {
              –0.5f, 0.5f, 0.0f,
              0.5f, 0.5f, 0.0f,
              –0.5f, –0.5f, 0.0f,
              0.5f, –0.5f, 0.0f,
       };

       unsigned short indices[] =
       {
              0,     2,     1,     2,     3,     1
       };
}

bool Chapter5Task::Start()
{
       Framework::Geometry* pGeometry = m_renderable.GetGeometry();
       pGeometry ->SetVertexBuffer(verts);
       pGeometry ->SetNumVertices(4);
       pGeometry ->SetIndexBuffer(indices);
       pGeometry ->SetNumIndices(6);
       pGeometry ->SetName("quad");

       pGeometry ->SetNumVertexPositionElements(3);
       pGeometry ->SetVertexStride(0);

       m_pRenderer->AddRenderable(&m_renderable);

       return true;
}

这里,我们在方法声明之前,在本地匿名名称空间中指定顶点和索引数据。将来,我们将从文件中加载这些数据。

Start方法从Renderable对象获取有效指针,然后设置所有相关数据。设置了顶点和索引缓冲区以及大小,我们给对象一个名称,并将每个顶点的位置元素的数量设置为 3,跨距设置为零。

然后我们将m_renderable添加到Renderer中进行绘制。

Stop方法 ( 清单 5-24 )有一个简单的任务,那就是从渲染器中移除可渲染对象。析构函数也应该这样做。

清单 6-24。 第五章任务停止

void Chapter5Task::Stop()
{
       m_pRenderer->RemoveRenderable(&m_renderable);
}

我们现在来看看如何将Chapter5Task添加到Kernel中,如清单 5-25 所示。

清单 6-25。 第五章 App

class Chapter5App
       :      public Framework::Application
{
private:
       Chapter5Task         m_chapter5Task;

public:
       Chapter5App(android_app* pState);
       virtualChapter5App();

       virtual bool Initialize();
};

创建Chapter5App类就像从Application继承一样简单。我们覆盖了Initialize方法并添加了一个类型为Chapter5Task的成员。

Chapter5App的方法非常简单,如清单 5-26 所示。

清单 6-26。 第五章 App 方法

Chapter5App::Chapter5App(android_app* pState)
       :      Framework::Application(pState)
       ,      m_chapter5Task(&m_rendererTask, Framework::Task::GAME_PRIORITY)
{
}

bool Chapter5App::Initialize()
{
       bool success = Framework::Application::Initialize();

        if (success)
       {
              m_kernel.AddTask(&m_chapter5Task);
       }

       return success;
}

您在这里看到的简单性是我们将所有将在未来应用之间共享的任务隐藏到代码的Framework层的结果。我们正在创建一个可重用的库,希望您开始看到的好处将在下一节中更加明显。我们的简单构造函数有一个简单的任务,即调用它的父对象并初始化Chapter5Task对象。

Initialize简单地调用它的父对象,如果一切正常,就将Chapter5Task对象添加到内核中。

到目前为止,我们做得很好,但是我们现在看到的代码只会在屏幕上呈现一个空白的四边形,这不是特别有趣。输出的截图是图 5-2 中的。

9781430258308_Fig05-02.jpg

图 5-2 。基本着色器的渲染输出

让我们快速地转到如何渲染一个有纹理的四边形。

将纹理应用到几何体

在代码中指定几何图形和顶点是一项简单的任务。以同样的方式表示纹理数据将是一个困难得多的命题。我可以给你一个代码格式的预设纹理;然而,现在似乎是用安卓 NDK 加载文件的最佳时机。

加载文件

“文件”这个词显然是一个名词,基本的面向对象设计告诉我们,名词是成为类的很好的候选,所以我们将从这里开始。清单 5-27 中的显示了File类的接口。

清单 6-27。 文件类界面

class File
{
public:
       explicit File(std::string name);
       virtualFile();

       bool          Open();
       void          Read(void* pBuffer, const unsigned int bytesToRead, size_t& bytesRead);
       void          Close();

       unsigned int  Length() const;
};

该接口定义了我们希望在单个文件上执行的基本操作。现在我们来看看 NDK 提供的实现这些操作的函数。

如果您查看在项目中创建的文件夹,您应该会看到一个名为assets的文件夹。我们希望从我们的应用中访问的任何文件都将添加到该文件夹中。NDK 类为我们提供了这个文件夹的接口对象,称为AAssetManager。我们只需要一个对AAssetManager对象的引用,所以我们在File类中创建一个指向它的静态指针,如清单 5-28 所示。

清单 6-28。 向文件中添加 AAssetManager

class File
{
private:
       static AAssetManager* m_pAssetmanager;

public:
       static void SetAssetManager(AAssetManager* pAssetManager)
       {
              m_pAssetManager = pAssetmanager;
       }
};

为了确保在创建一个File的实例之前设置它,在构造函数中断言指针不为NULL是一个好主意,如清单 5-29 所示。

清单 6-29。 文件构造器

File::File(std::string name)
{
       assert(m_pAssetManager != NULL);
}

对文件执行的第一个操作是打开它。我们通过调用AAssetManager_open来做到这一点,如清单 5-30 所示。

清单 6-30。 文件打开

bool File::Open()
{
       m_pAsset = AAssetManager_open(m_pAssetManager, m_filename.c_str(), AASSET_MODE_UNKNOWN);
       return !!m_pAsset;
}

如您所见,这相对简单。您需要在类定义中添加一个AAsset指针和一个文件名字符串来表示m_pAssetm_filenamem_filename可以用传入File的构造函数的字符串初始化。

此时,我们可以向 NDK 询问文件的字节长度,如清单 5-31 所示。

清单 6-31。 文件长度

unsigned int File::Length() const
{
       return AAsset_getLength(m_pAsset);
}

我们也可以在完成后关闭文件,如清单 5-32 所示。

清单 6-32。 文件关闭

void File::Close()
{
       if (m_pAsset)
       {
              AAsset_close(m_pAsset);
              m_pAsset = NULL;
       }
}

在关闭程序之前,最好确保所有文件都已关闭;因此,我也建议从File的析构函数中调用Close(清单 5-33 )。

清单 6-33。∾文件

File::∼File()
{
       Close();
}

现在,对于File类的真正主力Read方法,如清单 5-34 所示。

清单 6-34。 文件的读取方法

void File::Read(void* pBuffer, const unsigned int bytesToRead, size_t& bytesRead)
{
       bytesRead = AAsset_read(m_pAsset, pBuffer, bytesToRead);
}

几乎不复杂,但有一个很好的理由。许多文件类型都有文件头,程序可能希望读取这些文件头,而不必读取一个大文件的全部内容。这对于作为其他文件集合的文件来说尤其如此。

由于File类本身不可能知道调用它的代码的意图,所以我们不会给它添加任何不必要的代码。接下来我们将看看如何处理一个纹理文件。

加载 TGA 文件

TGA 文件在游戏开发中被广泛使用。它们被广泛采用有一个简单的原因:它们非常容易读写,并且支持游戏所需的所有信息,包括 alpha 通道。TGA 格式中还指定了一个开发人员区域,开发人员可以根据自己的意愿使用该区域,这使得该格式非常灵活。现在,我们将处理一个基本的 TGA 文件。清单 5-35 显示了 TGA 文件头的精确字节模式。

清单 6-35。 TGAHeader

struct TGAHeader
{
       unsigned char        m_idSize;
       unsigned char        m_colorMapType;
       unsigned char        m_imageType;

       unsigned short       m_paletteStart;
       unsigned short       m_paletteLength;
       unsigned char        m_paletteBits;

       unsigned short       m_xOrigin;
       unsigned short       m_yOrigin;
       unsigned short       m_width;
       unsigned short       m_height;
       unsigned char        m_bpp;
       unsigned char        m_descriptor;
} __attribute__ ((packed));

这个 18 字节的部分存在于每个有效的 TGA 文件的开头,并且总是采用相同的格式。目前许多数据对我们来说是不必要考虑的。最初,我们将处理未压缩的位图数据。虽然 TGA 文件可以支持压缩和托盘化纹理,但它们不是 OpenGL 支持的格式,因此我们将避免创建这种格式的纹理。在我们有一个普通位图文件的情况下,标题中唯一感兴趣的字段是widthheightbppbpp代表每个像素的字节数,值 1 表示我们正在处理灰度图像,值 3 表示 RGB,值 4 表示 RGBA。我们可以通过计算m_width * m_height * m_bpp来计算出标题后面的图像数据的大小。

不幸的是,我们不得不在这个时候涵盖一个相对先进的概念。当我们加载文件数据时,我们将从内存中加载整个 18 字节的文件头,或者整个文件。然后,我们可以将指向从文件中加载的数据的指针转换成一个TGAHeader指针;以这种方式使用强制转换可以避免将加载的数据复制到结构中,这通常称为内存映射。在这样做的时候,__attribute__ ((packed))指令是必不可少的。它的工作是确保编译器不会在结构中的成员之间添加任何填充。例如,前三个字段m_idSizem_colorMapTypem_imageType,用三个字节表示。大多数处理器在从在一定数量的字节的边界上对齐的存储器地址复制和访问数据方面更有效。因此,编译器可以通过跳过第四个字节并将m_paletteStart存储在下一个可被 4 整除的地址来填充结构。

这给我们带来的问题是,不同的编译器可以随意地为它们所针对的处理器填充,而我们从内存中加载的文件保证没有任何填充;这意味着编译器可能会使结构字段的地址与二进制块中数据的位置不匹配。结构定义末尾的__attribute__ ((packed))行阻止编译器添加我们不想要的填充。

抱歉,在困难中稍微绕道和颠簸。如果最后一条信息有点复杂,请放心,您不必确切地理解此时此刻正在发生什么;你只需要知道在这种情况下它是需要的。我还把它添加到了本书中其他需要的地方,这样你就不用担心以后会不会得到正确的答案。

让我们从整体上看一下TGAFile类(参见清单 5-36 )。

清单 6-36。 TGAFile

class TGAFile
{
public:
       struct TGAHeader
       {
              unsigned char        m_idSize;
              unsigned char        m_colorMapType;
              unsigned char        m_imageType;

              unsigned short       m_paletteStart;
              unsigned short       m_paletteLength;
              unsigned char        m_paletteBits;

              unsigned short       m_xOrigin;

              unsigned short       m_yOrigin;
              unsigned short       m_width;
              unsigned short       m_height;
              unsigned char        m_bpp;
              unsigned char        m_descriptor;
       } __attribute__ ((packed));

       TGAFile(void* pData);
       virtualTGAFile();

       unsigned short              GetWidth() const;
       unsigned short              GetHeight() const;
       void*                       GetImageData() const;

private:
       TGAHeader*                  m_pHeader;
       void*                       m_pImageData;
};

inline unsigned short TGAFile::GetWidth() const
{
       unsigned short width = m_pHeader
              ?     m_pHeader->m_width
              :     0;
       return width;
}

inline unsigned short TGAFile::GetHeight() const
{
       unsigned short height = m_pHeader
              ?     m_pHeader->m_height
              :     0;
       return height;
}

inline void* TGAFile::GetImageData() const
{
       return m_pImageData;
}

这在很大程度上很容易理解。现在我们可以看看如何将纹理呈现给Renderer

代表一个 GL 纹理

纹理在计算机图形学中被用来给*面提供比单独使用几何图形更多的细节。典型的例子是砖墙。砖块本身表面粗糙,砖块之间的砂浆通常与砖块颜色不同。

使用纯粹的几何方法来表示这些表面将需要比我们在实时帧速率下可能处理的更多的顶点。我们通过在表面上绘制图像来伪造表面的外观,从而绕过处理限制。这些图像是纹理。

纹理现在被用于许多目的。它们可以通过定义多边形上每个像素的颜色以传统方式使用。它们现在也用于不同的应用,例如绘制法线和照明数据。这些分别被称为法线贴图和光照贴图。在初级阶段,我们将坚持传统的使用方法,并在本章中看看我们如何使用纹理贴图。清单 5-37 展示了我们如何用代码表示一个纹理贴图。

清单 6-37。 框架的纹理类

class Texture
{
public:
       struct Header
       {
              unsigned int               m_width;
              unsigned int               m_height;
              unsigned int               m_bytesPerPixel;
              unsigned int               m_dataSize;

              Header()
                     :      m_width(0)
                     ,      m_height(0)
                     ,      m_bytesPerPixel(0)
                     ,      m_dataSize(0)
              {
              }

              Header(const Header& header)
              {
                     m_width              = header.m_width;
                     m_height             = header.m_height;
                     m_bytesPerPixel      = header.m_bytesPerPixel;
                     m_dataSize           = header.m_dataSize;
              }
       };

private:
       GLuint        m_id;
       Header        m_header;
       void*         m_pImageData;

public:
       Texture();
       ∼Texture();

       void SetData(Header& header, void* pImageData);

       GLuint GetId() const { return m_id; }

       void Init();
};

这个类是另一个相当简单的事情,并且大部分是自文档化的。它接受一些指针,并将描述纹理数据所需的信息存储在一个名为Header的结构中。一个值得好好研究的方法是Init ,我们在清单 5-38 中做了这个。

清单 6-38。 纹理的初始化

void Texture::Init()
{
       GLint  packBits             = 4;
       GLint  internalFormat       = GL_RGBA;
       GLenum format               = GL_RGBA;
       switch (m_header.m_bytesPerPixel)
       {
       case 1:
       {
              packBits             = 1;
              internalFormat       = GL_ALPHA;
              format               = GL_ALPHA;
       }
       break;
       };

       glGenTextures(1, &m_id);

       glBindTexture(GL_TEXTURE_2D, m_id);

       glPixelStorei(GL_UNPACK_ALIGNMENT, packBits);

       glTexImage2D(
              GL_TEXTURE_2D,
              0,
              internalFormat,
              m_header.m_width,
              m_header.m_height,
              0,
              format,
              GL_UNSIGNED_BYTE,
              m_pImageData);
}

现在,Init被写来仅仅处理GL_RGBA或者GL_ALPHA纹理。glGenTextures创建一个新的纹理,通过参数引用返回一个 id。一次创建多个纹理是可能的,但是现在我们很乐意一次创建一个纹理。

glBindTexture 用于将指定 ID 的纹理附加到指定的纹理单元,并将纹理锁定到该类型。目前,我们只对传统的二维纹理感兴趣,所以我们在第一个参数中指定了这一点。

glPixelStorei 告知 OpenGL 每个像素有多少字节。对于灰度,我们每像素一个字节,对于 RGBA 纹理,我们有四个字节。具体来说,这个函数告诉 OpenGL 它应该如何将纹理读入自己的内存。

我们接着用glTexImage2D 。这个函数让 OpenGL 上下文将图像数据从我们的源数组复制到它自己的可用内存空间中。这些参数如下:

  • target -要读入的纹理单元,在我们的例子中是GL_TEXTURE_2D
  • level -要读入的 mip 级别;现在我们只对零级感兴趣。
  • internalFormat -要复制到的纹理的格式。这可能与format不同,但是我们没有使用这个功能。
  • width -纹理的宽度,以像素为单位。
  • height -纹理的高度,以像素为单位。
  • border -该值必须始终为零。
  • format -源像素数据的格式。我们使用GL_ALPHAGL_RGBA,并将匹配传递给 internalFormat 的值。
  • type -单个像素的数据类型。我们使用无符号字节。
  • data -指向图像数据中第一个像素的指针。

一旦这个函数被成功调用,我们将在创建的 ID 上有一个可用的纹理。

创建 TextureShader

现在我们知道了纹理在 OpenGL 中的样子,我们可以编写着色器来将纹理应用到几何图形中。我们从再次继承清单 5-39 中的Shader的一个新类开始。

清单 6-39。texture shader 类

class TextureShader
       :      public Shader
{
private:
       Texture*      m_pTexture;
       GLint         m_positionAttributeHandle;
       GLint         m_texCoordAttributeHandle;
       GLint         m_samplerHandle;

public:
       TextureShader();
       virtualTextureShader();

       virtual void  Link();
       virtual void  Setup(Renderable& renderable);

       void          SetTexture(Texture* pTexture);
       Texture*      GetTexture();
};

代码并不比我们之前创建的BasicShader复杂多少。突出的区别是,我们没有纹理坐标的属性句柄,也没有采样器的属性句柄,我将在下面的文本中详细解释。让我们来看看TextureShader的构造函数,如清单 5-40 所示。

清单 6-40。texture shader 构造函数

TextureShader::TextureShader()
       :      m_pTexture(NULL)
{
       m_vertexShaderCode =
              "attribute vec4 a_vPosition;                            \n"
              "attribute vec2 a_texCoord;                             \n"
              "varying   vec2 v_texCoord;                             \n"
              "void main(){                                           \n"
              "    gl_Position = a_vPosition;                         \n"
              "    v_texCoord = a_texCoord;                           \n"
              "}                                                      \n";

       m_fragmentShaderCode =
              "precision highp float;                                 \n"
              "varying vec2 v_texCoord;                               \n"
              "uniform sampler2D s_texture;                           \n"
              "void main(){                                           \n"
              "    gl_FragColor = texture2D(s_texture, v_texCoord);   \n"
              "}                                                      \n";
}

着色器代码现在应该看起来有点熟悉了。我们有对应于传入顶点着色器的数据的属性,一个表示位置,另一个表示纹理坐标。然后,我们还有一个名为v_textCoord的变量,它将用于插值当前纹理坐标,以便在片段着色器中进行处理。您可以看到这种变化是在顶点着色器中设置的,我们将纹理坐标属性传递给变化。

片段着色器引入了一个新概念,即采样器。采样是 GPU 获取纹理坐标并查找纹理元素颜色的过程。请注意术语的变化:当谈到纹理时,我们倾向于将单个元素作为纹理元素而不是像素来谈论。

当在讨论纹理中查找纹理元素时,坐标本身通常也称为 UV 坐标。u 对应通常的 x 轴,V 对应 y 轴。UV 坐标的原点在位置(0,0)处,位于图像的左上角。坐标被指定为范围从 0 到 1 的数字,其中 0 处的 U 是左手边,1 是右手边,这同样适用于从上到下的 V。

程序以熟悉的方式访问着色器变量的位置。正如你在清单 5-41 中看到的,我们通过使用glGetUniformPosition而不是glGetAttribLocation来访问采样器的位置。

清单 6-41。 TextureShader 链接

void TextureShader::Link()
{
       Shader::Link();

       m_positionAttributeHandle   = glGetAttribLocation(m_programId, "a_vPosition");
       m_texCoordAttributeHandle   = glGetAttribLocation(m_programId, "a_texCoord");
       m_samplerHandle             = glGetUniformLocation(m_programId, "s_texture");
}

剩下要做的最后一件事是设置我们的着色器以备使用,如清单 5-42 所示。

清单 6-42。 纹理着色器设置

void TextureShader::Setup(Renderable& renderable)
{
       assert(m_pTexture);
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry && m_pTexture)
       {
              Shader::Setup(renderable);

              glActiveTexture(GL_TEXTURE0);
              glBindTexture(GL_TEXTURE_2D, m_pTexture->GetId());
              glUniform1i(m_samplerHandle, 0);

              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
              glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

              glVertexAttribPointer(
                     m_positionAttributeHandle,
                     pGeometry->GetNumVertexPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     pGeometry->GetVertexBuffer());
                     glEnableVertexAttribArray(m_positionAttributeHandle);

              glVertexAttribPointer(
                     m_texCoordAttributeHandle,
                     pGeometry->GetNumTexCoordElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     &static_cast<GLfloat*>(pGeometry->GetVertexBuffer())[pGeometry->GetNumVertexPositionElements()]);
              glEnableVertexAttribArray(m_texCoordAttributeHandle);
               }
}

清单 5-42 向我们展示了设置一个纹理所需要的着色器。在调用父对象的Setup方法后,我们使用glActiveTexture激活一个带有 OpenGL 的纹理采样器供我们使用。然后我们将我们的纹理附加到GL_TEXTURE_2D单元,并将我们的采样器位置设置为纹理单元零。这些步骤对于 OpenGL 在我们的着色器中正确的位置设置正确的纹理是必要的。

下一步是为我们的纹理设置一个包装格式。使用包装值可以实现某些效果。例如,通过指定小于或大于零和一的纹理坐标,可以使纹理重复或镜像。在我们的例子中,我们将简单地将纹理坐标设置为 0 到 1 之间的范围。

然后我们指定一个过滤类型。当纹理不是以每个屏幕像素一个纹理元素绘制时,过滤被应用于纹理。当纹理元素远离相机时,在单个像素内可以看到多个纹理元素。线性过滤对纹理表面上 UV 坐标指向的点周围的块中的四个像素进行*均。结果是图像有点模糊,虽然这听起来可能不理想,但它有助于减少物体靠*或远离相机时纹理的闪烁效果。

然后,我们将顶点数据指定给 OpenGL,就像我们在BasicShader中所做的那样。指定顶点数据后,我们指定纹理坐标数据。除了我们将纹理坐标属性位置和纹理坐标元素的数量传递给glVertexAttribPointer之外,大部分内容看起来都是一样的。然后,我们需要将第一个纹理坐标的地址传递给最后一个参数。请记住,我们之前讨论过,我们将使用一个数组结构格式的数据,这意味着我们的顶点属性是交织成一个单一的数组。您可以看到,我们通过将顶点缓冲区转换为浮点指针来计算第一个纹理坐标的地址,然后使用带有位置元素数量的数组索引来将指针跳转到第一个位置。

这是我们设置纹理和着色器所需要的。我们现在可以看看如何确保 OpenGL 处于正确的状态来处理我们的纹理和着色器。

初始化纹理和着色器

正如我们在初始化 OpenGL 时看到的,API 有一个上下文,它是在 Android 操作系统通知我们已经为我们的应用创建了窗口结构时建立的。我们还看到了如何设置纹理和着色器,包括从已链接或附加的着色器程序中获取变量的位置。这些过程是在当前背景下进行的。这意味着我们需要一个有效的上下文,然后才能在着色器和纹理上执行这些操作。这也意味着,如果上下文被破坏,这种情况发生在用户将手机置于睡眠状态时,那么每个纹理和着色器都必须重新初始化。

为了确保这是可能的,我们将添加一个正在使用的纹理和着色器向量到Renderer,如列表 5-43 所示。

清单 6-43。 渲染器的纹理和着色器矢量

class Renderer
{
private:
       typedef std::vector<Shader*>              ShaderVector;
       typedef ShaderVector::iterator            ShaderVectorIterator;

       typedef std::vector<Texture*>             TextureVector;

       typedef TextureVector::iterator       TextureVectorIterator;

public:
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);
};

void Renderer::AddShader(Shader* pShader)
{
       assert(pShader);
       if (m_initialized)
       {
              pShader->Link();
       }
       m_shaders.push_back(pShader);
}

void Renderer::RemoveShader(Shader* pShader)
{
       for (ShaderVectorIterator iter = m_shaders.begin(); iter != m_shaders.end(); ++iter)
       {
              Shader* pCurrent = *iter;
              if (pCurrent == pShader)
              {
                     m_shaders.erase(iter);
                     break;
              }
       }
}

void Renderer::AddTexture(Texture* pTexture)
{
       assert(pTexture);
       if (m_initialized)
       {
              pTexture->Init();
       }
       m_textures.push_back(pTexture);
}

void Renderer::RemoveTexture(Texture* pTexture)
{
       for (TextureVectorIterator iter = m_textures.begin(); iter != m_textures.end(); ++iter)
       {
              Texture* pCurrent = *iter;
              if (pCurrent == pTexture)
              {
                     m_textures.erase(iter);
                     break;
              }
       }
}

前面的代码是我们维护当前使用的纹理和着色器列表所需的全部内容。当手机唤醒并且Renderer已被初始化时,为了重新初始化它们,或者为了初始化在Renderer准备好之前添加的任何代码,我们在 OpenGL 设置完成后,将来自清单 5-44 的代码添加到Renderer::Init

清单 6-44。 重新初始化纹理和着色器

for (TextureVectorIterator iter = m_textures.begin(); iter != m_textures.end(); ++iter)
{
       Texture* pCurrent = *iter;
       pCurrent->Init();
}

for (ShaderVectorIterator iter = m_shaders.begin(); iter != m_shaders.end(); ++iter)
{
       Shader* pCurrent = *iter;
       pCurrent->Link();
}

在任务中加载纹理

在我们加载一个纹理之前,我们需要指定相关的变量。我们在清单 5-45 中这样做。

清单 6-45。 给第五章任务添加纹理

class Chapter5Task
       :      public Framework::Task
{
private:
       enum State
       {
              LOADING_FILE,
              CREATE_TEXTURE,
              RUNNING
       };

       State                       m_state;

       Framework::File             m_file;
       Framework::Renderer*        m_pRenderer;
       Framework::Geometry         m_geometry;
       Framework::TextureShader    m_textureShader;
       Framework::Renderable       m_renderable;
       Framework::Texture          m_texture;

       void*                       m_pTGABuffer;
       unsigned int                m_readBytes;
       unsigned int                m_fileLength;
};

在我们学习修改后的方法时,我们将看看它们各自的用途。让我们从构造函数开始,如清单 5-46 所示。

清单 6-46。 第五章任务构造器

Chapter5Task::Chapter5Task(Framework::Renderer* pRenderer, const unsigned int priority)
       :      m_pRenderer(pRenderer)
       ,      Framework::Task(priority)
       ,      m_state(RUNNING)
       ,      m_file("test.tga")
       ,      m_pTGABuffer(NULL)
       ,      m_readBytes(0)
{
       m_renderable.SetGeometry(&m_geometry);
       m_renderable.SetShader(&m_textureShader);
}

在这里,你可以看到我们已经用默认值设置了变量,包括指定文件名test.tga

清单 5-47 展示了Start方法。

清单 6-47。 第五章任务开始

float verts[] =
{
       –0.5f, 0.5f, 0.0f,
       0.0f, 1.0f,
       0.5f, 0.5f, 0.0f,
       1.0f, 1.0f,
       –0.5f, –0.5f, 0.0f,
       0.0f, 0.0f,
       0.5f, –0.5f, 0.0f,
       1.0f, 0.0f
};

bool Chapter5Task::Start()
{
       Framework::Geometry* pGeometry = m_renderable.GetGeometry();
       pGeometry->SetVertexBuffer(verts);
       pGeometry->SetNumVertices(4);
       pGeometry->SetIndexBuffer(indices);
       pGeometry->SetNumIndices(6);
       pGeometry->SetName("quad");

       pGeometry->SetNumVertexPositionElements(3);
       pGeometry->SetNumTexCoordElements(2);
       pGeometry->SetVertexStride(sizeof(float) * 5);

       bool success = false;
       if (m_file.Open())
       {
              m_fileLength = m_file.Length();

              m_pTGABuffer = new char[m_fileLength];

              m_state = LOADING_FILE;
              success = true;
       }

       return success;
}

这里我们修改了顶点数组,在每个位置指定了纹理坐标的四个角。对Geometry类参数进行了相应的更改,即纹理坐标的数量被设置为 2,顶点字符串被设置为一个浮点数乘以 5 的大小。这会计算出我们的步幅为 20 字节,这很容易验证。我们有三个位置浮点和两个纹理坐标浮点。一个浮点数的大小是 4 个字节,所以 5 乘以 4 是 20;太好了。关于纹理坐标需要注意的重要一点是它们是“颠倒的”虽然零在顶部,一在底部是正常的,但 TGA 文件实际上是垂直翻转保存图像数据的。我们不需要查看复杂的代码来翻转图像数据或预处理文件,我们只需在这里反转纹理坐标。本书中的所有纹理都是 TGAs,所以这是一个可以接受的方法,但是如果你决定使用其他图像格式,这是一个你需要注意的问题。

然后我们有了一个新的代码块,它打开我们的文件,检索它的长度,并分配一个足够大的字节数组来存储它的全部内容。然后我们的状态变量被设置为LOADING_FILE;我们将看看在Update方法中的重要性,如清单 5-48 所示。

清单 6-48。 第五章任务::更新( )

void Chapter5Task::Update()
{
       switch (m_state)
       {
       case LOADING_FILE:
       {
              void* pCurrentDataPos =
                     static_cast<char*>(m_pTGABuffer) + (sizeof(char) * m_readBytes);

              size_t bytesRead = 0;
              m_file.Read(pCurrentDataPos, 512 * 1024, bytesRead);

              m_readBytes += bytesRead;
              if (m_readBytes == m_fileLength)
              {
                     m_state = CREATE_TEXTURE;
              }
       }

       break;

       case CREATE_TEXTURE:
       {
              Framework::TGAFile tgaFile(m_pTGABuffer);

              Framework::Texture::Header textureHeader;
              textureHeader.m_height = tgaFile.GetHeight();
              textureHeader.m_width = tgaFile.GetWidth();
              textureHeader.m_bytesPerPixel = 4;
              textureHeader.m_dataSize =
                     textureHeader.m_height *
                     textureHeader.m_width *
                     textureHeader.m_bytesPerPixel;

              m_texture.SetData(textureHeader, tgaFile.GetImageData());

              m_pRenderer->AddShader(&m_textureShader);
              m_pRenderer->AddTexture(&m_texture);

              m_textureShader.SetTexture(&m_texture);

              m_pRenderer->AddRenderable(&m_renderable);

              m_state = RUNNING;
       }
       break;
       };
}

我们在Update中拥有的是一个基本的状态机。状态机是一种代码结构,它指定对象中操作的当前阶段。我们的Task有三种状态:LOADING_FILECREATE_TEXTURERUNNING。以下过程显示了状态是如何变化的。

  1. LOADING_FILE状态 每次以 512 千字节的块将test.tga文件读入分配的内存缓冲区。它通过将已经读取的字节数偏移到m_pTGABuffer来计算要读入的当前位置。File::Read为每个调用传递它读入所提供的缓冲区的字节数,我们把它加到m_readBytes的值中。一旦读取的字节数与文件的大小匹配,我们就可以满意地完成并进入下一个状态CREATE_TEXTURE
  2. CREATE_TEXTURE状态 获取读取的文件并从中创建一个TGAFile的实例。然后我们用来自tgaFile的数据创建一个Texture::Header对象,并用它来初始化m_texture和来自tgaFile的图像数据。
  3. 纹理和着色器然后被添加到Renderer,这将确保它们被正确初始化。在我们切换到RUNNING状态 之前,纹理也被添加到渲染四边形的着色器中,最后可渲染的被添加到Renderer中。

在我们进入下一章之前,我想向你展示给几何图形添加纹理可以实现什么(见图 5-3 )。

9781430258308_Fig05-03.jpg

图 5-3 。有纹理的四边形

渲染文本是一个复杂的话题,我们不会在本书中详细讨论,但是通过将文本嵌入到纹理中,我们可以将文字添加到我们的游戏引擎中。对于我们之前拥有的同一个简单的矩形,纹理为我们提供了一种为玩家提供更多细节和数据的方法。

摘要

我们并没有涵盖到这一步所需的每一行代码;相反,我把重点放在对我们试图完成的任务很重要的主要方法上。我建议您看一下本章附带的示例代码,构建它,并在调试器中使用断点来找出所有内容是如何组合在一起的。

我想重申一下写游戏引擎的好处。我们刚刚讨论的许多代码都很难完成。将这些封装成可重用代码的好处是,您再也不用编写这些代码了。从Chapter5Task类中可以清楚地看到,我们现在可以相对容易地将几何、纹理和着色器添加到未来的应用中,这将提高我们的工作效率,这正是我们将要做的。

这本书的第一部分现在已经完成,我们已经研究了视频游戏从历史到今天的发展,并且已经开始编写代码,我们希望用这些代码来影响它的未来。在下一节中,我们将开始看代码,这些代码将塑造我们将要构建的游戏 Droid Runner 的游戏性。

六、游戏实体

所有的游戏都是由不同的对象和这些对象的行为方式组成的。吃豆人拥有吃豆人本人、幽灵、吃豆点、能量球和墙壁等物品。这些对象中的每一个都有不同的行为。吃豆人对玩家的输入做出反应,通过吃能量球可以从猎物变成捕食者。这改变了鬼魂的行为,它们被置于一种逃离玩家的状态。

我们的游戏需要一组更简单的行为,但我们将使用最先进的方法来构造对象,以感受现代游戏是如何构造游戏对象的。传统上,游戏对象是用一个普通的类层次结构构建的,从一个基类开始,在每一层中添加专门化,直到我们有了一个在游戏中可用的类。这种方法的问题是不灵活。一旦游戏达到合理的复杂程度,添加新的类型会变得特别困难。还可能存在与菱形继承相关的问题,特定的对象不能很好地适应层次结构,导致理论上应该很简单的对象构造过于复杂。

今天,现代游戏架构更有可能使用基于组件的系统来构建。在这一章中,我们将看看如何使用这样的系统来构造对象。

我们还将看到一个事件系统,它将允许我们告诉游戏对象关于游戏事件的信息,然后他们可以对这些事件做出反应。这又是一个非常有用的现代系统,允许对象选择他们感兴趣的事件。以前,生成事件的系统负责通知它认为可能需要做出反应的每个对象。

最后,我们将继续实现玩家和 AI 对象,它们将在我们的游戏中使用。

什么是游戏实体?

游戏实体是一个非常简单的概念。游戏世界中存在的任何物体都是一个实体。从汽车到人,爆炸桶,电源和射弹,如果对象是一个需要在游戏世界中建模以对游戏产生影响的对象,它就是一个实体。

对于初学游戏的程序员来说,有些实体可能不太清楚。这些是世界上必须存在的物体,但不一定是可见的。灯、照相机、触发盒和声音发射器都可以是属于这一类别的游戏对象的例子。图 6-1 显示了车辆的传统等级体系。

9781430258308_Fig06-01.jpg

图 6-1 。车辆对象层次结构

我们将有一个名为GameObject的类,而不是使用这个类的层次来定义我们将在游戏中使用的对象的类型。复杂对象将通过向该对象添加组件来构建。我们将在本章的后面看一下我们将需要什么类型的组件;目前,清单 6-1 显示了GameObject类。

清单 6-1。 游戏对象类

class GameObject
{
       template <class T>
       friend T* component_cast(GameObject& object);

       template <class T>
       friend T* component_cast(GameObject* pObject);

private:
       typedef std::tr1::unordered_map<unsigned int, Component*>    ComponentUnorderedMap;
       typedef ComponentUnorderedMap::iterator            ComponentUnorderedMapIterator;
       ComponentUnorderedMap                              m_components;

       template <class T>
       T*    GetComponent() { return static_cast<T*>(GetComponent(T::GetId())); }

       Component*    GetComponent(unsigned int id);

public:
       GameObject();
       ∼GameObject();

       template <class T>
       bool AddComponent();
};

清单 6-1 包含了我们到目前为止看到的一些最复杂的代码,所以我们将一行一行地浏览。C++ 中的关键字friend用于允许其他类或函数调用属于该类实例的私有方法。在我们的例子中,我们定义了一个名为component_cast的方法,它将被用来把一个对象转换成一个指向组件类型的指针。为了方便起见,component_cast方法 被重载以获取指向GameObject的指针或引用,这样我们就不需要在整个代码库中取消对指针的引用。这些方法也被模板化,这样我们就可以使用模板语法来指定要将对象转换成哪种类型的组件。

然后我们可以看到一个typedef表示一个unordered_mapComponent指针,另一个表示一个 map 类型的迭代器。

模板方法 GetComponent是从friend component_cast函数中调用的方法,我们将在清单 6-2 中快速查看。这个方法调用GetComponent的非模板化版本,并从模板类型传递从static GetId方法获得的 id。如果通过模板作为类型传递的类不包含static GetId方法,我们将在编译时得到一个错误。

然后,在声明另一个用于向对象添加组件的模板化方法之前,我们声明我们的构造函数和析构函数。

清单 6-2 显示了重载component_cast方法的函数定义。

清单 6-2。 组件 _ 铸件

template <class T>
T* component_cast(GameObject& object)
{
       return object.GetComponent<T>();
}

template <class T>
T* component_cast(GameObject* pObject)
{
       T* pComponent = NULL;
       if (pObject)
       {
           pComponent = pObject->GetComponent<T>();
       }

       return pComponent;
}

这两种方法的主要区别在于,基于指针的版本在对传递的对象调用GetComponent之前会检查指针是否不为空。对于熟悉 C++ cast 类型如static_cast 的程序员来说,这种为GameObject包含的Component对象实现访问器的方法将会非常熟悉。实际中的代码看起来像清单 6-3 中的所示。

清单 6-3。 组件 _cast 用法举例

ComponentType* pComponent = component_cast<ComponentType>(pOurObject);

当我们在本章后面讨论机器人跑垒员Component实现时,我们会更多地关注这一点。

清单 6-4 显示GetComponent ,这是一个简单的方法。

清单 6-4。 GetComponent

Component* GameObject::GetComponent(unsigned int id)
{
       ComponentUnorderedMapIterator result = m_components.find(id);
       return result == m_components.end()
              ?      NULL
              :      result->second;
}

我们的map使用每个Component的 id 进行键控,所以我们可以通过简单地调用find并把 id 作为参数传递来从map中检索一个Component

清单 6-5 中的所示的AddComponent方法 正如它的名字所暗示的那样:它给我们的对象添加了一个组件。

清单 6-5。 AddComponent

template <class T>
bool GameObject::AddComponent()
{
       bool added = false;

       ComponentUnorderedMapIterator result = m_components.find(T::GetId());
       if (result == m_components.end())
       {
              T* pNewComponent = new T(this);

              if (pNewComponent)
              {
                     std::pair<unsigned int, Component*> newComponent(
                            T::GetId(),
                            pNewComponent);
                     std::pair< ComponentUnorderedMapIterator, bool> addedIter =
   m_components.insert(newComponent);
                     added = addedIter.second;
              }
       }

       return added;
}

首先,它调用 m_ components 上的find来检查我们是否已经将这种类型的Component添加到对象中。如果Componentunordered_map中还不存在,我们创建一个通过模板参数传递的类类型的新实例。对指针的有效性检查将有助于防止我们的代码在没有创建对象的情况下崩溃。new通常在内存不足的情况下会失败,所以如果失败了,我们很可能会看到很糟糕的事情发生。

为了给unordered_map添加一个元素,我们需要创建一个std::pair。我们的pair在键(在我们的例子中是由T::GetId()返回的值)和指向新创建组件的指针之间创建一个映射。m_components.insert以传递的pair作为参数被调用。insert返回另一个pair,由一个iteratormap和一个bool组成。如果新的Component被成功添加到map中,bool的值将为true

GameObject 中最后一个注意的方法是析构函数。我们在的清单 6-6 中看看这个。

清单 6-6。 游戏对象:*游戏对象

GameObject::∼GameObject()
{
       for (ComponentUnorderedMapIterator iter = m_components.begin();
            iter != m_components.end();
            ++iter)
       {
             Component* pComponent = iter->second;
              if (pComponent)
              {
                     delete pComponent;
                     pComponent = NULL;
              }
       }
}

GameObject的析构函数有一个简单的工作:它遍历m_components unordered_map并删除每个添加的Component

组件的基类是一段简单的代码,如清单 6-7 所示。

清单 6-7。 组件声明

class Component
{
private:
       GameObject*    m_pOwner;

public:
       explicit Component(GameObject* pOwner)
              :      m_pOwner(pOwner)
       {
       }

       virtualComponent()    {}

       virtual void Initialize()    = 0;

       GameObject* GetOwner() { return m_pOwner; }
};

如您所见,Component将只包含一个指向其所有者的指针和一个纯虚拟初始化方法。GetOwner方法稍后会有用,因为它允许Components访问它们所属的GameObject,并在必要时使用component_cast访问其他的Components

既然我们已经有了一个封装我们的对象的类和一个组件的基本接口,我们应该看看将用于与对象通信的事件系统。

通过事件与游戏对象通信

与对象通信的正常过程是调用组成类的方法。这要求我们有一个合适的进程来访问对象,并且我们知道在该对象上调用哪些方法。如果我们需要在多个对象上调用同一个方法,我们会增加代码的复杂性,因为我们需要一个方法来收集我们将要调用的所有对象。随着游戏项目的增长,这些任务变得越来越难以正确管理,需要适当的规划和管理来确保它们不会消耗太多时间。

我们可以通过切换到基于事件的系统来避免所有这些问题。当我们希望对象执行一项任务时,我们不需要调用每个类的方法,而是简单地广播一条事件消息。订阅它们希望被通知的事件是其他对象的责任。真的就这么简单。

事件类

首先,我们来看看封装了一个Event的类。代码可以在清单 6-8 中看到。

清单 6-8。 事件宣言

typedef unsigned int EventID;

class Event
{
private:
       typedef std::vector<EventHandler*>        EventHandlerList;
       typedef EventHandlerList::iterator        EventHandlerListIterator;

       EventHandlerList                m_listeners;
       EventID                        m_id;

public:
       explicit Event(EventID eventId);
       ∼Event();

       void Send();
       void SendToHandler(EventHandler& eventHandler);
       void AttachListener(EventHandler& eventHandler);
       void DetachListener(EventHandler& eventHandler);

       EventID        GetID()    const            { return m_id; }
};

一个Event的类是另一个简单的类。你可以看到我们将在m_listeners中存储一个EventHandler指针的列表。我们的Event也将有一个id字段,我们将确保它对每个Event都是唯一的。

公共方法SendSendToHandlerAttachListenerDetachListener是执行对象的主要工作的地方。

清单 6-9 展示了Send方法。

清单 6-9。 事件::发送( )

void Event::Send()
{
       for (EventHandlerListIterator iter = m_listeners.begin();
            iter != m_listeners.end();
            ++iter)
       {
              EventHandler* pEventHandler = *iter;
              assert(pEventHandler);
              if (pEventHandler)
              {
                     pEventHandler->HandleEvent(this);
              }
       }
}

这个方法简单地遍历m_listeners列表的所有成员,从迭代器中检索EventHandler指针,并在对象上调用EventHandler::HandleEvent。很简单。清单 6-10 展示了我们如何发送一个Event给一个单独的对象。

清单 6-10。 事件::SendToHandler( )

void Event::SendToHandler(EventHandler& eventHandler)
{
       for (EventHandlerListIterator iter = m_listeners.begin();
            iter != m_listeners.end();
            ++iter)
       {
              if (&eventHandler == *iter)
              {
                     eventHandler.HandleEvent(this);
              }
       }
}

在这里您可以看到,我们将只把事件发送给参数中传递的特定对象,并且只有当该对象存在于我们的侦听器列表中时。我们通过AttachListenerDetachListener方法管理列表。首先,AttachListener?? 如图清单 6-11 所示。

清单 6-11。 事件::AttachListener()

void Event::AttachListener(EventHandler& eventHandler)
{
       m_listeners.push_back(&eventHandler);
}

将一个对象附加到事件上就像将其地址推送到 m_ listeners上一样简单。

DetachListener 见清单 6-12 。

清单 6-12。Event::detach listener()

void Event::DetachListener(EventHandler& eventHandler)
{
       for (EventHandlerListIterator iter = m_listeners.begin();
            iter != m_listeners.end();
            ++iter)
       {
              if (&eventHandler == *iter)
              {
                     m_listeners.erase(iter);
                     break;
              }
       }
}

要删除一个监听器,我们遍历m_listeners并在迭代器上调用 erase,该迭代器匹配参数中传递的EventHandler的地址。

您可能已经注意到,这些方法不能防止同一个对象被多次附加,并且只会从列表中删除侦听器的一个实例。为了简单起见,我将确保对象不会被多次添加到调用代码中。

EventHandler 类

现在我们知道了Event类的样子,我们将看看清单 6-13 中的EventHandler类。

清单 6-13。 事件处理类 声明

class EventHandler
{
public:
       virtualEventHandler()    {}

       virtual void HandleEvent(Event* pEvent) = 0;
};

这门课实在是再简单不过了。我们提供了一个纯虚拟方法HandleEvent,让我们的继承类覆盖。

事件管理器

EventManager 如清单 6-14 所示。EventManager 是游戏代码和事件系统之间的接口。

清单 6-14。event manager 类声明

class EventManager
       :      public Singleton<EventManager>
{
       friend void SendEvent(EventID eventId);
       friend void SendEventToHandler(EventID eventId, EventHandler& eventHandler);
       friend bool RegisterEvent(EventID eventId);
       friend void AttachEvent(EventID eventId, EventHandler& eventHandler);
       friend void DetachEvent(EventID eventId, EventHandler& eventHandler);

private:
       typedef std::tr1::unordered_map<EventID, Event*>    EventMap;
       typedef EventMap::iterator                EventMapIterator;

       EventMap                        m_eventMap;

       void SendEvent(EventID eventId);
       void SendEventToHandler(EventID eventId, EventHandler& eventHandler);
       bool RegisterEvent(EventID eventId);
       void AttachEvent(EventID eventId, EventHandler& eventHandler);
       void DetachEvent(EventID eventId, EventHandler& eventHandler);

public:
       EventManager();
       ∼EventManager();
};

从清单 6-14 中收集到的第一条重要信息是,我们已经把这个类变成了一个单例对象。Singleton 是一个有点争议的设计模式,它允许我们从代码库中的任何一点访问一个类的单个实例。这两个属性对我们的EventManager实现都很重要。关于如何实现 Singleton 的更多细节,请查看本书的附录。

事件管理器的朋友函数

你可以从清单 6-14 中的类中看到,我们再次利用了friend关键字来降低该类调用者代码的复杂性。我们来看看清单 6-15 中的朋友函数,看看为什么。

清单 6-15。 EventManager 的好友

inline void SendEvent(EventID eventId)
{
       EventManager* pEventManager = EventManager::GetSingletonPtr();
       assert(pEventManager);
       if (pEventManager)
       {
              pEventManager->SendEvent(eventId);
       }
}

inline void SendEventToHandler(EventID eventId, EventHandler& eventHandler)
{
       EventManager* pEventManager = EventManager::GetSingletonPtr();
       assert(pEventManager);
       if (pEventManager)
       {
              pEventManager->SendEventToHandler(eventId, eventHandler);
       }
}

inline bool RegisterEvent(EventID eventId)
{
       EventManager* pEventManager = EventManager::GetSingletonPtr();
       assert(pEventManager);
       if (pEventManager)
       {
              pEventManager->RegisterEvent(eventId);
       }
}

inline void AttachEvent(EventID eventId, EventHandler& eventHandler)
{
       EventManager* pEventManager = EventManager::GetSingletonPtr();
       assert(pEventManager);
       if (pEventManager)
       {
              pEventManager->AttachEvent(eventId, eventHandler);
       }
}

inline void DetachEvent(EventID eventId, EventHandler& eventHandler)
{
       EventManager* pEventManager = EventManager::GetSingletonPtr();
       assert(pEventManager);
       if (pEventManager)
       {
              pEventManager->DetachEvent(eventId, eventHandler);
       }
}

每个友元函数包装了检索对象的Singleton指针的代码,验证它已经被创建,然后调用同名的EventManager类方法。这将安全调用所需的六行代码减少为调用代码中的一行代码,这将有助于提高代码的可读性和生产率。

大 o 符号〔??〕

如果你再看一下清单 6-14 中的,你会发现我们再次使用了一个unordered_map来存储我们的Events。我们上次在GameObject中使用了一个unordered_map来存储我们的Components,但是我们当时没有讨论为什么使用这个结构。可以根据算法预计完成所需的时间来评估算法。计算这个时间的方法叫做大 O 记数法。大 O 记数法本身并不测量时间;相反,它为我们提供了一种方法,用于评估对于给定的一组大小为 n 的元素,完成一个算法需要多长时间。

当我们访问一个unordered_map时,我们给容器一个哈希值,它将这个值转换成存储我们元素的地址。不管在我们的unordered_map中有多少个元素,这都需要相同的时间长度,因此被称为在常数时间内执行,或者用大 O 符号表示为 O(1)。如果我们使用了一个list容器来存储元素,list中的每个新元素都会增加以线性方式查找任何给定元素的时间。在大 O 符号中,这将是 O( n ),对于一个*衡良好的树容器,我们将看到一个大 O of O(log( n ))。

我选择使用unordered_map,因为我们将对组件和事件执行的最常见操作是从它们的容器中检索它们。当一个游戏试图尽可能快地执行它的代码时,我们可能会有这样的情况,我们有许多事件和组件附加到任何给定的对象上,这对于我们利用unordered_map访问的 O(1)属性来实现这个目的是有意义的。

EventManager 的接口方法

清单 6-16 显示了用于调用Event类的SendSendToHandler方法的代码。

清单 6-16。event manager::send event()

void EventManager::SendEvent(EventID eventId)
{
       EventMapIterator result = m_eventMap.find(eventId);
       if (result != m_eventMap.end())
       {
              assert(result->second);
              if (result->second)
              {
                     result->second->Send();
              }
       }
}

void EventManager::SendEventToHandler(EventID eventId, EventHandler& eventHandler)
{
       EventMapIterator result = m_eventMap.find(eventId);
       if (result != m_eventMap.end())
       {
              assert(result->second);
              if (result->second)
              {
                     result->second->SendToHandler(eventHandler);
              }
       }
}

我们从m_eventMap容器中检索iterator,然后在它包含的Event对象上调用相关方法。

清单 6-17 中的方法展示了我们如何向m_eventMap 容器添加一个新事件。

清单 6-17。event manager::RegisterEvent()

bool EventManager::RegisterEvent(EventID eventId)
{
       bool added = false;

       EventMapIterator result = m_eventMap.find(eventId);
       if (result == m_eventMap.end())
       {
              Event* pNewEvent = new Event(eventId);

              if (pNewEvent)
              {
                     std::pair<EventID, Event*> newEvent(eventId, pNewEvent);
                     std::pair<EventMapIterator, bool> addedIter = m_eventMap.insert(newEvent);
                     added = addedIter.second;
              }
       }

       assert(added);
       return added;
}

正如我们之前对unordered_map所做的一样,我们创建了一个新元素,用它的key将它捆绑到一个pair中,并将pair插入到unordered_map中。

清单 6-18 显示了EventManager的析构函数以及从m_eventMap中清除元素所需的代码。

清单 6-18. 事件管理员:*事件管理员( )

EventManager::∼EventManager()
{
       for (EventMapIterator iter = m_eventMap.begin(); iter != m_eventMap.end(); ++iter)
       {
              Event* pEvent = iter->second;
              if (pEvent)
              {
                     delete pEvent;
                     iter->second = NULL;
              }
       }

       m_eventMap.clear();
}

EventManager最重要的最后两个方法是AttachEventDetachEvent方法。这些用于确保希望接收特定事件的对象被设置为这样做,如清单 6-19 所示。

清单 6-19。 事件管理器的 AttachEvent 和 DetachEvent

void EventManager::AttachEvent(EventID eventId, EventHandler& eventHandler)
{
       EventMapIterator result = m_eventMap.find(eventId);
       assert(result != m_eventMap.end());
       if (result != m_eventMap.end())
       {
              assert(result->second);
              result->second->AttachListener(eventHandler);
       }
}

void EventManager::DetachEvent(EventID eventId, EventHandler& eventHandler)
{
       EventMapIterator result = m_eventMap.find(eventId);
       assert(result != m_eventMap.end());
       if (result != m_eventMap.end())
       {
              assert(result->second);
              result->second->DetachListener(eventHandler);
       }
}

这是我们目前对EventManager类的所有内容。在下一节中,我们将创建一个可以添加到GameObjects中的Component,并告诉他们将自己添加到Renderer中。我们将通过使用一个Event来实现这一点,当游戏应该告诉它的对象进行渲染时,就会发送这个消息。

渲染对象

正如我们刚刚提到的,在这一节中,我们将通过创建一个RenderableComponent来实践我们刚刚学习过的类。

TransformComponent 类

在我们渲染一个物体之前,我们需要知道它应该放在游戏世界的什么地方。我们将把这些信息存储在另一个ComponentTransformComponent中,如清单 6-20 所示。

清单 6-20。transform component 类声明

class TransformComponent
       :      public Component
{
private:
       static const unsigned int s_id = 0;

       Transform    m_transform;

public:
       static unsigned int GetId()    { return s_id; }

       explicit TransformComponent(GameObject* pOwner);
       virtualTransformComponent();

       virtual void Initialize();

       Transform& GetTransform()    { return m_transform; }
};

TransformComponent的构造函数、析构函数和Initialize方法都是空的,因为它们不需要执行任何任务。TransformComponent的唯一工作是为我们的游戏对象提供一个Transform对象。

转换类

转换类的定义如清单 6-21 中的所示。

清单 6-21。 转换类声明

class Transform
{
private:
       Matrix3        m_rotation;
       Vector3        m_translation;
       float          m_scale;
       Matrix4        m_matrix;

public:
       Transform();
       virtualTransform();

       void                 Clone(const Transform& transform);

       void                 SetRotation(const Matrix3& rotation);
       const Matrix3&       GetRotation() const;

       void                 SetTranslation(const Vector3& translation);
       const Vector3&       GetTranslation() const;

       void                 SetScale(const float scale);
       const float          GetScale() const;

       void                 ApplyForward(const Vector3& in, Vector3& out) const;
       void                 ApplyInverse(const Vector3& in, Vector3& out) const;

       void                 UpdateMatrix();

       const Matrix4&       GetMatrix() const;
       void                 GetInverseMatrix(Matrix4& out) const;
       void                 GetInverseTransposeMatrix(Matrix4& out) const;
};

这个Transform类是从大卫·埃伯利在他的网站www.geometrictools.com/上提供的实现中派生出来的。如果你需要复习你的数学技能,我在这本书的附录中提供了一个向量和矩阵的快速纲要。

Transform类的访问器方法是不言自明的,所以让我们看看清单 6-22 中的UpdateMatrix

清单 6-22。 变换::更新矩阵( )

void Transform::UpdateMatrix()
{
       m_matrix.m_m[0] = m_rotation.m_m[0] * m_scale;
       m_matrix.m_m[1] = m_rotation.m_m[1];
       m_matrix.m_m[2] = m_rotation.m_m[2];
       m_matrix.m_m[3] = 0.0f;
       m_matrix.m_m[4] = m_rotation.m_m[3];
       m_matrix.m_m[5] = m_rotation.m_m[4] * m_scale;
       m_matrix.m_m[6] = m_rotation.m_m[5];
       m_matrix.m_m[7] = 0.0f;
       m_matrix.m_m[8] = m_rotation.m_m[6];
       m_matrix.m_m[9] = m_rotation.m_m[7];
       m_matrix.m_m[10] = m_rotation.m_m[8] * m_scale;
       m_matrix.m_m[11] = 0.0f;
       m_matrix.m_m[12] = m_translation.m_x;
       m_matrix.m_m[13] = m_translation.m_y;
       m_matrix.m_m[14] = m_translation.m_z;
       m_matrix.m_m[15] = 1.0f;
}

UpdateMatrix顾名思义:它用转换的当前状态更新内部矩阵。3×3 旋转矩阵的每个成员都被复制到 4×4 变换矩阵的正确条目中,并在矩阵的对角线上进行适当的缩放。正如 OpenGL 所期望的那样,翻译值被复制到矩阵的翻译条目中的位置 12、13 和 14。

GetMatrix简单地返回一个我们在清单 6-22 中看到的内部矩阵的引用,所以我们将继续看方法GetInverseMatrix ,如清单 6-23 所示。

清单 6-23。Transform::getinversmatrix()

void Transform::GetInverseMatrix(Matrix4& out) const
{
       float invScale = 1.0f / m_scale;
       out.m_m[0] = m_rotation.m_m[0] * invScale;
       out.m_m[1] = m_rotation.m_m[3];
       out.m_m[2] = m_rotation.m_m[6];
       out.m_m[3] = 0.0f;
       out.m_m[4] = m_rotation.m_m[1];
       out.m_m[5] = m_rotation.m_m[4] * invScale;
       out.m_m[6] = m_rotation.m_m[7];
       out.m_m[7] = 0.0f;
       out.m_m[8] = m_rotation.m_m[2];
       out.m_m[9] = m_rotation.m_m[5];
       out.m_m[10] = m_rotation.m_m[8] * invScale;
       out.m_m[11] = 0.0f;
       out.m_m[12] = -m_translation.m_x;
       out.m_m[13] = -m_translation.m_y;
       out.m_m[14] = -m_translation.m_z;
       out.m_m[15] = 1.0f;
}

如果你以前学过矩阵背后的数学,你可能已经知道逆矩阵是用来逆转原矩阵效果的矩阵。就简单代数而言 1 × 10 = 10。乘以 10 的倒数就是乘以 1/10,所以 10 × (1/10) = 1。逆矩阵执行相同的工作。计算矩阵的逆矩阵是一个计算量很大的过程,但是在游戏开发中,变换矩阵的逆矩阵计算起来要简单得多。

在这种情况下,我们可以使用一些特殊的属性。由于缩放是简单的乘法,我们可以乘以反倍数,正如你在GetInverseMatrix的第一行看到的,我们通过用 1 除以m_scale来计算反比例。

我们可以利用的下一个特殊属性是旋转矩阵的情况。旋转矩阵是一种特殊类型的矩阵,称为正交矩阵。这意味着矩阵中的每一行代表一个单位向量。在我们的例子中,旋转矩阵的每一行都应该是一个单位向量,代表要应用的旋转的 x、y 和 z 轴。正交矩阵的逆就是它的转置,所以我们很幸运,因为我们可以很容易地转置矩阵。查看清单 6-23 ,你可以从我们索引到代表矩阵的数组的方式中看到这一点:注意它们是如何不完全匹配的。例如,在第一行中,我们不是按 0,1,2 的顺序复制,而是取第一列 0,3,6。

最后但同样重要的是翻译组件。*移可以简单地认为是加法运算,因此我们可以通过将*移向量元素的负值相加来计算逆运算。

你可能会同意,这比使用传统方法计算倒数要简单得多。

可渲染组件

现在我们有了在世界中放置物体的方法,让我们看看我们将如何渲染它们(见清单 6-24 )。

清单 6-24。RenderableComponent

class RenderableComponent
       :      public Component
       ,      public EventHandler
{
private:
       static const unsigned int s_id = 1;

       Renderable        m_renderable;

public:
       static unsigned int GetId()    { return s_id; }

       explicit RenderableComponent(GameObject* pOwner);
       virtualRenderableComponent();

       virtual void Initialize();

       Renderable& GetRenderable()    { return m_renderable; }

       virtual void HandleEvent(Event* pEvent);
};

同样,我们有一个非常简单的组件。唯一的字段是Renderable,我们可以将它传递给Renderer。该声明与TransformComponent声明的主要区别在于RenderableComponentComponent一起继承了EventHandler。这意味着我们需要覆盖HandleEvent;它的代码在清单 6-25 中列出。

清单 6-25。RenderableComponent::handle event()

void RenderableComponent::HandleEvent(Event* pEvent)
{
       assert(pEvent);
       if (pEvent->GetID() == RENDER_EVENT)
       {
              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              if (pTransformComponent)
              {
                     m_renderable.GetTransform().Clone(pTransformComponent->GetTransform());
              }

              assert(Renderer::GetSingletonPtr());
              Renderer::GetSingleton().AddRenderable(&m_renderable);
       }
}

事情开始以这种方式第一次走到一起。第一个任务是检查我们被传递的事件是否是RENDER_EVENT。然后,我们使用之前编写的component_cast方法将所有者指针转换为TransformComponent指针。一旦我们有了一个有效的TransformComponent指针,我们就把它的Transform克隆到另一个Transform对象中,我们已经把它添加到了Renderable类中。

在这个方法中,你可以看到《??》第五章中代码的另一个变化是,我们从Singleton继承了Renderer。这允许我们从这个调用中直接将Renderable对象添加到Renderer中。

TransformShader 类

能够使用Transform呈现对象的下一步是创建一个支持矩阵的Shader。清单 6-26 显示了我们TransformShader的声明。

清单 6-26。transform shader 类声明

class TransformShader
       :      public Shader
{
private:
       Matrix4        m_projection;

       GLint          m_transformUniformHandle;
       GLint          m_positionAttributeHandle;
       GLint          m_colorAttributeHandle;

public:
       TransformShader();
       virtualTransformShader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);
};

该着色器将具有统一的变换和顶点位置和颜色属性。我们在课堂上也有一个 4×4 矩阵。这是一项临时措施,将允许我们建立一个预测矩阵。当我们考虑实现游戏摄像机时,我们将在第八章中更详细地讨论投影。

清单 6-27 显示了我们的顶点和片段着色器的 GLSL 代码。

清单 6-27。 TransformShader 的构造函数

TransformShader::TransformShader()
{
       m_vertexShaderCode =
                  "uniform mat4 u_mModel;                      \n"
                  "attribute vec4 a_vPosition;                 \n"
                  "void main(){                                \n"
                  "    gl_Position = u_mModel * a_vPosition;   \n"
                  "}                                           \n";

       m_fragmentShaderCode =
                  "precision highp float;                      \n"
                  "uniform vec4 a_vColor;                      \n"
                  "void main(){                                \n"
                  "    gl_FragColor = a_vColor;                \n"
                  "}                                           \n";
}

在顶点着色器中,我们有一个uniform mat4矩阵,它将存储从Renderer传来的变换矩阵。该矩阵用于乘以顶点位置,并将结果存储在gl_Position中。

对于片段着色器,我们有一个统一的片段颜色。这将允许我们使用着色器来渲染多个对象,并使每个对象具有不同的颜色。

现在我们来看看访问清单 6-28 中的统一和属性位置所需的代码。

清单 6-28。transform shader::Link()

void TransformShader::Link()
{
       Shader::Link();

       m_transformUniformHandle     = glGetUniformLocation(m_programId, "u_mModel");
       m_positionAttributeHandle    = glGetAttribLocation(m_programId, "a_vPosition");
       m_colorAttributeHandle       = glGetUniformLocation(m_programId, "a_vColor");

       float halfAngleRadians  = 0.5f * 45.0f * (3.1415926536f / 180.0f);
       float m_top             = 1.0f * (float)tan(halfAngleRadians);
       float m_bottom          = -m_top;
       float m_right           = (1280.0f / 720.0f) * m_top;
       float m_left            = -m_right;
       float m_near            = 1.0f;
       float m_far             = 100.0f;

       m_projection.m_m[0]     = (2.0f * m_near) / (m_right - m_left);
       m_projection.m_m[1]     = 0.0f;
       m_projection.m_m[2]     = 0.0f;
       m_projection.m_m[3]     = 0.0f;
       m_projection.m_m[4]     = 0.0f;
       m_projection.m_m[5]     = (2.0f * m_near) / (m_top - m_bottom);
       m_projection.m_m[6]     = 0.0f;
       m_projection.m_m[7]     = 0.0f;
       m_projection.m_m[8]     = -((m_right + m_left) / (m_right - m_left));
       m_projection.m_m[9]     = -((m_top + m_bottom) / (m_top - m_bottom));
       m_projection.m_m[10]    = (m_far + m_near) / (m_far - m_near);
       m_projection.m_m[11]    = 1.0f;
       m_projection.m_m[12]    = 0.0f;
       m_projection.m_m[13]    = 0.0f;
       m_projection.m_m[14]    = -(2.0f * m_near * m_far) / (m_far - m_near);
       m_projection.m_m[15]    = 0.0f;
}

在这个清单中,您可以看到我们使用glGetUniformLocation来获取转换u_mModel的位置,使用glGetAttribLocation来获取a_vPositiona_vColor的位置。

然后我们有一段代码,它计算出一个叫做*截头体的形状的边,并使用这个*截头体来构造一个透视投影矩阵。这个主题将在第八章中详细介绍,并将被纳入正式的Camera课程中。

清单 6-29 中的 TransformShader::Setup方法展示了我们如何将矩阵提供给着色器。

清单 6-29。transform shader::Setup()

void TransformShader::Setup(Renderable& renderable)
{
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry)
       {
              Shader::Setup(renderable);

              Matrix4 mMVP;
              renderable.GetTransform().GetMatrix().Multiply(m_projection, mMVP);
              glUniformMatrix4fv(m_transformUniformHandle, 1, false, mMVP.m_m);

              glVertexAttribPointer(
                         m_positionAttributeHandle,
                         pGeometry->GetNumVertexPositionElements(),
                         GL_FLOAT,
                         GL_FALSE,
                         pGeometry->GetVertexStride(),
                         pGeometry->GetVertexBuffer());
              glEnableVertexAttribArray(m_positionAttributeHandle);

              Vector4& color = renderable.GetColor();
              glUniform4f(m_colorAttributeHandle, color.m_x, color.m_y, color.m_z, color.m_w);
       }
}

我们建立了一个临时矩阵,mMVP。该矩阵存储可渲染矩阵与投影矩阵相乘的结果。然后我们使用glUniformMatrix4fv 将矩阵传递给 OpenGL。第一个参数是统一变换的位置,第二个参数是要上传的矩阵数量,在我们的例子中是一个。传递的下一个参数是false,它告诉驱动程序我们不希望它在将矩阵发送到着色器之前转置我们的矩阵。最后,我们将指针传递给矩阵数据。

接下来的几行设置了顶点数据流,就像我们之前处理着色器一样。

该功能的最后一个任务是使用glUniform4f 设置颜色均匀性。我们向该方法传递颜色在着色器中的位置以及颜色的 rgba 值的四个浮点值。

现在我们终于写了几个组件,一个支持变换的着色器,并看看EventHandlers如何响应事件。我们将在下一节使用这些组件,我们将制作一个基本的播放器GameObject并在应用中渲染它。

玩家对象

在我们使用玩家对象之前,我们将为这个示例应用创建一个任务,如清单 6-30 所示。

清单 6-30。 第六章任务类申报

class Chapter6Task
       :      public Framework::Task
{
private:
       Framework::Geometry           m_geometry;
       Framework::TransformShader    m_transformShader;

       Framework::GameObject         m_playerObject;

public:
       Chapter6Task(const unsigned int priority);
       virtualChapter6Task();

       // From Task
       virtual bool    Start();
       virtual void    OnSuspend();
       virtual void    Update();
       virtual void    OnResume();
       virtual void    Stop();
};

我们有一个Geometry类的实例、一个TransformShader和一个GameObject,我们将其命名为m_playerObject。现在我们来看看Start方法 ,如清单 6-31 所示。

清单 6-31。 第六章 Task::Start()

bool Chapter6Task::Start()
{
       using namespace Framework;

       Renderer* pRenderer = Renderer::GetSingletonPtr();
       if (pRenderer)
       {
              pRenderer->AddShader(&m_transformShader);
       }

       m_geometry.SetVertexBuffer(verts);
       m_geometry.SetNumVertices(sizeof(verts) / sizeof(verts[0]));
       m_geometry.SetIndexBuffer(indices);
       m_geometry.SetNumIndices(sizeof(indices) / sizeof(indices[0]));
       m_geometry.SetName("android");

       m_geometry.SetNumVertexPositionElements(3);
       m_geometry.SetVertexStride(0);

       RegisterEvent(UPDATE_EVENT);
       RegisterEvent(RENDER_EVENT);
       RegisterEvent(JUMP_EVENT);

       m_playerObject.AddComponent<MovementComponent>();
       MovementComponent* pMovementComponent =
              component_cast<MovementComponent>(m_playerObject);
       if (pMovementComponent)
       {
              Framework::AttachEvent(Framework::UPDATE_EVENT, *pMovementComponent);
              Framework::AttachEvent(Framework::JUMP_EVENT, *pMovementComponent);
       }

       m_playerObject.AddComponent<TransformComponent>();
       TransformComponent* pTransformComponent =
              component_cast<TransformComponent>(m_playerObject);
       if (pTransformComponent)
       {
              Vector3 translation(-10.0f, 0.0f, 50.0f);
              Transform& transform = pTransformComponent->GetTransform();
              transform.SetTranslation(translation);
       }

       m_playerObject.AddComponent<RenderableComponent>();
       RenderableComponent* pRenderableComponent =
              component_cast<RenderableComponent>(m_playerObject);
       if (pRenderableComponent)
       {
              Renderable& renderable = pRenderableComponent->GetRenderable();
              renderable.SetGeometry(&m_geometry);
              renderable.SetShader(&m_transformShader);
              Vector4& color = renderable.GetColor();
              color.m_x = 0.0f;
              color.m_y = 1.0f;
              color.m_z = 0.0f;
              color.m_w = 1.0f;
              Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
       }

       return true;
}

最后,清单 6-31 显示了用户可以编写的客户端代码,以使用我们为事件、组件和游戏对象系统创建的框架系统。

我们通过向Renderer注册我们的TransformShader然后配置Geometry对象来开始Start方法。顶点和索引的数量现在是在运行时通过使用sizeof操作符将包含数据的数组的大小除以数组中单个元素的大小来计算的。

我们接着记录一些事件。我们注册了UPDATE_EVENTRENDER_EVENTJUMP_EVENT。我们已经看到RenderableComponent将使用RENDER_EVENTRenderable添加到渲染队列,但是我们还没有看到更新和跳转的事件;我们很快就会看到这些。

此时,我们开始向我们的GameObject添加组件。在添加了一个MovementComponent之后,我们还添加了一个TransformComponent和一个RenderableComponent。添加完RenderableComponent后,我们使用component_castm_playerObject中检索它,然后设置GeometryShader以及我们希望与其Renderable一起使用的颜色。最后,我们附上RENDER_EVENT

在继续查看MovementComponent之前,我们先来看看Update方法 ,如清单 6-32 所示。

清单 6-32。 第六章 Task::Update()

void Chapter6Task::Update()
{
       Framework::SendEvent(Framework::UPDATE_EVENT);
       Framework::SendEvent(Framework::RENDER_EVENT);
}

这个系统的美妙之处现在变得越来越明显。代码在大多数方面都非常简洁。为了更新和呈现所有的GameObjects,我们只需发送更新和呈现事件,所有附加了这些事件的GameObject实例将会更新和呈现。这再简单不过了。

我们现在将使用这个系统让玩家与游戏互动。该代码将检测玩家何时触摸屏幕,玩家的角色将在空中跳跃。

让玩家跳跃

我们将从直接进入MovementComponent 的代码开始这一部分,如清单 6-33 所示。

清单 6-33。movement component 类声明

class MovementComponent
       :      public Framework::Component
       ,      public Framework::EventHandler
{
private:
       static const unsigned int s_id = 9;

       Framework::Vector3        m_acceleration;
       Framework::Vector3        m_velocity;

public:
       static unsigned int GetId()    { return s_id; }

       explicit MovementComponent(Framework::GameObject* pObject);
       virtualMovementComponent();

       virtual void Initialize();

       virtual void HandleEvent(Framework::Event* pEvent);
};

你可以看到我们定义了两个Vector3对象,一个用于加速度,一个用于速度。你也许可以从这些名字中看出,在HandleEvent方法 中,我们将看一看一些基本的物理学。看看清单 6-34 。

清单 6-34。movement component::handle event()

void MovementComponent::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == JUMP_EVENT)
       {
              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              if (pTransformComponent &&
                  pTransformComponent->GetTransform().GetTranslation().m_y < FLT_EPSILON)
              {
                     static const float JUMP_ACCELERATION = 80.0f;
                     m_acceleration.m_y = JUMP_ACCELERATION;
              }
       }
       else if (pEvent->GetID() == UPDATE_EVENT)
       {
              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              if (pTransformComponent)
              {
                     const Vector3& position =
                            pTransformComponent->GetTransform().GetTranslation();
                     bool onFloor = false;
                     if (position.m_y < FLT_EPSILON)
                     {
                            onFloor = true;
                     }

                     bool falling = m_acceleration.m_y < 0.0f;

                     Timer& timer = Timer::GetSingleton();
                     Vector3 translation = m_velocity;
                     translation.Multiply(timer.GetTimeSim());
                     translation.Add(position);
                     if (falling && translation.m_y < 0.0f)
                     {
                            translation.m_y = 0.0f;
                     }

                     pTransformComponent->GetTransform().SetTranslation(translation);

                     Vector3 accel = m_acceleration;
                     accel.Multiply(timer.GetTimeSim());
                     m_velocity.Add(accel);

                     static const float GRAVITY_MULTIPLIER    = 15.0f;
                     static const float GRAVITY_CONSTANT    =  -9.8f;
                     m_acceleration.m_y +=
                              GRAVITY_MULTIPLIER *
                              GRAVITY_CONSTANT *
                              timer.GetTimeSim();
                     if (falling && onFloor)
                     {
                            m_acceleration.m_y  = 0.0f;
                            m_velocity.m_y        = 0.0f;
                     }
              }
       }
}

在清单 6-34 中执行的第一个任务是处理JUMP_EVENT 。当这个事件被发送到MovementComponent时,我们首先使用component_cast从所有者对象获取TransformComponent。现在,我们使用 y 的位置来查看玩家是否在地面上并被允许再次跳跃。我们将在下一章讨论水*碰撞时解决这个问题,但是现在如果玩家被允许跳跃,我们设置他的垂直加速度。

游戏物理学是基于导数和积分的数学概念。物体位置或位移的导数就是它的速度。速度的导数是加速度。鉴于这一事实,如果我们有一个加速度,我们可以积分,以达到期望的速度,我们也可以积分速度,以获得新的位置。

计算加速度的基本方程如下:加速度=力/质量。为了简单起见,我们使用质量为 1 的物体,所以现在我们可以将方程简化为加速度=力。因此,我们可以像处理JUMP_EVENT时那样直接设置加速度值。清单 6-35 中的代码显示了当前速度位置的积分。

清单 6-35。 整合位置

Vector3 translation = m_velocity;
translation.Multiply(Timer::GetSingleton().GetTimeSim());
translation.Add(position);

这种简单的速度乘以当前时间步长并加上位置的方法是一种简单的数值方法,称为欧拉积分。有很好的理由解释为什么你不在商业游戏中使用欧拉积分,但是为了我们简单的目的和速度,我们在这里使用了它。

如果加速度不恒定并且时间步长逐帧变化,欧拉积分可能不稳定并且给出不可靠的位置,这在我们的游戏场景变得繁忙时肯定会发生。幸运的是,我们的加速是恒定的,我们的简单游戏应该以一贯的高帧速率运行。对于任何想研究更稳定的数值积分技术并强烈推荐用于游戏开发的人来说,你应该研究四阶龙格库塔积分器。

清单 6-36 显示了我们将加速度积分成速度的欧拉积分。

清单 6-36。 用欧拉积分加速

Vector3 accel = m_acceleration;
accel.Multiply(timer.GetTimeSim());
m_velocity.Add(accel);

我们再次乘以当前模拟时间步长,并添加到现有值。

为了让我们的玩家落回地面,我们需要对我们的模型施加重力。同样,我们可以通过使用重力的一个有用特性来稍微简化代码。重力作用于物体,将它们拉向地面。你可能会认为,随着力的方程式将加速度乘以质量,较大的物体可能会下落得更快。事实并非如此,因为物体也有一种被称为惯性的属性,这种属性也与质量有关。因此,重力方程中的质量与惯性方程中的质量相抵消,我们发现地球上所有物体在重力作用下都以大约每秒 9.8 米的速度加速。清单 6-37 展示了我们如何使用这个值让我们的物体落回地面。

清单 6-37。 对加速度应用重力

static const float GRAVITY_MULTIPLIER    = 15.0f;
static const float GRAVITY_CONSTANT    =  -9.8f;
m_acceleration.m_y += GRAVITY_MULTIPLIER * GRAVITY_CONSTANT * timer.GetTimeSim();

此时,我们的跳跃加速度为 80,重力乘数为 15。我完全是通过反复试验得出这些数字的。您可以尝试本章附带的示例代码,增加或减少这些数字,看看它们对我们的跳转模拟有什么影响。

这段代码在收到UPDATE_EVENT 时运行每一帧,所以一旦玩家跳起来,他们就会起来,然后落回地面。用于UPDATE_EVENT的代码块中的三个 if 语句也有助于通过计算玩家是否在地板上、更新后是否在地板上以及他们是否正在下落(由于 y 方向上的速度为负)来决定跳跃应该何时结束。

本节要看的最后一段代码是我们如何发送跳转事件(见清单 6-38 )。

清单 6-38。 安卓::安卓( )

Android::Android(android_app* pState, unsigned int priority)
    :    Task(priority)
{
       m_pState                  = pState;
       m_pState->onAppCmd        = android_handle_cmd;
       m_pState->onInputEvent    = android_handle_input;
}

我们已经更新了之前的android_app状态对象来保存另一个函数指针,这一次是为了当游戏的输入事件准备好的时候。清单 6-39 显示了函数声明。

清单 6-39。 安卓 _ 手柄 _ 输入

static int android_handle_input(struct android_app* app, AInputEvent* event)
{
       int handled = 0;
       if (AInputEvent_getType(event) == AINPUT_EVENT_TYPE_MOTION)
       {
              int action = AMotionEvent_getAction(event);
              if (action == AMOTION_EVENT_ACTION_DOWN)
              {
                     Framework::SendEvent(Framework::JUMP_EVENT);
                     handled = 1;
              }
       }
       return handled;
}

我们的简单游戏要求玩家只有在接触到屏幕时才能跳跃。当收到AMOTION_EVENT_ACTION_DOWN时,我们发送我们的JUMP_EVENT,任何监听该事件的EventHandlers都将被处理。

现在我们有了一个玩家对象,我们将添加一个遵循路径的 AI 对象。

一个基本的人工智能实体

首先,我们将向Chapter6Task类添加另一个GameObject,如清单 6-40 所示。

清单 6-40。 添加一个 AI 对象

private:
       Framework::Geometry          m_geometry;
       Framework::TransformShader   m_transformShader;

       Framework::GameObject        m_playerObject;
       Framework::GameObject        m_aiObject;

我们将这个对象的设置代码添加到Chapter6Task::Start,如清单 6-41 所示。

清单 6-41。 更新章节 6Task::Start()

       m_playerObject.AddComponent<RenderableComponent>();
       RenderableComponent* pRenderableComponent =
              component_cast<RenderableComponent>(m_playerObject);
       if (pRenderableComponent)
       {
              Renderable& renderable = pRenderableComponent->GetRenderable();
              renderable.SetGeometry(&m_geometry);
              renderable.SetShader(&m_transformShader);
              Vector4& color = renderable.GetColor();
              color.m_x = 0.0f;
              color.m_y = 1.0f;
              color.m_z = 0.0f;
              color.m_w = 1.0f;
              Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
       }

       m_aiObject.AddComponent<TransformComponent>();
       pTransformComponent = component_cast<TransformComponent>(m_aiObject);

       m_aiObject.AddComponent<PatrolComponent>();
       PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(m_aiObject);
       if (pPatrolComponent)
       {
              Vector3 startPoint(10.0f, -10.0f, 75.0f);
              pPatrolComponent->SetStartPoint(startPoint);
              Vector3 endPoint(15.0f, 7.5f, 25.0f);
              pPatrolComponent->SetEndPoint(endPoint);
              pPatrolComponent->SetSpeed(25.0f);
              Framework::AttachEvent(UPDATE_EVENT, *pPatrolComponent);
       }

       m_aiObject.AddComponent<RenderableComponent>();
       pRenderableComponent = component_cast<RenderableComponent>(m_aiObject);
       if (pRenderableComponent)
       {
              Renderable& renderable = pRenderableComponent->GetRenderable();
              renderable.SetGeometry(&m_geometry);
              renderable.SetShader(&m_transformShader);
              Vector4& color = renderable.GetColor();
              color.m_x = 1.0f;
              color.m_y = 0.0f;
              color.m_z = 0.0f;
              color.m_w = 1.0f;
              Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
       }

       return true;
}

我们给我们的人工智能对象添加了一个TransformComponent和一个RenderableComponent,就像我们为玩家做的一样。然而,这一次,我们将敌人的物体设为红色,而玩家的物体设为绿色。

我们为敌人对象添加的新Component是一个PatrolComponent,如清单 6-42 所示。这个组件被编写为在空间中的两点之间来回移动。我们设置起点和终点,以及我们希望物体在这里移动的速度。我们还将PatrolComponent连接到UPDATE_EVENT

清单 6-42。patrol component 类声明

class PatrolComponent
       :      public Framework::Component
       ,      public Framework::EventHandler
{
private:
       Framework::Vector3        m_direction;
       Framework::Vector3        m_startPoint;
       Framework::Vector3        m_endPoint;
       Framework::Vector3*       m_pOriginPoint;
       Framework::Vector3*       m_pTargetPoint;
       float                     m_speed;

       static const unsigned int s_id = 10;

public:
       static unsigned int GetId()    { return s_id; }

       explicit PatrolComponent(Framework::GameObject* pObject);
       virtualPatrolComponent();

       virtual void Initialize();

       virtual void HandleEvent(Framework::Event* pEvent);

       void SetStartPoint(Framework::Vector3& startPoint);
       void SetEndPoint(Framework::Vector3& endPoint);
       void SetSpeed(float speed)    { m_speed = speed; }
};

PatrolComponent有三个Vector3字段,一个用于当前运动方向,一个用于起点,一个用于终点。我们还有两个Vector3指针和一个float速度指针。我们将看看这些指针是如何在清单 6-45 中使用的。首先,我们需要看一下SetStartPoint方法 ,如清单 6-43 所示。

清单 6-43. 巡逻组件:设置起点( )

void PatrolComponent::SetStartPoint(Vector3& startPoint)
{
       m_startPoint.Set(startPoint);
       TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
       assert(pTransformComponent);
       if (pTransformComponent)
       {
              pTransformComponent->GetTransform().SetTranslation(m_startPoint);
       }
}

SetStartPoint设置m_startPoint字段以匹配通过参数传入的Vector3。我们还使用这个向量来设置对象的TransformComponent的位置。

接下来是SetEndPoint方法 ,如清单 6-44 所示。

清单 6-44。patrol component::set endpoint()

void PatrolComponent::SetEndPoint(Vector3& endPoint)
{
       assert(m_startPoint.LengthSquared() > FLT_EPSILON);

       m_endPoint.Set(endPoint);

       m_direction = m_endPoint;
       m_direction.Subtract(m_startPoint);
       m_direction.Normalise();

       m_pOriginPoint = &m_startPoint;
       m_pTargetPoint = &m_endPoint;
}

SetEndPoint开头的断言用于在SetStartPoint方法未被调用时警告我们。我们需要已经设置好的起点,这样我们就可以计算初始的行进方向。我们通过从终点减去起点来实现,结果存储在m_direction 中。方向是标准化的,这样当组件更新时,我们可以使用单位法线作为参数线。最后,我们将起始点的地址存储在m_pOriginPoint指针中,将结束点的地址存储在m_pTargetPoint指针中。现在我们准备更新我们的PatrolComponent,如清单 6-45 所示。

清单 6-45。patrol component::handle event()

void PatrolComponent::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == Framework::UPDATE_EVENT && m_pTargetPoint)
       {
              assert(m_direction.LengthSquared() > FLT_EPSILON);
              assert(m_speed > 0.0f);

              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              if (pTransformComponent)
              {
                     Vector3 translation = m_direction;
                     translation.Multiply(m_speed * Timer::GetSingleton().GetTimeSim());
                     translation.Add(pTransformComponent->GetTransform().GetTranslation());
                     pTransformComponent->GetTransform().SetTranslation(translation);

                     Vector3 distance = *m_pTargetPoint;
                     distance.Subtract(translation);
                     if (distance.LengthSquared() < 2.0f)
                     {
                            Vector3* temp     = m_pTargetPoint;
                            m_pTargetPoint    = m_pOriginPoint;
                            m_pOriginPoint    = temp;

                            m_direction = *m_pTargetPoint;
                            m_direction.Subtract(*m_pOriginPoint);
                            m_direction.Normalise();
                     }
              }
       }
}

如果我们收到一个UPDATE_EVENT并且我们有一个有效的m_pTargetPoint,我们决定当前组件是否准备好被处理。如果我们这样做了,我们就断言 m_direction 和m_speed字段的有效性,以确保我们在运行时没有任何问题。

一旦我们对准备好进行处理感到满意,我们就为对象的所有者检索TransformComponent。如果我们给一个没有TransformComponent的对象添加了一个PatrolComponent,这里的断言将被触发。我们通过使用方向乘以速度和当前模拟时间增量来更新Transform的*移元素。然后,我们将当前帧的速度添加到对象的旧位置。

一旦位置被更新,我们测试看对象是否已经到达目标点。我们这样做是通过从我们当前行进的位置减去物体的新位置,并检查剩余向量的*方长度是否小于 2。

如果我们已经到达目标点,我们交换m_pOriginPointm_pTargetPoint地址并重新计算行进方向。这就把我们的对象送回了它刚来的地方。

我相信你已经和敌人玩过基本的游戏,这些敌人看起来只是在很少实际智力的情况下来回移动,现在你已经看到了在你自己的游戏中实现相同行为的一种方法。

在开发游戏引擎的过程中,我们有一个玩家对象呈现在屏幕上。游戏当前状态的截图如图 6-2 所示。

9781430258308_Fig06-02.jpg

图 6-2 。机器人信使的当前状态

摘要

这是另一场关于大公司如何制作游戏的旋风之旅。我们刚刚谈到的一些主题从引擎系统和图形编程到游戏性和人工智能编程,甚至是游戏物理学的简要介绍。这些主题中的每一个都有很多关于它们的书籍,没有办法在一个章节中对它们进行公正的讨论,但是我们已经涵盖了足够的基础知识,可以让我们开始自己的项目。

组件系统是创建游戏实体的前沿方法。这种方法是创建灵活的、可扩展的系统所需要的基础。通过使用这样的系统,我们可以避免规划和维护复杂的继承层次树所涉及的复杂性,以及当不同分支上的实体需要类似的行为时所需要的重复代码。它还允许我们在运行时通过动态添加和删除组件来更新对象的行为。这在人工智能开发中可能是一个强大的方法,你可以根据一个复杂的玩家或人工智能对象的当前状态来添加或删除组件。

我们的事件系统同样降低了代码的复杂性,使得在整个游戏中响应事件变得非常容易。到目前为止,我们看到的简单例子包括响应输入、更新游戏对象和渲染这些对象。向前发展,我们将能够添加越来越多的事件。游戏事件,比如播放声音和通知物体碰撞,都可以用我们的事件系统来处理。这个系统也给单个对象一定程度的操作控制权。处理我们在MovementComponent中接收到多个跳转事件的情况的一个有效的替代方法是,在第一次处理跳转事件之后分离它,直到我们准备好接收另一个跳转事件。这样的系统有许多可能的用途,我们将在以后的工作中了解更多。

现在我们在屏幕上有了两个物体,是时候看看在它们周围建立一个世界,让我们的应用感觉更像一个游戏。在下一章,我们将通过一个碰撞系统,然后建立一个游戏关卡。

七、建立带有碰撞的游戏关卡

几乎每个游戏的一个关键部分是能够检测不同物体之间的碰撞并做出反应。即使回过头来看看经典游戏,如 Pong太空入侵者,我们也可以看到碰撞已经成为游戏体验的一部分。

使用数学来处理碰撞检测也就不足为奇了。如果您必须处理对象的多边形网格之间的碰撞,涉及的一些数学会特别复杂。在这一章中,我们将在更高的层次上研究碰撞。包围体被用作一种测试,以更详细地检测两个对象是否是碰撞的候选对象。我们游戏的设计足够简单,这种程度的碰撞检测将足够好。

检测碰撞也可能是一件计算量很大的事情,这促使我们寻找优化引擎中碰撞检测阶段的方法。我们将通过把我们的场景分割成离散的部分并只在这些部分内检查物体之间的碰撞来实现这一点。这被称为碰撞检测算法的广义阶段,而对单个对象的测试被称为狭义阶段。

一旦我们确定物体发生了碰撞,我们必须进行碰撞响应。这将在我们的代码中通过发送冲突事件来处理。我们在前一章中已经讨论了事件和组件系统,并且在我们实现碰撞检测系统时将会以这些为基础。

既然我们知道了本章要看什么,那就让我们开始吧。

用碰撞数据表示游戏世界对象

随着 GPU 能力的提高,我们用来渲染游戏对象的网格变得越来越复杂。这意味着用于渲染对象的网格不再适用于碰撞检测系统。检测非常复杂的物体之间的碰撞可能非常耗时,以至于实现实时帧速率简直是不可能的。对于我们的简单游戏,我们不需要在那个细节层次上检测不同对象之间的碰撞,所以我们可以通过简单地比较我们的对象的包围盒来早期优化碰撞检测过程。

边界体积是代表模型所占空间范围的形状。球体、立方体和胶囊等 3D 形状通常用于表示这些体积。对于我们的例子,我们将使用一种称为轴对齐包围盒(AABB)的包围体。AABB 是一个长方体,其侧面*行于 x、y 和 z 轴。由于所有 AABBs 的所有边都*行于这些轴,我们可以使用一种非常快速的算法来检测我们的两个对象是否碰撞。这种算法被称为分离轴定理。这听起来比实际要复杂得多,事实上,你已经在我们在《??》第二章中开发的简单的突围游戏中实现了 2D 版本的算法。

要了解这个算法是如何工作的,最简单的方法就是看一下我们用来实现它的代码。清单 7-1 显示了CollisionComponent ,我们将用它来添加我们的对象。

清单 7-1。 碰撞组件类声明

class CollisionComponent
       :      public Component
{
private:
       static const unsigned int s_id = 2;

       Vector3       m_min;
       Vector3       m_max;

public:
       static unsigned int GetId()       { return s_id; }

       explicit CollisionComponent(GameObject* pOwner);
       virtualCollisionComponent();

       virtual void Initialize();

       void SetMin(const Vector3& point)       { m_min = point; }
       void SetMax(const Vector3& point)       { m_max = point; }

       bool Intersects(CollisionComponent& target);
};

我们的CollisionComponent有两个Vector3对象,它们将用于存储包围体的范围。m_min Vector3对象将存储 x、y 和 z 轴的所有最小值,而m_max将存储 x、y 和 z 轴的最大值。

Intersect方法是执行组件工作和执行分离轴算法的地方。在我们看实现这一点的代码之前,我们先来看看这个算法在理论上是如何工作的。图 7-1 显示了我们将用来思考分离轴算法的图表。

9781430258308_Fig07-01.jpg

图 7-1 。分离轴定理

顾名思义,我们通过每次在每个单独的轴上寻找它们之间的间隙来检测重叠的对象。对于轴对齐的边界框,体积的每条边都将*行于世界空间中相应的轴。如果前面的图是沿着 z 轴看的,那么 x 轴会向右,y 轴向上。该图将允许我们在 x 轴上可视化 AABBs 之间的间隙。

如果我们对照第一条线测试第二条线,那么我们会说我们知道它们没有重叠,因为这条线的左边比第一条线的右边更靠右。如果我们在相反的情况下测试这些线,并将第一条线与第二条线进行比较,我们会说它们没有重叠,因为第一条线的右边比第二条线的左边更靠左。

在游戏运行时,我们不能确定哪个物体在哪一边,所以我们检查这两种情况。我们还对 y 轴和 z 轴上的边缘重复这个测试。现在也清楚了为什么我们需要将线的最小值和最大值存储在组件中它们各自的Vector3对象中:我们需要知道线的哪一侧最左边,哪一侧最右边,以便算法容易工作。

现在我们已经讨论了算法背后的理论,让我们来看看CollisionComponent::Intersect 的实现(见清单 7-2 )。

清单 7-2。collision component::Intersect()

bool CollisionComponent::Intersects(CollisionComponent& target)
{
       bool intersecting = true;

       Vector3 thisMin = m_min;
       Vector3 thisMax = m_max;

       TransformComponent* pThisTransformComponent =
              component_cast<TransformComponent>(GetOwner());
       if (pThisTransformComponent)
       {
              Transform& transform = pThisTransformComponent->GetTransform();
              thisMin.Add(transform.GetTranslation());
              thisMax.Add(transform.GetTranslation());
       }

       Vector3 targetMin = target.m_min;
       Vector3 targetMax = target.m_max;

       TransformComponent* pTargetTransformComponent =
              component_cast<TransformComponent>(target.GetOwner());
       if (pTargetTransformComponent)
       {
              Transform& transform = pTargetTransformComponent->GetTransform();
              targetMin.Add(transform.GetTranslation());
              targetMax.Add(transform.GetTranslation());
       }

       if (thisMin.m_x > targetMax.m_x ||
              thisMax.m_x < targetMin.m_x ||
              thisMin.m_y > targetMax.m_y ||
              thisMax.m_y < targetMin.m_y ||
              thisMin.m_z > targetMax.m_z ||
              thisMax.m_z < targetMin.m_z)
       {
              intersecting = false;
       }

       return intersecting;
}

Intersect方法中要完成的第一个任务是转换每个对象的 AABB。我们简单地将测试中每个对象的位置从TransformComponent添加到它们的边界框中,而不是应用整个变换。如果应用整个变换,我们将最终旋转轴对齐的框,这将把它变成定向的边界框,并且我们的 AABB 测试将不再可靠。我们在这里也忽略了变换的比例,因为我们知道我们没有使用它,但是如果你决定缩放你的对象,这也需要应用。

然后我们有一个if测试,确定对象是否没有重叠。回想一下图 7-1 中的图表,我们可以看到最小值代表线条的左边,最大值代表右边。我们测试两种情况中的每一种,在这两种情况下,我们知道对象对于每个轴都不重叠。首先,我们检查第一个对象的左边是否比第二个对象的右边更靠右。然后我们检查第一个对象的右边是否比第二个对象的左边更靠左。如果这两个条件中的任何一个为真,那么我们的对象没有重叠。然后对 y 轴和 z 轴完成相同的测试。如果这些测试中的任何一个是肯定的,我们知道我们的对象没有重叠。

这就是使用轴对齐的边界框检测物体间碰撞的全部内容。碰撞检测并不总是如此琐碎,但这是一个很好的起点,因为它涵盖了我们如何检测世界上两个对象之间的碰撞的基础知识。在这一点上,我们仍然没有真正的世界,所以我们将在下一节通过创建一个级别来解决这个问题。

建立游戏关卡

现在我们可以让物体碰撞,是时候考虑我们将如何在游戏世界中定位我们的物体了。侧滚游戏已经存在了很长很长一段时间,许多围绕着在侧滚游戏关卡中放置物体的挑战在二十多年前就已经解决了。一个最好的例子就是超级马里奥兄弟系列,它用统一大小的积木来建造关卡。这些块被称为 tiles,它们允许游戏开发者解决许多问题,包括纹理的有限内存、有限的调色板和有限的系统 RAM 来存储级别数据信息。因此,用重复图案的可重复使用的积木来建造关卡是一种非常有效的技术,可以建造比其他方式更大的游戏关卡。

我们的简单游戏将基于同样的基本技术构建。出于本书的目的,我们的设计被有意地写得尽可能简单,以展示用于构建游戏的技术。我们将使用的单块积木只是一个供玩家站立的*台。

在上一章中,我们直接在Chapter6Task类中创建了游戏对象。这一次,我们将创建一个类来包含我们的级别,因为这将允许我们在将来创建多个级别。清单 7-3 显示了DroidRunnerLevel类的声明。

清单 7-3。DroidRunnerLevel 类

class DroidRunnerLevel
       :     public Framework::EventHandler
{
private:
       Framework::CollisionComponent*              m_pPlayerCollisionComponent;

       Framework::Geometry                     m_sphereGeometry;
       Framework::Geometry                     m_cubeGeometry;
       Framework::TransformShader              m_transformShader;

       enum TileTypes
       {
              EMPTY = 0,
              BOX,
              AI,
              PLAYER
       };

       typedef std::vector<Framework::GameObject*>       GameObjectVector;
       typedef GameObjectVector::iterator              GameObjectVectorIterator;

       GameObjectVector                            m_levelObjects;

       void SetObjectPosition(
              Framework::GameObject* pObject,
              const unsigned int row,
              const unsigned int column);

       void AddMovementComponent(Framework::GameObject* pObject);

       void AddCollisionComponent(
              Framework::GameObject* pObject,
              const Framework::Vector3& min,
              const Framework::Vector3& max);

       void AddPatrolComponent(
              Framework::GameObject* pObject,
              const unsigned int startRow,
              const unsigned int startColumn,
              const unsigned int endRow,
              const unsigned int endColumn);

       void AddRenderableComponent(
              Framework::GameObject* pObject,
              Framework::Geometry& geometry,
              Framework::Shader& shader,
              Framework::Vector4& color);

       static const float              TILE_WIDTH       = 6.0f;
       static const float              TILE_HEIGHT       = 6.0f;

       Framework::Vector3              m_origin;

public:
       DroidRunnerLevel();
       ∼DroidRunnerLevel();

       void Initialize(const Framework::Vector3& origin);

       virtual void HandleEvent(Framework::Event* pEvent);
};

DroidRunnerLevel类的私有部分是我们现在为场景找到GeometryShader对象的地方。这一章我们还有一个球体、立方体和TransformShader。除此之外,我们还存储了一个指向玩家对象的CollisionComponent;的指针,我们将在讨论DroidRunnerLevel::HandleEvent的代码时看看为什么要这样做。

枚举TileTypes 定义了我们将在游戏关卡中支持的不同类型的方块。我们需要一个玩家牌,一个 AI 牌和一个盒子牌。这些是我们用来创建关卡的基本构件。

我们定义了一个vectoriterator 来存储属于这个级别的GameObjects,然后还定义了一些 helper 方法,这些方法将在这个级别初始化时用来构造对象。

接下来,我们有两个静态的floats,它存储瓷砖的 2D 尺寸,还有一个Vector3,它存储空间中定义关卡原点的点。我们所有的对象都将从这个原点偏移创建。

这个类需要的唯一公共方法是constructordestructorInitializeHandleEvent。像往常一样,HandleEvent 是在EventHandler父类中定义的被覆盖的虚方法。

在清单 7-4 中列出的Initialize方法贯穿了我们关卡的设置。

清单 7-4。 DroidRunnerLevel::初始化

void DroidRunnerLevel::Initialize(const Vector3& origin)
{
       m_sphereGeometry.SetVertexBuffer(sphereVerts);
       m_sphereGeometry.SetNumVertices(sizeof(sphereVerts) / sizeof(sphereVerts[0]));
       m_sphereGeometry.SetIndexBuffer(sphereIndices);
       m_sphereGeometry.SetNumIndices(sizeof(sphereIndices) / sizeof(sphereIndices[0]));
       m_sphereGeometry.SetName("android");

       m_sphereGeometry.SetNumVertexPositionElements(3);
       m_sphereGeometry.SetVertexStride(0);

       m_cubeGeometry.SetVertexBuffer(cubeVerts);
       m_cubeGeometry.SetNumVertices(sizeof(cubeVerts) / sizeof(cubeVerts[0]));
       m_cubeGeometry.SetIndexBuffer(cubeIndices);
       m_cubeGeometry.SetNumIndices(sizeof(cubeIndices) / sizeof(cubeIndices[0]));
       m_cubeGeometry.SetName("cube");

       m_cubeGeometry.SetNumVertexPositionElements(3);
       m_cubeGeometry.SetVertexStride(0);

       m_origin.Set(origin);

       CollisionManager::GetSingleton().AddCollisionBin();
       const Vector3 min(–3.0f, –3.0f, –3.0f);
       const Vector3 max(3.0f, 3.0f, 3.0f);

       const unsigned char tiles[] =
       {
          EMPTY,       EMPTY,       EMPTY,       EMPTY,       AI,       AI,       AI,       AI,
          EMPTY,       EMPTY,       EMPTY,       EMPTY,       BOX,       BOX,       BOX,       BOX,
          EMPTY,      PLAYER,      EMPTY,      EMPTY,      EMPTY,      EMPTY,      EMPTY,     EMPTY,
          BOX,        BOX,        BOX,        BOX,         BOX,         BOX,        BOX,       BOX
       };

       const unsigned int numTiles = sizeof(tiles) / sizeof(tiles[0]);

       const unsigned int numRows       = 4;
       const unsigned int rowWidth        = numTiles / numRows;

       for (unsigned int i=0; i<numTiles; ++i)
       {
              if (tiles[i] == BOX)
              {
                     const unsigned int row              = i / rowWidth;
                     const unsigned int column       = i % rowWidth;

                     GameObject* pNewObject = new GameObject();

                     SetObjectPosition(pNewObject, row, column);
                     AddCollisionComponent(pNewObject, min, max);

                     Vector4 color(0.0f, 0.0f, 1.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_cubeGeometry,
                            m_transformShader,
                            color);

                     m_levelObjects.push_back(pNewObject);
              }
              else if (tiles[i] == PLAYER)
              {
                     const unsigned int row              = i / rowWidth;
                     const unsigned int column       = i % rowWidth;

                     GameObject* pNewObject = new GameObject();

                     SetObjectPosition(pNewObject, row, column);
                     AddMovementComponent(pNewObject);

                     AddCollisionComponent(pNewObject, min, max);

                     MovementComponent* pMovementComponent =
                            component_cast<MovementComponent>(pNewObject);

                     m_pPlayerCollisionComponent =
                            component_cast<CollisionComponent>(pNewObject);

                     if (pMovementComponent && m_pPlayerCollisionComponent)
                     {
                            m_pPlayerCollisionComponent->AddEventListener(pMovementComponent);
                     }

                     Vector4 color(0.0f, 1.0f, 0.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_sphereGeometry,
                            m_transformShader,
                            color);

                     m_levelObjects.push_back(pNewObject);
              }
              else if (tiles[i] == AI)
              {
                     const unsigned int row              = i / rowWidth;
                     const unsigned int column              = i % rowWidth;

                     unsigned int patrolEndRow              = 0;
                     unsigned int patrolEndColumn       = 0;

                     for (unsigned int j=i; j<numTiles; ++j)
                     {
                            if (tiles[j] != AI)
                            {
                                   i = j;

                                   --j;
                                   patrolEndRow                     = j / rowWidth;
                                   patrolEndColumn              = j % rowWidth;
                                   break;
                            }
                     }

                     GameObject* pNewObject = new GameObject();

                     SetObjectPosition(pNewObject, row, column);
                     AddCollisionComponent(pNewObject, min, max);

                     AddPatrolComponent(pNewObject, row, column, patrolEndRow, patrolEndColumn);

                     Vector4 color(1.0f, 0.0f, 0.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_sphereGeometry,
                            m_transformShader,
                            color);
              }
       }

       Renderer* pRenderer = Renderer::GetSingletonPtr();
       if (pRenderer)
       {
              pRenderer->AddShader(&m_transformShader);
       }
}

Initialize从初始化GeometryShader类开始,就像我们在第六章中所做的一样。

然后,我们用作为参数传递给方法的值origin初始化原点向量m_origin的位置。

然后一个新的碰撞箱被添加到CollisionManager ;中,我们将在清单 7-15 中覆盖CollisionManager

然后,初始化一个由unsigned char值组成的数组,以包含我们想要为该级别构建的图块布局。在这里,我们指定了人工智能应该在哪里巡逻,世界碰撞盒应该放在哪里,以及玩家的起始位置应该在哪里。现在,我们已经将该层分为四行八列。这个级别足够大,可以放在一个屏幕上;我们将在第八章的中扩展这一等级以覆盖多个屏幕。

然后通过将*铺数组的大小除以单个元素的大小来计算*铺的数量,并且我们使用我们将总是有四行的事实来计算我们当前在*铺数组中定义的列的数量。

然后使用一个for循环来遍历数组的每个元素。当我们遇到一个空瓷砖,没有采取任何行动。如果瓷砖是一个盒子,那么我们将在世界的这一点上创建一个盒子对象。首先,我们需要计算我们占据了哪一行和哪一列。我们可以通过将当前索引除以行的宽度来计算出该行。例如,元素 16 是第三行的第一个瓦片:这将给出第 0 列和第 2 行(因为我们从第 0 行开始计数)。我们使用除法运算符 16 / 8 = 2 来计算行,使用模(或余数)运算符 16 % 8 = 0 来计算列。

一个新的游戏对象被创建,我们在这个对象上设置我们在 ,方法中得到的位置,如清单 7-5 所示。

清单 7-5。 自动喷水灭火::SetObjectPosition

void DroidRunnerLevel::SetObjectPosition(
       Framework::GameObject* pObject,
       const unsigned int row,
       const unsigned int column)
{
       assert(pObject);
       pObject->AddComponent<TransformComponent>();
       TransformComponent* pTransformComponent = component_cast<TransformComponent>(pObject);
       if (pTransformComponent)
       {
              Vector3 translation(m_origin);
              translation.m_x += TILE_WIDTH * column;
              translation.m_y -= TILE_HEIGHT * row;
              pTransformComponent->GetTransform().SetTranslation(translation);
       }
}

SetObjectPosition是一个帮助器方法,它给我们的对象添加了一个TransformComponent。然后TransformComponent?? 的*移被设置到世界空间中我们希望我们的对象占据的位置。我们通过将瓷砖的宽度乘以柱索引,然后将其加到标高原点的 x 位置来计算。类似地,我们通过将瓦片的高度乘以行索引并从原点 y 位置减去该值来计算 y 位置。

SetObjectPosition中设置好对象的位置后,DroidRunnerLevel调用对象上的AddCollisionComponent;该方法如清单 7-6 所示。

清单 7-6。 【自动喷水灭火::加压素组分

void DroidRunnerLevel::AddCollisionComponent(
       Framework::GameObject* pObject,
       const Framework::Vector3& min,
       const Framework::Vector3& max)
{
       assert(pObject);
       pObject->AddComponent<CollisionComponent>();
       CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(pObject);
       if (pCollisionComponent)
       {
              pCollisionComponent->SetMin(min);
              pCollisionComponent->SetMax(max);
              AttachEvent(COLLISION_EVENT, *pCollisionComponent);
              CollisionManager::GetSingleton().AddObjectToBin(0, pCollisionComponent);
       }
}

这是另一个简单的助手方法。我们简单地给对象添加一个CollisionComponent,并初始化它的minmax字段。在这一点上,我们确实需要对CollisionComponent进行更新。我们注册了一个新的活动类型名称COLLISION_EVENT,,并继承了EventHandlerCollisionComponent。我们来看看清单 7-28 中的CollisionComponent::HandleEventCollisionComponent 也被添加到索引为 0 的CollisionManager的 bin 中。我们将在本章的下一节讨论CollisionManager

应用于盒子的最后一个助手方法是AddRenderableComponent ,如清单 7-7 所示。

清单 7-7。DroidRunnerLevel::AddRenderableComponent

void DroidRunnerLevel::AddRenderableComponent(
       GameObject* pObject,
       Geometry& geometry,
       Shader& shader,
       Vector4& color)
{
       assert(pObject);
       pObject->AddComponent<RenderableComponent>();
       RenderableComponent* pRenderableComponent = component_cast<RenderableComponent>(pObject);
       if (pRenderableComponent)
       {
              Renderable& renderable = pRenderableComponent->GetRenderable();
              renderable.SetGeometry(&geometry);
              renderable.SetShader(&shader);
              Vector4& renderableColor = renderable.GetColor();
              renderableColor.Set(color);
              Framework::AttachEvent(Framework::RENDER_EVENT, *pRenderableComponent);
       }
}

这是另一个简单的方法,向我们的对象添加一个RenderableComponent并初始化它的字段。在盒子的例子中,我们指定m_cubeGeometrym_transformShader和蓝色作为这个方法的参数。

DroidRunnerLevel::Initialize当磁贴被设置为代表一个玩家时,也可以创建一个玩家对象。初始化一个盒子和初始化一个玩家之间唯一的区别是玩家由球体几何图形和绿色表示,并且调用方法AddMovementComponent 作为参数。这个方法如清单 7-8 所示。

清单 7-8。 自动喷水灭火::AddMovementComponent

void DroidRunnerLevel::AddMovementComponent(GameObject* pObject)
{
       assert(pObject);
       pObject->AddComponent<MovementComponent>();
       MovementComponent* pMovementComponent = component_cast<MovementComponent>(pObject);
       if (pMovementComponent)
       {
              AttachEvent(JUMP_EVENT, *pMovementComponent);
              AttachEvent(UPDATE_EVENT, *pMovementComponent);
       }
}

正如其他助手方法一样,我们将目标 c omponent添加到对象中,在本例中是MovementComponent,并初始化任何数据。MovementComponent 唯一需要的初始化是附加JUMP_EVENTUPDATE_EVENT消息。

在前一章的清单 6-34 中,我们看到如果MovementComponent在 y 轴上移动到 0 以下,我们阻止了它的下落。在这一章中,如果玩家在一个盒子上休息,我们想要阻止他们掉下来。我们将在清单 7-24 和 7-26 中看看我们是如何做到这一点的,但是现在我们可以看到初始化代码将MovementComponent作为一个Listener对象添加到播放器的CollisionComponent中。

我们要处理的最后一种牌是 AI 类型。我们的 AI 图块不覆盖单个图块,而是覆盖一系列图块,这些图块表示我们的 AI 对象应该巡视的路径。清单 7-9 恢复了计算出巡逻路径起点和终点的代码。

清单 7-9。 计算 AI 巡逻路径

const unsigned int row                     = i / rowWidth;
const unsigned int column              = i % rowWidth;

unsigned int patrolEndRow              = 0;
unsigned int patrolEndColumn       = 0;

for (unsigned int j=i; j<numTiles; ++j)
{
       if (tiles[j] != AI)
       {
              i = j;

              --j;
              patrolEndRow                     = j / rowWidth;
              patrolEndColumn                     = j % rowWidth;
              break;
       }
}

正如你所看到的,清单 7-9 从计算当前图块的行和列开始。然后我们使用一个内部循环,直到我们找到一个非人工智能瓷砖。此时,我们增加i以跳过 AI 图块,然后减少j以使其指向巡视路径中的最后一个图块。然后计算该端点的行和列。这种定义路径的基本方法有一个缺点,那就是在路径中的最后一个图块之后,我们必须总是有一个非 AI 图块。这包括在行的末尾结束的路径;下一行的第一个图块不能是 AI 路径,否则此代码会将其视为前一行路径的延续,此外,该级别中的最后一个图块不能是 AI 图块,否则路径不会终止,并将使用图块 0,0 作为终点。

就像盒子和玩家对象一样,AI 对象有一个由我们的助手方法添加的TransformComponentCollisionComponent,RenderableComponent

我们还使用AddPatrolComponent 给我们的 AI 对象添加一个PatrolComponent,如清单 7-10 中的所示。

清单 7-10。 自动喷水灭火::addpatrolcomponent

void DroidRunnerLevel::AddPatrolComponent(
       Framework::GameObject* pObject,
       const unsigned int startRow,
       const unsigned int startColumn,
       const unsigned int endRow,
       const unsigned int endColumn)
{
       assert(pObject);
       pObject->AddComponent<PatrolComponent>();
       PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(pObject);
       if (pPatrolComponent)
       {
              Vector3 startPoint(m_origin);
              startPoint.m_x += TILE_WIDTH * startColumn;
              startPoint.m_y -= TILE_HEIGHT * startRow;

              Vector3 endPoint(m_origin);
              endPoint.m_x += TILE_WIDTH * endColumn;
              endPoint.m_y -= TILE_HEIGHT * endRow;

              pPatrolComponent->SetStartPoint(startPoint);
              pPatrolComponent->SetEndPoint(endPoint);
              pPatrolComponent->SetSpeed(12.0f);

              AttachEvent(UPDATE_EVENT, *pPatrolComponent);
       }
}

AddPatrolComponent向对象添加一个PatrolComponent并初始化开始和结束字段。起点和终点在世界空间中的位置使用与我们用来定位物体的TransformComponents相同的方法来计算。

最后但同样重要的是,DroidRunnerLevel::Initializem_transformShader添加到Renderer中。

这个方法包含了我们构建关卡所需的所有代码。在这一点上,我们处在这样一个位置,我们已经有了对象,但是仍然没有一个方法来检测和响应它们之间的交互。在下一节中,我们将看看如何有效地确定物体是否发生了碰撞。

宽相位滤波

游戏关卡可以包含大量的GameObject。检测物体间碰撞的最简单方法是暴力操作。这包括将场景中的每个对象与其他对象进行对比测试。检测物体之间的碰撞可能是一个计算量很大的过程。例如,现代 FPS 游戏中使用的大型游戏引擎可能具有大量可破坏的对象,这些对象必须与世界上的大量子弹对象进行测试。在这些可破坏的游戏环境中,计算撞击的点和准确时间是必不可少的,因为玩家可以很快判断出某个物体是否以正确的方式变形,或者他们的射击是否没有针对正确的身体部位。

当使用强力方法 时,要执行的碰撞测试的数量使用下面的等式 x(x–1)/2 以二次方增加。对于 10 个对象,这给出了要执行的 45 个测试,这还不算太坏。对于 1,000 个对象,您需要测试 499,500 次。

减少测试对象数量的过程被称为宽相位过滤。对象的这种传递通常由空间算法组成。这意味着我们利用对象相距较远的事实来忽略这些对象之间的任何碰撞测试,只考虑靠*的对象之间的碰撞。

四叉树和八叉树

四叉树是一种将 2D 空间细分成相等大小的空间的数据结构;八叉树执行相同的工作,但是包括第三维。

如图 7-2 所示,一个四叉树被用来在每一层将一个 2D 空间细分成大小相等的部分。首先,外部部分被水*和垂直分割,形成四个四分之一。然后,根据每个部分中存在的对象数量来分割每个四分之一。给定区域中存在的对象越多,我们将使用越多的细分来减少对象之间的碰撞次数。

9781430258308_Fig07-02.jpg

图 7-2 。四叉树

八叉树以完全相同的方式工作,但是将 3D 空间分割成相等大小的立方体,每个部分有多达八个分割,而不是四叉树使用的四个分割。

二元空间划分

二进制空间划分(或 BSP )在细分空间方面的工作方式类似于四叉树和八叉树。 DoomQuake 使用这种算法来渲染他们的场景,因为他们在剔除不应在其 3D 软件渲染引擎中渲染的几何图形方面非常有效。

虽然 BSP 由于其对硬件 3D 加速器的低效使用而不再被用作渲染算法,但它仍然是加速涉及静态几何的碰撞检测的有效选择。最常见的 BSP 类型包括使用*面分割几何体。对于这项任务来说,*面是一个有效的几何体,因为它有一个简单的测试来确定对象位于*面的哪一侧。

BSP 结构是在游戏运行之前离线生成的,并且算法通过拾取根多边形、构建多边形所在的*面、然后继续拾取*面每一侧的多边形以构建新的*面来运行。重复该过程,直到细分达到足够精细的细节级别。

如果您正在使用大型网格构建大型室外层级,这是一种有效的技术。

Droid Runner 中的宽相位滤波

我们对宽相位滤波的需求要小得多。我们的关卡有一个固定的高度,所有的物体都在一个 2D *面上,所以我们可以把关卡分成大小相等的箱子。我们已经看到,我们的级别是用一个分为行和列的数组定义的,所以我们将对每八列使用一个 bin。

碰撞箱

我们来看看清单 7-11 中的碰撞库代码。

清单 7-11。 碰撞宾类声明

class CollisionBin
{
private:
       typedef std::vector<CollisionComponent*>               CollisionComponentVector;
       typedef CollisionComponentVector::iterator       CollisionComponentVectorIterator;

       CollisionComponentVector                             m_collisionObjects;
       CollisionComponentVectorIterator                      m_currentObject;

public:
       CollisionBin();
       ∼CollisionBin();

       void                      AddObject(CollisionComponent* pCollisionComponent);
       CollisionComponent*       GetFirst();
       CollisionComponent*       GetNext();
};

这是一个简单的类。作为一个 bin,它的任务只是存储一个对象集合,并为这些对象提供访问器方法。在这种情况下,CollisionBin存储了一个CollisionComponent指针的vector。bin 存储一个 current iterator,用于向调用类提供第一个和下一个对象。

清单 7-12 显示了向 CollisionBin 添加一个新对象所需的代码。

清单 7-12。 碰撞 Bin::AddObject

void CollisionBin::AddObject(CollisionComponent* pCollisionComponent)
{
       m_collisionObjects.push_back(pCollisionComponent);
}

方法AddObject有一个简单的工作:它将传递的CollisionComponent指针添加到它的数组中。

清单 7-13 展示了GetFirst如何将内部迭代器设置为vector的开始。return语句使用三元运算符来确定iterator是否指向有效对象。如果是,我们返回被解引用的iterator;,如果不是,我们返回NULL

清单 7-13。 碰撞 Bin::GetFirst

CollisionComponent* CollisionBin::GetFirst()
{
       m_currentObject = m_collisionObjects.begin();
       return m_currentObject != m_collisionObjects.end()
              ?           *m_currentObject
              :           NULL;
}

在清单 7-14 中,我们在方法GetNext中两次测试有效对象的当前iterator。在尝试递增之前,我们需要确保迭代器不在vector的末尾,这样我们就可以确保不会离开末尾。一旦我们增加了迭代器,在返回解引用的对象或NULL之前,我们再次检查迭代器是否有效。

清单 7-14。collision bin::get next

CollisionComponent* CollisionBin::GetNext()
{
       CollisionComponent* pRet = NULL;

       if (m_currentObject != m_collisionObjects.end())
       {
              ++m_currentObject;
              pRet = m_currentObject != m_collisionObjects.end()
                     ?     *m_currentObject
                     :     NULL;
       }

       return pRet;
}

箱本身不执行任何碰撞测试;它们只是存储算法。实际测试将由CollisionManager执行。

碰撞管理器

CollisionManager,其声明在清单 7-15 中描述,负责存储碰撞箱,并为其余代码提供碰撞测试的接口。

清单 7-15。collision manager 类声明

class CollisionManager
       :     public Singleton<CollisionManager>
{
private:
       typedef std::vector<CollisionBin>       CollisionBinVector;

       CollisionBinVector                     m_collisionBins;

public:
       CollisionManager();
       ∼CollisionManager();

       void AddCollisionBin();
       void AddObjectToBin(const unsigned int binIndex, CollisionComponent* pObject);
       void TestAgainstBin(const unsigned int binIndex, CollisionComponent* pObject);
};

我们在这个类中再次使用 vector,这次存储的是一个由CollisionBin实例组成的 vector。我们提供了一些公共方法,用于创建新的容器,将CollisionComponents添加到容器中,以及针对容器中的所有其他对象测试单个对象。

AddCollisionBin,中,我们将新的箱子推到vector的后面。这显示在清单 7-16 中。

清单 7-16。 碰撞管理器::addcollisionbin

void CollisionManager::AddCollisionBin()
{
       m_collisionBins.push_back();
}

清单 7-17 中描述的AddObjectToBin断言所提供的索引小于 bin vector的大小。然后它调用在binIndex找到的 bin 上的AddObject

清单 7-17。 碰撞管理器::AddObjectToBin

void CollisionManager::AddObjectToBin(const unsigned int binIndex, CollisionComponent* pObject)
{
       assert(binIndex < m_collisionBins.size());
       m_collisionBins[binIndex].AddObject(pObject);

}

窄相位碰撞检测

窄相位碰撞检测与加速确定两个特定物体是否碰撞的过程有关。过滤碰撞的整个过程基于这样的假设,即碰撞检测算法中最昂贵的部分是几何图元之间的相交测试。这可能是三角形、射线或任何其他类型的几何图形用于表示物体的表面。对于现代视频游戏来说,模型中所需的细节导致了由成千上万个几何图元组成的网格,并且测试其中的每一个都将是昂贵的。

我们已经在本章前面讨论了我们的方法:我们将使用轴对齐的边界框来*似我们的对象。如果我们在构建人形角色,我们会有一个整体的包围体,然后是一个粗略表示每个身体部分的体。这可能意味着头部有一个球体,四肢的上部和下部有一个胶囊,躯干有一个 AABB。

由于我们的测试非常简单,整个算法适合一个方法。CollisiongManager::TestAgainstBin列在清单 7-18 中。

清单 7-18。 CollisionManager::TestAgainstBin

void CollisionManager::TestAgainstBin(const unsigned int binIndex, CollisionComponent* pObject)
{
       assert(binIndex < m_collisionBins.size());
       CollisionBin& bin = m_collisionBins[binIndex];
       CollisionComponent* pBinObject = bin.GetFirst();
       while (pBinObject)
       {
              if (pBinObject != pObject &&
                     pBinObject->Intersects(*pObject))
              {
                     CollisionEventData collisionData;
                     collisionData.m_pCollider = pBinObject->GetOwner();
                     SendEventToHandler(
                            COLLISION_EVENT,
                            *static_cast<EventHandler*>(pObject),
                            &collisionData);
              }
              pBinObject = bin.GetNext();
       }
}

我们将想要测试的对象作为参数传递给 bin。我们之前讨论的强力方法会涉及到测试 bin 中的每一个对象;然而,我们知道我们只对测试玩家对象感兴趣,这给了我们一个很好的优化。

该方法简单地遍历 bin 中的每个对象,如果对象不相同,则调用Intersect方法。当Intersect方法返回 true 时,我们将COLLISION_EVENT发送给作为参数传递的CollisionComponent

CollisionEventData结构仅仅持有一个指向我们已经碰撞过的GameObject的指针。我们在清单 7-19 中查看这个结构。

清单 7-19。collision event data 结构

struct CollisionEventData
{
       GameObject*       m_pCollider;
};

TestAgainstBin方法突出显示了我们将对事件系统进行的更新。通常在发送事件时,随事件一起传递数据是很方便的。在这种情况下,我们想知道我们碰撞了哪个物体。

为了方便用 e vent,发送数据,我们添加了一个指向 e vent类的 void 指针。我们还添加了指向SendSendToHandler方法的空指针。我们在清单 7-20 和 7-21 中这样做。

清单 7-20。 更新事件类

       EventHandlerList                     m_listeners;
       EventID                            m_id;
       void*                            m_pData;

public:
       explicit Event(EventID eventId);
       ∼Event();

       void Send(void* pData);
       void SendToHandler(EventHandler&
 **eventHandler, void* pData);**
       `void AttachListener(EventHandler& eventHandler);`
       `void DetachListener(EventHandler& eventHandler);`

`清单 7-21。 更新事件::发送和事件::SendToHandler

void Event::Send(void* pData)
{
       m_pData = pData;
       for (EventHandlerListIterator iter = m_listeners.begin();
              iter != m_listeners.end();
              ++iter)
       {

void Event::SendToHandler(EventHandler&
 **eventHandler, void* pData)**
`{`
       **m_pData = pData;**
       `for (EventHandlerListIterator iter = m_listeners.begin();`
              `iter != m_listeners.end();`
              `++iter)`
       `{`

随着这一变化,将 e vent传递到EventManager和从EventManager传递 e SendSendToHandler的每个方法也需要更新,以包含void`指针参数。

现在我们有了一个检测碰撞的方法和一个告诉一个对象它何时被卷入碰撞的事件,我们可以编写代码来响应这些碰撞。

响应碰撞

我们的游戏设计要求对发生的碰撞做出两种截然不同的反应。我们可以被支撑在箱子的顶部,或者我们可以被箱子的侧面或敌人杀死。

我们的玩家对象的更新代码大部分包含在MovementComponent中。我们现在的问题是从CollisionComponent获取对特定碰撞数据感兴趣的物体的COLLISION_EVENT

我们将通过转向观察者模式来实现这一点。在这种情况下,我们想要做的是当CollisionEvent发生时,使用特定的方法通知对象。将使用CollisionListener接口来实现这一点,如清单 7-22 所示。

清单 7-22。 碰撞监听器接口

class CollisionListener
{
public:
       virtual void HandleCollision(CollisionEventData* pData) = 0;
};

在清单 7-23 的中,MovementComponent 继承了这个类。

清单 7-23。 更新运动组件

class MovementComponent
       :      public Framework::Component
       ,      public Framework::EventHandler
       ,      public Framework::CollisionListener
{
private:
       static const unsigned int s_id = 9;
public:

       virtual void HandleCollision(Framework::CollisionEventData* pData);
};

清单 7-24 中的方法体处理冲突。

清单 7-24。 运动组件::手柄碰撞

void MovementComponent::HandleCollision(Framework::CollisionEventData* pData)
{
       PatrolComponent* pPatrolComponent = component_cast<PatrolComponent>(pData->m_pCollider);
       if (pPatrolComponent)
       {
              // We're colliding with an AI; we're dead!
       }
       else
       {
              // We're colliding with a block
              TransformComponent* pColliderTransformComponent =
                     component_cast<TransformComponent>(pData->m_pCollider);

              CollisionComponent* pColliderCollisionComponent =
                     component_cast<CollisionComponent>(pData->m_pCollider);

              assert(pColliderTransformComponent && pColliderCollisionComponent);

              const Vector3& translation =
                     pColliderTransformComponent->GetTransform().GetTranslation();

              Vector3 minPosition(pColliderCollisionComponent->GetMin());
              minPosition.Add(translation);

              TransformComponent* pObjectTransformComponent =
                     component_cast<TransformComponent>(GetOwner());

              if (pObjectTransformComponent->GetTransform().GetTranslation().m_x <
                     minPosition.m_x)
              {
                     // We're dead because we've hit the side of the block
              }
              else
              {
                     SetIsSupported(
                            true,
                            pColliderCollisionComponent->GetMax().m_y + translation.m_y);
              }
       }
}

正如你在这里看到的,我们通过检查物体是否有PatrolComponent;来确定我们击中的物体是盒子还是人工智能。

如果是一个盒子,那么我们需要确定我们是碰到了顶部还是侧面。为了做到这一点,我们得到碰撞物体的TransformComponentCollisionComponent

因为我们将从左向右移动,我们只会死于击中盒子的左侧,所以我们需要检查边界盒的最小位置。最小位置被转换到世界空间并存储在minPosition向量中。然后我们得到MovementComponent的所有者的TransformComponent。如果物体*移的 x 位置在盒子最小左边位置的左边,我们确定我们碰到了盒子的边。如果我们在左边位置的右边,我们已经到达顶部。

如果我们在盒子的顶部,我们调用SetIsSupported并传递边界框的*移顶部。如清单 7-25 所示。

清单 7-25。movement component::SetIsSupported

void MovementComponent::SetIsSupported(bool isSupported, float floor = 0.0f)
{
       m_isSupported = isSupported;
       m_floor = floor;
}

为了允许玩家坐在盒子上面,我们还必须更新 HandleEvent 方法;我们在清单 7-26 中这样做。

SetIsSupported方法简单地设置了m_isSupported字段和m_floor字段。

清单 7-26。 更新 MovementComponent::HandleEvent

else if (pEvent->GetID() == UPDATE_EVENT)
{
       TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
       assert(pTransformComponent);
       CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(GetOwner());
       assert(pCollisionComponent);
       if (pTransformComponent && pCollisionComponent)
       {
              const Vector3& position = pTransformComponent->GetTransform().GetTranslation();

              bool falling = m_acceleration.m_y < 0.0f;

              Vector3 bvMin = pCollisionComponent->GetMin();

              Vector3 translation = m_velocity;
              translation.Multiply(Timer::GetSingleton().GetTimeSim());
              translation.Add(position);

              const float offsetFloor = m_floor – bvMin.m_y;
              if (m_isSupported &&falling &&(translation.m_y < offsetFloor))
              {
                     translation.m_y = offsetFloor;
              }

              pTransformComponent->GetTransform().SetTranslation(translation);

              Timer& timer = Timer::GetSingleton();
              Vector3 accel = m_acceleration;
              accel.Multiply(timer.GetTimeSim());
              m_velocity.Add(accel);

              static const float GRAVITY_MULTIPLIER       = 15.0f;
              static const float GRAVITY_CONSTANT       =  –9.8f;
              m_acceleration.m_y += GRAVITY_MULTIPLIER * GRAVITY_CONSTANT * timer.GetTimeSim();
              if (falling &&m_isSupported)
              {
                     m_acceleration.m_y   = 0.0f;
                     m_velocity.m_y       = 0.0f;
              }
       }

       // Always undo support after an update: we'll be resupported if we are colliding with a block.
       SetIsSupported(false);
}

现在我们正在测试冲突,是否支持我们的决定已经为我们做出了。在这种情况下,我们需要得到CollisionComponent,这样我们就可以确定我们的*移距离对象的底部有多远。我们通过从地板值中减去包围体的最小 y 位置来做到这一点。

然后我们测试我们的位置是否应该固定在地板上。应该是,如果我们被支持,正在下降,我们的*移的 y 场低于我们的offsetFloor值。

如果我们在下落,并且被支撑在一个表面上,我们的加速度和速度就被清除了。

最后,我们总是希望清除支持的标志。我们的碰撞系统将重新测试,以确定我们是否仍然与我们正在休息的盒子碰撞,如果是,我们将在下一帧再次被设置为受支持。

现在我们知道了MovementComponent如何处理碰撞。我们还没有处理死亡案例,因为我们还没有搬家。一旦我们让玩家带着摄像机移动,我们将在第八章讲述死亡。我们也不知道CollisionListener接口是如何使用的。我们需要再看一眼更新后的CollisionComponent,并在清单 7-27 中这样做。

清单 7-27。 向 CollisionComponent 类添加监听器

class CollisionComponent
       :     public Component
       ,     public EventHandler
{
private:
       static const unsigned int s_id = 2;

       Vector3       m_min;
       Vector3       m_max;

       typedef std::vector<CollisionListener*>              CollisionListenerVector;
       typedef CollisionListenerVector::iterator       CollisionListenerVectorIterator;

       CollisionListenerVector                            m_eventListeners;

public:
       static unsigned int GetId()       { return s_id; }

       explicit CollisionComponent(GameObject* pOwner);
       virtualCollisionComponent();

       .
       .
       .

       void AddEventListener(CollisionListener* pListener)
       {
              m_eventListeners.push_back(pListener);
       }
};

我们在清单 7-4 中看到了对AddEventListener的调用,所以我们已经从最初创建我们的对象时回到了原点。管理监听器就像维护一个指向CollisionListener对象的指针向量一样简单。

CollisionComponent不关心对碰撞本身的响应;它只需要将COLLISION_EVENT数据分发给对涉及该对象的碰撞感兴趣的对象。在清单 7-28 中,我们遍历CollisionListener对象的向量,并调用它们的HandleCollision方法。

清单 7-28。 碰撞组件::手柄事件

void CollisionComponent::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == COLLISION_EVENT)
       {
              CollisionEventData* pCollisionData =
                     static_cast<CollisionEventData*>(pEvent->GetData());
              if (pCollisionData && pCollisionData->m_pCollider)
              {
                     for (CollisionListenerVectorIterator iter = m_eventListeners.begin();
                            iter != m_eventListeners.end();
                            ++iter)
                     {
                             (*iter)->HandleCollision(pCollisionData);
                     }
              }
       }
}

既然碰撞处理已经完成,我们需要在每一帧触发系统来检测碰撞。

运行碰撞测试

场景中的对象更新后,最好检测碰撞。为此,我们将注册一个新事件POSTUPDATE_EVENT

我们在清单 7-3 中看到,DroidRunnerLevel类存储了一个指向播放器对象CollisionComponent的指针。我们还在窄阶段碰撞检测部分看到,我们可以通过只测试玩家对象的碰撞来优化检测算法。这两个事实密切相关。在清单 7-29 中,我们使用存储的指针来测试碰撞。

清单 7-29。DroidRunnerLevel::handle events

void DroidRunnerLevel::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == POSTUPDATE_EVENT && m_pPlayerCollisionComponent)
       {
              CollisionManager::GetSingleton().TestAgainstBin(0, m_pPlayerCollisionComponent);
       }
}

这就是我们运行碰撞检测算法所需的全部代码。

清单 7-30 显示了对章节的更新方法的更新,这是触发POSTUPDATE_EVENT所必需的。在这里,我们确保应该在更新后完成的任何任务都以正确的顺序完成。

清单 7-30。 触发 POSTUPDATE_EVENT

void Chapter7Task::Update()
{
       if (Renderer::GetSingleton().IsInitialized())
       {
              Framework::SendEvent(Framework::UPDATE_EVENT);
              Framework::SendEvent(Framework::POSTUPDATE_EVENT);
              Framework::SendEvent(Framework::RENDER_EVENT);
       }
}

这就是我们游戏中碰撞检测所需要的。我们还需要对Renderer做一个改变,你应该知道。

使用 Z 缓冲器

当对象被渲染到帧缓冲区时,默认行为是将每个像素渲染到屏幕上。当一个对象被渲染但应该在先前已经被渲染的对象之后时,问题就出现了。由于像素总是被写入帧缓冲区,我们的第二个对象将覆盖第一个对象,即使我们不希望这样。

我们可以通过使用 z 缓冲区来解决这个问题。z 缓冲区或深度缓冲区存储每个渲染像素写入帧缓冲区时的深度。深度缓冲器中的每个像素最初被设置为 1。变换对象的顶点并为片段着色做好准备后,将计算每个片段的 z 值,其深度介于 0 和 1 之间。如果新片段的深度小于该位置的现有深度,它将在片段着色器中进行处理,并将颜色写入帧缓冲区。如果深度大于现有像素,该片段将被丢弃,而帧和深度缓冲区中的现有值将被保留。

我们通过以下方式在 OpenGL 中启用深度测试。

  1. Select an EGL configuration which supports a depth buffer, shown in Listing 7-32.

    清单 7-31。 EGL 深度配置属性

    const EGLint attribs[] =
    {
           EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
           EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
           EGL_BLUE_SIZE, 8,
           EGL_GREEN_SIZE, 8,
           EGL_RED_SIZE, 8,
           EGL_DEPTH_SIZE, 24,
           EGL_NONE
    };
    
    
  2. 启用深度测试(清单 7-32 )。

  3. Clear the depth buffer (Listing 7-32).

    清单 7-32。 启用 GL_DEPTH_TEST 并清除深度缓冲

    void Renderer::Update()
    {
           if (m_initialized)
           {
                  glEnable(GL_DEPTH_TEST);
                  glClearColor(0.95f, 0.95f, 0.95f, 1);
    
                  glClear(GL_COLOR_BUFFER_BIT| GL_DEPTH_BUFFER_BIT);
    
    

清单 7-31 和 7-32 中修改过的三行是在我们的游戏代码中启用深度测试所需要的。从现在开始,我们可以确定场景中的所有像素都是那些被栅格化为离相机最*的物体的一部分的像素。

摘要

这一章是关于建立游戏关卡和碰撞检测系统的旋风介绍。我们所看到的是,围绕游戏关卡的挑战很大程度上是那些涉及创建对象来填充关卡的挑战。在我们的游戏中,我们使用了老式 2D 游戏中屡试不爽的*铺方法,用同样大小的方块构建了一个 3D 关卡。

学习游戏编程时使用这样一个系统的主要好处是它非常容易通过计算来创建。现代游戏更倾向于使用离线工具来构建关卡,这些关卡可以在运行时加载前进行预处理。然而,这些系统所面临的许多问题与我们在本章中看到的以及我们在前一章中开始解决的问题是一样的,即如何在对象之间进行通信以及如何管理大量的对象。当构建初始级别时,我们的组件和事件系统使其中一些任务变得微不足道。

一旦我们有了等级数据,我们就开始计算物体之间的碰撞。我们了解了为什么物体的宽相位过滤对于减少我们需要测试的对的数量是重要的,以及为什么窄相位有助于减少碰撞测试本身的计算复杂性。我们已经将这些经验应用到我们的游戏中,创建了一个宁滨系统,允许我们以游戏代码选择的任何方式将对象存储在一起,并且我们已经为我们的游戏实现了最佳的测试算法,其中我们只关心单个对象上的碰撞效果。

为了结束这一章,我们稍微绕了一下图形编程,看看如何启用深度测试,以确保渲染到帧缓冲区的像素实际上是我们希望看到的像素。

在下一章中,我们将显著扩展我们的级别,并更好地利用碰撞管理器中的离散箱。我们还将看看如何创建一个游戏摄像头,让玩家在关卡中移动,并编写代码来决定玩家死后该做什么。我们还将看看如何使用相机对象来减少我们将发送到 GPU 的几何量。``

八、虚拟摄像机

对于摄影师和电影制作人来说,相机是一个显而易见的工具。它们使艺术家能够通过控制光线捕捉的位置和设置来捕捉他们周围的生活世界,从而创造出他们所选择场景的表现形式。电子游戏也不例外。

到目前为止,我们的场景基本上保持静态,但这不会成为一个特别有趣的游戏。你们中的大多数人以前都玩过 3D 游戏,我相信你会同意在世界中移动的能力是让游戏引人注目的一个关键方面。如果不定义摄像机,这是不可能的。

在摄影和电影制作领域,相机是一个相对容易理解的概念。要捕捉不同的视角,你只需将相机移动到某个位置,它就能捕捉到你想要描绘的场景。3D 图形中的相机并不那么简单。在这一章中,我们将看看我们如何定义一个相机游戏对象,以及我们如何使用该对象来通过我们的机器人跑垒员关卡。

让我们开始看看游戏摄像机背后的理论。

模型和视图矩阵

正如我们已经看到的,我们可以通过改变变换矩阵,将该矩阵提供给顶点着色器,并将顶点乘以该矩阵,在 3D 世界中移动对象。变换矩阵包含缩放、旋转和*移元素,这些元素会影响游戏对象的大小、方向和位置。

图 8-1 显示了比例矩阵对游戏对象的影响。

9781430258308_Fig08-01.jpg

图 8-1 。缩放立方体

变换的旋转元素围绕对象的原点旋转顶点。如图 8-2 中的所示。

9781430258308_Fig08-02.jpg

图 8-2 。旋转对游戏对象的影响

变换矩阵的*移组件负责在 3D 空间中移动对象。图 8-3 显示了应用于游戏对象位置的偏移。

9781430258308_Fig08-03.jpg

图 8-3 。翻译游戏对象

我们创建的清单 6-20 中的负责存储每个对象的变换信息。清单 6-28 中的顶点着色器代码用于在渲染管道中处理顶点时将变换矩阵应用于顶点。

我们的相机对象将是一个游戏对象,就像任何其他对象一样。它将通过更新包含在其TransformComponent中的Transform在场景中移动,但相机本身不会被渲染;相反,相机的位置和方向将用于操纵场景中的其他对象。

在第五章中,我们介绍了规范视图体的概念。我们讨论了顶点着色器负责将顶点转换到相对于该体积的位置。到目前为止,我们缺乏一个相机模型,这意味着我们必须在世界空间中移动对象,以使它们在这个体积内正确显示。现在将由相机负责修改顶点以适应这个体积。

当我们用相机拍照时,我们知道我们可以移动相机来获得不同的场景视图。我们可以在我们的游戏世界中,通过使用它的TransformComponent来操控相机,达到同样的效果;然而,这不会帮助我们操纵顶点着色器中其他对象的顶点。我们必须把渲染管道中的摄像机想象成一个永远不会移动的物体。所有其他对象必须相对于相机对象四处移动。为了帮助解决这个问题,想象一下你正在用相机拍全家福。如果你决定你真的喜欢从稍微偏右的位置拍摄,你可以向右移动一步。在 3D 游戏中,这是不可能的,因为渲染管道中的相机基本上是固定的,所以我们必须想象我们必须移动世界中的对象。在我们的视觉化中,你可以想象,为了获得相同的场景视图,你可以要求照片中的人向左走一步,而不是向右走一步。你会拿着相机保持不动,但你会得到相同的角度来拍摄你的对象,就好像你走到了左边。

我们通过应用变换的逆变换在 3D 数学中实现这一点。转换中最容易可视化的部分是翻译组件。如果我们的摄影机对象的*移包含沿 z 轴 10 个单位的正偏移,我们将通过对顶点位置应用–10 的偏移来操纵场景中的每个其他对象。我们已经在清单 6-23 的中写了一个获得逆变换的方法。

快速回顾一下,我们的相机对象将是一个带有一个TransformComponentGameObject,就像其他的一样。我们将对摄像机应用正向变换,使其在游戏世界中移动。我们将把摄像机的Transform的逆矩阵提供给Renderer作为视图矩阵。

这涵盖了片段着色器中顶点变换的两个基础。模型矩阵将对象的顶点从其局部空间操纵到世界空间。视图矩阵然后将顶点从世界空间操纵到相机或视图空间。

顶点着色器中顶点变换过程的最后一部分是投影矩阵。

投影矩阵

为了理解为什么投影矩阵如此重要,尤其是在移动开发中,我们必须再次考虑规范视图体。该体积是顶点经过顶点着色器处理后所在的空间。规范视图体在 OpenGL ES 2.0 中是边维数相等的立方体(在其他 API 中可能有不同的属性;例如,DirectX 使用在 z 轴上范围仅从 0 到 1 的立方体)。如果你看到一系列 Android 设备并排放置,很明显屏幕可能都是不同的尺寸。

设备本身的大小不是问题,因为我们的游戏将会适应更大的屏幕。问题来自于不同长宽比的屏幕。我们用宽度除以高度来计算长宽比。我现在的设备是 Galaxy Nexus,屏幕分辨率为 1280 * 720,长宽比为 1.778。最初的华硕变压器的屏幕分辨率为 1280 * 800,长宽比为 1.6。这些可能看起来没有明显的区别;但是,它们会直接影响我们的产出。如果我们没有校正设备的纵横比,我们将允许立方体视图体积被水*拉伸以适合设备的屏幕。正方形会变成长方形,而球体看起来不会是正圆。

这是投影矩阵解决的问题之一。

3D 图形中通常使用两种不同类型的投影矩阵。一个是正投影,一个是透视投影。正投影的关键特性是*行线在投影后保持*行。透视投影并不保持*行线,顾名思义,这可以让我们产生透视感。我们大多数人都知道消失点的概念。绘画中的这种效果是通过让*行线在远处的一个点上会聚来实现的,这和我们使用透视投影得到的效果是一样的。

图 8-4 显示了长方体正投影体和透视图体的*截头体,从俯视图上覆盖在标准视图体上。

9781430258308_Fig08-04.jpg

图 8-4 。投影视图体积

如图 8-4 所示,透视图体由一个*截头体表示。*截头体的**面以与远*面相同的方式映射到立方体上。由于远*面要宽得多,*截头体后面的对象在尺寸上被挤压以适合规范视图体积。物体离相机越远,这种挤压就是我们获得透视感的方式。这种挤压不会发生在正交投影中,无论对象离相机多*,它们看起来都是相同的大小。

我们将在我们的相机中使用投影的透视方法,我们将在下一节看看我们如何实现这一点。

定义相机对象

与我们目前所有的其他对象一样,我们的相机将是一个带有新组件类型CameraComponentGameObject

清单 8-1 中的类声明显示了CameraComponent 的接口。我们有常用的static intGetId方法来添加对组件系统的支持。

清单 8-1。CameraComponent类声明。相机组件. h

class CameraComponent
       :      public Component
       ,      public EventHandler
{
private:
       static const unsigned int s_id = 3;
       float            m_frustumParameters[Renderer::NUM_PARAMS];
public:
       static unsigned int GetId()      { return s_id; }
       explicit CameraComponent(GameObject* pOwner);
       virtualCameraComponent();

       virtual void Initialize() {}
       void SetFrustum(
              const float verticalFieldOfView,
              const float aspectRatio,
              const float near,
              const float far);

       virtual void HandleEvent(Event* pEvent);
};

然后我们会看到存储的截锥参数。这些参数定义了视图截锥边缘的边界。*截头体的顶部、底部、左侧、右侧、*侧和远侧*面都有一个参数。

我们还有两个公共方法,SetFrustumHandleEvent

我们来看看清单 8-2 中的SetFrustum

清单 8-2。 CameraComponent::SetFrustum。相机组件. cpp

void CameraComponent::SetFrustum(
       const float verticalFieldOfView,
       const float aspectRatio,
       const float near,
       const float far)
{
       float halfAngleRadians   = 0.5f * verticalFieldOfView * (3.1415926536f / 180.0f);

       m_frustumParameters[Renderer::TOP]       = near * (float)tan(halfAngleRadians);
       m_frustumParameters[Renderer::BOTTOM]    = -m_frustumParameters[Renderer::TOP];
       m_frustumParameters[Renderer::RIGHT]     =
              aspectRatio * m_frustumParameters[Renderer::TOP];
       m_frustumParameters[Renderer::LEFT]      = -m_frustumParameters[Renderer::RIGHT];
       m_frustumParameters[Renderer::NEAR]      = near;
       m_frustumParameters[Renderer::FAR]       = far;
}

SetFrustum根据传入的参数计算锥台参数。我们将基于屏幕的长宽比和以度为单位的垂直视野来计算我们的*截头体。视野是一个角度,表示*截头体的宽度:视野越大,**面和远*面的宽度差越大。

更大的视野会给我们一种在传统相机上缩小的效果;更窄的视野给我们一种放大物体的感觉。这种效果最常见的例子是第一人称射击游戏中的狙击瞄准镜。范围视图将具有更窄的视野,并且远处物体的比例在帧缓冲器中大大增加。

我们的*截头体被计算成两半,相反的一半只是它的对应部分被求反。因为我们是对半计算,所以我们把垂直视野乘以 0.5f .然后我们把verticalFieldOfView乘以 3.1415926536 f/180.0 f;这会将过去的角度从度转换为弧度。结合起来,这给了我们字段halfAngleRadians

通过用弧度表示的半角的正切值乘以 near 值来计算*截头体的顶部。这是简单的三角学,其中 tan(x) =对立/相邻,在我们的例子中,**面的距离是相邻的,所以对立=相邻* tan(x)。C++ 中的 tan 方法采用弧度作为角度,这也是该方法第一行转换成弧度的原因。人更习惯于和度打交道,这很正常,所以我们用度传递我们的角度,内部转换。

*截头体的底部就是顶部的反面。

我们的右参数是顶部参数乘以提供的纵横比;左只是右的反义词。这些线条显示了为什么我们使用垂直视场法来计算截锥的尺寸。通过提供垂直视野,我们锁定了所有设备上的*截头体的高度。不管我们的纵横比是 16:9、16:10 还是其他什么,这都是一致的。在我们的宽高比较宽的情况下,我们将能够在每一侧看到更多的场景,当屏幕较窄时,情况正好相反。垂直视野在我们的例子中起作用,因为我们正在开发一个横向的游戏;如果您正在开发一个纵向方向,您可能要考虑使用锁定的水*视野。

**面和远*面的距离存储在函数参数中。

更新CameraComponent包括向渲染器提供摄像机的当前状态,这样我们就可以正确地渲染场景中的物体(见清单 8-3 )。

清单 8-3。 CameraComponent::HandleEvent。相机组件. cpp

void CameraComponent::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == POSTUPDATE_EVENT)
       {
              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              Renderer& renderer = Renderer::GetSingleton();

              Matrix4 inverseCamera;
              pTransformComponent->GetTransform().GetInverseMatrix(inverseCamera);
              renderer.SetViewMatrix(inverseCamera);
              renderer.SetFrustum(m_frustumParameters);
       }
}

CameraComponentPOSTUPDATE_EVENT做出反应,以确保必要时UPDATE_EVENT中的TransformComponent已经更新。

我们获得一个指向我们所有者的TransformComponent的指针,并向它请求其Transform对象的逆。我们在本章前面讨论了为什么我们应该使用相机的Transform的反转。

逆视图矩阵和当前视锥参数通过SetViewMatrixSetFrustum方法提供给Renderer

现在我们有了一个CameraComponent来添加到游戏对象并模拟一个虚拟摄像机,我们需要看看如何更新Renderer来适应视图和投影矩阵。

更新渲染器

现在我们有了一个将Camera表示为GameObject的方法,我们需要为我们的Renderer添加对视图和投影矩阵的支持。

在清单 8-4 的中,我们通过向Renderer类添加以下更新来做到这一点。

清单 8-4。Renderer增加视图和投影矩阵支持。Renderer.h

class Renderer
       :     public Task
       ,     public Singleton<Renderer>
{
public:
       enum FrustumParameters
       {
              TOP,
              BOTTOM,
              RIGHT,
              LEFT,
              NEAR,
              FAR,
              NUM_PARAMS
       };

private:
       android_app*     m_pState;
       EGLDisplay       m_display;
       EGLContext       m_context;
       EGLSurface       m_surface;
       Int              m_width;
       Int              m_height;
       Bool             m_initialized;

       typedef std::vector<Shader*>             ShaderVector;
       typedef ShaderVector::iterator           ShaderVectorIterator;

       typedef std::vector<Texture*>            TextureVector;
       typedef TextureVector::iterator          TextureVectorIterator;

       typedef std::vector<Renderable*>         RenderableVector;
       typedef RenderableVector::iterator       RenderableVectorIterator;

       RenderableVector m_renderables;
       TextureVector    m_textures;
       ShaderVector     m_shaders;

       float            m_frustumParameters[NUM_PARAMS];

       Matrix4          m_viewMatrix;
       Matrix4          m_projectionMatrix;

       void Draw(Renderable* pRenderable);
public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtualRenderer();
       void Init();
       void Destroy();

       void AddRenderable(Renderable* pRenderable);
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);

       // From Task
       virtual bool     Start();
       virtual void     OnSuspend();
       virtual void     Update();
       virtual void     OnResume();
       virtual void     Stop();

       bool IsInitialized() { return m_initialized; }
       void             SetViewMatrix(const Matrix4&
viewMatrix)
       {
              m_viewMatrix = viewMatrix;
       }

       const Matrix4&   GetViewMatrix() const           { return m_viewMatrix; }

       void             SetFrustum(const float frustumParameters[]);
       const Matrix4&   GetProjectionMatrix() const     { return m_projectionMatrix; }

       int              GetWidth() cons                 { return m_width; }
       int              GetHeight() const               { return m_height; }
};

第一个添加是 enum,它添加了六个截锥参数的定义。还有一个存储参数的浮点数组和两个存储视图和投影矩阵的矩阵。

接下来是获取和设置新字段的访问器方法。

唯一不直接的方法是SetFrustum法。CameraComponent::SetFrustum方法采用垂直视野和纵横比来构建创建虚拟相机截锥边界所需的截锥参数。RendererSetFrustum方法将这些参数作为输入,并从中构建一个投影矩阵。我们来看看清单 8-5 中的这个方法。

清单 8-5。 Renderer::SetFrustum,渲染器. cpp

void Renderer::SetFrustum(const float frustumParameters[])
{
       for (unsigned int i=0; i<NUM_PARAMS; ++i)
       {
              m_frustumParameters[i] = frustumParameters[i];
       }

       m_projectionMatrix.m_m[0] =
              (2.0f * m_frustumParameters[NEAR]) /
              (m_frustumParameters[RIGHT] - m_frustumParameters[LEFT]);
       m_projectionMatrix.m_m[1]        = 0.0f;
       m_projectionMatrix.m_m[2]        = 0.0f;
       m_projectionMatrix.m_m[3]        = 0.0f;

       m_projectionMatrix.m_m[4]        = 0.0f;
       m_projectionMatrix.m_m[5]        =
               (2.0f * m_frustumParameters[NEAR]) /
               (m_frustumParameters[TOP] - m_frustumParameters[BOTTOM]);
       m_projectionMatrix.m_m[6]        = 0.0f;
       m_projectionMatrix.m_m[7]        = 0.0f;
       m_projectionMatrix.m_m[8]        =
              -((m_frustumParameters[RIGHT] + m_frustumParameters[LEFT]) /
              (m_frustumParameters[RIGHT] - m_frustumParameters[LEFT]));
       m_projectionMatrix.m_m[9]        =
              -((m_frustumParameters[TOP] + m_frustumParameters[BOTTOM]) /
              (m_frustumParameters[TOP] - m_frustumParameters[BOTTOM]));
       m_projectionMatrix.m_m[10]       =
              (m_frustumParameters[FAR] + m_frustumParameters[NEAR]) /
              (m_frustumParameters[FAR] - m_frustumParameters[NEAR]);
       m_projectionMatrix.m_m[11]       = 1.0f;
       m_projectionMatrix.m_m[12]       = 0.0f;
       m_projectionMatrix.m_m[13]       = 0.0f;
       m_projectionMatrix.m_m[14]       =
              -(2.0f * m_frustumParameters[NEAR] * m_frustumParameters[FAR]) /

               (m_frustumParameters[FAR] - m_frustumParameters[NEAR]);
       m_projectionMatrix.m_m[15]       = 0.0f;
}

透视投影矩阵是通过将缩放矩阵与修改要挤压的顶点的矩阵以及用于为渲染流水线中被称为透视分割的步骤准备顶点的矩阵相结合而创建的。

透视投影中涉及的数学可能会变得非常复杂,因此我们不会在此详述,因为我们需要知道的是,前面的代码将创建一个透视投影矩阵,该矩阵适用于将顶点转换为 Android 上 OpenGL ES 2.0 使用的规范视图体。

连接模型、视图和投影矩阵

我们将要依赖的矩阵变换的一个性质是连接。矩阵可以相乘,它们各自的变换将合并成一个矩阵。这些矩阵相乘的顺序非常重要。在我们的例子中,我们希望确保应用模型转换,然后是视图转换,最后是投影转换。我们通过从模型矩阵开始并乘以视图矩阵来实现这一点。然后,我们将得到的矩阵乘以投影矩阵。

这段代码可以在TransformShader::Setup中找到,我们可以在的清单 8-6 中看到。

清单 8-6。 更新TransformShader::Setup。TransformShader.cpp

void TransformShader::Setup(Renderable& renderable)
{
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry)
       {
              Shader::Setup(renderable);
              Renderer&**renderer = Renderer::GetSingleton();**
              **const Matrix4&****viewMatrix = renderer.GetViewMatrix();**
              **const Matrix4&****projectionMatrix = renderer.GetProjectionMatrix();**
              **Matrix4 modelViewMatrix;**
              **renderable.GetTransform().GetMatrix().Multiply(viewMatrix, modelViewMatrix);**
              **Matrix4 modelViewProjectionMatrix;**
              **modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);**
              **glUniformMatrix4fv(**
                     **m_transformUniformHandle,**
                     **1,**
                     **false,**
                     **modelViewProjectionMatrix.m_m);**

              `glVertexAttribPointer(`
                     `m_positionAttributeHandle,`
                     `pGeometry->GetNumVertexPositionElements(),`
                     `GL_FLOAT,`
                     `GL_FALSE,`
                     `pGeometry->GetVertexStride(),`
                     `pGeometry->GetVertexBuffer());`
              `glEnableVertexAttribArray(m_positionAttributeHandle);`
              `Vector4& color = renderable.GetColor();`
              `glUniform4f(`
                     `m_colorAttributeHandle,`
                     `color.m_x,`
                     `color.m_y,`
                     `color.m_z,`
                     `color.m_w);`
       `}`
`}`

`清单 8-6 中更新的代码显示了我们如何将矩阵连接在一起。

RenderableTransform为这个对象提供了模型矩阵。我们将这个模型矩阵乘以从Renderer中获得的viewMatrix。得到的矩阵modelViewMatrix然后乘以projectionMatrix,也是从Renderer获得的。

最终的矩阵通过glUniformMatrix4fv调用提供给 OpenGL(在清单 8-6 中粗体代码块的末尾)。

现在我们的框架已经更新到支持相机对象,我们应该添加代码来更新每一帧中相机的位置。

更新摄像机的变换

我们在第三章中展示的机器人跑垒员的设计要求我们的游戏从左到右自动更新玩家的位置。如果我们不与玩家同时更新摄像机,我们玩家的GameObject就会移出屏幕,玩家就看不到动作了。

对于如何处理这种情况,有几种选择。我们可以选择以与玩家对象每帧完全相同的方式更新摄像机的位置,并依靠两个对象以相同的速度移动来保持玩家的GameObject在屏幕上。

另一种选择是将相机“附加”到玩家的对象上,并让相机使用玩家位置的偏移来更新其每一帧的位置。这是我决定使用的方法。

为了实现这一点,我们将创建一个新的组件,BoundObjectComponent ,这个组件的声明如清单 8-7 所示。

清单 8-7。BoundObjectComponent类声明。BoundObjectComponent.h

class BoundObjectComponent
       :      public Component
       ,      public EventHandler
{
private:
       static const unsigned int s_id = 4;
       Transform                                m_offsetTransform;

       const TransformComponent*                m_pBoundObject;

public:
       static unsigned int GetId()       { return s_id; }
       explicit BoundObjectComponent(GameObject* pOwner);
       virtualBoundObjectComponent();

       virtual void Initialize() {}
       Transform&           GetTransform()              { return m_offsetTransform; }
       const Transform&     GetTransform() const        { return m_offsetTransform; }

       void                 SetBoundObject(const TransformComponent* pComponent)
       {
              m_pBoundObject = pComponent;
       }

       const TransformComponent*        GetBoundObject() const  { return m_pBoundObject; }
       virtual void HandleEvent(Event* pEvent);
};

BoundObjectComponent的设置方式和我们其他的Component职业和EventHandlers一样。重要字段是m_offsetTransformm_pBoundObject

m_offsetTransform 将存储从父对象偏移的Transform信息。m_pBoundObject 将存储一个指向TransformComponent的指针,用于我们希望通过关卡跟踪的对象。

这个Component完成的所有努力都包含在HandleEvent方法中。我们来看看清单 8-8 中的方法。

清单 8-8。 BoundObjectComponent::HandleEvent。BoundObjectComponent.cpp

void BoundObjectComponent::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == UPDATE_EVENT && m_pBoundObject)
       {

              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              Transform& ourTransform = pTransformComponent->GetTransform();
              const Transform& boundTransform = m_pBoundObject->GetTransform();
              Vector3 translation = m_offsetTransform.GetTranslation();
              translation.Add(boundTransform.GetTranslation());
              ourTransform.SetTranslation(translation);
              ourTransform.UpdateMatrix();
       }
}

如你所见,HandleEvent附属于UPDATE_EVENT。当这个对象被更新时,我们得到我们的所有者对象的Transform和我们被绑定到的对象的Transform

最有趣的代码是我们创建新的Vector3translation的三行代码。初始值被初始化以匹配m_offsetTransform的翻译。然后我们添加绑定对象的翻译,并设置我们的Transform来包含新计算的翻译。最后,我们更新由我们的Transform对象存储的矩阵。

这段代码将允许我们的摄像机跟随玩家对象通过我们的关卡。当玩家跳跃时,摄像机会随着玩家上升和下降,一旦我们编写代码让玩家通过关卡向右移动,摄像机也会跟着移动。

现在是时候把所有东西绑在一起,给我们的等级加上一个带CameraComponentGameObject

给 DroidRunnerLevel 添加摄像头

我们的相机对象将被添加到我们的级别对象列表中。让我们看看清单 8-9 中的代码。

清单 8-9。 一更新为DroidRunnerLevel::Initialize。DroidRunnerLevel.cpp

void DroidRunnerLevel::Initialize(const Vector3& origin)
{
       m_sphereGeometry.SetVertexBuffer(sphereVerts);
       m_sphereGeometry.SetNumVertices(sizeof(sphereVerts) / sizeof(sphereVerts[0]));
       m_sphereGeometry.SetIndexBuffer(sphereIndices);
       m_sphereGeometry.SetNumIndices(sizeof(sphereIndices) / sizeof(sphereIndices[0]));
       m_sphereGeometry.SetName("android");
       m_sphereGeometry.SetNumVertexPositionElements(3);
       m_sphereGeometry.SetVertexStride(0);

       m_cubeGeometry.SetVertexBuffer(cubeVerts);
       m_cubeGeometry.SetNumVertices(sizeof(cubeVerts) / sizeof(cubeVerts[0]));
       m_cubeGeometry.SetIndexBuffer(cubeIndices);
       m_cubeGeometry.SetNumIndices(sizeof(cubeIndices) / sizeof(cubeIndices[0]));
       m_cubeGeometry.SetName("cube");
       m_cubeGeometry.SetNumVertexPositionElements(3);
       m_cubeGeometry.SetVertexStride(0);

       m_origin.Set(origin);
       CollisionManager::GetSingleton().AddCollisionBin();
       const Vector3 min(-3.0f, -3.0f, -3.0f);
       const Vector3 max(3.0f, 3.0f, 3.0f);

       TransformComponent* pPlayerTransformComponent = NULL;
       const unsigned char tiles[] =
       {
              EMPTY,    EMPTY,  EMPTY,  EMPTY,  AI,     AI,     AI,     AI,
              EMPTY,    EMPTY,  EMPTY,  EMPTY,  BOX,    BOX,    BOX,    BOX,
              EMPTY,    PLAYER, EMPTY,  EMPTY,  EMPTY,  EMPTY,  EMPTY,  EMPTY,
              BOX,      BOX,    BOX,    BOX,    BOX,    BOX,    BOX,    BOX
       };

       const unsigned int numTiles = sizeof(tiles) / sizeof(tiles[0]);
       const unsigned int numRows  = 4;
       const unsigned int rowWidth = numTiles / numRows;

       for (unsigned int i=0; i<numTiles; ++i)
       {
              if (tiles[i] == BOX)
              {
                     const unsigned int row     = i / rowWidth;
                     const unsigned int column  = i % rowWidth;

                     GameObject* pNewObject = new GameObject();
                     SetObjectPosition(pNewObject, row, column);
                     AddCollisionComponent(pNewObject, min, max);
                     Vector4 color(0.0f, 0.0f, 1.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_cubeGeometry,
                            m_transformShader,
                            color);

                     m_levelObjects.push_back(pNewObject);
              }
              else if (tiles[i] == PLAYER)
              {
                     const unsigned int row     = i / rowWidth;
                     const unsigned int column  = i % rowWidth;

                     GameObject* pNewObject = new GameObject();
                     SetObjectPosition(pNewObject, row, column);
                     AddMovementComponent(pNewObject);
                     AddCollisionComponent(pNewObject, min, max);
                     MovementComponent* pMovementComponent =
                            component_cast<MovementComponent>(pNewObject);
                     m_pPlayerCollisionComponent =
                            component_cast<CollisionComponent>(pNewObject);
                     if (pMovementComponent && m_pPlayerCollisionComponent)
                     {
                            m_pPlayerCollisionComponent->AddEventListener(pMovementComponent);
                     }

                     pPlayerTransformComponent = component_cast<TransformComponent>(pNewObject);
                     Vector4 color(0.0f, 1.0f, 0.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_sphereGeometry,
                            m_transformShader,
                            color);

                     m_levelObjects.push_back(pNewObject);
              }
              else if (tiles[i] == AI)
              {
                     const unsigned int row             = i / rowWidth;
                     const unsigned int column          = i % rowWidth;

                     unsigned int patrolEndRow          = 0;
                     unsigned int patrolEndColumn       = 0;

                     for (unsigned int j=i; j<numTiles; ++j)
                     {
                            if (tiles[j] != AI)
                            {
                                   i = j;
                                   --j;
                                   patrolEndRow         = j / rowWidth;
                                   patrolEndColumn      = j % rowWidth;
                                   break;
                            }
                     }

                     GameObject* pNewObject = new GameObject();
                     SetObjectPosition(pNewObject, row, column);
                     AddCollisionComponent(pNewObject, min, max);
                     AddPatrolComponent(pNewObject, row, column, patrolEndRow, patrolEndColumn);
                     Vector4 color(1.0f, 0.0f, 0.0f, 1.0f);
                     AddRenderableComponent(
                            pNewObject,
                            m_sphereGeometry,
                            m_transformShader,
                            color);

                     m_levelObjects.push_back(pNewObject);
              }
       }

       // Create a camera object
       GameObject* pCameraObject = new GameObject();

       pCameraObject->AddComponent<TransformComponent>();
       pCameraObject->AddComponent<BoundObjectComponent>();
       BoundObjectComponent* pBoundObjectComponent =
              component_cast<BoundObjectComponent>(pCameraObject);
       assert(pBoundObjectComponent);
       pBoundObjectComponent->SetBoundObject(pPlayerTransformComponent);
       pBoundObjectComponent->GetTransform().SetTranslation(Vector3(6.0f, 4.25f, -45.0f));
       AttachEvent(UPDATE_EVENT, *pBoundObjectComponent);
       pCameraObject->AddComponent<CameraComponent>();
       CameraComponent* pCameraComponent = component_cast<CameraComponent>(pCameraObject);
       assert(pCameraComponent);
       const Renderer&renderer = Renderer::GetSingleton();
       float width = static_cast<float>(renderer.GetWidth());
       float height = static_cast<float>(renderer.GetHeight());
       pCameraComponent->SetFrustum(35.0f, width / height, 1.0f, 100.0f);
       AttachEvent(POSTUPDATE_EVENT, *pCameraComponent);
       m_levelObjects.push_back(pCameraObject);
       Renderer* pRenderer = Renderer::GetSingletonPtr();
       if (pRenderer)
       {
              pRenderer->AddShader(&m_transformShader);
       }

       m_initialized = true;
}

我们更新的DroidRunnerLevel::Initialize方法现在有必要的代码来从我们在本章中创建的新Components创建一个相机对象。

第一个变化涉及到缓存一个指向玩家对象的TransformComponent的指针。这是在创建玩家对象后将相机绑定到玩家对象所必需的。

相机本身需要一个TransformComponent,一个BoundObjectComponent,一个CameraComponent。这是我们创建的第一个没有RenderableComponent的游戏对象。将BoundObjectComponent 绑定到播放器,并将偏移量设置为Vector3(6.0f, 4.25f, –45.0f)。该偏移意味着玩家对象在场景中略低于相机,并略偏左。这将允许玩家看到玩家上方一定高度的壁架,以及更多进入视图右侧的场景。

SetFrustum法 称垂直视场 35 度。纵横比是通过将帧缓冲器的宽度除以帧缓冲器的高度来计算的。框架的宽度和高度由Renderer保存,当Renderer初始化时,从EGL获取。

这导致了另一个需要的变化。之前,我们在Chapter7Task::Start中初始化关卡。对于第八章,CameraComponent要求显示器初始化,以便我们可以访问帧缓冲区的宽度和高度。我们在的清单 8-10 中看看我们是如何做到这一点的。

清单 8-10。 第八章任务::更新。第八章任务

void Chapter8Task::Update()
{
       if (Renderer::GetSingleton().IsInitialized())
       {
              if (!m_level.IsInitialized ())
              {
                     Framework::Vector3 levelOrigin(–21.0f, 7.75f, 35.0f);
                     m_level.Initialize(levelOrigin);
                     Framework::AttachEvent(POSTUPDATE_EVENT, m_level);
              }

              Framework::SendEvent(Framework::UPDATE_EVENT);
              Framework::SendEvent(Framework::POSTUPDATE_EVENT);
              Framework::SendEvent(Framework::RENDER_EVENT);
       }
}

如你所见,我们现在不在Update中做任何工作,直到Renderer被初始化。我们在DroidRunnerLevel::Initialize的最后设置了清单 8-9 中的bool,如果它还没有被完成,我们就在m_level调用Initialize

将我们的更新推迟到渲染器和关卡都已初始化之后,这是我们的应用试图成为 Android 生态系统中的好公民的一部分。我们将看看如何更新我们的渲染器和 Android 类,以便在暂停和恢复时表现得更好。

暂停和恢复时正确的应用行为

到目前为止,当 Android 生态系统向我们发送暂停和恢复事件时,我们的应用一直没有正常运行。清单 4-14 包含了我们的应用用来检测是否应该暂停或恢复的代码,但是我们还没有实际使用这些信息。当我们在执行 OpenGL 应用时收到暂停事件,手机将会破坏我们的 OpenGL 上下文和渲染表面。这对我们的应用没有致命的影响,但是如果您查看来自 LogCat 的输出,您将能够在日志中看到一串红色的输出错误。我们可以通过在此时停止游戏渲染来防止这些错误发生。

添加暂停和恢复事件

我们将通过在事件发生时向广播添加新事件来处理暂停和恢复事件。清单 8-11 显示了新事件。

清单 8-11。PAUSEAPP_EVENTRESUMEAPP_EVENT的定义。EventId.h

static const EventID UPDATE_EVENT               = 0;
static const EventID POSTUPDATE_EVENT           = 1;
static const EventID RENDER_EVENT               = 2;
static const EventID JUMP_EVENT                 = 3;
static const EventID COLLISION_EVENT            = 4;
static const EventID PAUSEAPP_EVENT             = 5;
static const EventID RESUMEAPP_EVENT            = 6;

我们使用Application::CreateSingletons中的RegisterEvent来注册这些事件,如清单 8-12 所示。

清单 8-12。 注册 PAUSEAPP_EVENT 和 RESUMEAPP_EVENT。应用. cpp

void Application::CreateSingletons()
{
       new Timer(Task::TIMER_PRIORITY);
       new Renderer(m_pAppState, Task::RENDER_PRIORITY);
       new EventManager();
       new CollisionManager();

       RegisterEvent(PAUSEAPP_EVENT);
       RegisterEvent(RESUMEAPP_EVENT);
}

现在事件被注册了,清单 8-13 展示了我们如何从android_handle_cmd内部发送它们。

清单 8-13。 发送PAUSEAPP_EVENTRESUMEAPP_EVENT。Android.cpp

static void android_handle_cmd(struct android_app* app, int32_t cmd)
{
       switch (cmd)
       {
       case APP_CMD_INIT_WINDOW:
              {
                     assert(Renderer::GetSingletonPtr());
                     Renderer::GetSingleton().Init();
              }
              break;
       case APP_CMD_DESTROY:
              {
                     assert(Renderer::GetSingletonPtr());
                     Renderer::GetSingleton().Destroy();
              }
              break;
       case APP_CMD_TERM_WINDOW:
              {
                     assert(Renderer::GetSingletonPtr());
                     Renderer::GetSingleton().Destroy();
              }
              break;
       case APP_CMD_RESUME:
              {
                     SendEvent(RESUMEAPP_EVENT);
              }
              break;
       case APP_CMD_PAUSE:
              {
                     SendEvent(PAUSEAPP_EVENT);
              }
              break;
       }
}

您可以从该类中移除静态方法和m_bPaused字段,因为我们将不再使用它们。

在渲染器中处理暂停和恢复事件

有兴趣了解应用何时被系统暂停和恢复的对象现在可以附加到这些事件。清单 8-14 显示了我们的Renderer 被更新以继承EventHandler

清单 8-14。 为渲染器添加暂停和恢复支持。Renderer.h

class Renderer
       :     public Task
       ,     public EventHandler
       ,     public Singleton<Renderer>
{
public:
       enum FrustumParameters
       {
              TOP,
              BOTTOM,
              RIGHT,
              LEFT,
              NEAR,
              FAR,
              NUM_PARAMS
       };

private:
       android_app*         m_pState;
       EGLDisplay           m_display;
       EGLContext           m_context;
       EGLSurface           m_surface;
       int                  m_width;
       int                  m_height;
       bool                 m_initialized;
       bool m_paused;

       typedef std::vector<Shader*>              ShaderVector;
       typedef ShaderVector::iterator            ShaderVectorIterator;

       typedef std::vector<Texture*>             TextureVector;
       typedef TextureVector::iterator           TextureVectorIterator;

       typedef std::vector<Renderable*>          RenderableVector;
       typedef RenderableVector::iterator        RenderableVectorIterator;

       RenderableVector                          m_renderables;
       TextureVector                             m_textures;
       ShaderVector                              m_shaders;

       float                                     m_frustumParameters[NUM_PARAMS];

       Matrix4                                   m_viewMatrix;
       Matrix4                                   m_projectionMatrix;

       void          Draw(Renderable* pRenderable);

public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtualRenderer();
       void Init();
       void Destroy();

       void AddRenderable(Renderable* pRenderable);
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);

       // From Task
       virtual bool  Start();
       virtual void  OnSuspend();
       virtual void  Update();
       virtual void  OnResume();
       virtual void  Stop();

       virtual void HandleEvent(Event* event);
       bool          IsInitialized() { return m_initialized; }

       void                 SetViewMatrix(const Matrix4& viewMatrix)
       {
              m_viewMatrix = viewMatrix;
       }
       const Matrix4&       GetViewMatrix() const       { return m_viewMatrix; }

       void                 SetFrustum(const float frustumParameters[]);
       const Matrix4&       GetProjectionMatrix() const { return m_projectionMatrix; }

       int                  GetWidth() const            { return m_width; }
       int                  GetHeight() const           { return m_height; }
};

你应该在Renderer的构造函数中初始化m_pausedfalse。清单 8-15 和 8-16 显示了我们在Renderer's StartStop方法中附加和分离PAUSEAPP_EVENTRESUMEAPP_EVENT

清单 8-15。PAUSEAPP_EVENTRESUMEAPP_EVENT附在Renderer::Start上。Renderer.cpp

bool Renderer::Start()
{
       AttachEvent(PAUSEAPP_EVENT, *this);
       AttachEvent(RESUMEAPP_EVENT, *this);

       return true;
}

清单 8-16。 脱离Renderer::Stop中的PAUSEAPP_EVENTRESUMEAPP_EVENT。Renderer.cpp

void Renderer::Stop()
{
       DetachEvent(RESUMEAPP_EVENT, *this);
       DetachEvent(PAUSEAPP_EVENT, *this);
}

一旦我们附加到事件上,我们必须在HandleEvent 中观察它们,如清单 8-17 所示。

清单 8-17。 Renderer::HandleEvent,渲染器. cpp

void Renderer::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == PAUSEAPP_EVENT)
       {
              m_paused = true;
       }
       else if (pEvent->GetID() == RESUMEAPP_EVENT)
       {
              m_paused = false;
       }
}

当我们收到这些事件时,我们分别将m_paused字段设置为truefalse。现在我们更新 Renderer::Update 方法来防止应用暂停时的渲染。清单 8-18 显示了这一点。

清单 8-18。Renderer::Update中暂停渲染。Renderer.cpp

void Renderer::Update()
{
       if (m_initialized &&!m_paused)
       {
              glEnable(GL_DEPTH_TEST);
              glClearColor(0.95f, 0.95f, 0.95f, 1);

              glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
              for (RenderableVectorIterator iter = m_renderables.begin();
                   iter != m_renderables.end();
                   ++iter)
              {
                     Renderable* pRenderable = *iter;
                     if (pRenderable)
                     {
                            Draw(pRenderable);
                     }
              }

              eglSwapBuffers(m_display, m_surface);
              m_renderables.clear();
       }
}

将此代码添加到渲染器后,您将不会再看到与缺少 OpenGL 上下文和表面相关的错误消息。如前所述,如果没有这一点,应用就不会崩溃,但通常我们的应用最好按照 Android 的预期运行,以确保我们与所有过去、现在和未来的 Android 版本和设备驱动程序完全兼容。

在我们的小弯路之后,是时候回到相机的工作中了。下一部分将使用我们相机中的信息,这些信息与我们的相机位置和它能看到的东西有关,来优化我们的渲染过程。我们将实现的特定技术被称为视图截锥剔除。

视图截锥剔除

现代 GPU 是一个非常高效的协处理器,可以对我们提供给它的数据进行计算,比 CPU 快得多。这是因为 GPU 被设计为大规模并行,并且专门设计为除了搅动数据流之外什么也不做。我们的游戏逻辑代码是为手机中更加灵活的 CPU 编写的。CPU 的好处是它可以执行更广泛的任务,这些任务也不一定适合分解成小块。这意味着在 CPU 上执行的代码可以比 GPU 获得更多关于我们游戏世界的信息。这包括我们的相机对象和场景中的其他对象。

我们可以利用这一点。我们的相机对象可以用一个*截头体来表示。图 8-5 显示了三维空间中摄像机截锥的形状。

9781430258308_Fig08-05.jpg

图 8-5 。使用透视投影时的相机截锥

图 8-5 显示了透视摄像机截锥的形状。*截头体是截棱锥。当我们在场景中循环遍历Renderables的向量时,我们可以做一个测试来确定物体的一部分是否在摄像机截锥内;如果是,我们就画出这个物体。如果我们可以检测到整个对象都在截锥之外,我们可以丢弃这个对象,永远不要将其发送到 GPU。在图 8-5 的中,直线相交的点是摄像机的位置,截锥指向摄像机变换矩阵的 z 轴。

在我们的渲染器中实现*截头体剔除的第一个任务是存储每帧的相机矩阵和*截头体参数。清单 8-19 显示了我们必须对Renderer进行的更新。

清单 8-19。Renderer添加*截体剔除支持。Renderer.h

class Renderer
       :      public Task
       ,      public EventHandler
       ,      public Singleton<Renderer>
{
public:
       enum FrustumParameters
       {
              TOP,
              BOTTOM,
              RIGHT,
              LEFT,
              NEAR,
              FAR,
              NUM_PARAMS
       };

private:
       android_app*         m_pState;
       EGLDisplay           m_display;
       EGLContext           m_context;
       EGLSurface           m_surface;
       int                  m_width;
       int                  m_height;
       bool                 m_initialized;
       bool                 m_paused;

       typedef std::vector<Shader*>              ShaderVector;
       typedef ShaderVector::iterator            ShaderVectorIterator;

       typedef std::vector<Texture*>             TextureVector;
       typedef TextureVector::iterator           TextureVectorIterator;

       typedef std::vector<Renderable*>          RenderableVector;
       typedef RenderableVector::iterator        RenderableVectorIterator;

       RenderableVector                          m_renderables;
       TextureVector                             m_textures;
       ShaderVector                              m_shaders;

       float                                     m_frustumParameters[NUM_PARAMS];

 Matrix4 m_cameraMatrix;
       Matrix4                                   m_viewMatrix;
       Matrix4                                   m_projectionMatrix;

       void          Draw(Renderable* pRenderable);
 void BuildFrustumPlanes(Plane frustumPlanes[]);
 bool ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const;

public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtualRenderer();
       void Init();
       void Destroy();

       void AddRenderable(Renderable* pRenderable);
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);

       // From Task
       virtual bool  Start();
       virtual void  OnSuspend();
       virtual void  Update();
       virtual void  OnResume();
       virtual void  Stop();

       virtual void  HandleEvent(Event* event);
       bool          IsInitialized() { return m_initialized; }

 void SetCameraMatrix(const Matrix4&cameraMatrix)
 {
 m_cameraMatrix = cameraMatrix;
 }
 const Matrix4&GetCameraMatrix() const { return m_cameraMatrix; }

       void   SetViewMatrix(const Matrix4& viewMatrix)  { m_viewMatrix = viewMatrix; }
       const Matrix4&       GetViewMatrix() const       { return m_viewMatrix; }

       void                 SetFrustum(const float frustumParameters[]);
       const Matrix4&       GetProjectionMatrix() const { return m_projectionMatrix; }

       int                  GetWidth() const            { return m_width; }
       int                  GetHeight() const
       {
              return m_height;
       }
};

我们的Renderer现在有另一个Matrix4来存储当前摄像机的矩阵。我们还有两个新方法,BuildFrustumPlanesShouldDraw

*截头体剔除利用了几何*面的属性。*面可用于将三维空间分成两半。然后,我们可以使用一个简单的点积来确定我们测试的点是在*面的前面还是后面。如果你现在不明白这背后的数学原理,不要担心;本书在http://www.apress.com/9781430258308提供了代码,附录 D 涵盖了代码示例中包含的数学类。我建议您通读附录和源代码,直到弄清楚这些*面是如何工作的。

BuildFrustumPlanes 将用于构建六个*面(一个用于*裁剪*面,一个用于远裁剪*面,一个代表*截头体的四个边:顶、底、左、右)。我们希望这些*面中的每一个都有一个正的半空间,指向*截头体的中心。*面的正半空间是*面法线指向的那一边。清单 8-20 显示了BuildFrustumPlanes的代码。

清单 8-20。 Renderer::BuildFrustumPlanes,渲染器. cpp

void Renderer::BuildFrustumPlanes(Plane frustumPlanes[])
{
       // Get the camera orientation vectors and position as Vector3
       Vector3 cameraRight(
              m_cameraMatrix.m_m[0],
              m_cameraMatrix.m_m[1],
              m_cameraMatrix.m_m[2]);
       Vector3 cameraUp(
              m_cameraMatrix.m_m[4],
              m_cameraMatrix.m_m[5],
              m_cameraMatrix.m_m[6]);
       Vector3 cameraForward(
              m_cameraMatrix.m_m[8],
              m_cameraMatrix.m_m[9],
              m_cameraMatrix.m_m[10]);
       Vector3 cameraPosition(
              m_cameraMatrix.m_m[12],
              m_cameraMatrix.m_m[13],
              m_cameraMatrix.m_m[14]);

       // Calculate the center of the near plane
       Vector3 nearCenter = cameraForward;
       nearCenter.Multiply(m_frustumParameters[NEAR]);
       nearCenter.Add(cameraPosition);

       // Calculate the center of the far plane
       Vector3 farCenter = cameraForward;
       farCenter.Multiply(m_frustumParameters[FAR]);
       farCenter.Add(cameraPosition);

       // Calculate the normal for the top plane
       Vector3 towardsTop = cameraUp;
       towardsTop.Multiply(m_frustumParameters[TOP]);
       towardsTop.Add(nearCenter);
       towardsTop.Subtract(cameraPosition);
       towardsTop.Normalize();
       towardsTop = cameraRight.Cross(towardsTop);
       frustumPlanes[TOP].BuildPlane(cameraPosition, towardsTop);

       // Calculate the normal for the bottom plane
       Vector3 towardsBottom = cameraUp;
       towardsBottom.Multiply(m_frustumParameters[BOTTOM]);
       towardsBottom.Add(nearCenter);
       towardsBottom.Subtract(cameraPosition);
       towardsBottom.Normalize();
       towardsBottom = towardsBottom.Cross(cameraRight);
       frustumPlanes[BOTTOM].BuildPlane(cameraPosition, towardsBottom);

       // Calculate the normal for the right plane
       Vector3 towardsRight = cameraRight;
       towardsRight.Multiply(m_frustumParameters[RIGHT]);
       towardsRight.Add(nearCenter);
       towardsRight.Subtract(cameraPosition);
       towardsRight.Normalize();
       towardsRight = towardsRight.Cross(cameraUp);
       frustumPlanes[RIGHT].BuildPlane(cameraPosition, towardsRight);

       // Calculate the normal for the left plane
       Vector3 towardsLeft = cameraRight;
       towardsLeft.Multiply(m_frustumParameters[LEFT]);
       towardsLeft.Add(nearCenter);
       towardsLeft.Subtract(cameraPosition);
       towardsLeft.Normalize();
       towardsLeft = cameraUp.Cross(towardsLeft);
       frustumPlanes[LEFT].BuildPlane(cameraPosition, towardsLeft);

       Vector3 towardsNear = cameraForward;
       frustumPlanes[NEAR].BuildPlane(nearCenter, towardsNear);
       Vector3 towardsFar = cameraForward;
       towardsFar.Negate();
       frustumPlanes[FAR].BuildPlane(farCenter, towardsFar);
}

正如我们所看到的,在BuildFrustumPlanes方法中有一段代码用于摄像机截锥的每一侧。**面和远*面可以优化,这意味着计算它们所需的代码更少。

按顺序浏览该方法将显示我们如何得到代表*截头体的六个*面。首先,我们解构相机矩阵。如果你还记得,在第六章中,我们讨论过旋转矩阵是正交矩阵。这意味着矩阵的每一行代表 3D 空间中的一个轴。一个 3x3 矩阵第一行代表右向量(x 轴),第二行代表上向量(y 轴),第三行代表 at 向量(z 轴)。代码的第一部分从矩阵中提取每个轴的三个法向量,以及矩阵的*移分量。每个都转换成一个Vector3

然后我们计算远**面的中心点。相机矩阵的 at 向量是一个单位法线,因此将其乘以到**面的距离,就得到**面相对于原点的中心点(0,0,0 处的点)。然后,我们添加相机的位置,以获得相对于相机位置的**面的中心。我们对远*面重复这个过程,但是使用到远*面的距离。

现在我们应该回顾一下我们是如何计算清单 8-2 中的截锥参数的。顶部参数通过将到**面的距离乘以垂直视场的半角来计算。这其实就是利用了直角三角形和三角学。图 8-6 显示了将视野一分为二形成的两个直角三角形的侧视图。

9781430258308_Fig08-06.jpg

图 8-6 。相机位置到**面的侧面轮廓

通过使用三角学,我们能够通过使用 tan 函数计算**面上半部分的垂直高度。我们可以再次使用这个值和相机变换矩阵中的上方向向量来计算代表*截头体顶部的*面的法线。

清单 8-21 是取自清单 8-20 中已经显示的BuildFrustumPlanes函数的一段代码。

清单 8-21。 计算圆台的顶面。Renderer.cpp

// Calculate the normal for the top plane
Vector3 towardsTop = cameraUp;
towardsTop.Multiply(m_frustumParameters[TOP]);
towardsTop.Add(nearCenter);
towardsTop.Subtract(cameraPosition);
towardsTop.Normalize();
towardsTop = cameraRight.Cross(towardsTop);
frustumPlanes[TOP].BuildPlane(cameraPosition, towardsTop);

我们首先将相机的上方向向量分配给Vector3towardsTop。 图 8-7 显示了原点处的这个矢量:虚线表示剖面中的 y 轴和 z 轴,y 向上,z 向右。x 轴指向远离你的方向。我们将逐步完成这一过程;在每个阶段,虚线将显示前面的步骤。

9781430258308_Fig08-07.jpg

图 8-7 。创建顶部*截头体*面的第一步

然后我们将这个向量乘以**面上半部分的高度,它存储在m_frustumParameters[TOP]中,如图 8-8 中的所示。

9781430258308_Fig08-08.jpg

图 8-8 。相机上方向向量乘以*截头体上半部分的高度

图 8-9 显示了下一步,将该矢量添加到nearCenter位置。

9781430258308_Fig08-09.jpg

图 8-9 。正在将*中心位置添加到我们的矢量中

接下来的两个步骤很难用数字来表示,所以我们将通过它们来讨论。向量减法很有用,因为它允许我们在 3D 空间中移动向量。在这个过程中的这个时刻,我们已经设法得到了一个向量,它代表了从相机的位置指向**面顶部的线。

我们现在想把我们的矢量转换成指向这个方向的单位法线。为了实现这一点,我们需要通过减去相机位置将向量移动到原点。请记住,在这个过程的开始,我们将nearCenter向量添加到新的上方向向量中,并且最初使用cameraPosition向量来计算nearCenter向量。这就是为什么我们可以通过减去cameraPosition将向量移回原点。现在我们在矢量上调用Normalize来创建一个指向**面顶部的单位法线。

我们现在得到的矢量指向*截头体的顶面。相机的右向量也在这个*面上。我们想要的是一个指向*截头体的单位向量,我们可以用叉积得到其中之一。两个单位矢量的叉积是垂直于两个矢量所在*面的另一个单位矢量。结果向量的方向由右手定则决定。右手定则可能很难让你理解,所以我用螺丝刀的类比来计算我的叉积会给我哪个方向。

如果你以前用过螺丝刀,你会知道顺时针转动螺丝会导致螺丝移入物体。逆时针转动会导致螺钉脱出。叉积与此相反:顺时针旋转会产生一个朝向你的矢量,逆时针旋转会远离你。我通过想象螺钉指向我,头部在另一端来想象这个。如果螺丝顺时针旋转,意味着它会离我更*,如果逆时针旋转,它会远离我。逻辑有点混乱,但它一直很好地服务于我。

你可以看到我们如何通过放置你的左手,手掌向下,拇指指向右边,食指指向前方,中指向下,来获得指向*截头体的矢量。你的拇指代表cameraRight向量,你的食指代表towardsTop向量。现在旋转你的手,让你的拇指移动到你食指的位置。这就是我们正在做的cameraRight.Cross(towardsTop)操作,产生的矢量就是我们的*面法线。我们现在使用BuildPlane方法建造我们的飞机。

现在使用它们各自的*截头体参数和适当的叉积方向对每个其他*面重复该过程。有趣的是,底部*面的叉积与顶部*面的叉积相反。这就是我们如何实现指向两边截锥的向量。左右*面也是如此。

计算远**面的过程要简单得多。这些*面已经沿着现有的相机指向矢量。**面指向与相机相同的方向,远*面指向相反的方向。我们在BuildPlane方法中使用的*面上的点分别是nearCenterfarCenter点,而不是cameraPosition向量。

既然我们已经构建了我们的*截头体*面,我们可以在调用Draw之前检测一个对象是否位于*截头体内。

视锥剔除测试

为了能够针对*截头体测试我们的对象,我们必须首先知道它们的边界在空间中的位置。幸运的是,我们可以在这个任务中重用来自CollisionComponent的数据。我们从给清单 8-22 中的Renderable类添加最小和最大边界向量开始。

清单 8-22。Renderable添加界限。可渲染. h

class Renderable
{
private:
       Geometry*     m_pGeometry;
       Shader*       m_pShader;
       Transform     m_transform;
       Vector4       m_color;

 Vector3 m_min;
 Vector3 m_max;
 bool m_useBounds;

public:
       Renderable();
       ∼Renderable();

       void                 SetGeometry(Geometry* pGeometry);
       Geometry*            GetGeometry();

       void                 SetShader(Shader* pShader);
       Shader*              GetShader();

       Transform&           GetTransform()                     { return m_transform; }
       Vector4&             GetColor()                         { return m_color; }

 void SetBoundMin(const Vector3&min) { m_min = min; }
 const Vector3&GetBoundMin() const      { return m_min; }

 void SetBoundMax(const Vector3&max) { m_max = max; }
 const Vector3&GetBoundMax() const { return m_max; }

 void SetUseBounds(bool enabled) { m_useBounds = enabled; }
 bool GetUseBounds() const { return m_useBounds; }

       bool                 IsInitialized() const
       {
              return m_pGeometry && m_pShader;
       }
};

在清单 8-23 的中,我们更新了方法RenderableComponent::HandleEvent来设置当我们添加对象到渲染列表时的边界。

清单 8-23。 更新RenderableComponent::HandleEvent。RenderableComponent.cpp

void RenderableComponent::HandleEvent(Event* pEvent)
{
       assert(pEvent);
       if (pEvent->GetID() == RENDER_EVENT)
       {
              TransformComponent* pTransformComponent = component_cast<TransformComponent>(GetOwner());
              if (pTransformComponent)
              {
                     m_renderable.GetTransform().Clone(pTransformComponent->GetTransform());
              }

 CollisionComponent* pCollisionComponent = component_cast<CollisionComponent>(GetOwner());
 if (pCollisionComponent)
 {
 m_renderable.SetBoundMin(pCollisionComponent->GetMin());
 m_renderable.SetBoundMax(pCollisionComponent->GetMax());
 m_renderable.SetUseBounds(true);
 }
 else
 {
 m_renderable.SetUseBounds(false);
 }

              assert(Renderer::GetSingletonPtr());
              Renderer::GetSingleton().AddRenderable(&m_renderable);
       }
}

如果被渲染的对象有一个CollisionComponent对象,现在HandleEvent设置Renderable的边界。

现在我们可以将代码添加到Renderer::Update中,以确定对象是否在截锥内。清单 8-24 显示了这次更新。

清单 8-24。 更新Renderer::Update。Renderer.cpp

void Renderer::Update()
{
       if (m_initialized && !m_paused)
       {
 Plane  frustumPlanes[NUM_PARAMS];
 BuildFrustumPlanes(frustumPlanes);

              glEnable(GL_DEPTH_TEST);
              glClearColor(0.95f, 0.95f, 0.95f, 1);

              glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
              for (RenderableVectorIterator iter = m_renderables.begin();
                     iter != m_renderables.end();
                     ++iter)
              {
                     Renderable* pRenderable = *iter;
                     if (pRenderable)
                     {
 bool bDraw = ShouldDraw(pRenderable, frustumPlanes);
 if (bDraw)
 {
 Draw(pRenderable);
 }
                     }
              }

              eglSwapBuffers(m_display, m_surface);
              m_renderables.clear();
       }
}

现在渲染器的Update方法在每次被调用时都会构建新的*截头体*面。这是因为相机会移动每一帧,我们必须构建新的*面来表示这一点。

我们现在也确定一个对象是否应该使用ShouldDraw方法 来渲染。清单 8-25 展示了这个方法的内容。

清单 8-25。 Renderer::ShouldDraw,渲染器. cpp

bool Renderer::ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const
{
       bool shouldDraw = true;
       if (pRenderable && pRenderable->GetUseBounds())
       {
              shouldDraw = false;
              Vector3 min = pRenderable->GetBoundMin();
              min.Add(pRenderable->GetTransform().GetTranslation());
              Vector3 max = pRenderable->GetBoundMax();
              max.Add(pRenderable->GetTransform().GetTranslation());
              static const unsigned int NUM_POINTS = 8;
              Vector3 points[NUM_POINTS];
              points[0] = min;
              points[1] = max;

              points[2].m_x = min.m_x;
              points[2].m_y = min.m_y;
              points[2].m_z = max.m_z;

              points[3].m_x = min.m_x;
              points[3].m_y = max.m_y;
              points[3].m_z = max.m_z;

              points[4].m_x = max.m_x;
              points[4].m_y = min.m_y;
              points[4].m_z = max.m_z;

              points[5].m_x = max.m_x;
              points[5].m_y = max.m_y;
              points[5].m_z = min.m_z;

              points[6].m_x = min.m_x;
              points[6].m_y = max.m_y;
              points[6].m_z = min.m_z;

              points[7].m_x = max.m_x;
              points[7].m_y = min.m_y;
              points[7].m_z = min.m_z;

              for (unsigned int j=0; j<NUM_POINTS; ++j)
              {
                     unsigned int numPlanesInFront = 0;
                     for (unsigned int i=0; i<6; ++i)
                     {
                            if (!frustumPlanes[i].IsInFront(points[j]))
                            {
                                   break;
                            }

                            ++numPlanesInFront;
                     }

                     if (numPlanesInFront == 6)
                     {
                            shouldDraw = true;
                            break;
                     }
              }
       }

       return shouldDraw;
}

ShouldDraw首先检查Renderable指针是否有效,对象是否有我们可以测试的边界。如果这是真的,我们设置shouldDraw为假;这个物体现在只有通过我们的测试才会被渲染。然后我们得到最小和最大边界,加上物体的*移,这样我们就可以在世界空间中定位边界。最小和最大位置代表长方体的两个极限角;我们使用 min 和 max 元素创建其他六个角,并将所有八个角存储到 points 数组中。

第一个 for 循环依次遍历这些点。然后,一个内部循环针对六个*截头体*面中的每一个测试该点。对于在*截头体内部的给定点,针对*截头体*面的所有六个测试都必须通过。记住一个*面把世界分成两半,*面前面是正空间,后面是负空间。如果一个点落在任何一个*面的后面,它就不在相机的截锥内。一旦我们检测到该点不在截锥内,我们就中断,以确保我们不会浪费时间来测试不必要的*面。

一旦一个对象通过了所有六个*面的测试,shouldDraw布尔值被设置回true并且函数返回。

我们现在已经成功实现了一个优化策略,只有那些真正有助于场景最终渲染的对象才会被 GPU 处理。这将允许我们在广告标题中实现更高的帧速率或呈现更详细的场景。

摘要

这一章已经让我们快速浏览了我们可以用游戏中的相机实现的东西。你现在应该明白虚拟相机对任何游戏来说是多么的重要。能够在 3D 空间中移动是每个现代游戏不可或缺的功能。就相机开发而言,我们只是触及了皮毛。如今的商业游戏使用复杂的物理和人工智能来控制他们的相机,尤其是在第三人称游戏中,即使这样,许多游戏玩家也会对特定游戏中相机的行为方式感到沮丧。制造好的相机是一项复杂但有益的工作,希望这一章已经给了你一个很好的主题介绍。

我们还看到了如何使用相机提供的信息来加速我们的渲染过程。一旦我们想要创建任何大尺寸的关卡和世界,*截头体剔除就成了一种无价的加速算法。如果试图在任何给定时间渲染世界上的每个物体,像湮没这样的开放世界游戏在今天的硬件上是不可能的。游戏团队很大一部分工作就是想办法用有限的资源创造尽可能详细的世界。这些技术在移动设备上尤其重要,因为从 CPU 向 GPU 传输数据的可用内存带宽是有限的。我们可以更有效地使用这个带宽,不要把在当前帧中看不到的对象转移到我们的 GPU。

现在我们已经有了基本的游戏,是时候让它看起来更吸引人了。到目前为止,我们这个世界上的所有东西都是用一种*面颜色着色的。在下一章,我们将研究模拟照明和材质,让我们的场景更有趣,看起来更像 3D 游戏。`

九、照明和材质

编程团队可以通过灯光和材质模型的应用,帮助美术团队实现他们渴望的游戏外观。这些年来,许多技术被开发出来赋予游戏一种特定的风格;2000 年发行的《喷气研磨电台》就是这样一款游戏。 Jet Grind Radio 因其单元格阴影图形风格而闻名,这使其在当时的竞争中脱颖而出。

鉴于它是在没有顶点和碎片着色器的*台上开发的,因此 Jet Grind Radio 的风格更加令人印象深刻。在过去的十年中,随着消费类硬件的发展,特殊的光照和材质效果在视频游戏中变得更加普遍,这使得实时着色器成为可能。这些功能现在在移动设备上也很普遍,着色器的灵活性为游戏开发人员提供了高级图形技术的使用。

在这一章中,我们将会看到基本的照明和材质技术,它们是用来制作更高级效果的基础。本章中的基本灯光和材质是 OpenGL ES 1.0 中固定功能灯光和材质的再现。

注意 OpenGL ES 2.0 没有任何内置的光照支持;由程序员使用着色器来实现他们想要的模型。

首先,我们来看看光线对于程序员是如何定义的,以及光线是如何通过材质与我们的游戏实体相互作用的。

一个基本的灯光和材质模型

自然界中的光是一种复杂的现象。幸运的是,作为游戏开发者,我们不必用物理现实来模拟光线;相反,我们使用一个模型来模拟光的作用以及它如何与场景中的物体相互作用。

在最基本的层面上,无论我们是在顶点着色器还是片段着色器中处理灯光,我们的光源最终都会归结为一个方向和一个颜色,它会使用某种形式的方程来影响我们对象的输出颜色。

光的属性构成了我们照明方程的一组输入;另一组输入是对象本身的属性。我们将这些对象属性称为材质。当讨论材质时,我们说我们正在将材质应用于物体。

现代游戏引擎中的材质定义包括用于对模型进行着色的着色器程序、要应用的纹理以及任何其他特殊效果和渲染状态,这些都是正确渲染对象所必需的。

这是照明的简要概述,当你得知模型的实际实现稍微复杂一些时,你不会感到惊讶。我们的灯光和材质将被用来计算光线的三种不同成分:环境光,漫射光和镜面反射光。在这一章中,我们将介绍这些组件中的每一个,并看看每个组件的效果。

一旦我们看了照明等式的组成部分,我们将看一下引擎中光源的三种不同表示。这些将是*行光,位置光和聚光灯。

逐顶点或逐片段着色

OpenGL ES 1.0 中使用的着色模型称为 Blinn-Phong 着色模型。它以吉姆·布林和裴祥风的名字命名。Phong 在 1973 年描述了他的照明模型,这个模型后来被 Blinn 修改,并由此得名。

模型本身描述了我们将在本章中使用的公式,来计算应用于对象的颜色的环境、漫射和镜面反射分量。我们必须考虑的模型的另一个方面是我们希望计算有多精确。如果我们在顶点着色器中计算光线、表面和材质之间交互结果的颜色,颜色将存储在一个变量中,然后在多边形的其余部分进行插值。这种形式的插值颜色被称为 Gouraud 阴影,也是以该技术的作者 Henri Gouraud 的名字命名的。

这种技术可以给出可接受的结果,但通常可以很容易地看到多边形的边缘。另一种形式的插值是冯着色模型,这是由裴祥风描述,以及他的着色模型技术的其余部分。这种形式的插值包括对片段表面顶点的法线进行插值,并计算片段着色器中每个单独像素的照明颜色。这给出了更好的结果,但是显然计算量更大。

对我们来说幸运的是,在两种情况下照明的等式是相同的,因为与商业游戏相比,我们有一个非常简单的场景,我们将使用 Blinn-Phong 照明模型和 Phong 着色来给我们最好的结果。

在我们开始执行场景中的照明任务之前,我们必须创建一个可以表示材质的类。

代表材质

Material类将用于存储所有与我们场景中物体表面最终外观相关的信息。这包括任何着色器,纹理和颜色,我们将使用它们来表示照明等式的各个部分。清单 9-1 描述了Material类。我们还没有涵盖这个类的字段将用于什么,所以现在不要担心它们。

清单 9-1。Material类声明。材质. h

class Material
{
       private:
              Shader*       m_pShader;
              Texture*      m_pTexture;

              Vector4       m_ambientColor;
              Vector4       m_diffuseColor;
              Vector4       m_specularColor;
              float         m_specularExponent;

public:
       Material()
              :      m_pShader(NULL)
              ,      m_pTexture(NULL)
              ,      m_specularExponent(0.0f)
       {

       }

       ∼Material()
       {

       }

       void SetShader(Shader* pShader)
       {
              m_pShader = pShader;
       }

       Shader* GetShader() const
       {
              return m_pShader;
       }

       void SetTexture(Texture* pTexture)
       {
              m_pTexture = pTexture;
       }

       Texture* GetTexture() const
       {
              return m_pTexture;
       }

       void SetAmbientColor(Vector4 ambientColor)
       {
              m_ambientColor = ambientColor;
       }

       const Vector4& GetAmbientColor() const
       {
              return m_ambientColor;
       }

       void SetDiffuseColor(Vector4 diffuseColor)
       {
              m_diffuseColor = diffuseColor;
       }

       const Vector4& GetDiffuseColor() const
       {
              return m_diffuseColor;
       }

       void SetSpecularColor(Vector4 specularColor)
       {
              m_specularColor = specularColor;
       }

       const Vector4& GetSpecularColor() const
       {
              return m_specularColor;
       }

       void SetSpecularExponent(float specularExponent)
       {
              m_specularExponent = specularExponent;
       }

       const float GetSpecularExponent() const
       {
              return m_specularExponent;
       }
};

如你所见,我们的Material类只是一个容器,用来存储一些我们可以与对象关联的数据。我们通过将它添加到我们的Renderable类中来做到这一点。清单 9-2 展示了我们新的Renderable类,用一个Material代替了之前的着色器指针和颜色Vector4字段。

清单 9-2。 添加一个Material来渲染。可渲染. h

class Renderable
{
private:
       Geometry*     m_pGeometry;
 Material* m_pMaterial;
       Transform     m_transform;

       Vector3       m_min;
       Vector3       m_max;
       bool          m_useBounds;

public:
       Renderable();
       ∼Renderable();

       void          SetGeometry(Geometry* pGeometry);
       Geometry*     GetGeometry();

 void SetMaterial(Material* pMaterial);
 Material* GetMaterial();

       Transform&    GetTransform()       { return m_transform; }

       void                 SetBoundMin(const Vector3& min)    { m_min = min; }
       const Vector3&       GetBoundMin() const                { return m_min; }

       void                 SetBoundMax(const Vector3& max)    { m_max = max; }
       const Vector3&       GetBoundMax() const                { return m_max; }

       void                 SetUseBounds(bool enabled)         { m_useBounds = enabled; }
       bool                 GetUseBounds() const               { return m_useBounds; }

       bool                 IsInitialized() const
       {
 return m_pGeometry
&&
m_pMaterial;
       }
};

inline Renderable::Renderable()
       :      m_pGeometry(NULL)
 , m_pMaterial(NULL)
{

}

inline Renderable::∼Renderable()
{

}

inline void Renderable::SetGeometry(Geometry* pGeometry)
{
       m_pGeometry = pGeometry;
}

inline Geometry* Renderable::GetGeometry()
{
       return m_pGeometry;
}

inline void Renderable::SetMaterial(Material* pMaterial)
{
 m_pMaterial = pMaterial;
}

inline Material* Renderable::GetMaterial()
{
 return m_pMaterial;
}

现在我们有了一个可以为我们的可渲染对象存储材质属性的类,我们将看看如何在着色器中使用这些属性来为我们的场景添加光线。

环境照明

照明模型的环境成分用于模拟我们场景中的背景光。你可以把这想象成一个有窗户的房间里的日光。

房间里没有任何东西被光源直接照亮;然而,一切都有光反弹。这是因为来自太阳的光足够强大,它可以从许多物体上反弹回来,但仍然继续前进,照亮更多的物体。一旦太阳下山,一切都暗了很多,因为周围反射的环境光少了很多。

从这个意义上来说,环境光被视为基本级别的照明,以确保场景中的对象不会看起来完全是黑色的。环境照明组件的等式非常简单。

最终颜色=环境光颜色×环境颜色

到目前为止,我们在TransformShader 中使用的当前渲染实际上相当于环境光值为(1,1,1,1),它指定我们的对象应该被环境光完全照亮;图 9-1 显示了游戏在没有灯光的情况下是如何渲染的。

9781430258308_Fig09-01.jpg

图 9-1 。《机器人赛跑者》中一个没有灯光的场景

我们现在将通过添加一个新的着色器来改变这一点。清单 9-3 显示了TransformAmbientShader类。

清单 9-3。TransformAmbientShader类声明。transformambientsharder . h

class TransformAmbientShader
       :      public Shader
{
private:
       GLint  m_transformUniformHandle;
       GLint  m_positionAttributeHandle;
       GLint  m_colorUniformHandle;
       GLint  m_ambientLightUniformHandle;

public:
       TransformAmbientShader();
       virtualTransformAmbientShader();

       virtual void Link();
       virtual void Setup(Renderable& renderable);
};

我们的TransformAmbientShader班和我们的TransformShader几乎一模一样;唯一增加的是一个新的字段来存储环境光制服的句柄。

TransformAmbientShader的构造函数包含了我们新着色器的新 GLSL 代码。片段着色器包含一个新的统一,u_vAmbientLight。这个制服是一个vec4,包含环境光常数。该常数与片段颜色相乘,以确定存储在gl_FragColor中的片段的环境颜色。清单 9-4 显示了新的 GLSL 代码。

清单 9-4。 TransformAmbientShader's建造师。transformambientsharder . CPP

TransformAmbientShader::TransformAmbientShader()
{
       m_vertexShaderCode =
              "uniform mat4 u_mModel;                           \n"
              "attribute vec4 a_vPosition;                      \n"
              "void main(){                                     \n"
              "    gl_Position = u_mModel * a_vPosition;        \n"
              "}                                                \n";

       m_fragmentShaderCode =
              "precision mediump float;                         \n"
              "                                                 \n"
              "uniform vec4 u_vColor;                           \n"
              "uniform vec4 u_vAmbientLight;                    \n"
              "                                                 \n"
              "void main(){                                     \n"
              "    gl_FragColor = u_vAmbientLight * u_vColor;   \n"
              "}                                                \n";
}

我们需要获得新制服的句柄,我们在TransformAmbientShader::Link中这样做,如清单 9-5 所示。

清单 9-5。 TransformAmbientShader::Link 。transformambientsharder . CPP

void TransformAmbientShader::Link()
{
       Shader::Link();
       m_transformUniformHandle            = glGetUniformLocation(m_programId, "u_mModel");
       m_positionAttributeHandle          = glGetAttribLocation(m_programId, "a_vPosition");
       m_colorUniformHandle               = glGetUniformLocation(m_programId, "u_vColor");
       m_ambientLightUniformHandle        = glGetUniformLocation(m_programId, "u_vAmbientLight");
}

这个新着色器的Setup方法也类似于TransformShader的方法。只有设置环境光颜色所需的行是新的。清单 9-6 突出了这些变化。

清单 9-6。 TransformAmbientShader::Setup 。transformambientsharder . CPP

void TransformAmbientShader::Setup(Renderable& renderable)
{
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry)
       {
              Shader::Setup(renderable);
              Renderer& renderer = Renderer::GetSingleton();
              const Matrix4& viewMatrix = renderer.GetViewMatrix();
              const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
              Matrix4 modelViewMatrix;
              renderable.GetTransform().GetMatrix().Multiply(viewMatrix, modelViewMatrix);
              Matrix4 modelViewProjectionMatrix;
              modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
              glUniformMatrix4fv(m_transformUniformHandle, 1, false, modelViewProjectionMatrix.m_m);
              glVertexAttribPointer(
                     m_positionAttributeHandle,
                     pGeometry->GetNumVertexPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     pGeometry->GetVertexBuffer());
              glEnableVertexAttribArray(m_positionAttributeHandle);
              const Vector4& color = renderable.GetMaterial()->GetAmbientColor();
              glUniform4f(m_colorUniformHandle, color.m_x, color.m_y, color.m_z, color.m_w);
 const Vector4
&
ambientLightColor = renderer.GetAmbientLightColor();
 glUniform4f(m_ambientLightUniformHandle,
 ambientLightColor.m_x,
 ambientLightColor.m_y,
 ambientLightColor.m_z,
 ambientLightColor.m_w);
       }
}

这个清单表明,我们还必须向我们的Renderer添加一些新方法。清单 9-7 显示了这个小的增加;只需向Renderer类添加一个新的private Vector4字段,并添加设置和获取值的方法。

清单 9-7。 给渲染器添加 m_ambientLightColor。Renderer.h

class Renderer
       :      public Task
       ,      public EventHandler
       ,      public Singleton<Renderer>
{
public:
       enum FrustumParameters
       {
              TOP,
              BOTTOM,
              RIGHT,
              LEFT,
              NEAR,
              FAR,
              NUM_PARAMS
       };

private:
       android_app*         m_pState;
       EGLDisplay           m_display;
       EGLContext           m_context;
       EGLSurface           m_surface;
       int                  m_width;
       int                  m_height;
       bool                 m_initialized;
       bool                 m_paused;

       typedef std::vector<Shader*>             ShaderVector;
       typedef ShaderVector::iterator           ShaderVectorIterator;

       typedef std::vector<Texture*>            TextureVector;
       typedef TextureVector::iterator          TextureVectorIterator;

       typedef std::vector<Renderable*>         RenderableVector;
       typedef RenderableVector::iterator       RenderableVectorIterator;

       RenderableVector     m_renderables;
       TextureVector        m_textures;
       ShaderVector         m_shaders;

       float                m_frustumParameters[NUM_PARAMS];

       Matrix4              m_cameraMatrix;
       Matrix4              m_viewMatrix;
       Matrix4              m_projectionMatrix;

       void Draw(Renderable* pRenderable);
       void BuildFrustumPlanes(Plane frustumPlanes[]);
       bool ShouldDraw(Renderable* pRenderable, Plane frustumPlanes[]) const;

 Vector4 m_ambientLightColor;
public:
       explicit Renderer(android_app* pState, const unsigned int priority);
       virtualRenderer();
       void Init();
       void Destroy();

       void AddRenderable(Renderable* pRenderable);
       void AddShader(Shader* pShader);
       void RemoveShader(Shader* pShader);

       void AddTexture(Texture* pTexture);
       void RemoveTexture(Texture* pTexture);

       // From Task
       virtual bool  Start();
       virtual void  OnSuspend();
       virtual void  Update();
       virtual void  OnResume();
       virtual void  Stop();

       virtual void  HandleEvent(Event* event);
       bool IsInitialized() { return m_initialized; }
       void                 SetCameraMatrix(const Matrix4& cameraMatrix)
       {
              m_cameraMatrix = cameraMatrix;
       }

       const Matrix4&       GetCameraMatrix() const     { return m_cameraMatrix; }

       void                 SetViewMatrix(const Matrix4& viewMatrix)
       {
              m_viewMatrix = viewMatrix;
       }
       const Matrix4&       GetViewMatrix() const       { return m_viewMatrix; }

       void                 SetFrustum(const float frustumParameters[]);
       const Matrix4&       GetProjectionMatrix() const { return m_projectionMatrix; }

       int                  GetWidth() const            { return m_width; }
       int                  GetHeight() const           { return m_height; }

 void SetAmbientLightColor(const Vector4&
ambientLightColor)
 {
 m_ambientLightColor = ambientLightColor;
 }
 const Vector4&
GetAmbientLightColor() const
 {
 return m_ambientLightColor;
 }
};

图 9-2 显示了游戏的状态,环境光照被应用到渲染中。

9781430258308_Fig09-02.jpg

图 9-2 。环境照明场景

如图 9-2 所示,每种颜色成分的环境光水*为 0.2f 意味着我们的物体几乎是黑色的。几乎不可能辨认出 AI 敌人身上的任何红色阴影,但是玩家确实有一点绿色阴影。板条箱也是非常浅的蓝色。这对于我们的场景来说非常理想,当我们添加更多的颜色时,我们会看到这一点。

在我们进入照明等式的下一个元素之前,我们必须更新我们的几何图形。光照方程的漫反射和镜面反射部分需要知道模型中多边形的朝向。我们可以通过在着色器中提供一个法向量和每个顶点来做到这一点。

顶点法线

到目前为止,我们已经在这本书里碰到过几次法向量。如果你还记得,法向量是一个单位长度的向量,用来表示方向,而不是表示位移。

我们可以用*面方程算出多边形的法线。由于我们在为 Android 开发 OpenGL ES 2.0 游戏时总是在处理*面三角形,所以我们可以使用三角形的三个点来生成三角形的曲面法线。附录 D 中介绍了这个过程的数学,以及Plane类及其方法的列表。

幸运的是,我们所有的模型通常都是从 3D 建模包中导出的。这些 3D 建模软件包通常能够为我们正在创建的任何模型生成和导出表面法线。对于这本书,我一直使用免费的建模包 Blender,你可以从www.blender.org获得。

假设我们将从 3D 包中导出网格的顶点法线,我们应该看看如何在代码中表示这些数据。清单 9-8 显示了Geometry类中支持顶点法线所需的更新。

清单 9-8。 更新Geometry来处理顶点法线。Geometry.h

class Geometry
{
private:
       static const unsigned int NAME_MAX_LENGTH = 16;
       char   m_name[NAME_MAX_LENGTH];
       int    m_numVertices;
       int    m_numIndices;
       void*  m_pVertices;
       void*  m_pIndices;

       int    m_numVertexPositionElements;
       int m_numNormalPositionElements;
       int    m_numTexCoordElements;
       int    m_vertexStride;

public:
       Geometry();
       virtualGeometry();

       void   SetName(const char* name)                 { strcpy(m_name, name); }
       void   SetNumVertices(const int numVertices)     { m_numVertices = numVertices; }
       void   SetNumIndices(const int numIndices)       { m_numIndices = numIndices; }

       const char*   GetName() const                    { return m_name; }

       const int     GetNumVertices() const             { return m_numVertices; }
       const int     GetNumIndices() const              { return m_numIndices; }

       void*         GetVertexBuffer() const            { return m_pVertices; }
       void*         GetIndexBuffer() const             { return m_pIndices; }

       void          SetVertexBuffer(void* pVertices)   { m_pVertices = pVertices; }
       void          SetIndexBuffer(void* pIndices)     { m_pIndices = pIndices; }

       void          SetNumVertexPositionElements(const int numVertexPositionElements)
       {
              m_numVertexPositionElements = numVertexPositionElements;
       }

       int           GetNumVertexPositionElements() const
       {
              return m_numVertexPositionElements;
       }

 void SetNumNormalPositionElements(const int numNormalPositionElements)
 {
 m_numNormalPositionElements = numNormalPositionElements;
 }

 int GetNumNormalPositionElements() const
 {
 return m_numNormalPositionElements;
 }

       void          SetNumTexCoordElements(const int numTexCoordElements)
       {
              m_numTexCoordElements = numTexCoordElements;
       }

       int           GetNumTexCoordElements() const
       {
              return m_numTexCoordElements;
       }

       void          SetVertexStride(const int vertexStride)
       {
              m_vertexStride = vertexStride;
       }

       int           GetVertexStride() const
       {
              return m_vertexStride;
       }
};

我们添加了字段来存储我们的Geometry类的法线数量。这进一步扩展了我们的结构数组格式的顶点数据的存储。这是将几何数据流式传输到当前移动 GPU 的最佳方法。

随着Geometry类现在能够处理包含顶点法线数据的模型,让我们继续看看漫射光照着色器将如何利用它们。

漫射照明

我们试图用本章中的着色器实现的光照方程 是一个加法方程。这意味着我们的照明组件加在一起,形成最终的结果。在漫射照明这一节中,我们将看看下面等式的第二部分:

最终颜色=环境颜色+漫射颜色+镜面颜色

我们已经看到了环境颜色是如何设置物体的基础光值的。然而,环境光本身仍然让我们的物体看起来是*的。这是因为环境光功能没有考虑光源和表面朝向的方向之间的角度。

现在,我们将实现一个游戏光源的最简单的版本,一个方向灯。*行光 用于模拟极远处的光源。如果你想象太阳,我们可以通过把它想象成一个在各个方向上强度相等的球体来简化它所发出的光。当来自太阳的光到达地球时,来自球体的光线来自整个球体的一个非常小的碎片。在一个游戏场景中,我们将其简化为一个模型,在这个模型中,我们认为来自这个光源的所有光线都是*行传播的,并且从完全相同的方向照射到我们所有的物体上。

“方向”和“定向”这两个词在上一段中已经使用了几次,你可能已经猜到我们将使用另一个法向量来表示光线的方向。然而,我们将不会存储光传播的方向;我们实际上会存储相反的内容。当你看到我们的颜色的漫射照明分量的等式时,这个原因就变得很清楚了:

漫射颜色= max(L.N,0) ×漫射灯光颜色×漫射材质颜色

前面等式中的 L.N 项表示我们的方向光矢量和当前顶点法线之间的点积。点积给出了以下结果:

L.N = |L||N|cos(alpha)

L 和 N 周围的线代表这些向量的长度(或大小)。光照方程中的向量是法线;因此,它们的长度是 1。这意味着两个法向量点积的结果是两者夹角的余弦。0 度的余弦为 1,90 度的余弦为 0,180 度的余弦为 1。由于我们的片段颜色值输出范围是从 0 到 1,我们将使用点积结果或 0 中的较高值。对于 0 到 90 度之间的任何角度,我们将为这个片段添加一个漫反射颜色组件。

漫射组件顶点着色器

在我们查看漫反射组件的片段着色器代码之前,我们将检查设置顶点位置和法线所需的顶点着色器。清单 9-9 包含了TransformAmbientDiffuseShader的顶点着色器的代码。

清单 9-9。 TransformAmbientDiffuseShader's顶点着色器来源。transformationdiffuse shader . CPP

m_vertexShaderCode =
       "uniform mat4 u_mModelViewProj;                           \n"
       "uniform mat3 u_mModelIT;                                 \n"
       "attribute vec4 a_vPosition;                              \n"
       "attribute vec3 a_vNormal;                                \n"
       "varying   vec3 v_vNormal;                                \n"
       "void main(){                                             \n"
       "    gl_Position = u_mModelViewProj * a_vPosition;        \n"
       "    v_vNormal = normalize(u_mModelIT * a_vNormal);       \n"
       "}                                                        \n";

我们的顶点着色器易于阅读。我们有一个矩阵u_mModelViewProj,像往常一样,我们用它将顶点的位置属性转换成标准化的设备坐标。

我们现在还有一个顶点法线属性和一个可变变量来存储输出。GLSL 的变量用于在组成三角形的三个顶点之间插值。因为我们现在为每个顶点指定了一个法向量,所以我们必须将每个顶点存储到一个变量中,以便为每个要着色的片段进行插值。

当我们将法线存入v_vNormal时,我们也将它乘以矩阵u_mModelIT。这个矩阵负责将矩阵从模型的局部空间转换到世界空间。由于法向量不需要任何*移,矩阵本身只是一个 3x3 的旋转和缩放矩阵。不幸的是,我们不能简单地用模型的变换矩阵直接变换法线。可以应用于模型的任何缩放将导致法线相对于其表面改变方向。相反,我们必须使用模型矩阵的逆转置来转换法线。

如果你还记得,旋转矩阵是一个正交矩阵。这种矩阵的特殊之处在于它的逆矩阵也是它的转置矩阵;因此,模型变换的旋转部分的逆转置将保持不变。缩放矩阵是对角矩阵,因此缩放矩阵的转置与标准矩阵没有什么不同。逆标度元素包含 1 除以原始标度,得到逆。将法线与模型矩阵的逆转置相乘允许我们以与模型相同的方式将法线旋转到世界空间中,但也保留了原始法线相对于它所代表的表面的方向。

漫射组件片段着色器

随着顶点着色器的方式,我们可以看看片段着色器。我们在清单 9-10 中这样做。

清单 9-10。 TransformAmbientDiffuseShader's碎片着色器来源。transformationdiffuse shader . CPP

m_fragmentShaderCode =
       "precision mediump float;                                        \n"
       "varying vec3 v_vNormal;                                         \n"
       "                                                                \n"
       "uniform vec4 u_vAmbientColor;                                   \n"
       "uniform vec4 u_vDiffuseColor;                                   \n"
       "uniform vec4 u_vAmbientLight;                                   \n"
       "uniform vec4 u_vDiffuseLight;                                   \n"
       "uniform vec3 u_vLightDirection;                                 \n"
       "                                                                \n"
       "const float c_zero = 0.0;                                       \n"
       "const float c_one  = 1.0;                                       \n"
       "                                                                \n"
       "void main(){                                                    \n"
       "    gl_FragColor = vec4(c_zero, c_zero, c_zero, c_zero);        \n"
       "                                                                \n"
       "    float ndotl = dot(u_vLightDirection, v_vNormal);            \n"
       "    ndotl = max(ndotl, c_zero);                                 \n"
       "    gl_FragColor += ndotl * u_vDiffuseLight * u_vDiffuseColor;  \n"
       "                                                                \n"
       "    gl_FragColor += u_vAmbientLight * u_vAmbientColor;          \n"
       "                                                                \n"
       "    gl_FragColor.a = c_one;                                     \n"
       "}                                                               \n";

漫射照明组件的片段着色器首先声明该着色器程序的浮点运算的默认精度。在研究具体细节时,着色器精度限定符可能是一个复杂的主题。出于本书的目的,知道精度影响给定数据类型的可用值范围就足够了。

有三种精度限定符可用,lowpmediumphighp。出于照明等式的目的,lowp通常不能提供足够的精度,而highp通常能提供比我们需要的更多的精度。每一级精度的提高导致着色器执行时间更长;因此,选择适合任何给定着色器的精度级别是很重要的。我这里用了mediump;然而,当我将设置更改为lowp时,我实际上看不出有什么不同。

同样值得记住的是,有些*台可能不支持所有的精度限定符。OpenGL ES 2.0 标准规定片段着色器和顶点着色器所需的最低精度限定符分别为mediump和 highp。

注意目前,Nvidia 的 Tegra 3 *台是唯一不支持片段着色器中的highp限定符的芯片组。然而,如果你确实使用了highp,Tegra 3 着色器编译器将自动使用mediump,但是值得记住这一点。

接下来,我们声明包含插值法向量的变量。请记住,顶点着色器将为每个顶点计算转换后的法线,GPU 将使用线性插值来计算每个片段的法线位置。线性插值通过在每个极端使用值 0 和 1 来计算。数字 5 和 10 中间的线性插值看起来像下面的等式。

((10–5) 0.5)+5 = 7.5*

在这里,我们计算两个极端值之间的差值,在这种情况下,该差值被总和 10–5 覆盖。然后,我们将该范围乘以插值因子,插值因子为 0.5,代表两个极值之间的中间值。最后一步包括添加第一个极值来计算位于第一个和第二个点之间的点。

然后我们有了统一的价值观。制服是从游戏代码提供给片段着色器的所有实例的变量。在我们的漫反射着色器中,我们提供了代表物体材质的环境光和漫反射颜色、灯光的环境光和漫反射颜色以及灯光方向的制服。我们还指定了常数来表示值 0.0 和 1.0;分别是c_zeroc_one

我们的 main 方法是在声明了所有变量之后定义的。我们首先将gl_FragColor初始化为在每个元素包含c_zerovec4

然后使用点方法计算矢量v_vLightDirectionv_vNormal的点积。通过在我们的片段着色器中进行这种计算,我们已经实现了被称为逐像素照明的技术。如果我们已经在顶点着色器中计算了点积,我们将实现逐顶点照明。在顶点着色器中计算光照方程要快得多,但结果并不理想。如果你正在实现一个完整的游戏,用逐片段着色器照亮关键对象,用逐顶点着色器照亮其他不太重要的对象可能是一种用来优化游戏的技术。

着色器中的下一行使用 max 将点积的最低可能值限制为 0。然后,我们将计算漫反射颜色所需的三个元素相乘,ndotlu_vDiffuseLightu_vDiffuseColor

随着我们的漫射颜色分量的计算,我们然后添加环境分量的结果。其计算方法与清单 9-4 中的相同,将环境光向量乘以环境颜色向量。

使用 OpenGL ES 2.0 初始化着色器

清单 9-11 包含了获取我们制服和属性的句柄所需的Link方法 。

清单 9-11。 TransformAmbientDiffuseShader::Link。transformambientdevissueshader . CPP

void TransformAmbientDiffuseShader::Link()
{
       Shader::Link();
       m_modelViewProjUniformHandle       = glGetUniformLocation(m_programId, "u_mModelViewProj");
       m_modelITMatrixUniformHandle       = glGetUniformLocation(m_programId, "u_mModelIT");
       m_positionAttributeHandle          = glGetAttribLocation(m_programId,  "a_vPosition");
       m_normalAttributeHandle            = glGetAttribLocation(m_programId,  "a_vNormal");
       m_ambientColorUniformHandle        = glGetUniformLocation(m_programId, "u_vAmbientColor");
       m_diffuseColorUniformHandle        = glGetUniformLocation(m_programId, "u_vDiffuseColor");
       m_ambientLightUniformHandle        = glGetUniformLocation(m_programId, "u_vAmbientLight");
       m_diffuseLightUniformHandle        = glGetUniformLocation(m_programId, "u_vDiffuseLight");
       m_lightDirectionUniformHandle      = glGetUniformLocation(m_programId, "u_vLightDirection");
}

回想一下清单 9-9 中的内容,我们必须向顶点着色器提供模型的逆转置变换矩阵来变换顶点法线。清单 9-12 显示了TransformAmbientDiffuseShader::Setup方法,它包含了计算这个矩阵的代码。

清单 9-12。 TransformAmbientDiffuseShader::Setup 。transformambientdevissueshader . CPP

void TransformAmbientDiffuseShader::Setup(Renderable& renderable)
{
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry)
       {
              Shader::Setup(renderable);
              Renderer& renderer = Renderer::GetSingleton();
              const Matrix4& viewMatrix = renderer.GetViewMatrix();
              const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
              const Matrix4& modelMatrix = renderable.GetTransform().GetMatrix();
              Matrix4 modelViewMatrix;
              modelMatrix.Multiply(viewMatrix, modelViewMatrix);
              Matrix4 modelViewProjectionMatrix;
              modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
              glUniformMatrix4fv(
                     m_modelViewProjUniformHandle,
                     1,
                     false,
                     modelViewProjectionMatrix.m_m);

              Matrix3 modelIT;
              renderable.GetTransform().GetInverseTransposeMatrix(modelIT);
              glUniformMatrix3fv(m_modelITMatrixUniformHandle, 1, false, modelIT.m_m);
              glVertexAttribPointer(
                     m_positionAttributeHandle,
                     pGeometry->GetNumVertexPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     pGeometry->GetVertexBuffer());
              glEnableVertexAttribArray(m_positionAttributeHandle);
              glVertexAttribPointer(
                     m_normalAttributeHandle,
                     pGeometry->GetNumNormalPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     static_cast<float*>(pGeometry->GetVertexBuffer()) +
                            pGeometry->GetNumVertexPositionElements());
              glEnableVertexAttribArray(m_normalAttributeHandle);
              const Vector4& ambientColor = renderable.GetMaterial()->GetAmbientColor();
              glUniform4f(
                     m_ambientColorUniformHandle,
                     ambientColor.m_x,
                     ambientColor.m_y,
                     ambientColor.m_z,
                     ambientColor.m_w);

              const Vector4& diffuseColor = renderable.GetMaterial()->GetDiffuseColor();
              glUniform4f(
                     m_diffuseColorUniformHandle,
                     diffuseColor.m_x,
                     diffuseColor.m_y,
                     diffuseColor.m_z,
                     diffuseColor.m_w);

              const Vector4& ambientLightColor = renderer.GetAmbientLightColor();
              glUniform4f(
                     m_ambientLightUniformHandle,
                     ambientLightColor.m_x,
                     ambientLightColor.m_y,
                     ambientLightColor.m_z,
                     ambientLightColor.m_w);

              const Vector4& diffuseLightColor = renderer.GetDiffuseLightColor();
              glUniform4f(
                     m_diffuseLightUniformHandle,
                     diffuseLightColor.m_x,
                     diffuseLightColor.m_y,
                     diffuseLightColor.m_z,
                     diffuseLightColor.m_w);

              const Vector3& lightDirection = renderer.GetLightDirection();
              glUniform3f(
                     m_lightDirectionUniformHandle,
                     lightDirection.m_x,
                     lightDirection.m_y,
                     lightDirection.m_z);
       }
}

我们通过获得对当前视图矩阵 、投影矩阵 和模型矩阵 的引用来开始该方法。然后将modelMatrix乘以viewMatrix得到modelViewMatrixmodelViewMatrix 再乘以projectionMatrix。这给了我们modelViewProjection矩阵,这是将我们的模型顶点转换成标准视图体所必需的。我们使用glUniformMatrix4fv将这个矩阵上传到 GPU,与我们的顶点着色器中的统一u_mModelViewProj一起使用。

该方法的下一步是获得模型的变换矩阵的逆转置。我们使用Transform::GetInverseTransposeMatrix来做这件事。清单 6-21 显示了Transform的类声明;我们在清单 9-13 中描述了GetInverseTransposeMatrix?? 的代码。

清单 9-13。 Transform::GetInverseTransposeMatrix。Transform.cpp

void Transform::GetInverseTransposeMatrix(Matrix4& out) const
{
       float invScale = 1.0f / m_scale;
       out.m_m[0] = m_rotation.m_m[0] * invScale;
       out.m_m[1] = m_rotation.m_m[1];
       out.m_m[2] = m_rotation.m_m[2];
       out.m_m[3] = 0.0f;
       out.m_m[4] = m_rotation.m_m[3];
       out.m_m[5] = m_rotation.m_m[4] * invScale;
       out.m_m[6] = m_rotation.m_m[5];
       out.m_m[7] = 0.0f;
       out.m_m[8] = m_rotation.m_m[6];
       out.m_m[9] = m_rotation.m_m[7];
       out.m_m[10] = m_rotation.m_m[8] * invScale;
       out.m_m[11] = 0.0f;
       out.m_m[12] = -m_translation.m_x;
       out.m_m[13] = -m_translation.m_y;
       out.m_m[14] = -m_translation.m_z;
       out.m_m[15] = 1.0f;
}

void Transform::GetInverseTransposeMatrix(Matrix3& out) const
{
       float invScale = 1.0f / m_scale;
       out.m_m[0] = m_rotation.m_m[0] * invScale;
       out.m_m[1] = m_rotation.m_m[1];
       out.m_m[2] = m_rotation.m_m[2];
       out.m_m[3] = m_rotation.m_m[3];
       out.m_m[4] = m_rotation.m_m[4] * invScale;
       out.m_m[5] = m_rotation.m_m[5];
       out.m_m[6] = m_rotation.m_m[6];
       out.m_m[7] = m_rotation.m_m[7];
       out.m_m[8] = m_rotation.m_m[8] * invScale;
}

清单 9-13 包含了从Transform获得逆转置矩阵的方法的两个版本。我们已经知道,旋转矩阵的逆转置是原矩阵,所以我们按照正常顺序复制旋转矩阵。标度矩阵的转置不会改变任何东西,我们可以通过将标度分成 1 来非常容易地计算标度分量的倒数。我们在清单 9-12 中的代码使用了这个方法的版本,它获得了一个 3x3 的矩阵,因为我们的法线不需要*移组件。

然后我们的顶点属性就设置好了。用几何类的适当参数初始化m_positionAttributeHandle,并用glEnableVertexAttribArray启用。然后我们对法线做同样的事情。第一法线的地址通过将顶点缓冲区指针转换为浮点指针并加上顶点位置元素的数量来计算。

然后使用glUniform4fglUniform3f 初始化包含材质和光色属性的矢量。

我们的代码现在应该完成了,我们将在游戏中看到一些漫射灯光。这是我们第一次能够真正看到场景的深度,并且能够告诉我们已经成功地创建了一个三维游戏。图 9-3 显示了启用漫射照明的场景截图。

9781430258308_Fig09-03.jpg

图 9-3 。漫射照明

从前面的截图可以明显看出,我们已经将漫射光源设置在物体的右上方。灯光在这些区域最亮,在对象的左下方变暗。我们还可以看到玩家和 AI 对象的球形形状,以及我们立方体的深度。

Blinn-Phong 照明模型的剩余部分是镜面反射部分。我们接下来看看这个。

镜面照明

照明等式的镜面反射部分决定了给定对象的表观亮度。到目前为止,环境光组件已经给出了一个基本级别的光,以显示对象最暗区域的颜色。漫射组件为对象添加了大部分照明,这有助于确定场景中对象的颜色和形状。现在,镜面反射组件被添加到这些组件中,以使对象看起来或多或少具有反射性。就像漫反射组件一样,镜面反射组件有一个等式,我们将在片段着色器中实现它。那个方程 如下:

高光颜色= max(H.N,0)^S ×高光颜色×高光材质颜色

前面的等式包含了 Blinn 对 Phong 着色模型 的修改。原始模型包含向量 R 而不是 h。R 代表反射光向量,必须为给定模型中的每个顶点计算该向量。向量 H 表示半向量,并且可以为每个模型计算一次:

半矢量=归一化(眼睛矢量+光线矢量)

前面的等式依赖于分别指向相机位置和光源的眼睛矢量和光矢量。

镜面反射方程中的镜面反射指数 S 控制着材质的光泽度。该指数越高,表面越不亮。

我们现在来看看光照公式中这个部分的顶点着色器。

镜面组件顶点着色器

Blinn-Phong 模型的镜面反射部分的顶点着色器如列表 9-14 所示。

清单 9-14。 TransformAmbientDiffuseSpecularShader's顶点明暗器。transformationdifferential mirror shader . CPP

m_vertexShaderCode =
       "uniform mat4 u_mModelViewProj;                                         \n"
       "uniform mat3 u_mModelIT;                                               \n"
       "attribute vec4 a_vPosition;                                            \n"
       "attribute vec3 a_vNormal;                                              \n"
       "varying   vec3 v_vNormal;                                              \n"
       "void main(){                                                           \n"
       "    gl_Position = u_mModelViewProj * a_vPosition;                      \n"
       "    v_vNormal = normalize(u_mModelIT * a_vNormal);                     \n"
       "}                                                                      \n";

希望我们的顶点着色器与TransformAmbientDiffuseShader中使用的顶点着色器没有什么不同,这并不奇怪。我们使用 Phong 着色来计算每个像素的光照值。我们将直接进入片段着色器。

镜面组件片段着色器

我们的片段着色器将添加 Blinn-Phong 模型的下一个附加组件,镜面组件。我们已经看过这个组件的方程式,所以清单 9-15 直接进入片段着色器源代码。

清单 9-15。 TransformAmbientDiffuseSpecularShader's碎片着色器。transformationdifferential mirror shader . CPP

m_fragmentShaderCode =
       "precision mediump float;                                               \n"
       "varying   vec3 v_vNormal;                                              \n"
       "                                                                       \n"
       "uniform vec4 u_vAmbientColor;                                          \n"
       "uniform vec4 u_vDiffuseColor;                                          \n"
       "uniform vec4 u_vSpecularColor;                                         \n"
       "uniform float u_fSpecularExponent;                                     \n"
       "uniform vec4 u_vAmbientLight;                                          \n"
       "uniform vec4 u_vDiffuseLight;                                          \n"
       "uniform vec4 u_vSpecularLight;                                         \n"
       "uniform vec3 u_vLightDirection;                                        \n"
       "uniform vec3 u_vLightHalfVector;                                       \n"
       "                                                                       \n"
       "const float c_zero = 0.0;                                              \n"
       "const float c_one  = 1.0;                                              \n"
       "                                                                       \n"
       "void main(){                                                           \n"
       "    gl_FragColor = vec4(c_zero, c_zero, c_zero, c_zero);               \n"
       "                                                                       \n"
       "    float ndoth =  dot(u_vLightHalfVector, v_vNormal);                 \n"
       "    ndoth = max(ndoth, c_zero);                                        \n"
       "    float dotPow = pow(ndoth, u_fSpecularExponent);                    \n"
       "    gl_FragColor += dotPow * u_vSpecularColor * u_vSpecularLight;      \n"
       "                                                                       \n"
       "    float ndotl = dot(u_vLightDirection, v_vNormal);                   \n"
       "    ndotl = max(ndotl, c_zero);                                        \n"
       "    gl_FragColor += ndotl * u_vDiffuseLight * u_vDiffuseColor;         \n"
       "                                                                       \n"
       "    gl_FragColor += u_vAmbientLight * u_vAmbientColor;                 \n"
       "                                                                       \n"
       "    gl_FragColor.a = c_one;                                            \n"
       "}                                                                      \n";

由于我们的新着色器包含环境和漫射组件,它保留了这些计算所需的所有均匀性。我们新推出的制服是u_vSpecularColoru_fSpecularExponentu_vSpecularLightu_vLightHalfVector

着色器中有四条新的线用于计算镜面反射分量。第一个计算u_vLightHalfVectorv_vNormal之间的点积。这给了我们半矢量和碎片的法向量之间的角度。然后我们取点积或零的较高值。然后,将前面步骤的结果乘以镜面反射分量的幂,最后,将乘以镜面反射指数的点积乘以镜面反射材质和灯光颜色。

因为这个等式是一个加法过程,我们添加了漫射分量,然后添加了环境分量来得到最终的碎片颜色。

我们的着色器代码完成后,最后一步是查看将着色器投入使用所需的 OpenGL ES 2.0 代码。

正在初始化 transformambientdevisvirsershader

像我们所有的着色器一样,我们必须覆盖LinkSetup方法才能使用这个特定的着色器。

我们看看清单 9-16 中的中的TransformAmbientDiffuseSpecularShader::Link

清单 9-16。 TransformAmbientDiffuseSpecularShader::Link。transformambientdevisvirsershader . CPP

void TransformAmbientDiffuseSpecularShader::Link()
{
       Shader::Link();
       m_modelViewProjUniformHandle     = glGetUniformLocation(m_programId, "u_mModelViewProj");
       m_modelITMatrixUniformHandle     = glGetUniformLocation(m_programId, "u_mModelIT");
       m_positionAttributeHandle        = glGetAttribLocation(m_programId,  "a_vPosition");
       m_normalAttributeHandle          = glGetAttribLocation(m_programId,  "a_vNormal");
       m_ambientColorUniformHandle      = glGetUniformLocation(m_programId, "u_vAmbientColor");
       m_diffuseColorUniformHandle      = glGetUniformLocation(m_programId, "u_vDiffuseColor");
       m_specularColorUniformHandle     = glGetUniformLocation(m_programId, "u_vSpecularColor");
       m_specularExponentUniformHandle  = glGetUniformLocation(m_programId, "u_fSpecularExponent");
       m_ambientLightUniformHandle      = glGetUniformLocation(m_programId, "u_vAmbientLight");
       m_diffuseLightUniformHandle      = glGetUniformLocation(m_programId, "u_vDiffuseLight");
       m_specularLightUniformHandle     = glGetUniformLocation(m_programId, "u_vSpecularLight");
       m_lightDirectionUniformHandle    = glGetUniformLocation(m_programId, "u_vLightDirection");
       m_lightHalfVectorUniformHandle   = glGetUniformLocation(m_programId, "u_vLightHalfVector");
}

在 Link 中,我们使用 OpenGL ES 2.0 方法glGetUniformLocationglGetAttribLocation获得了设置制服和属性所需的所有句柄。我们将这些句柄用于 TransformAmbientDiffuseSpecularShader::Setup,如清单 9-17 所示。

清单 9-17。 清单 9-17。TransformAmbientDiffuseSpecularShader::Setup。transformambientdevisvirsershader . CPP

void TransformAmbientDiffuseSpecularShader::Setup(Renderable& renderable)
{
       Geometry* pGeometry = renderable.GetGeometry();
       if (pGeometry)
       {
              Shader::Setup(renderable);
              Renderer& renderer = Renderer::GetSingleton();
              const Matrix4& viewMatrix = renderer.GetViewMatrix();
              const Matrix4& projectionMatrix = renderer.GetProjectionMatrix();
              const Matrix4& modelMatrix = renderable.GetTransform().GetMatrix();
              Matrix4 modelViewMatrix;
              modelMatrix.Multiply(viewMatrix, modelViewMatrix);
              Matrix4 modelViewProjectionMatrix;
              modelViewMatrix.Multiply(projectionMatrix, modelViewProjectionMatrix);
              glUniformMatrix4fv(
                     m_modelViewProjUniformHandle,
                     1,
                     false,
                     modelViewProjectionMatrix.m_m);

              Matrix3 modelIT;
              renderable.GetTransform().GetInverseTransposeMatrix(modelIT);
              glUniformMatrix3fv(m_modelITMatrixUniformHandle, 1, false, modelIT.m_m);
              glVertexAttribPointer(
                     m_positionAttributeHandle,
                     pGeometry->GetNumVertexPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     pGeometry->GetVertexBuffer());
              glEnableVertexAttribArray(m_positionAttributeHandle);
              glVertexAttribPointer(
                     m_normalAttributeHandle,
                     pGeometry->GetNumNormalPositionElements(),
                     GL_FLOAT,
                     GL_FALSE,
                     pGeometry->GetVertexStride(),
                     static_cast<float*>(pGeometry->GetVertexBuffer()) +
                            pGeometry->GetNumVertexPositionElements());
              glEnableVertexAttribArray(m_normalAttributeHandle);
              const Vector4& ambientColor = renderable.GetMaterial()->GetAmbientColor();
              glUniform4f(
                     m_ambientColorUniformHandle,
                     ambientColor.m_x,
                     ambientColor.m_y,
                     ambientColor.m_z,
                     ambientColor.m_w);

              const Vector4& diffuseColor = renderable.GetMaterial()->GetDiffuseColor();
              glUniform4f(
                     m_diffuseColorUniformHandle,
                     diffuseColor.m_x,
                     diffuseColor.m_y,
                     diffuseColor.m_z,
                     diffuseColor.m_w);

 const Vector4&specularColor = renderable.GetMaterial()->GetSpecularColor();
 glUniform4f(
 m_specularColorUniformHandle,
 specularColor.m_x,
 specularColor.m_y,
 specularColor.m_z,
 specularColor.m_w);

 glUniform1f(
 m_specularExponentUniformHandle,
 renderable.GetMaterial()->GetSpecularExponent());
              const Vector4& ambientLightColor = renderer.GetAmbientLightColor();
              glUniform4f(
                     m_ambientLightUniformHandle,
                     ambientLightColor.m_x,
                     ambientLightColor.m_y,
                     ambientLightColor.m_z,
                     ambientLightColor.m_w);

              const Vector4& diffuseLightColor = renderer.GetDiffuseLightColor();
              glUniform4f(
                     m_diffuseLightUniformHandle,
                     diffuseLightColor.m_x,
                     diffuseLightColor.m_y,
                     diffuseLightColor.m_z,
                     diffuseLightColor.m_w);

 const Vector4&
specularLightColor = renderer.GetSpecularLightColor();
 glUniform4f(
 m_specularLightUniformHandle,
 specularLightColor.m_x,
 specularLightColor.m_y,
 specularLightColor.m_z,
 specularLightColor.m_w);

              const Vector3& lightDirection = renderer.GetLightDirection();
              glUniform3f(
                     m_lightDirectionUniformHandle,
                     lightDirection.m_x,
                     lightDirection.m_y,
                     lightDirection.m_z);

 Vector3 lightHalfVector = renderer.GetCameraTransform().GetTranslation();
 lightHalfVector.Subtract(
 Vector3(modelMatrix.m_m[12], modelMatrix.m_m[13], modelMatrix.m_m[14]));
 lightHalfVector.Normalize();
 lightHalfVector.Add(lightDirection);
 lightHalfVector.Normalize();
 glUniform3f(
 m_lightHalfVectorUniformHandle,
 lightHalfVector.m_x,
 lightHalfVector.m_y,
 lightHalfVector.m_z);
       }
}

前面清单中粗体显示的代码行是TransformAmbientDiffuseSpecularShader::SetupTransformAmbientDiffuseShader::Setup之间的不同代码行。

第一个模块负责将物体材质的镜面颜色和指数上传到 GPU。第二个确定的部分将光的镜面颜色上传到 GPU。

最后一部分负责计算光线的半矢量。我们从获取相机物体在世界空间中的位置开始。通过减去模型的位置,我们从模型中获得一个指向摄像机方向的向量。然后我们通过调用Normalize方法将它转换成单位法线。现在,我们通过将眼睛向量和光线向量相加,找到它们之间的中间向量。一旦我们有了这个向量,我们希望再次成为单位法线,所以我们第二次调用Normalize。为每个模型计算一次这个向量是 Blinn 对原始 Phong 光照模型的优化。

随着镜面反射组件现在添加到着色器,我们可以看到完整的照明方程的结果。图 9-4 显示了包含最终结果的屏幕截图。

9781430258308_Fig09-04.jpg

图 9-4 。完整的 Blinn-Phong 照明模型

图 9-5 显示了本章中的每个图。

9781430258308_Fig09-05.jpg

图 9-5 。一张合成的截图在第九章中见过

从左上角开始,我们有一个没有灯光的场景。颜色完全饱和,场景中的物体看起来都完全*坦。

移动到右上角的瓷砖,我们看到了环境照明场景。虽然第一个单幅图块显示场景没有照明,但实际上它是用环境颜色完全照亮的。一个没有照明的场景实际上会呈现完全的黑色。我们的环境场景添加了一个基本的光照层,我们可以稍微看到玩家对象的绿色阴影。

左下角的图片第一次显示了物体的一些定义。这要归功于漫射照明,它是第一个考虑给定碎片是指向光源还是远离光源的照明组件。

最后一张图片显示了我们的场景,添加了光照方程的镜面反射部分。这给我们的物体表面增加了一点反射性。当漫射照明的物体看起来像固体物体时,它们就有点无趣了。最终图像上的高光使我们的球体看起来更真实。

这个简单的模型足以为我们的游戏增加照明。本章的以下部分将描述一些通过着色器向游戏添加照明和材质的不同选项。

不同类型的灯

我们在本章中开发的这种类型的光叫做*行光。这是一个计算简单的光模型,有利于我们的手机游戏。移动 GPU 目前没有游戏控制台或台式电脑中的强大。毫无疑问,未来几年,手机和*板电脑中 GPU 的处理能力将大幅提高,但目前,针对这些设备的游戏不能在着色器中使用过于复杂的照明模型。

本节将讨论另外两种类型的灯,随着移动 GPU 变得更加强大,它们可能会有更大的用途。

正向阴影

在正向着色中,使用一个单一过程,在该过程中,执行创建最终图像的所有计算。我们在本章中实现的*行光是属于前向阴影类别的光的例子。

这种方法的主要缺点是,它需要大量的计算来为每个渲染对象计算来自多个光源的照明分量。每个光源的计算成本限制了任何给定场景中活动灯光的数量,许多游戏求助于预先计算它们的照明,并将其包含在应用于游戏关卡本身的纹理中,或者通过更改关卡中使用的纹理,或者通过实现光照贴图。特别是在现代移动图形硬件上,其优势在于使用了单个目标渲染图面,因此在运行多个渲染过程时,不存在与切换渲染目标相关联的成本。

在正向明暗处理中有用的其他类型的光源是点光源和聚光灯。

点光源

点光源不同于我们在着色器中描述的*行光,因为它在世界中有一个位置。这意味着灯光可以放在世界内部,从不同的方向照亮物体。

点光源通过从光源位置减去顶点位置来计算每个顶点的光源方向,从而影响所有对象。这样做可以为光源创建一个球形。

点光源通常也用衰减算法实现。光的衰减使得物体离光越远,光对物体的影响就越小。*行光用于模拟无限远的光,例如简单的阳光模型,因此衰减系数对它们来说没有什么意义。

光矢量和衰减因子的计算增加了顶点着色器的计算复杂度。如果你的游戏非常简单,这种类型的灯光可能是可行的,但在大多数游戏中,需要多种这种类型的灯光来创建想要的效果。对于具有一些几何复杂性的场景,这通常使这种技术超出了大多数低端移动 GPU 的能力。

聚光灯

聚光灯是另一种比点光源更复杂的光源。聚光灯有位置和方向。当使用聚光灯时,它们类似于手电筒的效果。聚光灯像点光源一样包含衰减因子,但它们也有聚光灯截止参数。

此截止参数定义聚光灯圆锥的半角。这决定了聚光灯有多宽。由方向和角度创建的圆锥体之外的任何东西都不会被聚光灯照亮。聚光灯还有另一个衰减系数,它决定了从圆锥体中心到侧面的光的亮度。这使得聚光灯在中间更亮,并向圆锥体的边缘逐渐减弱。

点光源和聚光灯是 OpenGL ES 1.0 提供的光源类型,它们以传统方式实现被称为前向着色。在现代游戏中,一种用于合成场景的现代技术被称为延迟渲染。

延期渲染

在 Xbox 360 和 Playstation 3 上的许多游戏中使用的另一种技术是延迟渲染。

延迟渲染通常分两步实现。第一遍渲染到几个缓冲区。对象的漫反射颜色存储在漫反射颜色 G 缓冲区中。这不同于我们在 Blinn-Phong 模型中实现的漫射颜色。存储的颜色只是对象的单一漫反射材质颜色。延迟渲染中不使用环境光,任何镜面反射照明都是稍后计算的。除了扩散 G 缓冲区,普通 G 缓冲区存储每个片段的法线向量,z 缓冲区存储每个片段在每个位置的深度。

然后,第二遍渲染每个灯光的几何形状。使用灯光信息以及从第一遍写入的缓冲区读取的颜色、法线和深度,在每个位置计算照明方程式。

这种模型的好处是,在一个场景中可以渲染数百个灯光,因为它们只针对帧中的像素进行计算,这些像素实际上会成为最终的图像。缺点是它不能处理透明物体,所以你仍然需要一个传统的渲染过程。

可以用着色器实现的另一个高级主题是不同类型的材质。这是通过在着色器中使用不同的照明方程式来完成的。这些方程被称为双向反射分布函数(BRDFs)。

双向反射分布函数

BRDFs 是一组描述计算反射向量的方法的方程。我们在本章中实现的 Blinn-Phong 方程只是这组方程中的一个例子。

Blinn-Phong 模型非常适合于*似具有类似于塑料外观的材质。固定功能图形管道(如 OpenGL ES 1.0 中的图形管道)仅向游戏开发人员展示了这种模型,但借助于着色器,OpenGL ES 2.0 程序员可以自由实现更多类型的 BRDFs。

整本书都是以计算机图形学中的光照为主题的,如果你有兴趣了解更多关于 BRDFs 的知识,可以从托兰斯-斯派洛模型、柳文欢-纳耶漫反射模型和沃德反射模型开始。

这些不同的照明方程更适合于再现不同材质的外观,如金属、纸张、天鹅绒、沙子和木材。

摘要

在这一章中,我们终于给场景增加了深度感。我们通过添加新的着色器程序实现了这一点。

我们已经知道在游戏中使用*行光来创建一个简单的光源模型,它离我们的游戏对象很远。定向光源的一个真实例子是太阳。我们都知道太阳不发射*行光线,这表明我们可以使用简化来模拟现实世界现象的简单模型。这种简化物理世界的能力对于从事实时游戏的游戏程序员来说是一项关键技能,因为实时游戏依赖于在 33 毫秒或更短的时间内完成整个帧的计算。

Blinn-Phong 着色模型已在 GLSL 着色器中涵盖和实现。本章附带的示例代码包含本章中描述的所有着色器阶段的实现。您可以在它们之间切换,以查看每个照明组件的行为,并且可以改变材质和灯光颜色以及镜面反射指数,以感受该照明模型在不同参数下的行为。

你现在也应该知道,Blinn-Phong 模型由三个不同的组件组成,它们构成了片段着色器中的最终颜色。环境颜色是应用于对象的灯光的基本级别。漫反射颜色决定了对象颜色的主要方面,并根据顶点法线和灯光向量之间的角度考虑了反射光的强度。最后一个部分是镜面反射部分。该组件为曲面添加了一个反射元素,也是 Phong 模型的组件,Blinn 采用该组件创建了 Blinn-Phong 模型。

最后,我们讲述了照明和材质领域的更多细节。虽然这是一个初级水*的文本,但重要的是要知道游戏引擎的照明和材质属性构成了一个深刻而非常有趣的主题。顶点和片段着色器为游戏开发人员提供了照片级材质渲染的可能性,随着 GPU 能够实现越来越复杂的照明模型,着色器编程将成为一个不断发展的有趣主题。

在下一章,我们将从玩家的视觉转移到他们的听觉。音频是现代视频游戏的一个非常重要的方面,虽然音频曾经是开发过程中被忽视的一部分,但现在不再是这样了。成功的游戏在制作的早期就计划好它们的音频,音频设计师和工程师被雇佣来扮演这些特殊的角色。

十、游戏音频

音频是制作引人入胜的现代视频游戏的关键组成部分。AAA 游戏中的声音和音乐设计已经开始达到可以媲美一些好莱坞电影的高质量效果和管弦乐配乐的水准。

不幸的是,就制作成本和应用所有所需效果、流式传输多个音频源以及将所有结果混合到最终输出所需的处理能力而言,这种级别的制作非常昂贵。

在这一章中,我们将会看到在我们的游戏中加入音效,这些音效更符合你在 16 位游戏机上听到的音效类型。这些声音是通过使用数字滤波器处理声波和噪音来创建所需的效果。现代游戏倾向于使用福利艺术家使用各种各样的技术来重现和录制声音。一个经典的传闻例子就是光环系列中的*战攻击。据说,这个动作的声音效果是在录音棚录音时用锡纸包裹西瓜并用棒球棒击打而产生的。如果你有时间、设备和兴趣去尝试这类事情,这可能是一个有趣的探索途径。

在我们开始创建效果之前,了解一点音频在现实世界中的工作原理是有益的。

声音的物理学

处理声音时所涉及的物理学是一个复杂的数学课题。幸运的是,除了音频物理学的基础知识,我们不需要更多的东西来创造一些合适的声音效果。

声音通过介质传播。对我们来说,这种介质通常是空气,但它也可以穿过固体物体和物质,如水。电影《异形》的广告语“在太空中没有人能听到你的尖叫”是完全准确的。空间是一个真空,缺少声音传播的媒介意味着空间中实际上没有声音。尽管如此,很多以太空为背景的电影和游戏都有音效;如果没有激光束和爆炸,太空战的场面将会相当沉闷。

声音通过一组高压缩和低压缩区域的介质传播。我们用代表声波的图形来形象化这种现象。图 10-1 显示了使用 Audacity 创建的正弦波图。

9781430258308_Fig10-01.jpg

图 10-1 。正弦波图

该图的波峰代表声波中的高压缩区域,波谷代表低压缩区域。

这个声音图有两个我们感兴趣的主要特性。首先是它的振幅。

振幅

声波的振幅代表声波储存的能量强度。振幅越大,听者感受到的音量就越大。图 10-2 显示了 Audacity 中创建的另一个正弦波,但这一次振幅要低得多。

9781430258308_Fig10-02.jpg

图 10-2 。低振幅声波

在图 10-1 和 10-2 之间的直接比较显示,声波具有完全相同数量的波峰和波谷。唯一的区别是波峰和波谷与黑色中心线的距离。图 10-2 中的图形表示与图 10-1 相同的声波,但能量和强度要小得多。对听者来说,两种波的区别仅仅在于音量。当从相同的距离听到第一个波时,第一个波的声音会比第二个波大得多。

在处理不同强度的声音时,我们可以将它们排列在一个音阶上。这个标度用分贝(dB) 来定义。分贝是一个相对的测量单位,因此用于描述两个声音级别之间的强度差异。

如果我们拿人类对声音的感知来说,我们听不到的声音水*代表 0dB。表 10-1 包含一些常见声音及其相对于感知寂静的分贝水*(来源:http://articles.washingtonpost.com/2009-03-10/news/36815953_1_db-temporary-threshold-shift-subway-train)。

表 10-1 。不同分贝水*的声音

分贝数 示例声音
0dB 听不到声音
20dB 滴答作响的手表
50dB 降雨
70dB 洗衣机
80dB 两英尺高的闹钟
105 分贝 割草机
130 分贝 喷气式飞机在 100 英尺

当我们试图计算两种声音的分贝差异时,我们使用以下等式:

10 日志(P2/P1) = dB

使用前面的等式,我们可以知道当一个声音的强度是另一个声音的两倍时(所以 P2/P1 是 2),分贝的差异是 3dB。当一个声音的强度是十倍时,差异是 10dB,当它的强度是一百万倍时,差异是 60dB。

前面的数字向我们展示了用对数标度来表示分贝的好处。我们可以用相对较小的数字来表示强度相差很大的声波之间的差异。不幸的是,当比较 3dB、10dB 和 60dB 的声音时,这并不直观。

我们感兴趣的声波的第二个特性是它的频率。

频率

声波的频率影响它的音调。不同的音高被人们认为是音乐中较高或较低的音符。在处理声音效果时,改变声音效果的频率可以给我们带来相同效果的微妙变化。同样重要的是,在设计特定声音时,要知道哪一级别的音高最有可能有效。一个简单的例子是,高频声音更适合激光枪效果,低频声音更适合爆炸。

频率在人类听觉中也起着重要的作用。普通人的耳朵只对 20 赫兹到 20,000 赫兹之间的一个很窄的频率范围敏感。符号 Hz 表示测量单位赫兹。赫兹是用来测量重复的。如果你从图 10-1 中回忆起,我们的声波由三个波峰和两个波谷组成。增加声波的频率会给我们一个更高的声音效果,并通过在相同的时间内有更多的波峰和波谷在图表上显示出来。图 10-3 显示了频率增加的声波。

9781430258308_Fig10-03.jpg

图 10-3 。更高音调的声波

其他类型的声波

图 10-1 至 10-3 所示的曲线图显示了正弦波的波形。波形代表声音如何通过给定介质传播的图形表示。在人类听觉的正常情况下,这种介质是空气;然而,介质也可以是水、混凝土或金属。

正弦波在其波峰和波谷之间*稳振荡;其他类型的声波可以用不同的波形来表示。

图 10-4 显示了方波的波形。

9781430258308_Fig10-04.jpg

图 10-4 。方波

如图 10-4 所示,方波具有*坦的高频和低频部分,它们交替出现并产生与正弦波不同的声音。

另一种常见的波是锯齿波。如图 10-5 中的所示。

9781430258308_Fig10-05.jpg

图 10-5 。锯齿波

锯齿波产生的声音不同于正弦波和方波。这些波中的每一个都可以用作声音效果的基础,波和过滤器的组合用于生成不同类型的声音。

错认假频伪信号

混叠是使用数字采样技术时产生的一种不良效应。游戏程序员更可能听说过反走样这个术语,它是指游戏截图中出现的锯齿状边缘。在这方面,反走样用于消除渲染图形中出现的锯齿边缘。相同的“锯齿”可能出现在采样的音频中。

数字音频通过在特定时间点对音频进行离散采样来工作,但是数字音频还应用某些其他约束,例如它可以存储的最大和最小振幅。如果音频频率高于或低于这些频率,采样幅度可能会高于或低于其应有的点。当通过数模转换器重新创建数字表示时,这导致音频听起来不同于原始声音。在本章中,我们不打算讨论音频混叠,但是你应该知道它的存在。

现在我们已经介绍了声波的基础知识,我们来看看用来产生音效的软件包。

大胆

Audacity 是一个免费的声音编辑套件,适用于 Windows、Mac OSX 和 Linux。我在撰写本章时使用的 Audacity 版本是 2.0.2。图 10-6 显示了 Audacity 打开时出现的主窗口。

9781430258308_Fig10-06.jpg

图 10-6 。Audacity 的主窗口

在图 10-6 中,左上方可以看到主记录控件 。右边的下一个面板包含用于修改声波的选择工具。然后是输出和输入指示器,后面是另一个控制面板,其中包含音量、拷贝/粘贴、修剪、缩放等。下面的栏包含输入和输出源选择器。

在窗口顶部的菜单栏中,我们将使用生成和效果面板。

啁啾发生器

啁啾信号是振幅随时间增加或减少的信号。因此,了解到 Audacity 的啁啾发生器可以用来产生完全以这种方式运行的声波就不足为奇了。这一节包含了我们将如何使用这种类型的波来开始创建我们的声音效果的确切细节。

图 10-7 显示了生成菜单。

9781430258308_Fig10-07.jpg

图 10-7 。Audacity 的生成菜单

“生成”菜单用于生成基本声波。我们将使用的特定选项是啁啾发生器。点击 Chirp 时,我们会看到如图图 10-8 所示的对话框。

9781430258308_Fig10-08.jpg

图 10-8 。啁啾发生器对话框

啁啾发生器允许我们指定要产生的波形。显示的选项有正弦、方形、锯齿和方形,无混叠。每一种都会产生稍微不同的基本声波。

啁啾发生器是有用的,因为它允许我们指定开始和结束频率和振幅。所示的选择从低频声音开始,在波结束时增加。当到达终点时,产生的波的振幅也将减小。

插值类型允许我们选择线性或对数。线性插值将允许我们创建从开始状态到结束状态均匀变化的效果。对数选项创建的波形变化更加*滑。最后一个变量是产生声音所需的持续时间。

一旦我们有了一个基本的声波,我们可以使用效果菜单中的过滤器来修改它。

效果

Audacity 提供了一些预配置的过滤器,可以操纵声波。这些滤镜可以在效果菜单中找到。效果可以用来增加声波 的频率或振幅。它们还可以用来消除波中的高频或低频声音,甚至消除噪音。

这一节将看看我们如何使用一些提供的效果来塑造我们的声波到一个特定的效果。

图 10-9 显示了 Audacity 的效果菜单。

9781430258308_Fig10-09.jpg

图 10-9 。Audacity 效果菜单

我们对这个菜单中的音效感兴趣的是放大效果、改变音高、改变速度、改变速度、淡入、淡出、高通滤波器和低通滤波器。这些效果可以应用于整个声波或只是声波中的选择,这些效果的组合可以创建一些有趣的声音。

  • 放大效应确实做了你认为它可能做的事情。它允许我们改变声波的振幅,从而改变音量。我们可以使用这个工具来增加或减少振幅。
  • 变化速度效果 允许我们改变声音效果的持续时间。缩短效果的播放时间具有增加声波频率的效果,使其看起来以更高的音调播放。拉长声音会降低频率,使效果声音更低。
  • 改变音调 允许我们改变声波的频率而不影响其持续时间。
  • 改变速度 允许我们改变效果的长度而不改变其频率。

注意由于处理声波的方式不同,最后两种效果会给声波增加一些明显的滴答声。

  • 淡入和淡出完全按照它们的名称工作;他们将线性淡入或淡出声音效果的选择。这对于开始时听起来太刺耳的效果,或者我们希望随着时间的推移声音变大或变小的效果非常有用。它们影响声波选定部分的振幅。
  • 低通滤波器 允许我们降低特定频率以上的频率幅度。该效果将衰减值和频率作为其参数。截止频率允许我们指定不受滤波器影响的频率。例如,如果我们指定 1000Hz 为截止频率,则 1000Hz 以下的频率幅度不会降低。滚降允许我们指定滤波器的积极程度。我们可以选择的选项在 6dB 到 48dB 之间。根据声音高于截止频率的程度,衰减 dB 值作为倍数应用。衰减值越高,高频声音的振幅降低得越多。
  • 高通滤波器 的工作方式与低通滤波器完全相反。我们可以指定一个频率,高于该频率时声波不受影响,还可以指定一个衰减值来指定应用滤波器的力度。

随着我们的 Audacity 旋风之旅的结束,我们可以使用这些工具来创建一个基本的跳跃音效。

大胆创造跳跃音效

我们的玩家与 Droid Runner 的唯一互动是让角色跳跃,所以这个动作是我们第一个音效的理想选择。这一节将一步一步地介绍创造一种声音的过程,我们可以用这种声音来表现跳跃。

  1. From the Generate menu, select Chirp. In the Chirp Generator dialog box, generate a Square Waveform with the parameters set as shown in Figure 10-10.

    9781430258308_Fig10-10.jpg

    图 10-10 。跳跃声音效果的方形啁啾声发生器对话框

    产生的声波应该类似于图 10-11 中的声波。

    9781430258308_Fig10-11.jpg

    图 10-11 。跳跃效应方波

  2. In the Effect Menu, select High Pass Filter to run a high-pass filter over the entire wave with the parameters from Figure 10-12.

    9781430258308_Fig10-12.jpg

    图 10-12 。跳转高通滤波器对话框

  3. Next, we select Amplify from the Effect Menu and amplify the sound wave by -2.8dB, as shown in Figure 10-13.

    9781430258308_Fig10-13.jpg

    图 10-13 。跳转放大对话框

  4. 再次从效果菜单中选择高通滤波器,并使用与图 10-11 中相同的参数,即 6db 滚降和 1000Hz 截止频率。

  5. 选择效果➤放大并运行另一个放大滤波器,放大参数设置为 1.2。

  6. Run the Fade Out Filter by selecting Effect ➤ Fade Out; then run Effect ➤ Change Speed with the parameter set to 60. At this point, your sound wave should resemble the one shown in Figure 10-14.

    9781430258308_Fig10-14.jpg

    图 10-14 。跳跃效应的第一阶段

在这一点上,我们有一个效果,在 0.6 秒内音高增加,听起来像是我们可以用来跳跃的效果。我们现在将使用更多的效果来稍微修饰一下声音。

  1. First off, duplicate the sound wave using the Duplicate option from the Edit menu. Audacity should now resemble Figure 10-15.

    9781430258308_Fig10-15.jpg

    图 10-15 。复制的跳跃效果

  2. Now use the Time Shift Tool from the Tools toolbar to move the second sound wave over to the right. Drag it over until the selection start time reads 0.025s. The result of this step is shown in Figure 10-16.

    9781430258308_Fig10-16.jpg

    图 10-16 。跳跃效应复制移位

  3. 现在,选择两个轨迹,使用菜单选项轨迹➤混合和渲染。

  4. 这个复制、移动和混合的步骤给我们的声音效果增加了一点回声,但是让声音的开始变得有点尖锐,整体效果有点刺耳和走调。我们将使用效果菜单中的低通滤波器降低高音音调,参数为滚降 6dB,频率为 1200Hz。完成后,运行 7.3dB 的效果➤放大器来提高音量。

  5. 我们现在将通过应用参数 600Hz 和 12dB 的效果➤低音增强来再次增强低端。我们现在将运行 6dB 和 1200Hz 参数的➤低通滤波器。在最后一个低通滤波器之后,幅度有点低,因此我们将通过运行参数为 3.2dB 的效果➤放大器来再次提高幅度

  6. 我们的效果现在在开始时有一个从我们的复制和移动中遗留下来的刺耳的双重弹出,所以我们将使用效果➤淡入来*滑它一点。使用工具工具栏中的选择工具选择音轨的第一个 0.025 秒,然后在中选择效果➤淡入。随着轨道开始处的振幅淡入,我们现在使用效果➤放大器提高整个轨道的振幅,参数为 2.5dB,并勾选允许削波复选框。

  7. 最后一步是在音轨的前 0.75 秒使用效果➤淡入。Audacity 中的最终效果应该类似于图 10-17 中的轨迹。

9781430258308_Fig10-17.jpg

图 10-17 。完成的跳跃效果

我们现在准备好导出我们的声音文件。我们将使用 Ogg Vorbis 音频格式的声音。Android 上的 OpenSL 支持加载和播放 Ogg 文件,而无需我们付出任何努力,并且它们是压缩的,因此它们是 Android 游戏开发的理想选择。

Ogg Vorbis 音频格式由两部分组成。Vorbis 组件指定音频的压缩算法。这是一种由Xiph.Org基金会创建的有损压缩格式。Ogg 组件指定了一个容器,它规定了如何将数据保存到文件中。结合起来,这些组件创建了 Ogg Vorbis 格式。

声音文件应该放在项目的“资源”文件夹中。使用文件➤导出选项将音频导出到素材/声音下的 jump.ogg 中,如图图 10-18 所示。

9781430258308_Fig10-18.jpg

图 10-18 。素材文件夹中的 Ogg 文件

图 10-18 也显示了我们需要一个名为 impact.ogg 的音频文件。这个文件附带了补充本章的示例代码,可以在本书的网站http://www.apress.com/9781430258308上找到。

冲击效果是按照 Audacity explosion 音频教程创建的,该教程可以在音效部分http://wiki.audacityteam.org/wiki/Category:Tutorial#Sound_Effects找到,通过“如何在 Audacity 中创建爆炸和激光枪音效”中的爆炸链接。

现在我们已经创建好了声音,我们可以编写必要的代码来播放本地代码的效果。

使用 OpenSL ES 播放音频

到目前为止,在本书中,我们一直在使用 Android NDK 创建一个游戏,而没有使用任何 Java 代码。我们已经使用 EGL 与 Android 的窗口系统 OpenGL 交互,将数据提交给 GPU 进行渲染,现在我们将使用原生的 OpenSL ES API 提交音频数据进行播放。

当我们显示图形时,我们创建了 Renderer 类来封装代码,并将我们的 OpenGL 代码与我们的游戏逻辑分开。我们将对我们的音频代码做同样的事情。将创建AudioManager类来封装 OpenSL 代码并简化我们未来的游戏逻辑代码。这有助于我们制作一个游戏引擎,并促进跨多个项目的代码重用。

创建音频管理器

AudioManager类将用于创建游戏逻辑和 OpenSL ES API 之间的接口。清单 10-1 展示了AudioManager类的类声明。

清单 10-1。AudioManager阶级宣言

class AudioManager
       :      public Singleton<AudioManager>
{
public:
       typedef unsigned int        AudioHandle;
       static const AudioHandle    INVALID_HANDLE = 0xFFFFFFFF;

private:
       AAssetManager*              m_pAssetManager;

public:
       explicit AudioManager(AAssetManager* pAssetManager);
       virtualAudioManager();

       bool          Initialize();
       void          Destroy();

       AudioHandle   CreateSFX(std::string& filename);
       void          PlaySFX(AudioHandle handle);
       void          DestroySFX(AudioHandle handle);
};

这个简单的界面将允许我们初始化和销毁 OpenSL ES,以及创建、播放和销毁单独的音效。我们使用一个无符号整型的 typedef 来存储特定音频事件的句柄。

AudioManager的构造函数取一个指向安卓 NDK 的AAssetManager类的指针,用来访问我们之前添加到项目中的文件。清单 10-2 显示了构造函数的代码。

清单 10-2。 AudioManager’s建造师

AudioManager::AudioManager(AAssetManager* pAssetManager)
       :     m_pAssetManager(pAssetManager)
{
       Initialize();
}

构造函数简单地调用Initialize方法。析构函数如清单 10-3 所示。

清单 10-3。 AudioManager’s析构函数

AudioManager::∼AudioManager()
{
       Destroy();
}

正如您所料,析构函数调用Destroy

这涵盖了AudioManager类的接口以及构造函数和析构函数。现在我们将看看初始化 OpenSL 所必需的代码。

OpenSL ES 简介

OpenSL ES 是 Khronos 提供的另一个 API。其中 OpenGL ES 是他们用于嵌入式系统的图形库 API,OpenSL ES 是他们用于嵌入式系统的声音库 API。由于这两个 API 的开发方式不同,它们之间的理念完全不同。OpenGL 有一个更像 C 的接口,我们在那里调用函数;API 本身不涉及任何对象。另一方面,OpenSL 是一个基于对象的 API。

API 的主要结构包括创建对象实例和与这些对象交互的接口对象实例。我们必须与之交互的第一个对象是引擎对象。引擎对象用于创建我们的声音系统所需的其他对象。在使用 OpenSL 之前,我们需要向AudioManager类添加一些字段。这些字段被添加到清单 10-4 中。

清单 10-4。 添加 OpenSL 对象到AudioManager

class AudioManager
       :      public Singleton<AudioManager>
{
public:
       typedef unsigned int        AudioHandle;
       static const AudioHandle    INVALID_HANDLE = 0xFFFFFFFF;

private:
       SLObjectItf                 m_engineObject;
       SLEngineItf                 m_engine;
       SLObjectItf                 m_outputMixObject;

       AAssetManager*              m_pAssetManager;

public:
       explicit AudioManager(AAssetManager* pAssetManager);
       virtualAudioManager();

       bool          Initialize();
       void          Destroy();

       AudioHandle   CreateSFX(std::string& filename);
       void          PlaySFX(AudioHandle handle);
       void          DestroySFX(AudioHandle handle);
};

我们在清单 10-4 中添加了两个SLObjectItf字段和一个SLEngineItfAudioManager。我们看看这些是如何在清单 10-5 中的Initialize方法中使用的。

清单 10-5。 AudioManager::Initialize

bool AudioManager::Initialize()
{
       SLresult result;

       result = slCreateEngine( &m_engineObject, 0, NULL, 0, NULL, NULL );
       assert(result == SL_RESULT_SUCCESS);

       result = (*m_engineObject)->Realize(m_engineObject, SL_BOOLEAN_FALSE);
       assert(result == SL_RESULT_SUCCESS);

       result = (*m_engineObject)->GetInterface(m_engineObject, SL_IID_ENGINE, &m_engine);
       assert(result == SL_RESULT_SUCCESS);

       result = (*m_engine)->CreateOutputMix(m_engine, &m_outputMixObject, 0, NULL, NULL);
       assert(result == SL_RESULT_SUCCESS);

       result = (*m_outputMixObject)->Realize(m_outputMixObject, SL_BOOLEAN_FALSE);
       assert(result == SL_RESULT_SUCCESS);

       return result == SL_RESULT_SUCCESS;
}

在 Android NDK 中,初始化 OpenSL 用于基本用途的代码并不多。首先,我们调用slCreateEngine。这个方法的第一个参数是m_engineObject的地址。其余参数都是默认值,因为我们不需要引擎对象的任何特定功能。

OpenSL 对象分两步创建。第一步是告诉 OpenSL 对象您希望它支持哪些特性。一旦完成,我们就指示 OpenSL 正式创建对象;这叫做实现,是用Realize方法完成的。调用slCreateEngine后,我们指令 OpenSL 实现引擎。

一旦创建并实现了引擎,我们就可以得到引擎接口。您可以看到这是通过在m_engineObject上调用GetInterface来完成的。我们将对象作为第一个参数传递。第二个参数,SL_IID_ENGINE ,告诉 OpenSL 我们想要为对象检索哪个接口,最后一个参数是我们想要存储接口的字段。

我们现在使用引擎接口来创建一个输出混合对象。输出组合正如其名称所暗示的那样。它是负责接收音频、混合音频并通过操作系统以及最终设备的扬声器播放出来的对象。同样,一旦创建了输出混合对象,就必须实现它。

这就是在 Android 上初始化 OpenSL ES 的基本设置的全部内容。我们有一个引擎对象、一个引擎接口和一个输出混合对象。现在我们将看看从 Ogg 文件中创建声音所需的代码。

为文件创建 OpenSL ES 播放器对象

在这一章的开始,我们介绍了创建音效并将其保存为 Ogg Vorbis 音频格式的过程。在这一部分,我们将看看如何在游戏中使用这些音效。

在播放声音效果之前,我们必须创建一个音频播放器对象,该对象可以引用包含声音的文件并从该对象中检索播放接口。

我们很可能想要创建多个玩家对象,所以我们从给清单 10-6 中的类添加一个PlayInstance结构和一个unorderer_map开始。

清单 10-6。PlayInstance 添加到AudioManager

class AudioManager
       :     public Singleton<AudioManager>
{
public:
       typedef unsigned int AudioHandle;
       static const AudioHandle INVALID_HANDLE  = 0xFFFFFFFF;

private:
       SLObjectItf                 m_engineObject;
       SLEngineItf                 m_engine;
       SLObjectItf                 m_outputMixObject;

       AAssetManager*              m_pAssetManager;

       struct PlayInstance
       {
              SLObjectItf          m_playerObject;
              SLPlayItf            m_playerPlay;
       };
       typedef std::tr1::unordered_map<AudioHandle, PlayInstance*>    PlayInstanceMap;
       typedef PlayInstanceMap::iterator                              PlayInstanceMapIterator;

       PlayInstanceMap                                  m_playInstances;

public:
       explicit AudioManager(AAssetManager* pAssetManager);
       virtualAudioManager();

       bool     Initialize();
       void     Destroy();

       AudioHandle      CreateSFX(std::string& filename);
       void             PlaySFX(AudioHandle handle);
       void             DestroySFX(AudioHandle handle);
};

我们的音效实例将存储在由PlayInstance表示的结构中。为了便于拥有多种音效,我们将PlayInstance的实例存储在一个unordered_map中,其中一对实例由AudioHandle和一个指向PlayInstance对象的指针组成。我们还为映射定义了一个类型的iterator,并在m_playInstances中创建了一个映射实例。

清单 10-7 显示了AudioManager::CreateSFX 的代码,它创建了一个声音效果的新实例。

清单 10-7。 AudioManager::CreateSFX

AudioManager::AudioHandle AudioManager::CreateSFX(std::string& filename)
{
       AudioHandle handle = INVALID_HANDLE;

       AAsset* asset = AAssetManager_open(
              m_pAssetManager,
              filename.c_str(),
              AASSET_MODE_UNKNOWN);

       if (asset != NULL)
       {
              handle = Hash(filename.c_str());

              PlayInstanceMapIterator iter = m_playInstances.find(handle);
              if (iter == m_playInstances.end())
              {
                     PlayInstance* pNewInstance = new PlayInstance();

                     if (pNewInstance)
                     {
                            std::pair<AudioHandle, PlayInstance*> newInstance(handle, pNewInstance);
                            std::pair<PlayInstanceMapIterator, bool> addedIter =
                                   m_playInstances.insert(newInstance);
                     }

                     off_t start;
                     off_t length;
                     int fd = AAsset_openFileDescriptor(asset, &start, &length);
                     assert(0 <= fd);
                     AAsset_close(asset);

                     // configure audio source
                     SLDataLocator_AndroidFD loc_fd = {
                            SL_DATALOCATOR_ANDROIDFD,
                            fd,
                            start,
                            length};

                     SLDataFormat_MIME format_mime = {
                            SL_DATAFORMAT_MIME,
                            NULL,
                            SL_CONTAINERTYPE_UNSPECIFIED};

                     SLDataSource audioSrc = {&loc_fd, &format_mime};

                     // configure audio sink
                     SLDataLocator_OutputMix loc_outmix = {
                            SL_DATALOCATOR_OUTPUTMIX,
                            m_outputMixObject};

                     SLDataSink audioSnk = {&loc_outmix, NULL};

                     // create audio player
                     const unsigned int NUM_INTERFACES = 1;
                     const SLInterfaceID ids[NUM_INTERFACES]    = {SL_IID_PLAY};
                     const SLboolean req[NUM_INTERFACES]        = {SL_BOOLEAN_TRUE };
                     SLresult result = (*m_engine)->CreateAudioPlayer(
                            m_engine,
                            &pNewInstance->m_playerObject,
                            &audioSrc,
                            &audioSnk,
                            NUM_INTERFACES,
                            ids,
                            req);
                     assert(SL_RESULT_SUCCESS == result);

                     // realize the player
                     result = (*pNewInstance->m_playerObject)->Realize(
                            pNewInstance->m_playerObject,
                            SL_BOOLEAN_FALSE);
                     assert(SL_RESULT_SUCCESS == result);

                     // get the play interface
                     result = (*pNewInstance->m_playerObject)->GetInterface(
                            pNewInstance->m_playerObject,
                            SL_IID_PLAY,
                            &pNewInstance->m_playerPlay);
                     assert(SL_RESULT_SUCCESS == result);
                     (*pNewInstance->m_playerPlay)->RegisterCallback(
                            pNewInstance->m_playerPlay,
                            play_callback,
                            NULL);
                      (*pNewInstance->m_playerPlay)->SetCallbackEventsMask(
                            pNewInstance->m_playerPlay,
                            SL_PLAYEVENT_HEADATEND);
              }
       }

       return handle;
}

我们通过使用 Android NDK 的AAssetManager来尝试打开 filename 方法参数中提供的文件,从而开始了CreateSFX方法 ??。如果文件存在,AAssetManager_open将返回一个指向Asset的指针。

然后,我们使用方法Hash创建文件名的散列。Hash返回文件名的 SDBM 哈希。Hash方法的代码包含在清单 10-9 中。

在创建散列之后,CreateSFX试图通过调用m_playInstances上的find方法来发现这个声音效果是否已经被添加到地图中。这意味着我们想在游戏中使用的任何声音效果都必须有一个唯一的文件名。

如果这是一个新的声音效果,我们创建一个新的PlayInstance实例,并用散列句柄和指针pNewInstance创建一对。这对新的被添加到m_playInstances

下一步需要为打开的Asset获取一个文件描述符。我们通过调用AAsset_openFileDescriptor 来做到这一点。此方法还提供了文件开头的偏移量和文件的长度。一旦我们有了自己的文件描述符,我们就在asset上调用AAsset_close

现在我们已经检查了所有的依赖项,能够使用 OpenSL 创建一个音频播放器对象。音频播放器需要的第一个依赖项是音频数据的来源。我们通过创建一个SLDataLocator_AndroidFD对象来通知 OpenSL 在哪里可以找到这个音效的数据。这个对象存储我们调用AAsset_openFileDescriptor时获得的fdstartlength变量。

我们还必须为 OpenSL 提供文件的 MIME 类型。MIME 类型传统上在创建网站时用于确定从服务器传输的文件类型。幸运的是,我们可以传递SL_CONTAINERTYPE_UNSPECIFIED,OpenSL 将从文件中确定类型。如果我们知道我们将只使用 Ogg 文件,我们可以指定SL_CONTAINERTYPE_OGG,但是您可以选择使用 MP3 文件或一些其他支持的媒体格式。然后loc_fdformat_mime都被捆绑到结构audioSrc中,该结构的类型为SLDataSource

SLDataSource为我们的音效指定输入时,我们需要创建一个SLDataSink对象来指定输出。我们的输出对象将由SLDataSink来表示,它接受一个类型为SLDataLocator_OutputMix的元素。第二个字段留空。变量loc_outmix用于指定我们的m_outputMixObject作为输出接收器。

我们现在准备为这个效果创建玩家对象。OpenSL 支持多种类型的播放器对象接口,但是我们只对 Play 接口感兴趣。NUM_INTERFACES用于指定我们想要的单一界面。ids存储播放接口的标识符,SL_IID_PLAY和 req 存储SL_BOOLEAN_TRUEreq用于通知 OpenSL 播放界面不是可选的。

然后使用引擎接口来创建播放器对象。这是使用CreateAudioPlayer 方法完成的。如果这个调用成功,那么我们将拥有一个存储在pNewInstance->m_playerObject中的玩家对象的引用。和所有 OpenSL 对象一样,我们调用Realize来完成创建过程。

随着我们的播放器对象的实现,我们可以获得允许我们播放音效的接口。我们通过用第二个参数SL_IID_PLAY调用GetInterface来做到这一点。

我们还没有完成CreateSFX。我们还想知道声音效果何时结束播放,并指定在该事件发生时要调用的函数。我们使用在m_playerPlay上调用的RegisterCallback来注册回调方法play_callback。回调也可以指定掩码,以确保回调只为我们感兴趣的事件触发。在这种情况下,我们面具为SL_PLAYEREVENT_HEADATEND。这将使play_callback函数在音效播放结束时被调用。这里创建一个新的 player 对象实例的代码比设置 OpenSL 本身的代码多,但是我们最终完成了CreateSFX。清单 10-8 查看play_callback函数。

清单 10-8。 play_callback

void SLAPIENTRY play_callback( SLPlayItf player, void *context, SLuint32 event )
{
       if( event & SL_PLAYEVENT_HEADATEND )
       {
               (*player)->SetPlayState(player, SL_PLAYSTATE_STOPPED);
       }
}

没什么可play_callback的。SLPlayItf界面在结束播放时不会自动停止,这可以防止它们在以后被重播。为了确保这种情况不会发生,我们使用SetPlayState方法将我们的播放器设置为SL_PLAYSTATE_STOPPED状态。

现在我们回过头来看看我们在本节中使用的Hash方法。

SDBM 哈希

我们的unordered_map要求使用惟一的键来标识存储在地图中的对象。SDBM 哈希函数允许我们将音效的文件名转换成唯一的标识符。清单 10-9 中描述了实现这一点的代码。

清单 10-9。HashSDBM 功能

inline unsigned int Hash(const std::string& key)
{
       unsigned int result = 0;

       for (unsigned int i=0; i<key.length(); ++i)
       {
              int c = key[i];
              result = c + (result << 6) + (result << 16) - result;
       }

       return result;
}

散列是对小字符串的快速操作,它创建了无符号整数结果,在我们的游戏循环中比较起来非常快;这就是为什么当我们创建新的音效时,我们散列一次,然后使用散列值进行查找。哈希生成字符串的唯一值的关键在于结果中使用当前字符的行。哈希是迭代构建的,每个字符用于计算哈希的当前值。可能会出现冲突(两个字符串创建相同的哈希),但这种情况非常罕见,尤其是对于文件名这样的短字符串。

我们现在已经涵盖了创建一个新的音效实例所需的所有代码;我们现在来看看如何播放声音。

使用 OpenSL 播放声音

一旦创建了 player 对象并获得了 play 接口,用 OpenSL 播放声音就非常简单了。

PlaySFX的方法很简单。它从m_playInstances获取PlayInstance对象,然后调用SetPlayState两次:一次是停止声音(如果声音已经在播放的话),另一次是将状态设置为SL_PLAYSTATE_PLAYING

清单 10-10 显示了PlaySFX的代码。

清单 10-10。 AudioManager::PlaySFX

void AudioManager::PlaySFX(AudioHandle handle)
{
       PlayInstanceMapIterator iter = m_playInstances.find(handle);
       if (iter != m_playInstances.end())
       {
              SLPlayItf pPlayInstance = iter->second->m_playerPlay;
              if (pPlayInstance != NULL)
              {
                     // set the player's state
                     (*pPlayInstance)->SetPlayState(pPlayInstance, SL_PLAYSTATE_STOPPED);
                     (*pPlayInstance)->SetPlayState(pPlayInstance, SL_PLAYSTATE_PLAYING);
              }
       }
}

AudioManager 的最后一项任务是清理不用的声音并关闭系统。

清理声音

一旦我们完成了一个声音效果,销毁它所占用的资源是一个好主意。我们通过调用DestroySFX 来实现音效。这方面的代码如清单 10-11 所示。

清单 10-11。 AudioManager::DestroySFX

void AudioManager::DestroySFX(AudioHandle handle)
{
       PlayInstanceMapIterator iter = m_playInstances.find(handle);
       if (iter != m_playInstances.end())
       {
              PlayInstance* pInstance = iter->second;
              if (pInstance && pInstance->m_playerObject)
              {
                     (*pInstance->m_playerObject)->Destroy(pInstance->m_playerObject);
                     pInstance->m_playerObject   = NULL;
                     pInstance->m_playerPlay     = NULL;
              }

              m_playInstances.erase(iter);
       }
}

DestroySFX中,我们为提供的句柄检索PlayInstance对象,验证该对象和m_playerObject是否有效,然后在m_playerObject上调用 Destroy。

我们还在从析构函数调用的Destroy方法中释放我们的资源。清单 10-12 显示了这段代码。

清单 10-12。 AudioManager::Destroy

void AudioManager::Destroy()
{
       for (PlayInstanceMapIterator iter = m_playInstances.begin();
            iter != m_playInstances.end();
            ++iter)
       {
              PlayInstance* pInstance = iter->second;
              if (pInstance && pInstance->m_playerObject)
              {
                     (*pInstance->m_playerObject)->Destroy(pInstance->m_playerObject);
                     pInstance->m_playerObject = NULL;
                     pInstance->m_playerPlay = NULL;
              }
       }
       m_playInstances.clear();

       if (m_outputMixObject != NULL)
       {
              (*m_outputMixObject)->Destroy(m_outputMixObject);
              m_outputMixObject = NULL;
       }

       if (m_engineObject != NULL)
       {
              (*m_engineObject)->Destroy(m_engineObject);
              m_engineObject = NULL;
              m_engine = NULL;
       }
}

清理我们所有的 OpenSL 资源。我们首先在m_playInstances中迭代所有音效,并对每个音效调用Destroy。然后我们也释放了m_outputMixObjectm_engineObject

随着我们所有的AudioManager代码的完成,我们现在可以给我们的游戏逻辑添加一些音效了。

给机器人赛跑者添加音效

我们的第一个音效是一个跳跃声,所以我们需要为玩家跳跃时添加一个新事件。我们在清单 10-13 中的所示的DroidRunnerLevel构造器中注册这个新事件。

清单 10-13。DroidRunnerLevel构造函数中注册PLAYERJUMP_EVENT

DroidRunnerLevel::DroidRunnerLevel()
       :      m_pPlayerMovementComponent(NULL)
       ,      m_pPlayerTransformComponent(NULL)
       ,      m_pPlayerCollisionComponent(NULL)
       ,      m_levelEnd(0.0f)
       ,      m_initialized(false)
       ,      m_levelBuilt(false)
{
       m_levelObjects.reserve(64);

       RegisterEvent(PLAYERJUMP_EVENT);
}

注册事件后,我们现在创建声音效果并在DroidRunnerLevel::Initialize中附加消息。清单 10-14 展示了如何创建两个声音效果:一个跳跃效果和一个爆炸效果,代表玩家撞到墙壁或人工智能角色的冲击。

清单 10-14。 DroidRunnerLevel::Initialize

       .
       .
       .
       CollisionManager::GetSingleton().AddCollisionBin();

       BuildLevelData();

       Renderer* pRenderer = Renderer::GetSingletonPtr();
       if (pRenderer)
       {
              pRenderer->AddShader(&m_shader);
       }

       AudioManager
&
audioManager = AudioManager::GetSingleton();
       std::string jumpEffectName("sounds/jump.ogg");
       m_jumpHandle = audioManager.CreateSFX(jumpEffectName);
       AttachEvent(PLAYERJUMP_EVENT, *this);

       std::string explosionEffectName("sounds/impact.ogg");
       m_explosionHandle = audioManager.CreateSFX(explosionEffectName);

       m_initialized = true;
}

我们在清单 10-15 和方法DroidRunnerLevel::HandleEvent中查看这两种音效的播放位置。

清单 10-15。 在 DroidRunnerLevel::HandleEvent 中播放声音

void DroidRunnerLevel::HandleEvent(Event* pEvent)
{
       if (pEvent->GetID() == POSTUPDATE_EVENT)
       {
              bool endLevel = false;

              if (m_pPlayerTransformComponent)
              {
                     if (m_pPlayerTransformComponent->GetTransform().GetTranslation().m_x >
                            m_levelEnd)
                     {
                            endLevel = true;
                     }
              }

              if (m_pPlayerMovementComponent && m_pPlayerMovementComponent->IsDead())
              {
                     AudioManager* pAudioManager = AudioManager::GetSingletonPtr();
                     if (pAudioManager)
                     {
                            pAudioManager->PlaySFX(m_explosionHandle);
                     }
                     endLevel = true;
              }

              if (endLevel)
              {
                     CleanLevel();
                     BuildLevelData();
              }

              if (m_pPlayerCollisionComponent)
              {
                     CollisionManager::GetSingleton().TestAgainstBin(
                            0,
                            m_pPlayerCollisionComponent);
              }
       }
       else if (pEvent->GetID() == PLAYERJUMP_EVENT)
       {
              AudioManager* pAudioManager = AudioManager::GetSingletonPtr();
              if (pAudioManager)
              {
                     pAudioManager->PlaySFX(m_jumpHandle);
              }
       }
}

HandleEvent中的新代码播放两种音效。当玩家死亡时,第一个玩家玩m_explosionHandle;第二个在玩家跳跃时播放m_jumpHandle。我们的新PLAYERJUMP_EVENT是法MovementComponent::HandleEvent派来的。我们在清单 10-16 中看到了这一点。

清单 10-16。 更新MovementComponent::HandleEvent

void MovementComponent::HandleEvent(Event* pEvent)
{
       if (m_isDead)
       {
              return;
       }

       if (pEvent->GetID() == JUMP_EVENT)
       {
              TransformComponent* pTransformComponent =
                     component_cast<TransformComponent>(GetOwner());
              assert(pTransformComponent);
              if (pTransformComponent &&
                     m_isSupported)
              {
                     static const float JUMP_ACCELERATION = 220.0f;
                     m_acceleration.m_y = JUMP_ACCELERATION;
                     SendEvent(PLAYERJUMP_EVENT);
              }
       }
       else if (pEvent->GetID() == UPDATE_EVENT)
       .
       .
       .

我们在这个方法中的一个新行是在PLAYERJUMP_EVENT上调用SendEvent。现在每次调用这个方法,都会触发我们的跳转效果。

摘要

我们现在已经涵盖了使用 Android NDK 创建游戏引擎所需的所有基础知识。本章讲述了如何使用 OpenSL ES API 向引擎添加音频。

我们已经研究了声音物理学的基础知识,试图理解我们如何通过 Audacity 改变基本声波的频率和振幅来创造声音效果。Audacity 是一个优秀的免费工具,有许多不同的过滤器,可以用来创建各种声音效果。

一旦我们创建了一些声音效果,我们看一下初始化 OpenSL ES 的代码。Android 提供了播放声音效果的 Java 接口,而 OpenSL ES 提供了同样的原生接口。

本章介绍了我们在本书中创建的游戏引擎框架的核心功能的完成情况。我们现在有一个渲染器,它使用 OpenGL ES 2.0 来创建一个使用几何图形、着色器、纹理、照明和透视投影的 3D 场景。该引擎还具有支持音频回放和现代功能的功能,例如用于创建游戏对象的基于组件的系统和用于在不同对象和系统之间通信的基于事件的系统。

这个引擎会给你一个很好的基础来为 Android 创建一个可运输的标题。下一章将介绍我们如何使用 Google 开发者控制台来实现这一点。

十一、自助发布 101

一旦您构建了自己的应用,并对其内容和质量感到满意,就该将您的作品发布到谷歌 Play 商店上了。Play Store 是谷歌面向安卓开发者的官方分销网络。值得注意的是,这不是唯一的选择,但绝对是在 Android 上分发所有类型的应用的最简单和最明显的选择。

通过 Play Store 分发应用不是免费的;像任何零售商一样,谷歌从售出的每一款应用中赚钱。谷歌的费用是税后销售价格的 30%。每当你在计算你的应用收费时,记住这一点是值得的。

准备出售你的应用是一个令人兴奋的时刻,但也可能令人生畏。在您的应用出现在 Play Store 之前,需要完成许多任务。本章的目的是看看这些是什么。

Google Play 开发者控制台

在谷歌 Play 商店上安装应用之前,您必须注册为开发人员。谷歌收取 25 美元的一次性费用,以注册成为开发者并获得 Google Play 开发者控制台帐户。报名的页面可以在这里找到:https://support.google.com/googleplay/android-developer/answer/113468

Google Play 开发者控制台是一个全功能的 web 应用,用于控制您的应用的分发。您可以在这里上传应用、上传更新、设定价格、上传营销材质,并查看您的下载次数和收入。

在我们上传任何应用到开发者控制台之前,我们必须创建我们的应用的安全签名版本。

密钥、密钥库和数字签名

Play Store 使用数字签名来验证通过 Google Play 开发者控制台上传的应用的真实性。签名过程的基础在于公钥-私钥系统,该系统也用于某些形式的加密。

用于这些目的的密钥成对出现:公钥和私钥。私钥是一个你必须保持安全的文件。私钥允许您创建加密的消息或签名,然后与他人共享。公钥是一个文件,您可以与他人共享,以允许他们解密这些数据。在 Android 生态系统中,apk 本身是简单的 zip 文件,可以被任何有能力的程序打开。这些没有加密,但应用将有一个数字签名。该签名包括用您创建的私钥加密的数据,还包括公钥,以便其他人可以读取该签名。这个两步过程允许设备上的 Android 操作系统验证应用是合法的。如果无法使用提供的公钥读取签名,或者无法从 APK 中包含的数据重新创建签名中包含的数据,则操作系统可以确定该应用是非法的。谷歌为 Android APKs 创建签名的确切过程并未公之于众;这本身就是一个额外的安全层。

密钥库是用于为 apk 提供公钥-私钥签名的文件。一个密钥库可以包含多个别名;每个别名代表一个公钥-私钥对。整个密钥库也是密码加密的。这种配置允许您为计划发布的每个应用创建不同的公钥-私钥对,并通过将它们存储在单个密钥库中来轻松管理这些密钥。

此时,您可能想知道为什么应用需要以这种方式签名。本质上,这是一种保护你身份的方法。通过使用您的私钥(您拥有该私钥的唯一副本)对应用进行签名,Google 可以相信使用该密钥上传的任何应用都是同一应用的新版本,可以安全地上传给用户。这可以防止其他人劫持您的应用并将受损的 apk 上传到商店。

用于加密通过互联网传输的数据的密钥通常由授权的认证机构发布。认证机构是提供公钥第三方验证的组织。Web 浏览器包含内置的证书颁发机构列表,这些证书颁发机构位于可信列表中,程序会联系它们来验证密钥是否合法以及是否应该使用。Android 不要求 apk 由认证机构提供的密钥签名。使用 Android SDK 提供的工具创建自己的密钥是可以接受的。然而,如果你妥善保管好自己的钥匙,你就能确保你的 apk 是安全的。请确保将它们保存在安全的位置,并且永远不要共享您的密钥库或密码。

为游戏商店签下 APK

当你为 Play Store 创建一个 app 时,你必须将其打包成一个 Android 应用包文件(APK) ,这是 Google 的分发格式。这种格式是谷歌的专有格式,但本质上是一个压缩文件 ,可以使用 7-Zip 之类的工具打开。

在通读这本书的过程中,每次您构建应用时,Eclipse 的 ADT 插件都会自动创建 apk,并将它们传输到您的设备上。所有 Android 应用都必须签名,ADT 插件使用调试密钥对这些调试 apk 进行签名。如果您尝试将使用调试密钥签名的 APK 上传到 Play Store,Google Play 开发者控制台将显示错误。

  1. 为了能够将应用上传到 Play Store,我们必须创建一个使用唯一密钥 签名的版本。Android SDK 包含实现这一点的方法,ADT 插件提供了一个易于使用的向导来帮助完成这一过程。以下说明将带您完成创建一个用唯一密钥签名的 APK 的过程,您可以使用该密钥上传到谷歌 Play 商店。运行 Eclipse 并在 Project Explorer 窗口中打开您的项目。

  2. Right-click your project folder and select the Export Signed Application Package … option under the Android Tools menu. The menu option is shown in Figure 11-1. Selecting this option will cause the Export wizard to begin to create your signed APK.

    9781430258308_Fig11-01.jpg

    图 11-1 。“导出签名的应用包”选项

  3. In the wizard, select the project which you are going to use to export the APK. If you right-clicked the project you would like to export, the name should already be entered. Figure 11-2 shows the Export Android Application window .

    9781430258308_Fig11-02.jpg

    图 11-2 。“导出 Android 应用”窗口

  4. Click Next; you will be taken to the Keystore Selection window. Figure 11-3 shows this dialog.

    9781430258308_Fig11-03.jpg

    图 11-3 。密钥库选择窗口

  5. 如果这是您第一次导出 Android 应用,请选择“创建新的密钥库”单选按钮。否则,您需要选择“使用现有密钥库”

图 11-4 显示了密钥创建窗口。此窗口用于输入用于识别钥匙所有者的信息。

9781430258308_Fig11-04.jpg

图 11-4 。密钥创建窗口

密钥创建窗口要求您提供关于密钥库所有者的一些详细信息。

该别名很可能是发行该游戏的公司或组织的名称。

别名也需要密码。

密钥过期;因此,选择一个长有效期 对于确保您的密钥库在您可能仍在使用时不会过期是至关重要的。谷歌在其文档中建议最低有效期为 25 年,可在http://developer.android.com/tools/publishing/app-signing.html找到。过期的密钥不会阻止您的应用在用户设备上使用,但会阻止您发布应用的更新。如果您的密钥过期,您需要注册一个新的应用来发布更新。

然后,应该输入员工或公司董事的个人详细信息。这是一个单人操作中的简单选择。

图 11-5 显示了导出过程的最后一步 ,为您导出的 APK 选择一个输出位置。

9781430258308_Fig11-05.jpg

图 11-5 。“执行检查”窗口

最后一个窗口对您的密钥库执行最后一分钟的错误检查,并将列出发现的任何错误。往往不会有错误,如图图 11-5 。

单击“完成”后,您的 APK 将被写入您选择的位置,并准备好上传到 Play Store 开发者控制台。

上传到 Google Play 开发者中心

Google Play 开发者控制台是您访问 Play 商店的门户。这个 web 应用是由 Google 提供的,允许开发者轻松访问他们的发布渠道,而不需要形成复杂的发布协议。这对于小型开发人员来说非常理想,因为它允许自我发布,而不需要与发布合作伙伴建立外部关系。

让你的游戏出现在 Play Store 的第一步是在开发者控制台中创建一个新的应用。开发者控制台可以在以下网址找到:http://play.google.com/apps/publish

当你第一次登录时,你的控制台将是空的,并显示在图 11-6 中。

9781430258308_Fig11-06.jpg

图 11-6 。Google Play 开发者控制台所有应用

此时,我们必须创建一个新的应用。

  1. We do this by clicking the Add New Application button. Doing this presents us with the Add New Application dialog, shown in Figure 11-7.

    9781430258308_Fig11-07.jpg

    图 11-7 。“添加新应用”对话框

  2. This dialog allows us to select the default language for our application and the app’s title. There are two options which we can now take: Upload APK and Prepare Store Listing. Figure 11-8 shows the screen which appears after selecting Upload APK. This screen now consists of the title for our app and screens which allow us to control our Store Listing, Pricing and Distribution, In-app products, and Services and APIs.

    9781430258308_Fig11-08.jpg

    图 11-8 。APK 屏幕

  3. 当您选择上传时,web 应用将显示一个对话框,您可以在其中拖放您的 APK 文件。一旦你这样做了,你会看到进度条如图图 11-9 所示。

9781430258308_Fig11-09.jpg

图 11-9 。上传 APK 进度条

APK 完成上传后,您的应用将处于草稿状态。这意味着它已经在 Play Store 上进行了设置和配置,但尚未“上线”

在让您的应用出现在用户设备上的 Play Store 列表中之前,您必须完成配置在商店中列出应用所需的详细信息。开发人员控制台在“为什么我不能发布”对话框中提供了该信息的列表。我们在图 11-10 中查看这个列表。

9781430258308_Fig11-10.jpg

图 11-10 。“为什么我不能发布”对话框

如您所见,在将您的应用推入 live Play 商店环境之前,仍有相当多的步骤必须完成。

图标和截图对你的列表是必不可少的。这些能吸引顾客,应该以展示你的游戏的最佳状态为目的。你应该看看一些最受欢迎的应用和游戏列表,并尝试确定是否有任何特定的趋势可能有助于他们的成功。为你的应用选择合适的类别和吸引人们玩你的游戏的描述也很重要。

这些详细信息通过商店列表屏幕提供。您可以在此处更改应用的标题,标题最多可包含 30 个字符。还可以提供不同语言的翻译。

您的描述最长可达 4000 个字符。在描述中为你的应用提供一个吸引人的介绍是很重要的。描述将传达游戏的类型和玩家在下载你的应用时的体验。如果这不是最*更改部分中的第一个版本,也可以提供您的应用中包含的最新更新列表。

图形素材对你的应用的成功至关重要。虽然不是所有的玩家都会阅读你的应用的冗长描述,但是几乎所有的用户都会看你提供的截图。重要的是提供截图,以最好的方式展示你的游戏图形,以及展示你的应用的游戏性的截图。谷歌还为你的游戏提供了一个 Youtube 视频的链接。如果你已经为这款应用制作了预告片,将它上传到 Youtube 并在你的商店列表中加入链接是一个非常好的主意。谷歌 Play 商店应用会自动将你提供的 Youtube 视频嵌入你的商店列表。如果您希望在 Play Store 中出现,在任何给定时间提供所有请求的素材是非常重要的。定期检查所需的素材很重要,因为众所周知,谷歌会改变和更新这些素材,以适应不同类型的设备和分辨率。如果 Play Store 应用或网站发生变化,那么如果你没有提供谷歌在新布局上展示你的应用所需的素材,你就不太可能成为特色。

选择合适的类别对你的游戏很重要。此类别决定了您的应用在 Play Store 中的位置。选择准确的类别将有助于人们在寻找该类型的应用时找到您的游戏。你可以期待你的游戏会更成功,因为这个类别很好地定位了你的用户。

很可能你会有一个网站来推广你的公司或你的应用。Google 要求您提供隐私政策的 URL,所以最好考虑一下您希望这个重要声明包含什么内容。像http://www.docracy.com/mobileprivacy/这样的网站可以作为一个很好的起点,但重要的是你的隐私政策要涵盖你使用用户数据的所有方式。

货币化

开发者控制台也是你为你的应用设置盈利策略的地方。应用按价格出售的传统商业模式是可用的,您可以在 Google Play 开发人员控制台的定价和分发部分为您的应用设置价格。

当设置价格时,您最初以您自己的本地货币为您的应用设置默认价格。这是你的应用价格,不含当地销售税。然后,您可以选择自动将应用的价格转换为您选择支持的所有国外市场的价格。Google Play 开发者控制台目前支持 135 个不同国家的发行。您也可以用这些国家的当地货币为它们设定特定的价格。某些国家还支持将分发限制到特定的移动网络运营商。

注意将你的应用设置为免费也是完全可能的。在最终确定设置之前,请确保这是您想要做的事情;将您的应用价格更改为免费是不可逆转的。

用你的应用赚钱的另一个选择是支持应用内购买。应用内购买有两种类型,永久购买和消耗品。可下载的内容包,包括关卡、武器、角色或汽车,可以被视为永久购买。通过这种购买方式,玩家只需为内容支付一次费用,内容就永远属于他们了。另一类购买是消耗品。这方面的一个例子可能是健康药水。一个用户可以购买一种药剂,但是一旦它被使用,他们将被要求购买另一种。获得应用内购买的成本和收益的*衡是一个微妙而复杂的话题,但对于做对的开发者来说,这是一个可以收获回报的话题。你最终做出的收费决定将包括开发成本、你的预期回报以及你认为客户愿意为你生产的产品花多少钱等因素。没有简单的方法来决定你应该如何做这些决定;然而,在http://book.personalmba.com/pricing-uncertainty-principle/可以找到定价理论的一个很好的起点。

最后但并非最不重要的是订阅。谷歌现在通过 Play Store 提供订阅服务和计费。这将允许开发者在移动*台上创建大型多人在线(MMO)类型的体验。如果你认为你的游戏可以让玩家在相当长的一段时间内保持活力,那么这可能是一个可行的选择。

一旦你发布了你的游戏,并开始让用户购买,数据将开始出现在 Play Store 的财务报告部分。这可以让你追踪你的应用的收入。您可以使用此部分来确定新功能、更新或宣传是否会对您的应用产生的收入产生任何影响。

应用质量

Google Play 开发者控制台还能够为您提供应用的崩溃数据、应用未响应用户通知的数据,以及优化您的商店列表的其他建议。

认真对待开发者控制台提出的任何建议是很重要的。这些建议和通知有助于指出您的应用可能不符合其他应用标准的地方,从而降低您的应用的评级。高收视率和高质量的列表是吸引用户的关键,甚至可能成为 Play Store 的特色。在 Play Store 中被 Google 收录将会对你的游戏的收入潜力产生巨大的影响。

随着时间的推移,可以通过多种方式提高应用的质量,这可以与增加用户群和收入的总体策略联系起来。在过去,游戏开发商在发布之前开发游戏标题的每个部分是很常见的。对于基于盒式磁带的游戏和基于光盘的游戏来说尤其如此,它们是在物理介质上分发的。

今天,互联网为开发者开启了全新的游戏开发方法。互联网允许开发者通过构建游戏的最小可行版本并向公众发布来逐步创建游戏。然后从玩游戏的人那里得到反馈,开发者可以遵循用户感兴趣的开发路线。

当前一个完美的例子是《我的世界》和 ??。《我的世界》在最初阶段是作为一个非常小的产品开发的。每个新版本都添加了越来越多的内容和游戏元素,将产品打造成为一个现代现象和一个非常成功的游戏。虽然不可能每款游戏都达到《我的世界》取得的成功水*,但这是一种商业模式和开发实践,较小的独立工作室应该效仿,而不是用大量预算和大量风险开发完整的游戏。

为了支持这种类型的开发,我推荐使用一个由三部分组成的游戏版本系统,它使用主要版本、次要版本和维护版本的方法对构建进行分类。这种版本号的一个例子是 1.0.1。

之前版本的主要版本是 1.0,应该用于预发布版本,例如提供给内部或外部测试人员的 alpha 和 beta 版本。Build 1 应该是第一个公开发行的游戏。

这里的次要版本是 0。次要版本通常适用于包含新内容的更新。小版本之间的游戏机制和结构可能是相同的,但新的内容已经被添加,这表明该游戏是对先前小版本的合理更新。

维护号应该用来表示只包含错误修复的版本。游戏附带 bug 是很常见的。这些 bug 中有些是严重的,有些是次要的,但是开发者发布更新来解决困扰用户的 bug 是有好处的。玩家已经开始使用 Google Play 5 星评级系统来鼓励开发者修复他们游戏中的漏洞。如果你看一下 Play Store 中任何顶级游戏的评论,用不了多久你就会看到一些用户的评论,让开发者知道如果某个让他们沮丧的 bug 可以修复,他们会给游戏打更高的分数。

最后一点对于游戏开发者来说尤为重要。谷歌将展示那些受欢迎的、高质量的、受到好评的应用。任何想让自己的游戏出现在 Google Play Store 中的开发者都应该对 Google 在开发者控制台中提供的反馈以及对你游戏的某个方面不满意的用户的评论做出回应。

摘要

本章涵盖了应用从开发到发布的最后一步。我们已经了解了为分发签名 APK 和创建密钥库以上传到开发人员控制台的过程。最后,我们研究了开发者可以从他们的游戏产品中获利的选择。

发布产品并不是游戏名称旅程的终点。对于独立的小开发者来说,完成游戏代码并将应用投入销售只是游戏商业周期第一阶段的结束。在这一点上,重要的是开始了解游戏玩家对产品的想法和感受,并定期对应用进行更改和更新,以保持兴趣并创造专门的追随者。

Android *台上一些最大的游戏没有遵循旧的游戏开发风格,即游戏预先完成,然后上市销售。现在,成功的游戏越来越常见,它们会发布,然后发展成一个庞大的忠实用户群。

作为一名独立开发者,这是一个激动人心的时刻。Play Store 等服务让任何有技能和决心的人都可以创造出商业上成功的游戏,而不一定需要庞大的预算或团队。

十二、附录 A:使用 Android 开发环境

谷歌向开发者提供 Android SDK,允许他们为 Android *台创建应用。SDK 包含创建可部署到 Android 设备的应用所需的所有工具和库。谷歌还提供 Android 原生开发套件(NDK)。NDK 用于创建比 Java 代码执行效率更高的本地代码库。这对于某些应用(如游戏)非常重要。本附录将介绍设置 Android SDK、Eclipse IDE 和 Android NDK 的过程,然后构建一个示例应用。

设置 Android SDK 和 Eclipse

Google 在一个预配置的下载中提供了他们的 SDK 和 Eclipse IDE。在撰写本文时,这可以在http://developer.android.com/sdk/index.html找到。

这些软件包和软件版本相当频繁地更新。您可以在以下网址找到该软件包的最新安装说明:developer.android.com/sdk/installing/bundle.html

一旦安装了这个包,就该尝试构建一个 Java 应用了。构建 Java 应用的过程是使用 NDK 构建应用的基础。谷歌在这里提供了构建你的第一个基于 Android SDK 的应用的最新说明:http://developer.android.com/training/basics/firstapp/index.html

安装安卓 NDK

Android NDK 是一套独立的库和命令行工具,可以从 Android SDK 单独下载。可从http://developer.android.com/tools/sdk/ndk/index.html下载适合您*台的 NDK 版本。

下载完 NDK 的压缩包后,应该将其解压缩到计算机上合适的文件夹中。

一旦我们安装了 NDK,我们需要让 Eclipse 知道 NDK 文件夹在我们计算机上的位置。我们可以通过选择窗口(OS X 上的应用)➤首选项选项来启动首选项对话框。打开首选项对话框后,我们可以通过浏览 Android ➤ NDK 来设置 NDK 位置,然后设置 NDK 位置字段。

构建原生应用

在我们可以构建一个示例本机应用之前,我们需要在 Eclipse 中创建一个项目来保存代码。

  1. When we first run Eclipse, we will be shown the welcome screen as in Figure A-1.

    9781430258308_App-01.jpg

    图 A-1 。月食欢迎屏幕

  2. We then create a new project by Selecting File ➤ New ➤ Project. This will show the New Project dialog which we see in Figure A-2.

    9781430258308_App-02.jpg

    图 A-2 。“新建项目”对话框

  3. After clicking Next, we will be presented with the New Android Application dialog. Figure A-3 shows this dialog with the Application Name, Project Name, and Package Name fields all set. We also set the Minimum Required SDK field to API 9 and the Theme to None.

    9781430258308_App-03.jpg

    图 A-3 。新的 Android 应用对话框

  4. 单击“下一步”后,我们将看到“配置项目”对话框。在这个对话框中,我们应该取消选中创建自定义启动器图标和创建活动图标。最后,我们单击“完成”。

我们现在有了一个基本的 Android SDK 应用。在我们与 NDK 合作之前,我们必须让 NDK 支持这个项目。要做到这一点,右键单击项目名称,然后选择 Android 工具➤添加本地支持。显示的对话框将包含一个库名:保留默认值并单击 Finish。

此时,我们将能够右键单击项目名称并选择 Build Project 来让 Android 构建默认代码。

此时,让我们将 Android NDK 的samples目录下的native-activity样本中的main.c内容复制到Native-Sample.cpp中。

列出 A-1 包含我们需要放入你的Android.mk文件中的内容。这个文件可以在jni文件夹中找到。

列举 A-1。 Android.mk

LOCAL_PATH := $(call my-dir)

include $(CLEAR_VARS)

LOCAL_MODULE    := native-activity
LOCAL_SRC_FILES := Native-Sample.cpp
LOCAL_LDLIBS    := -llog -landroid -lEGL -lGLESv1_CM
LOCAL_STATIC_LIBRARIES := android_native_app_glue

include $(BUILD_SHARED_LIBRARY)

$(call import-module,android/native_app_glue)

您还需要在jni文件夹中创建一个Application.mk文件,其单行显示在清单 A-2 中。

清单 A-2。 申请. mk

APP_PLATFORM := android-9

最后,AndroidManifest.xml文件应该包含来自清单 A-3 的内容。

清单 A-3。 AndroidManifest.xml

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.begingamecode.native_sample"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk
        android:minSdkVersion="9"
        android:targetSdkVersion="17" />

    <application
        android:allowBackup="true"
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >

        <activity
            android:label="@string/app_name"
            android:configChanges="orientation|keyboardHidden"
            android:name="android.app.NativeActivity">
                <!-- Tell NativeActivity the name of or .so -->
                <meta-data
                    android:name="android.app.lib_name"
                    android:value="native-activity"/>
                <intent-filter>
                    <action android:name="android.intent.action.MAIN"/>
                    <category android:name="android.intent.category.LAUNCHER"/>
                </intent-filter>
        </activity>
    </application>
</manifest>

完成所有配置后,我们现在可以右键单击项目并选择 Run As ➤ Android Application,让应用在您选择的设备或仿真器上运行。

十三、附录 B:Android 硬件概述

不出所料,Android 操作系统运行在计算机硬件上。其他移动*台的开发人员可以依赖于对单一 CPU 架构、指令集和 GPU 类型的支持。如果 Android 开发者想要最大化他们应用的覆盖范围,他们就没有这种奢侈。

在本附录中,我们来看看 Android *台上使用的主要 CPU 和 GPU 架构。

CPU 架构

目前,Android *台上使用的主要 CPU 架构有三种。它们基于 ARM、MIPS 和 x86 指令集。

手臂ˌ武器ˌ袖子ˌ装备

ARM 目前在移动领域享有主导 CPU 架构的地位。ARM 架构最初是由 Acorn 在 20 世纪 80 年代创建的,用于桌面计算机系统。*年来,ARM 更普遍地被称为 CPU 指令集和架构的设计者,并没有制造自己的 CPU。

目前有两种不同类型的 ARM 许可证持有者。公司可以获得整个 CPU 的设计许可,然后为他们的设备制造,或者他们可以获得架构本身的许可,然后设计他们自己的 CPU。

ARM 目前最受欢迎的授权 CPU 是 Cortex-A9 和 Cortex-A15。Cortex-A9 是一种较旧且不太灵活的设计,可以在当今许多现代智能手机和架构中找到。NVIDIA 的 Tegra 3 *台包含一个四核 Cortex-A9 CPU,Galaxy Nexus 等手机包含双核 A9。

Cortex-A15 是一个更强大的 CPU,它在更高的温度下运行,比 A9 耗电更多。迄今为止,它仅用于高端产品,如 Nexus 10、三星 Galaxy S4 八核变体以及英伟达即将推出的 Tegra 4 芯片组。

高通基于 ARM 架构许可创建了自己的 CPU。该公司生产的骁龙片上系统(SoC)部件在许多现代设备中都可以找到。一个备受瞩目的例子是 Nexus 4 智能手机。

MIPS

当谈到 Android 时,MIPS 方面没有太多可说的,因为目前没有多少设备使用这种架构。然而,作为一家公司,MIPS 在游戏业务方面有着丰富的历史。Playstation、Playstation 2、任天堂 64 等游戏机都有 MIPS 打造的 CPU。

最*,MIPS 被 PowerVR GPUs 背后的公司 Imagination Technologies 收购。Android NDK 支持 MIPS 架构的构建,这是一个未来可能用于更多设备的 CPU。

x86

x86 体系结构在台式机和笔记本电脑中的地位由来已久。迄今为止,x86 在移动领域还没有太多的表现。这种情况可能即将改变,因为英特尔已经通过其 Atom 系列处理器在能效和性能方面取得了巨大进步。今年,他们的 Haswell 架构将更加节能,在未来几年,我们可能会开始看到英特尔台式机和移动处理器的融合。在这一点上,确保你的游戏能在 x86 上运行是非常重要的。

GPU 架构

Android 支持 Khronos 的 OpenGL ES APIs,这允许我们的游戏将图形驱动程序作为外部芯片,我们不需要关心这些。对于学习用本书中的 NDK 创建游戏的基础来说,这是一个非常好的方法;然而,在 Android 上创建高性能游戏需要更深入地了解可用的 GPU 架构。

powervr!power VR

目前最常见的 PowerVR GPU 芯片组是 SGX 系列芯片。PowerVR GPUs 在 GPU 界有着独特的架构。这些芯片的独特卖点是其基于图块的延迟渲染引擎。我们将分解这个短语来理解 PowerVR SGX 是如何操作的。

首先要考虑的是瓷砖。SGX 的工作原理是将屏幕分割成一系列小方块。当我们向驱动程序提交一个glDrawElements命令时,它会执行一个叫做宁滨的过程。每个绘制命令都被分入一个图块中。然后,使用将影响正在执行的图块的所有绘制命令,按顺序渲染图块。这一昂贵步骤的好处来自 GPU 本身。GPU 包含少量非常快速的片上内存,而不是必须不断访问主内存来读取深度值和写出像素颜色。该存储器足够大,可以包含处理该帧时读写单个图块所需的数据。填充从图块读取所需数据(如深度或模板值)的过程称为恢复,从图块通道写出数据的过程称为解析。

该术语的延期部分涉及 PowerVR 独特的隐藏表面移除(HSR)算法。处理图块时,GPU 会在执行片段着色器之前计算离相机最*的多边形的深度。这完全消除了对不透明对象的过度绘制,并且通过不必对隐藏多边形的纹理进行采样或写出它们的深度和颜色来节省存储器带宽,并且节省了与对将被覆盖的像素执行片段着色器相关联的片段处理时间。

这种方法的缺点是,它可能会限制具有大量顶点的场景的性能。

马里和阿德诺

对于开发者来说,Mali 和 Adreno 的高层架构非常相似。这两种 GPU 架构都实现了基于图块的渲染。就像在 PowerVR 芯片上一样,draw 命令被分箱并按图块执行,但它们不执行隐藏表面移除步骤。

相反,它们按照收到命令的顺序对每个图块执行命令。这被称为即时模式渲染(IMR)。然而,GPU 执行早期 z 拒绝;因此,开发人员可以在 GPU 成为瓶颈的游戏中提高性能,方法是将几何图形从最靠*相机的位置排序到最远的位置,以减少过度绘制。

整数〔??〕

最后但并非最不重要的是 NVIDIA 的 Tegra 架构。与其他 GPU 相比,Tegra 的工作方式更像传统的桌面 GPU,因为它不使用*铺渲染。它只是按照接收的顺序执行所有的渲染命令。它还包含早期 z 拒绝测试,并将受益于几何图形的从前到后排序。

十四、附录 C:C++ 编程

C++ 是一种编程语言,它为解决问题提供了几乎无限多的选项。这本书使用了一些初学者可能不熟悉的技术。本附录旨在介绍一些更高级的语言特性。

朋友关键字

C++ 类可以包含不同范围的字段和方法。这些可用的作用域是公共的、私有的和受保护的。

  • 公共范围确保所有的字段和方法都可以从类本身的外部访问。
  • 私有字段和方法只能从在同一个类中声明的方法中访问。
  • 受保护的字段和方法类似于 private,但也可以由派生类访问。

人们通常希望能够从类外部访问私有字段或方法,但只能在受限制的地方访问。这些受限的地方可能是其他的类或者函数。实现这一点的一种方法是公开这些字段和方法;然而,这允许任何类或函数访问。另一个选择是使用friend关键字。考虑清单 C-1 中的类。

清单 C-1。Simple

class Simple
{
private:
       void Interact();
}

如您所见,Interact方法是私有的,因此只能从同一个类中的其他方法访问。如果我们有另一个我们希望能够访问这个方法的类,我们可以让它成为朋友,如清单 C-2 中的所示。

清单 C-2。 Simple类‘朋友’

class SimpleFriend
{
public:
       void Interact(Simple* pSimple)
       {
              pSimple->Interact();
       }
}

class Simple
{
       friend class SimpleFriend;

private:
       void Interact();
}

清单 C-2 展示了我们如何使用friend关键字为特定的类提供对私有字段和数据的访问。

模板

C++ 是一种类型安全的语言。这意味着编译器在编译时必须知道所有变量的预期类型。有时,这种对类型的严格使用会导致大量的重复代码,而这些代码只是为了提供相同的功能来处理不同类型的对象。幸运的是,C++ 以模板的形式提供了一个解决方案。

模板允许我们创建通用的代码实现,并在代码中需要时指定特定的类型。

清单 C-3 展示了返回两个数的最小值的两种方法。

清单 C-3。 min 功能

inline int min(int a, int b)
{
       return (a<b) ? a : b;
}

inline float min(float a, float b)
{
       return (a<b) ? a : b;
}

如果我们继续沿着这条路走下去,我们将不得不为 C++ 支持的每种类型提供不同版本的min。相反,清单 C-4 展示了我们如何使用模板实现min

清单 C-4。 临时化最小

template<class T>
inline const T& min(const T& a, const T& b)
{
       return (a < b) ? a : b;
}

这里你可以看到我们没有指定函数的多个版本,类型已经被替换为TT来自第一行,它告诉编译器这个方法是一个模板,并使用T来代替特定的类型。如果您将T更改为不同的东西,您还需要在函数本身中进行更改。这个模板的另一个不同之处是函数参数和返回值是通过引用传递的。最初的intfloat方法返回内置类型,因此是简单的四字节副本。由于该模板可能用于类,所以通过引用传递以确保不会在这些对象上调用复制构造函数是很重要的。

现在来看使用清单 C-5 中的模板的代码。

清单 C-5。 使用模板

min<int>(1, 2);
min<float>(1.0f, 2.0f);

这里的代码显示,我们现在可以指定我们希望在需要的地方和时间使用的min的类型。C++ 将只为我们在代码中实际使用的类型创建特定版本的min

单一模式

本书提供的代码示例利用了 singleton 模式。模式的具体实现可以在样本代码中看到,样本代码可以从本书附带的网站http://www.apress.com/9781430258308获得。

singleton 模式用于在整个代码中提供对单个对象实例的全局访问。许多人不同意使用全局对象;然而,它们可以在游戏开发中提供有用的特性。例如,渲染器和音频管理器是我们在游戏开发中经常只需要一个实例的对象。

通过使用单例,我们可以确保我们有这些对象的单个实例,我们可以从代码中的任何地方访问它们。以一种我们仍然必须在对象上调用 new 和 delete 的方式实现单例也意味着我们可以控制如何以及在哪里为对象分配内存。对象唯一的全局特征是静态指针,我们用它来访问我们的实例。

所使用的 singleton 类的原作者是 Scott Bilas。Scott 在他的网站http://scottbilas.com/publications/gem-singleton/上有该技术的完整源代码和详细解释。

十五、附录 D:C++ 数学

如果没有对数学,尤其是几何的基本理解,就无法开发电子游戏。

本附录涵盖了本书网站http://www.apress.com/9781430258308上的示例所提供的基本数学课程。

向量

矢量有两个用途:表示位移和方向。

游戏中的向量有三种不同的形式:二维(2D)、3D 和 4D 同质向量。这本书只利用了 3D 和 4D 矢量。

清单 D-1 显示了 3D Vector3类的类声明。

清单 D-1。Vector3阶级宣言

class Vector3
{
public:
       float m_x;
       float m_y;
       float m_z;

       Vector3();
       Vector3(const float x, const float y, const float z);
       virtualVector3();

       void       Set(const Vector3& in);
       void       Multiply(const float scalar);
       void       Divide(const float scalar);
       void       Add(const Vector3& in);
       void       Subtract(const Vector3& in);
       void       Negate();
       float       Length() const;
       float       LengthSquared() const;
       void       Normalize();
       void       GetNormal(Vector3& normal);

       Vector3& operator=(const Vector3& in);
       Vector3& operator=(const Vector4& in);

       float       Dot(const Vector3& in) const;
       Vector3     Cross(const Vector3& in) const;
};

存储在Vector3类中的数据由三个浮点数表示,分别对应三个基本轴:x、y 和 z。

然后,我们有了一组可以用来处理Vector3类的方法。清单 D-2 包含了Vector3的构造函数和析构函数。

清单 D-2。 Vector3构造函数和析构函数

Vector3::Vector3()
       :       m_x(0.0f)
       ,       m_y(0.0f)
       ,       m_z(0.0f)
{
}

Vector3::Vector3(const float x, const float y, const float z)
       :       m_x(x)
       ,       m_y(y)
       ,       m_z(z)
{
}

Vector3::∼Vector3()
{
}

我们还有一个方法,Set ( 列出 D-3 ),它覆盖了 vector 中的值。

清单 D-3。 Vector3::Set

void Vector3::Set(const Vector3& in) {
       m_x = in.m_x;
       m_y = in.m_y;
       m_z = in.m_z;
}

向量可以被浮点数乘除。这具有延长或缩短向量的效果。例如,将一个向量乘以二,向量的长度就会加倍。清单 D-4 展示了这些方法。

清单 D-4。 Vector3 MultiplyDivide

void Vector3::Multiply(const float scalar) {
       m_x *= scalar;
       m_y *= scalar;
       m_z *= scalar;
}

void Vector3::Divide(const float scalar) {
       float divisor = 1.0f / scalar;
       m_x *= divisor;
       m_y *= divisor;
       m_z *= divisor;
}

向量也可以与其他向量相加或相减。清单 D-5 显示了AddSubtract方法。

清单 D-5。 Vector3 AddSubtract

void Vector3::Add(const Vector3& in) {
       m_x += in.m_x;
       m_y += in.m_y;
       m_z += in.m_z;
}

void Vector3::Subtract(const Vector3& in) {
       m_x -= in.m_x;
       m_y -= in.m_y;
       m_z -= in.m_z;
}

我们对向量进行的另一个常见操作是求它们的反。清单 D-6 显示了实现这一点的方法。

清单 D-6。 矢量 3:: Negate

void Vector3::Negate()
{
       m_x = -m_x;
       m_y = -m_y;
       m_z = -m_z;
}

我们使用毕达哥拉斯定理来计算向量的长度。计算三角形斜边长度的函数如下:

斜边长度= √( x2+y2+z2)

毕达哥拉斯定理的标准实现包括*方根。计算*方根可能是一个昂贵的操作;因此,我们还实现了一个计算向量*方长度的方法。我们可以通过比较两个向量的*方长度来确定一个向量比另一个向量长还是短。清单 D-7 显示了LengthLengthSquared方法。

清单 D-7。 矢量 3 LengthLengthSquared

float Vector3::Length() const
{
       return sqrt((m_x*m_x) + (m_y*m_y) + (m_z*m_z));
}

float Vector3::LengthSquared() const
{
       return (m_x*m_x) + (m_y*m_y) + (m_z*m_z);
}

我们在游戏编程中使用的向量的一个常见变体是单位法向量。单位法线是一个向量,它代表一个方向,长度为 1。这很有用,因为我们可以利用法线在其他情况下有单位长度的事实。清单 D-8 显示了从Vector3对象获取法向量所必需的代码。

清单 D-8。 矢量 3 NormalizeGetNormal

void Vector3::Normalize()
{
       Divide(Length());
}

void Vector3::GetNormal(Vector3& normal)
{
       normal = *this;
       normal.Normalize();
}

点(或标量)积是对两个向量执行的运算。点积最常见的用途之一是计算这些向量之间的角度。点积的细节将在第九章介绍。清单 D-9 描述了Vector3::Dot方法。

清单 D-9。 Vector3:【圆点】

float Vector3::Dot(const Vector3& in) const
{
       return (m_x * in.m_x) + (m_y * in.m_y) + (m_z * in.m_z);
}

叉积用于计算垂直于两个输入向量的新向量。这也在第九章中有更详细的介绍;然而,代码显示在清单 D-10 中。

清单 D-10。 Vector3::十字

Vector3 Vector3::Cross(const Vector3& in) const
{
       return Vector3(
               (m_y * in.m_z) - (m_z * in.m_y),
               (m_z * in.m_x) - (m_x * in.m_z),
               (m_x * in.m_y) - (m_y * in.m_x));
}

4D 向量覆盖了与 3D 向量基本相同的操作,只增加了一点点,w 分量。该组件用于确定位移矢量和法向矢量之间的差异。当 4D 向量的 w 分量被设置为 1 时,我们表示该向量是位置向量,当它为 0 时,我们表示它是方向向量。

当我们将向量乘以 4×4 变换矩阵时,这种变化的意义就显现出来了。当 w 分量设置为 0 时,向量将不会被 4×4 矩阵的位置元素*移。

矩阵

3D 游戏编程中使用矩阵来表示变换信息。矩阵可以包含适合于增大或减小对象尺寸、旋转对象以及在 3D 空间中*移对象的信息。清单 D-11 包含了Matrix4类的类声明。

清单 D-11。Matrix4阶级宣言

class Matrix4
{
public:
       enum Rows
       {
              X,
              Y,
              Z,
              W,
              NUM_ROWS
       };

       float m_m[16];

       Matrix4();
       virtualMatrix4();

       void Identify();
       Vector3 Transform(const Vector3& in) const;
       Vector3 TransformTranspose(const Vector3& in) const;
       Vector4 Multiply(const Vector4& in) const;
       void RotateAroundX(float radians);
       void RotateAroundY(float radians);
       void RotateAroundZ(float radians);
       void Multiply(const Matrix4& in, Matrix4& out) const;

       Matrix4 Transpose() const;

       Matrix4& operator=(const Matrix3& in);
       Matrix4& operator=(const Matrix4& in);

       Vector4 GetRow(const Rows row) const;
};

清单显示,我们的矩阵包含 16 个浮点值,可以表示为一组 4 个向量,每个向量包含 4 个元素,这给出了我们的 4×4 矩阵。我们已经定义了一个枚举Rows,来表示矩阵的行。

单位矩阵

一种特殊类型的矩阵是对角矩阵。这是一个只设置对角线值的矩阵。单位矩阵中的每个对角线值都是 1。单位矩阵表示当另一个矩阵或向量相乘时保持不变的矩阵。清单 D-12 展示了方法Identify,我们用它来设置一个矩阵为单位矩阵。

清单 D-12。 Matrix4::识别

void Matrix4::Identify()
{
       m_m[0] = 1.0f;
       m_m[1] = 0.0f;
       m_m[2] = 0.0f;
       m_m[3] = 0.0f;
       m_m[4] = 0.0f;
       m_m[5] = 1.0f;
       m_m[6] = 0.0f;
       m_m[7] = 0.0f;
       m_m[8] = 0.0f;
       m_m[9] = 0.0f;
       m_m[10] = 1.0f;
       m_m[11] = 0.0f;
       m_m[12] = 0.0f;
       m_m[13] = 0.0f;
       m_m[14] = 0.0f;
       m_m[15] = 1.0f;
}

旋转矩阵

可以创建围绕每个主轴旋转的旋转矩阵。清单 D-13 显示了创建绕 x、y 和 z 轴旋转的矩阵所需的代码。

清单 D-13。 Matrix4旋转矩阵创建方法

void Matrix4::RotateAroundX(float radians)
{
       m_m[0] = 1.0f; m_m[1] = 0.0f; m_m[2] = 0.0f;
       m_m[4] = 0.0f; m_m[5] = cos(radians); m_m[6] = sin(radians);
       m_m[8] = 0.0f; m_m[9] = -sin(radians); m_m[10] = cos(radians);
}

void Matrix4::RotateAroundY(float radians)
{
       m_m[0] = cos(radians); m_m[1] = 0.0f; m_m[2] = -sin(radians);
       m_m[4] = 0.0f; m_m[5] = 1.0f; m_m[6] = 0.0f;
       m_m[8] = sin(radians); m_m[9] = 0.0f; m_m[10] = cos(radians);
}

void Matrix4::RotateAroundZ(float radians)
{
       m_m[0] = cos(radians); m_m[1] = sin(radians); m_m[2] = 0.0f;
       m_m[4] = -sin(radians); m_m[5] = cos(radians); m_m[6] = 0.0f;
       m_m[8] = 0.0f; m_m[9] = 0.0f; m_m[10] = 1.0f;
}

乘法矩阵

矩阵运算可以合并;这个过程被称为串联,是通过矩阵相乘来实现的。值得注意的是,连接矩阵时的操作顺序很重要。旋转对象然后*移它,与*移对象然后旋转相比,会产生不同的结果。Matrix4::Multiply在清单 D-14 中有描述。

清单 D-14。 Matrix4::Multiply

void Matrix4::Multiply(const Matrix4& in, Matrix4& out) const
{
       assert(this != &in && this != &out && &in != &out);
       out.m_m[0]
              = (m_m[0] * in.m_m[0]) +
                (m_m[1] * in.m_m[4]) +
                (m_m[2] * in.m_m[8]) +
                (m_m[3] * in.m_m[12]);

       out.m_m[1]
              = (m_m[0] * in.m_m[1]) +
                (m_m[1] * in.m_m[5]) +
                (m_m[2] * in.m_m[9]) +
                (m_m[3] * in.m_m[13]);

       out.m_m[2]
              = (m_m[0] * in.m_m[2]) +
                (m_m[1] * in.m_m[6]) +
                (m_m[2] * in.m_m[10]) +
                (m_m[3] * in.m_m[14]);

       out.m_m[3]
              = (m_m[0] * in.m_m[3]) +
                (m_m[1] * in.m_m[7]) +
                (m_m[2] * in.m_m[11]) +
                (m_m[3] * in.m_m[15]);

       out.m_m[4]
              = (m_m[4] * in.m_m[0]) +
                (m_m[5] * in.m_m[4]) +
                (m_m[6] * in.m_m[8]) +
                (m_m[7] * in.m_m[12]);

       out.m_m[5]
              = (m_m[4] * in.m_m[1]) +
                (m_m[5] * in.m_m[5]) +
                (m_m[6] * in.m_m[9]) +
                (m_m[7] * in.m_m[13]);

       out.m_m[6]
              = (m_m[4] * in.m_m[2]) +
                (m_m[5] * in.m_m[6]) +
                (m_m[6] * in.m_m[10]) +
                (m_m[7] * in.m_m[14]);

       out.m_m[7]
              = (m_m[4] * in.m_m[3]) +
                (m_m[5] * in.m_m[7]) +
                (m_m[6] * in.m_m[11]) +
                (m_m[7] * in.m_m[15]);

       out.m_m[8]
              = (m_m[8] * in.m_m[0]) +
                (m_m[9] * in.m_m[4]) +
                (m_m[10] * in.m_m[8]) +
                (m_m[11] * in.m_m[12]);

       out.m_m[9]
              = (m_m[8] * in.m_m[1]) +
                (m_m[9] * in.m_m[5]) +
                (m_m[10] * in.m_m[9]) +
                (m_m[11] * in.m_m[13]);

       out.m_m[10]
              = (m_m[8] * in.m_m[2]) +
                (m_m[9] * in.m_m[6]) +
                (m_m[10] * in.m_m[10]) +
                (m_m[11] * in.m_m[14]);

       out.m_m[11]
              = (m_m[8] * in.m_m[3]) +
                (m_m[9] * in.m_m[7]) +
                (m_m[10] * in.m_m[11]) +
                (m_m[11] * in.m_m[15]);

       out.m_m[12]
              = (m_m[12] * in.m_m[0]) +
                (m_m[13] * in.m_m[4]) +
                (m_m[14] * in.m_m[8]) +
                (m_m[15] * in.m_m[12]);

       out.m_m[13]
              = (m_m[12] * in.m_m[1]) +
                (m_m[13] * in.m_m[5]) +
                (m_m[14] * in.m_m[9]) +
                (m_m[15] * in.m_m[13]);

       out.m_m[14]
              = (m_m[12] * in.m_m[2]) +
                (m_m[13] * in.m_m[6]) +
                (m_m[14] * in.m_m[10]) +
                (m_m[15] * in.m_m[14]);

       out.m_m[15]
              = (m_m[12] * in.m_m[3]) +
                (m_m[13] * in.m_m[7]) +
                (m_m[14] * in.m_m[11]) +
                (m_m[15] * in.m_m[15]);
}

矩阵乘法是一个开销很大的过程,因为成员矩阵的每一行和输入矩阵的每一列在每个元素处相交,它们被用作向量,并且在每个位置计算点积。

矩阵转置

图形编程中的另一个常见操作是计算矩阵的转置。这是通过切换矩阵的行和列来实现的,方法如清单 D-15 中的所示。

清单 D-15。 Matrix4:: Transpose

Matrix4 Matrix4::Transpose() const
{
       Matrix4 out;
       out.m_m[0]       = m_m[0];
       out.m_m[1]       = m_m[4];
       out.m_m[2]       = m_m[8];
       out.m_m[3]       = m_m[12];
       out.m_m[4]       = m_m[1];
       out.m_m[5]       = m_m[5];
       out.m_m[6]       = m_m[9];
       out.m_m[7]       = m_m[13];
       out.m_m[8]       = m_m[2];
       out.m_m[9]       = m_m[6];
       out.m_m[10]      = m_m[10];
       out.m_m[11]      = m_m[14];
       out.m_m[12]      = m_m[3];
       out.m_m[13]      = m_m[7];
       out.m_m[14]      = m_m[11];
       out.m_m[15]      = m_m[15];
}

矩阵的转置被证明在图形编程中是有用的,因为正交旋转矩阵的转置也是它的逆。正交矩阵在第六章中讨论。

变换向量

组成Matrix4类的最后一个方法是TransformTransformTranspose方法。这些方法用于将向量乘以矩阵。清单 D-16 包含了实现这一点的代码。

清单 D-16。 Matrix4 TransformTransformTranspose

Vector3 Matrix4::Transform(const Vector3& in) const
{
       return Vector3((m_m[0] * in.m_x) + (m_m[1] * in.m_y) + (m_m[2] * in.m_z),
               (m_m[4] * in.m_x) + (m_m[5] * in.m_y) + (m_m[6] * in.m_z),
               (m_m[6] * in.m_x) + (m_m[7] * in.m_y) + (m_m[8] * in.m_z));
}

Vector3 Matrix4::TransformTranspose(const Vector3& in) const
{
       return Vector3((m_m[0] * in.m_x) + (m_m[3] * in.m_y) + (m_m[6] * in.m_z),
               (m_m[1] * in.m_x) + (m_m[4] * in.m_y) + (m_m[7] * in.m_z),
               (m_m[2] * in.m_x) + (m_m[5] * in.m_y) + (m_m[8] * in.m_z));
}

飞机

*面用来分隔空间。它们是*的,无限延伸。*面对于构建形状以确定对象位于空间内部还是外部非常有用。在本书中,*面被用来实现第八章中的视图截锥剔除。清单 D-17 展示了Plane类的类声明。

清单 D-17。Plane阶级宣言

class Plane
{
private:
       Vector3      m_normal;
       float        m_d;

public:
       Plane();
       Plane(const Vector3& point, const Vector3& normal);
       ∼Plane();

       void BuildPlane(const Vector3& point, const Vector3& normal);

       bool IsInFront(const Vector4& point) const;
       bool IsInFront(const Vector3& point) const;
};

我们的Plane课很基础。我们只需要建立一个*面并检验一个点是否在*面前面的方法。如果一个点不在*面前面,我们就知道它是否在*面后面。接受Vector3参数的构造函数简单地调用了BuildPlane,所以我们来看看清单 D-18 中BuildPlane的代码。

清单 D-18。 位面::BuildPlane

void Plane::BuildPlane(const Vector3& point, const Vector3& normal)
{
       m_normal = normal;
       m_normal.Normalize();
       m_d      = m_normal.Dot(point);
}

BuildPlane使用三角学计算*面常数 d。第九章详细介绍了点积。我们知道点积的结果是两个向量的长度乘以向量间夹角的余弦。在BuildPlane中,我们对法向量进行归一化,以确保该向量的长度为 1。这意味着我们点积的结果是点向量的长度乘以两者夹角的余弦。这就给出了法线和点矢量之间的直角三角形的相邻边的长度。这个长度是*面从原点沿着法向量方向的距离。

我们现在可以用这个来确定其他点是在*面的前面还是后面。我们在IsInFront方法中这样做,我们在清单 D-19 中展示了这个方法。

清单 D-19。 *面::IsInFront

bool Plane::IsInFront(const Vector4& point) const
{
       return IsInFront(Vector3(point.m_x, point.m_y, point.m_z));
}

bool Plane::IsInFront(const Vector3& point) const
{
       return m_normal.Dot(point) >= m_d;
}

IsInFrontVector4版本从Vector4m_xm_ym_z字段构造一个Vector3,并调用Vector3版本。

IsInFrontVector3版本简单地计算点积或法线和所提供的点,并确定它是否大于*面常数。如果它更大,那么我们知道这个点在*面的前面。

posted @   绝不原创的飞龙  阅读(42)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· spring官宣接入deepseek,真的太香了~
点击右上角即可分享
微信分享提示