安卓 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 上运行的游戏截图。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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.

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 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.

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 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).

    外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    图 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系统设计的,我们将在本书稍后使用它们来创建我们的游戏循环。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 中可以看到管道。裁剪阶段移除将不被渲染的多边形部分,以防止这些片段被发送通过昂贵的光栅化阶段。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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, a0.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 中的。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 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 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

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

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

摘要

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值